clearotron 0.2.4 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
package/shared/brand.mjs
CHANGED
|
@@ -78,6 +78,20 @@ export const BRAND = {
|
|
|
78
78
|
product: process.env.CLEAROTRON_BRAND_PRODUCT || "Trademark clearance",
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The organisation running this installation, or NULL when nobody has named one.
|
|
83
|
+
*
|
|
84
|
+
* NOT `BRAND.name`, and the difference is the whole point. That value falls back to the product's own
|
|
85
|
+
* name so a sentence the product speaks always has something to call itself — "ask Clearotron to enrol
|
|
86
|
+
* it" reads correctly on an installation nobody has branded. This is the other question: WHO runs this
|
|
87
|
+
* installation, which on a fresh install has no answer, and answering it with the product's name puts
|
|
88
|
+
* "Organisation: Clearotron" on the top bar of every install that never set it.
|
|
89
|
+
*
|
|
90
|
+
* Null rather than an empty string, so a caller cannot render it by accident. Nothing sets the variable
|
|
91
|
+
* today; setup gains the question that will.
|
|
92
|
+
*/
|
|
93
|
+
export const ORGANISATION_NAME = process.env.CLEAROTRON_BRAND_NAME?.trim() || null;
|
|
94
|
+
|
|
81
95
|
/**
|
|
82
96
|
* THE CONFIDENTIALITY POSTURE ON A DELIVERED DOCUMENT — one rule, both report templates.
|
|
83
97
|
*
|
|
@@ -614,7 +628,7 @@ export const logoLockup = ({ mark = 30, tag = BRAND.tagline, cls = "" } = {}) =>
|
|
|
614
628
|
// inherit the SAME staff-auto / client-explicit gating as every other token. `isolation:isolate` + z-index:-1
|
|
615
629
|
// keeps the wash/mark behind content without having to raise every child.
|
|
616
630
|
//
|
|
617
|
-
// THE WORDMARK RENDERS LOWERCASE, AND THE NAME IN PROSE DOES NOT.
|
|
631
|
+
// THE WORDMARK RENDERS LOWERCASE, AND THE NAME IN PROSE DOES NOT. Ruling 2026-08-21, and it is
|
|
618
632
|
// the THIRD casing ruling on this name — read the distinction before changing either half:
|
|
619
633
|
//
|
|
620
634
|
// `.lk-word` (here) the BRAND MARK. Lowercase — "in the UI, clearotron is lowercase as a brand".
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// bundle-freshness.mjs — is the portal bundle the one its sources would build?
|
|
5
5
|
//
|
|
6
|
-
// MOVED HERE FROM `bin/onboard.mjs` BECAUSE A SECOND READER NEEDS IT
|
|
6
|
+
// MOVED HERE FROM `bin/onboard.mjs` BECAUSE A SECOND READER NEEDS IT. `doctor` asked
|
|
7
7
|
// this question and answered it well; `/portal/health` asked a narrower one — present or absent — and
|
|
8
8
|
// answered `ui: "built", ok: true` over a bundle `doctor` had just called stale. Two surfaces, two
|
|
9
9
|
// answers, one of them wrong, and the operator has no reason to prefer either.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// ── WHY THE PRODUCT DOES THIS RATHER THAN TELLING THE READER TO ─────────────────────────────────────
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Ruling, 2026-09-05: "this is not a question a user should ever face." A packaged install ships
|
|
8
8
|
// the built UI, so an npm install or upgrade replaces the bundle and the sources together and none of
|
|
9
9
|
// this can arise. Only a SOURCE checkout updated by a plain `git pull` can be stale — `portal-ui/dist`
|
|
10
10
|
// is untracked on the public tree, so a pull can never update it — and that reader ran the documented
|
package/shared/checkout-move.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
3
|
// checkout-move.mjs — is this command about to repoint the whole deployment at a different tree?
|
|
4
4
|
//
|
|
5
|
-
// ── why this exists
|
|
5
|
+
// ── why this exists ─────────────────────────────────────────────────────────────────────────────────
|
|
6
6
|
//
|
|
7
7
|
// `clearotron connect` writes `CLEAROTRON_CHECKOUT_DIR` into the install's env file, set to whatever
|
|
8
8
|
// checkout it happened to be run from. Every shipped unit's `ExecStart` is `${CLEAROTRON_CHECKOUT_DIR}/…`,
|
|
@@ -152,7 +152,7 @@ export function describeConflict(posture, move) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
|
-
* Programs executing this product from a tree OTHER than the one the install names
|
|
155
|
+
* Programs executing this product from a tree OTHER than the one the install names.
|
|
156
156
|
*
|
|
157
157
|
* `doctor` already reports a running program older than the checkout. This is the same question with
|
|
158
158
|
* the more dangerous answer: a process on a DIFFERENT tree keeps working until it restarts, and then
|
package/shared/client-door.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
3
|
// client-door.mjs — the client connector's settings, its key, and the revocation of one person's key.
|
|
4
4
|
//
|
|
5
|
-
// ── THE DOOR NOW COMES UP WITH THE PRODUCT (
|
|
5
|
+
// ── THE DOOR NOW COMES UP WITH THE PRODUCT (ruling 2026-09-03,) ─────────
|
|
6
6
|
//
|
|
7
7
|
// There are TWO MCP doors. The ENGINE door (`mcp-server/http-server.mjs`) is what `clearotron start`
|
|
8
8
|
// runs and what the portal's Start button calls; it refuses an account-scoped key outright. The CLIENT
|
|
@@ -354,7 +354,7 @@ export function describeDoorState(door, {
|
|
|
354
354
|
* differ in ONE value — which variable carries that public address — so the composition is one function
|
|
355
355
|
* with that name as a parameter rather than two functions that agree until they do not.
|
|
356
356
|
*
|
|
357
|
-
* Written down after
|
|
357
|
+
* Written down after the census found the asymmetry: the client door's
|
|
358
358
|
* allow-list is composed by the installer and the engine door's was composed by nothing at all, so a
|
|
359
359
|
* hosted operator was asked for a value sitting next to an identical one the product works out. Two
|
|
360
360
|
* authors composing `host:port` in two places is what let them diverge in the first place.
|
|
@@ -386,7 +386,7 @@ export function allowedHosts(port, env = {}, { urlName = CLIENT_DOOR_URL_ENV } =
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
/**
|
|
389
|
-
* The allow-list an existing one should become, once the door's port has moved
|
|
389
|
+
* The allow-list an existing one should become, once the door's port has moved.
|
|
390
390
|
*
|
|
391
391
|
* The loopback entries are THIS INSTALLER'S and are re-derived from the port; every other host in the
|
|
392
392
|
* list belongs to the operator and is kept. That split is the whole point: a repair about a port must
|
|
@@ -522,7 +522,7 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
|
|
|
522
522
|
// identity. Its own preconditions are exactly what this plan has already established — the signing
|
|
523
523
|
// secret, account access on, a loopback host, and an allow-list.
|
|
524
524
|
const settings = {
|
|
525
|
-
// Written, not required — and this REPLACES whatever was there
|
|
525
|
+
// Written, not required — and this REPLACES whatever was there.
|
|
526
526
|
//
|
|
527
527
|
// The comment here used to say the opposite: "an installer that already set it keeps its value,
|
|
528
528
|
// because setEnvValue replaces only what this plan names". That reads as a preservation guarantee
|
|
@@ -586,7 +586,7 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
|
|
|
586
586
|
/**
|
|
587
587
|
* The change in words, in the tense of the moment the caller is in. ONE author for both.
|
|
588
588
|
*
|
|
589
|
-
* There is no consent prompt —
|
|
589
|
+
* There is no consent prompt — ruling 2026-08-31, *"One press does all of it, invisibly… No
|
|
590
590
|
* second step"* — so these sentences are reported AFTER the door is open, or ahead of it under
|
|
591
591
|
* `--dry-run`. What they must never be is two separately written sets that disagree about what was
|
|
592
592
|
* turned on; a stale future-tense sentence printed after the fact is a product describing a change it
|
|
@@ -611,7 +611,7 @@ export function describeChange(plan, { applied = false, publicAddress = null, re
|
|
|
611
611
|
// does not go on to think about who else can reach it, so the claim is made only when it is true, and
|
|
612
612
|
// when it cannot be established it is not made at all. Silence is the safe failure here; a reassuring
|
|
613
613
|
// sentence is not.
|
|
614
|
-
// ── A PUBLISHED ADDRESS IS NOT AUTOMATICALLY A PUBLIC ONE
|
|
614
|
+
// ── A PUBLISHED ADDRESS IS NOT AUTOMATICALLY A PUBLIC ONE ────────────────────────────────────────
|
|
615
615
|
//
|
|
616
616
|
// `publicAddress` is whatever the operator put in `CLEAROTRON_CLIENT_MCP_URL`, and a loopback value
|
|
617
617
|
// is a thing an operator does set — it is the address that works for them at the keyboard. The
|
|
@@ -739,7 +739,7 @@ export function applyEnablePlan(plan, io) {
|
|
|
739
739
|
|
|
740
740
|
// ══ THE LEDGER: what connect issued, as IDs and never as secrets ═════════════
|
|
741
741
|
//
|
|
742
|
-
//
|
|
742
|
+
// Ruling, 2026-08-31, verbatim shape: "Say yes. Recording key IDs, never secrets. … Store the
|
|
743
743
|
// jti beside it and disconnect is: remove the row, add the id to the denylist. No new bookkeeping."
|
|
744
744
|
//
|
|
745
745
|
// The record rides IN THE GRANTS FILE, beside the rows that give the key its reach — one file to read
|
|
@@ -799,7 +799,7 @@ export function connectKeyReport(grants, { now = Date.now(), revoked = () => fal
|
|
|
799
799
|
return { rows, valid: rows.filter((r) => r.state === "valid").length };
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
-
// ══ REVOCATION: disconnect is a PERSON, not a service (
|
|
802
|
+
// ══ REVOCATION: disconnect is a PERSON, not a service (ruling 2026-09-03, Q3) ═══════════════
|
|
803
803
|
//
|
|
804
804
|
// SUPERSEDED, AND THE OLD SHAPE IS WORTH KNOWING BECAUSE IT WAS COHERENT. Under the 2026-08-31 ruling
|
|
805
805
|
// the door existed only because a reader had asked for it, so its mirror was a teardown: revoke the
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
// Two tables partitioning the same clients on different axes do not merely risk drifting; they had
|
|
51
51
|
// already drifted before either was finished. The page said Codex needs a key address. This table says
|
|
52
52
|
// Codex needs no key at all. On a local install the page's answer resolved to `null`, so the page named
|
|
53
|
-
// a one-line command in its own instructions and then rendered no command — which is
|
|
54
|
-
//
|
|
53
|
+
// a one-line command in its own instructions and then rendered no command — which is that very
|
|
54
|
+
// defect, sitting inside the page written to answer it.
|
|
55
55
|
//
|
|
56
56
|
// So the browser no longer derives any of this. It is handed resolved rows and renders them. That is not
|
|
57
57
|
// a preference for server-side logic: the install's own filesystem path is not a browser fact, and any
|
|
@@ -123,7 +123,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
123
123
|
],
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
|
-
// NOT A SEPARATE PRODUCT (
|
|
126
|
+
// NOT A SEPARATE PRODUCT (decided: there is no such thing as desktop). This is
|
|
127
127
|
// Claude reached the way that runs on the reader's own machine, so it carries Claude's name and says
|
|
128
128
|
// which way it is in the sub-label. The `desktop-json` stdio shape is unchanged — what moved is what
|
|
129
129
|
// a reader is told this is, not how it connects.
|
|
@@ -136,7 +136,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
136
136
|
|
|
137
137
|
// ── Speaks HTTP. Connects from the vendor's own servers. ────────────────────────────────────────
|
|
138
138
|
//
|
|
139
|
-
// ONE ROW, BECAUSE IT IS ONE APP (
|
|
139
|
+
// ONE ROW, BECAUSE IT IS ONE APP (ruling in session: "you know its just ONE
|
|
140
140
|
// APP on a laptop which has cowork and code in it and claude is what its called"). `cowork` was a
|
|
141
141
|
// separate row here and is merged in; the sub-label carries where it is met, which is a fact about the
|
|
142
142
|
// reader's screen rather than about our software.
|
|
@@ -177,7 +177,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
177
177
|
],
|
|
178
178
|
},
|
|
179
179
|
{
|
|
180
|
-
// UNDRIVEN, AND WORDED LIKE IT (
|
|
180
|
+
// UNDRIVEN, AND WORDED LIKE IT (the owner drives this vendor himself this
|
|
181
181
|
// week and the dated stamp appears then). The old second step named "API Key" as the control to
|
|
182
182
|
// choose — the same assertion-from-no-observation that made the cowork row send clients hunting
|
|
183
183
|
// for a box that is not the way in. Two lines and a place to put each is what we actually know.
|
|
@@ -201,7 +201,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
201
201
|
// being read by somebody who is not us. Found by driving the four decks; neither instrument could
|
|
202
202
|
// see it, because both ask whether the right row rendered and neither asks whether the sentence reads.
|
|
203
203
|
//
|
|
204
|
-
// Owner's ruling 2026-09-06,
|
|
204
|
+
// Owner's ruling 2026-09-06, option B: this row gets its own line and the approved
|
|
205
205
|
// sentence is left untouched for the three named ones. Option A — renaming the row to "your
|
|
206
206
|
// assistant" — was rejected because it edits a line he approved to repair a line he did not.
|
|
207
207
|
//
|
|
@@ -230,7 +230,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
230
230
|
* A stub that restates a wire is a second author for one shape. This is the shape; both callers ask.
|
|
231
231
|
*/
|
|
232
232
|
// `sub`, `verifiedOn` and `by` ride only where the ROW carries them, and absent means absent rather than
|
|
233
|
-
// null
|
|
233
|
+
// null. Two of the three are load-bearing on the page:
|
|
234
234
|
//
|
|
235
235
|
// • `sub` is how one app can appear once per route without two rows claiming to be two products —
|
|
236
236
|
// "Claude · app, web, and Cowork" and "Claude · app, on this computer" are one product met two ways.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// ── why this exists ─────────────────────────────────────────────────────────────
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Two settings at the identity provider decide whether a remote assistant can sign
|
|
8
8
|
// in at all, and this product could see neither. The first — the challenge form — is covered:
|
|
9
9
|
// `shared/mcp-challenge.mjs` reads it out of an unauthenticated response and `doctor` reports it.
|
|
10
10
|
//
|
package/shared/env-aliases.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// install, the install writes the names in force, and the two boxes that predate the rename are
|
|
17
17
|
// REBUILT rather than deployed onto. There is no population left holding the old lines.
|
|
18
18
|
//
|
|
19
|
-
// THAT PREMISE STOPPED BEING TRUE WHEN THE PRODUCT MOVED TO THE REGISTRY
|
|
19
|
+
// THAT PREMISE STOPPED BEING TRUE WHEN THE PRODUCT MOVED TO THE REGISTRY. A
|
|
20
20
|
// tarball is replaced whole; a published package is UPGRADED, and an upgrade leaves the operator's
|
|
21
21
|
// environment file exactly where it was — so the population the premise says cannot exist is now
|
|
22
22
|
// created by `npm install`. Measured rather than predicted: upgrading the production install across
|
package/shared/env-local.mjs
CHANGED
|
@@ -280,7 +280,7 @@ const SHELL_EXPANSION = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
|
|
|
280
280
|
/**
|
|
281
281
|
* ── WHERE THIS INSTALL'S `.env` LIVES — ruled, 2026-09-05 ────────────────────────────────────────────
|
|
282
282
|
*
|
|
283
|
-
* `~/.config/clearotron/.env`. The owner's decision,
|
|
283
|
+
* `~/.config/clearotron/.env`. The owner's decision, taken from the three
|
|
284
284
|
* candidates below.
|
|
285
285
|
*
|
|
286
286
|
* WHAT IT FIXES. On a packaged install the wizard wrote `.env` to the package root, which is
|
|
@@ -349,7 +349,7 @@ export const LEGACY_ENV_LOCAL_LOCATION = "package-root";
|
|
|
349
349
|
* what a run can see, and it is the only honest address to give a reader whose units are missing a value.
|
|
350
350
|
*
|
|
351
351
|
* NAMED HERE BECAUSE TWO PLACES NEED IT AND ONE OF THEM IS NOT A CLI. `bin/start.mjs` composed this
|
|
352
|
-
* literal inline while it was the only writer;
|
|
352
|
+
* literal inline while it was the only writer; a later change moved the run-configuration refusal to
|
|
353
353
|
* `driver/runner.mjs`, which must name the same file in the same words. Two `join(homedir(), ".env")`
|
|
354
354
|
* calls are two authorities for one path, and the way that breaks is quiet: a refusal that sends an
|
|
355
355
|
* operator to edit a file the units do not read.
|
|
@@ -415,7 +415,7 @@ export function activeEnvPath({ repoRoot = REPO_ROOT, home = homedir(), location
|
|
|
415
415
|
*/
|
|
416
416
|
export function loadEnvLocal({ env = process.env, repoRoot = REPO_ROOT, note = defaultNote,
|
|
417
417
|
home = homedir(), location = ENV_LOCAL_LOCATION, file = null } = {}) {
|
|
418
|
-
// ── `file` NAMES A FILE OUTRIGHT, AND EXISTS BECAUSE A CALLER COULD NOT
|
|
418
|
+
// ── `file` NAMES A FILE OUTRIGHT, AND EXISTS BECAUSE A CALLER COULD NOT ────────────────────────────
|
|
419
419
|
//
|
|
420
420
|
// `readEnvFile(path)` in the wizard means "tell me what THIS file holds". It could only ask by handing
|
|
421
421
|
// over `repoRoot: dirname(path)` and hoping the resolution below landed there — and it did, by
|
|
@@ -514,7 +514,7 @@ export const loaded = isCliEntry(process.argv[1]) ? loadEnvLocal() : null;
|
|
|
514
514
|
*
|
|
515
515
|
* `read` and `absent` are both a yes — absent only means nobody has written it yet, and that IS the file
|
|
516
516
|
* to write. `service-managed`, `opted-out` and `unreadable` are a no, and a no means say nothing rather
|
|
517
|
-
* than guess
|
|
517
|
+
* than guess.
|
|
518
518
|
*/
|
|
519
519
|
export function envFileRead(l = loaded) {
|
|
520
520
|
return l && (l.reason === "read" || l.reason === "absent") ? l.path : null;
|
|
@@ -534,6 +534,6 @@ export function envFileRead(l = loaded) {
|
|
|
534
534
|
//
|
|
535
535
|
// IT WARNS AND RETURNS. There is nothing here that can refuse: the only names it knows are settings
|
|
536
536
|
// whose BEHAVIOUR was deleted, so there is no value to apply wrongly. The renamed install-surface names
|
|
537
|
-
// are not checked for at all —
|
|
537
|
+
// are not checked for at all — ruling, 2026-08-26 — because a machine reaches this code through
|
|
538
538
|
// the install and the two boxes that predate the rename are rebuilt rather than deployed onto.
|
|
539
539
|
export const aliased = warnRetiredEnv();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
// grants-edit.mjs — the changes a person makes to the grants file: give someone access, and file a new
|
|
4
|
+
// company under its organisation.
|
|
5
|
+
//
|
|
6
|
+
// PURE. Each function takes the parsed grants object and returns a new one; the caller reads the file,
|
|
7
|
+
// applies the change and writes it atomically. One function per change, shared by every writer — the
|
|
8
|
+
// portal's People page, its company creation and `clearotron grant` — so two writers cannot produce two
|
|
9
|
+
// shapes of the same fact. Every result is put through `assertGrantsShape` before it is handed back, so a
|
|
10
|
+
// change that would make the file one the product refuses to load is refused here, before the write.
|
|
11
|
+
import { assertGrantsShape } from "./scope.mjs";
|
|
12
|
+
|
|
13
|
+
const SWITCHES = ["run", "manage", "everything"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Give a person access. `points` are `[{ tenant, account? }]`: a whole organisation, or one company that
|
|
17
|
+
* organisation holds. Points are ADDED; nothing the person already holds is removed, and a company point
|
|
18
|
+
* inside an organisation they already hold whole is already covered.
|
|
19
|
+
*
|
|
20
|
+
* `switches` (`{ run, manage, everything }`) replace the person's entry under `people` when
|
|
21
|
+
* `setSwitches` is true. A caller that may not change them — the person's access reaches beyond the
|
|
22
|
+
* caller's own — passes false, and the entry is left exactly as it was.
|
|
23
|
+
*/
|
|
24
|
+
export function withPerson(grants, { email, points = [], switches = {}, setSwitches = true }) {
|
|
25
|
+
const e = String(email ?? "").trim().toLowerCase();
|
|
26
|
+
if (!e || e.indexOf("@") <= 0 || e.indexOf("@") !== e.lastIndexOf("@"))
|
|
27
|
+
throw new Error(`"${email}" is not one email address`);
|
|
28
|
+
const g = structuredClone(grants ?? { tenants: {} });
|
|
29
|
+
g.tenants ??= {};
|
|
30
|
+
for (const { tenant, account = null } of points) {
|
|
31
|
+
const t = g.tenants[tenant];
|
|
32
|
+
if (!t) throw new Error(`there is no organisation "${tenant}"`);
|
|
33
|
+
t.users = { ...(t.users ?? {}) };
|
|
34
|
+
if (account == null) { t.users[e] = "*"; continue; }
|
|
35
|
+
if (!(Array.isArray(t.accounts) ? t.accounts : []).includes(account))
|
|
36
|
+
throw new Error(`organisation "${tenant}" does not hold "${account}"`);
|
|
37
|
+
const held = t.users[e];
|
|
38
|
+
if (held === "*") continue;
|
|
39
|
+
t.users[e] = [...new Set([...(Array.isArray(held) ? held : []), account])];
|
|
40
|
+
}
|
|
41
|
+
if (setSwitches) {
|
|
42
|
+
const entry = { run: switches.run === true, manage: switches.manage === true };
|
|
43
|
+
if (switches.everything === true) entry.everything = true;
|
|
44
|
+
g.people = { ...(g.people ?? {}), [e]: entry };
|
|
45
|
+
}
|
|
46
|
+
assertGrantsShape(g, "the grants file after this change");
|
|
47
|
+
return g;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** File a company under the organisation that holds it. A company belongs to exactly one organisation. */
|
|
51
|
+
export function withCompany(grants, { tenant, account }) {
|
|
52
|
+
const g = structuredClone(grants ?? { tenants: {} });
|
|
53
|
+
const t = g.tenants?.[tenant];
|
|
54
|
+
if (!t) throw new Error(`there is no organisation "${tenant}"`);
|
|
55
|
+
t.accounts = [...new Set([...(Array.isArray(t.accounts) ? t.accounts : []), account])];
|
|
56
|
+
assertGrantsShape(g, "the grants file after this change");
|
|
57
|
+
return g;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Add an organisation. Its key is derived from the name — lowercase, hyphenated — and must not collide
|
|
62
|
+
* with one that exists. Returns `{ grants, key }`.
|
|
63
|
+
*/
|
|
64
|
+
export function withOrganisation(grants, { name }) {
|
|
65
|
+
const n = String(name ?? "").trim();
|
|
66
|
+
if (!n) throw new Error("an organisation needs a name");
|
|
67
|
+
const key = n.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "organisation";
|
|
68
|
+
const g = structuredClone(grants ?? { tenants: {} });
|
|
69
|
+
g.tenants ??= {};
|
|
70
|
+
if (g.tenants[key]) throw new Error(`there is already an organisation "${key}"`);
|
|
71
|
+
g.tenants[key] = { name: n, accounts: [], users: {} };
|
|
72
|
+
assertGrantsShape(g, "the grants file after this change");
|
|
73
|
+
return { grants: g, key };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { SWITCHES };
|
package/shared/install-auth.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
3
|
// install-auth.mjs — which auth values the units will refuse to start without, on THIS install.
|
|
4
4
|
//
|
|
5
|
-
// ── why this exists
|
|
5
|
+
// ── why this exists ─────────────────────────────────────────────────────────────────────────────────
|
|
6
6
|
//
|
|
7
7
|
// From the fresh-user documented-install walk: a reader who does everything the document asks gets two
|
|
8
8
|
// of four units dead. With auth enabled the portal refuses without `CLEAROTRON_OIDC_AUDIENCE` plus
|
package/shared/listen.mjs
CHANGED
|
@@ -127,7 +127,7 @@ export function explicitPortRequiredMessage({ what, port, portVar }) {
|
|
|
127
127
|
* `portFlag` an optional CLI equivalent, for the entry points that take one.
|
|
128
128
|
*/
|
|
129
129
|
/**
|
|
130
|
-
* The first free port at or after `from`, or `null` when nothing in range is free —
|
|
130
|
+
* The first free port at or after `from`, or `null` when nothing in range is free — ruling,
|
|
131
131
|
* 2026-09-09.
|
|
132
132
|
*
|
|
133
133
|
* WHAT THIS IS FOR AND WHAT IT IS NOT. A collision on a DEFAULT port is this process discovering it
|
|
@@ -172,7 +172,7 @@ export function listenErrorMessage(err, { what, host, port, portVar, portFlag =
|
|
|
172
172
|
// for the units' file, and `clearotron start` — the command that installs those units, so the command
|
|
173
173
|
// that is resolving the ports they will be born with — does not read it. Measured as a stranger on
|
|
174
174
|
// published 0.1.4: the three ports sat in the units' file, the refusal fired, and the only way to see
|
|
175
|
-
// why was to compare two lists of variable names in a log line
|
|
175
|
+
// why was to compare two lists of variable names in a log line.
|
|
176
176
|
//
|
|
177
177
|
// The CALLER passes the file, and passes it only when its own loader actually read one. This module
|
|
178
178
|
// cannot know: the same function serves four services booted by units — where naming the CLI's file
|
|
@@ -249,7 +249,7 @@ export function listenOrDie(server, {
|
|
|
249
249
|
// — the env file this process actually read, from `envFileRead()`. Same contract as
|
|
250
250
|
// `portSource` above: null is a caller that has not been taught the question, and its sentence is
|
|
251
251
|
// exactly what it was. Never composed here; a service booted by systemd read no file and must name
|
|
252
|
-
// none
|
|
252
|
+
// none.
|
|
253
253
|
portFile = null,
|
|
254
254
|
// — "env" | "default" | null. Null is a caller that has not been taught the question yet and
|
|
255
255
|
// behaves exactly as before; every service in this repo passes it.
|
package/shared/mcp-challenge.mjs
CHANGED
|
@@ -77,7 +77,7 @@ export function blockedByAccessChallenge(where, status) {
|
|
|
77
77
|
+ "changed at your identity provider, not here, and NOT repaired by recreating the application "
|
|
78
78
|
+ "(recreating it loses this setting and changes the audience, which is two symptoms from one "
|
|
79
79
|
+ "cause). "
|
|
80
|
-
// ── NAME THE SWITCH
|
|
80
|
+
// ── NAME THE SWITCH ──────────────────────────────────────────────────────────────────────────
|
|
81
81
|
// The rule first and the vendor second, because the identity interface is configuration on
|
|
82
82
|
// purpose: issuer, audience, claim and header are all settings, and a Cloudflare-only remedy
|
|
83
83
|
// re-narrows an interface that was widened deliberately. But a rule with no switch behind it is
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Every `CLEAROTRON_*` name this build reads. It exists so the retired-spelling check can name the
|
|
6
6
|
// replacement for an old `PRELIM_*` line rather than only saying the old one is dead — see
|
|
7
|
-
// shared/env-aliases.mjs
|
|
7
|
+
// shared/env-aliases.mjs. Re-mint with:
|
|
8
8
|
//
|
|
9
9
|
// node scripts/mint-names-in-force.mjs
|
|
10
10
|
//
|
|
@@ -114,6 +114,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
114
114
|
"CLEAROTRON_OPENAI_MODEL_JUDGMENT",
|
|
115
115
|
"CLEAROTRON_OPENAI_MODEL_SWEEP",
|
|
116
116
|
"CLEAROTRON_ORDER_PROBE_SEED",
|
|
117
|
+
"CLEAROTRON_ORGANISATION_NAME",
|
|
117
118
|
"CLEAROTRON_OUTBOX_BACKOFF_BASE_SEC",
|
|
118
119
|
"CLEAROTRON_OUTBOX_BACKOFF_CAP_SEC",
|
|
119
120
|
"CLEAROTRON_OUTBOX_BACKOFF_MAX_RETRIES",
|
|
@@ -14,9 +14,26 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync } from "node:fs";
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* A refusal is a sentence for the operator, not a stack trace. Every one names what was wrong.
|
|
19
|
+
*
|
|
20
|
+
* IT ALSO CARRIES A CODE AND THE FACTS BEHIND IT, because the same refusal now reaches two very
|
|
21
|
+
* different readers. The command line's reader typed a flag and is standing in a terminal, so a sentence
|
|
22
|
+
* naming that flag and the directory it wrote to is exactly right. The browser's reader is a lawyer who
|
|
23
|
+
* has never seen either, and a sentence mentioning `--platforms` and a filesystem path tells them
|
|
24
|
+
* nothing they can act on — it tells them they are in the wrong product.
|
|
25
|
+
*
|
|
26
|
+
* So the message stays the command line's, and `code` plus `detail` let the other door write its own
|
|
27
|
+
* sentence from the same facts. A door with no wording for a code falls back to the message, which is
|
|
28
|
+
* wrong for a browser but never blank — an unworded refusal must not become a silent one.
|
|
29
|
+
*/
|
|
18
30
|
export class Refusal extends Error {
|
|
19
|
-
constructor(message
|
|
31
|
+
constructor(message, { code = null, ...detail } = {}) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "Refusal";
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.detail = detail;
|
|
36
|
+
}
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
/**
|
|
@@ -249,6 +249,44 @@ export const CLASSES = [
|
|
|
249
249
|
// a floored line quietly gain a second one. The permissive half of a gate is the dangerous half.
|
|
250
250
|
const EVERY_MATCH = new Map(CLASSES.filter((c) => c.pattern).map((c) => [c.id, new RegExp(c.pattern.source, c.pattern.flags + "g")]));
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* ── A CITATION THAT WRAPPED IS IN NEITHER LINE ─────────────────────────────────────────────────────
|
|
254
|
+
*
|
|
255
|
+
* Every class above reads ONE line, and prose in this tree wraps at a fixed width, so a citation whose
|
|
256
|
+
* words end one line and whose number begins the next matches nothing. Measured on a real branch: four
|
|
257
|
+
* citations went in, the census counted four and refused; three came out and the count returned to its
|
|
258
|
+
* floor with the fourth still in the tree, because that one had wrapped.
|
|
259
|
+
*
|
|
260
|
+
* WORSE THAN A MISCOUNT, BECAUSE THE CENSUS IS A RATCHET. The floor only falls, and the check passing is
|
|
261
|
+
* the statement that the tree grew nothing new — so a wrapped citation is not merely uncounted, it joins
|
|
262
|
+
* the floor's silence, and nobody looks again because the number did not move.
|
|
263
|
+
*
|
|
264
|
+
* A WRAP IS A WRAP ONLY WHEN NEITHER LINE CARRIES ONE ALONE. Joining line N to N+1 also matches when the
|
|
265
|
+
* whole citation sits on N+1, which would report every ordinary hit a second time as a wrap on the line
|
|
266
|
+
* above it. `WRAPPED_HEAD` requires line N to END mid-citation, and that is what makes the pair disjoint
|
|
267
|
+
* from the per-line count rather than overlapping it.
|
|
268
|
+
*
|
|
269
|
+
* THE TWO HEAD FORMS ARE COMPLETED BY DIFFERENT THINGS, and collapsing them is the false positive that
|
|
270
|
+
* matters: a line ending in the bare word `tracker` needs the word `issue` on the next one, while a line
|
|
271
|
+
* ending in `tracker issue` needs only a number — and without the split, any line ending in `tracker`
|
|
272
|
+
* followed by a numbered list item reads as a citation.
|
|
273
|
+
*
|
|
274
|
+
* ONE DEFINITION. The sweep that removes these reads these same exports; a second detector agreeing today
|
|
275
|
+
* is two detectors disagreeing later, and while the sweep could see this class and the census could not,
|
|
276
|
+
* neither could report the disagreement.
|
|
277
|
+
*/
|
|
278
|
+
export const WRAPPED_HEAD = /\btracker\s*$|\btracker issues?\s*$/i;
|
|
279
|
+
export const WRAPPED_TAIL = /^\s*(?:\/\/|#|\*|--)?\s*(?:issues?\s+)?\d+/i;
|
|
280
|
+
|
|
281
|
+
/** True when `a` ends a citation that `b` completes. PURE. */
|
|
282
|
+
export const wrapsInto = (a, b) => {
|
|
283
|
+
if (!WRAPPED_HEAD.test(a) || b === undefined) return false;
|
|
284
|
+
return /\btracker\s*$/i.test(a) ? /^\s*(?:\/\/|#|\*|--)?\s*issues?\s+\d+/i.test(b) : /^\s*(?:\/\/|#|\*|--)?\s*\d+/.test(b);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** The class a wrapped citation belongs to — the spelled one, because that is what it spells. */
|
|
288
|
+
export const WRAPPED_CLASS = "spelled-citation";
|
|
289
|
+
|
|
252
290
|
export function offendingClasses(path, line) {
|
|
253
291
|
if (!isScannable(path) || !isProse(path, line)) return [];
|
|
254
292
|
const text = withoutColourValues(withoutLinkTargets(line));
|
|
@@ -311,8 +349,12 @@ export function censusOf(files, read) {
|
|
|
311
349
|
try { text = read(path); } catch { continue; }
|
|
312
350
|
if (text.includes("\0")) continue; // a binary blob is not prose
|
|
313
351
|
const counts = CLASSES.map(() => 0);
|
|
314
|
-
|
|
315
|
-
|
|
352
|
+
const lines = text.split("\n");
|
|
353
|
+
for (let i = 0; i < lines.length; i++) {
|
|
354
|
+
for (const { id } of offendingClasses(path, lines[i])) counts[COLUMN.get(id)]++;
|
|
355
|
+
// AND THE PAIR, which no per-line rule can see. Counted on the HEAD line and once: the head ends
|
|
356
|
+
// mid-citation, so this can never be the same hit the loop above just counted.
|
|
357
|
+
if (isProse(path, lines[i]) && wrapsInto(lines[i], lines[i + 1])) counts[COLUMN.get(WRAPPED_CLASS)]++;
|
|
316
358
|
}
|
|
317
359
|
const sum = counts.reduce((a, b) => a + b, 0);
|
|
318
360
|
if (sum) { out.files[path] = counts; out.total += sum; }
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// handed in as a PARAMETER by every caller. That was right while the only callers were the wizard and
|
|
10
10
|
// `bin/start.mjs`, which both hold it legitimately.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// A later change added a third and a fourth — the runner's intake wall and the intake doors — and
|
|
13
13
|
// neither is a CLI. Both would have had to dynamic-import a CLI entry point at call time to read a
|
|
14
14
|
// 44-line data table, and the cycle that trick avoids is not theoretical: a static import in that
|
|
15
15
|
// direction makes `clearotron doctor` exit 13 after printing most of a report, because onboard's
|