clearotron 0.3.0-beta.1 → 0.3.0-beta.2

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.
Files changed (63) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +12 -10
  4. package/README.md +3 -2
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/onboard.mjs +156 -55
  7. package/bin/passphrase.mjs +23 -4
  8. package/bin/start.mjs +177 -38
  9. package/build-info.json +2 -2
  10. package/docs/architecture/04-configuration-reference.md +5 -2
  11. package/docs/architecture/05-config-governance.md +3 -2
  12. package/driver/CHANGELOG.md +95 -0
  13. package/driver/demo-posture.mjs +59 -8
  14. package/driver/driver.config.mjs +36 -7
  15. package/driver/engine/child-record.mjs +93 -0
  16. package/driver/findings-model.mjs +6 -1
  17. package/driver/knockout-assess-record.mjs +6 -3
  18. package/driver/package.json +1 -1
  19. package/driver/portal-local-auth.mjs +98 -3
  20. package/driver/portal-service.mjs +65 -29
  21. package/driver/publish/knockout.mjs +12 -1
  22. package/driver/publish/office-record-links.mjs +61 -10
  23. package/driver/publish/render-knockout.mjs +31 -11
  24. package/driver/publish/xlsx.mjs +33 -1
  25. package/driver/register-records.mjs +6 -0
  26. package/driver/run-requirements.mjs +24 -1
  27. package/driver/runner.mjs +10 -5
  28. package/driver/skills/knockout-assess/SKILL.md +1 -1
  29. package/driver/suite-census.json +174 -30
  30. package/driver/unit-inventory.mjs +109 -5
  31. package/driver/updater-identity.mjs +178 -0
  32. package/driver/usage-ledger.mjs +5 -5
  33. package/mcp-server/CHANGELOG.md +6 -0
  34. package/mcp-server/http-server.mjs +16 -13
  35. package/mcp-server/lib/driver.mjs +7 -0
  36. package/mcp-server/lib/knockout.mjs +14 -2
  37. package/mcp-server/lib/ops.mjs +38 -16
  38. package/mcp-server/package.json +2 -2
  39. package/mcp-server/server.mjs +1 -1
  40. package/package.json +1 -1
  41. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
  42. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
  43. package/portal-ui/dist/index.html +2 -2
  44. package/portal-ui/package.json +3 -3
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  46. package/providers/oauth-mcp-bridge/package.json +2 -2
  47. package/scripts/changelog-plain-language.mjs +7 -30
  48. package/scripts/e2e.mjs +13 -2
  49. package/scripts/env-audit.mjs +10 -3
  50. package/scripts/env-classify.mjs +205 -12
  51. package/scripts/live-surface-check.mjs +74 -2
  52. package/scripts/plain-language-rules.mjs +103 -0
  53. package/scripts/release-note-required.mjs +118 -24
  54. package/scripts/release-notes-lint.mjs +22 -43
  55. package/scripts/release-version.mjs +59 -0
  56. package/scripts/revisit-render-check.mjs +6 -2
  57. package/scripts/text-difference.mjs +22 -0
  58. package/shared/env-local.mjs +25 -2
  59. package/shared/names-in-force.mjs +1 -0
  60. package/shared/reap-on-exit.mjs +27 -14
  61. package/shared/store-in-repo.mjs +147 -0
  62. package/shared/withheld-paths-access.mjs +6 -6
  63. package/shared/yes-no-echo.mjs +35 -0
@@ -0,0 +1,103 @@
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
+ //
4
+ // plain-language-rules.mjs — the line rules a release note and the assembled changelog are both held to.
5
+ //
6
+ // ONE LIST, TWO READERS. `release-notes-lint.mjs` reads each note on the pull request that adds it, and
7
+ // `changelog-plain-language.mjs` reads the assembled changelog for `release-version.mjs` after the merge.
8
+ // They held two lists, and the release's refused any two words joined by a slash as a path, where the
9
+ // lint's refused only a path into our tree. So a note CI passed turned main's Release red after it merged:
10
+ // "a yes/no question", 2026-09-10. Every line rule both of them apply lives here, and a test feeds the
11
+ // same notes to both.
12
+ //
13
+ // THE PATH RULE IS THE LINT'S, the narrow one, because that is the one ruled on (2026-09-05): what a reader
14
+ // types or opens is allowed, our tree is not, and ordinary prose with a slash in it is not a path.
15
+ import { readFileSync, readdirSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ // The four the issue names, plus the two spellings each of the -ise/-ize pair it names once. A word
22
+ // list is checked whole: "implementation" is jargon, "implement" inside "implemented" is the same word,
23
+ // but "complement" is not, which is why this is a boundary match and not a substring one.
24
+ export const BANNED_WORDS = [
25
+ "refactor", "refactors", "refactored", "refactoring",
26
+ "implement", "implements", "implemented", "implementing", "implementation", "implementations",
27
+ "leverage", "leverages", "leveraged", "leveraging",
28
+ "optimise", "optimises", "optimised", "optimising", "optimisation",
29
+ "optimize", "optimizes", "optimized", "optimizing", "optimization",
30
+ "utilise", "utilises", "utilised", "utilising", "utilisation",
31
+ "utilize", "utilizes", "utilized", "utilizing", "utilization",
32
+ ];
33
+
34
+ // A FILE NAME is the thing a reader cannot act on: they do not have the tree open. Matched by
35
+ // extension, because a bare word with a dot in it is how every file name in this repository reads.
36
+ const FILE_NAME_RE = /\b[\w.-]+\.(mjs|js|cjs|ts|tsx|jsx|json|yml|yaml|md|sh|txt)\b/g;
37
+ // A FUNCTION NAME, in the two shapes this tree writes them: a call, and a bare camelCase identifier.
38
+ const FUNCTION_RE = /\b[a-z][A-Za-z0-9_]*\(\)|\b[a-z]+[A-Z][A-Za-z0-9]*\b/g;
39
+ // A candidate PATH: slash-joined segments, which may open with `~`, `/`, `./` or `../`. Whether it IS one
40
+ // is decided below, by whose it is.
41
+ const PATH_TOKEN_RE = /(?:^|[\s`(])((?:~|\.{1,2})?\/?[\w.~-]+(?:\/[\w.~-]+)+\/?)/g;
42
+ const WORD_RE = new RegExp(`\\b(${BANNED_WORDS.join("|")})\\b`, "gi");
43
+
44
+ /**
45
+ * The repository's own top-level source directories.
46
+ *
47
+ * A path whose first segment is one of these is a SOURCE-TREE path — `driver/test/…`, `scripts/…` — and
48
+ * means nothing to somebody who has never opened this repository. A path that starts at the reader's own
49
+ * home does not: `~/.config/clearotron/` is where THEIR settings are, and telling them is the note's job.
50
+ * Ruling 2026-09-05, narrowing the contract's flat ban on "file paths": what a reader types or
51
+ * opens is allowed; our tree is not.
52
+ *
53
+ * Derived from the tree rather than typed, so a directory added next month is covered without anybody
54
+ * remembering to add it here.
55
+ */
56
+ export function sourceDirectories(root = ROOT) {
57
+ return new Set(readdirSync(root, { withFileTypes: true })
58
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
59
+ .map((d) => d.name));
60
+ }
61
+
62
+ /** Everything the user documentation shows a reader, as one blob, for "is this documented" questions. */
63
+ export function userDocs(root = ROOT) {
64
+ return readdirSync(root)
65
+ .filter((n) => n.endsWith(".md"))
66
+ .map((n) => readFileSync(join(root, n), "utf8"))
67
+ .join("\n");
68
+ }
69
+
70
+ /**
71
+ * Every line rule `raw` breaks, as `{kind, match}`, where `kind` is "jargon word", "file name", "path" or
72
+ * "function name". PURE apart from the defaults, which read the tree; callers pass them once per run.
73
+ *
74
+ * Code spans are exempt from the word, file-name and function-name rules: a line that says
75
+ * `` `clearotron framework <your-framework.md>` `` is telling a reader what to type, with a name of their
76
+ * own in it. A path is judged wherever it sits, by whose it is: `~/.config/clearotron/` in a code span is
77
+ * the reader's, and `driver/engine` is ours.
78
+ */
79
+ export function lineFindings(raw, { sourceDirs = sourceDirectories(), docs = userDocs() } = {}) {
80
+ const out = [];
81
+ const spanless = String(raw).replace(/`[^`]*`/g, (m) => " ".repeat(m.length));
82
+ for (const m of spanless.matchAll(WORD_RE)) out.push({ kind: "jargon word", match: m[0] });
83
+ for (const m of spanless.matchAll(FILE_NAME_RE)) out.push({ kind: "file name", match: m[0] });
84
+ // A path, judged by WHOSE it is. The reader's own — `~/.config/clearotron/` — is the note's job to
85
+ // give them. Ours is not, and "ours" is wider than this repository's directory names: an absolute
86
+ // path into a server's filesystem is our deployment, not their machine.
87
+ //
88
+ // WHAT COUNTS AS A PATH AT ALL is deliberately narrow, because `and/or` is not one. A token qualifies
89
+ // when it opens with `~`, `/`, `./` or `../`, when its first segment is one of our own directories,
90
+ // or when a segment carries a dot. Ordinary prose with a slash in it does not.
91
+ for (const m of String(raw).matchAll(PATH_TOKEN_RE)) {
92
+ const path = m[1];
93
+ if (/^https?:/.test(path)) continue;
94
+ const segments = path.replace(/^[~./]+/, "").split("/").filter(Boolean);
95
+ const looksLikePath = /^[~./]/.test(path) || sourceDirs.has(segments[0]) || segments.some((x) => x.includes("."));
96
+ if (!looksLikePath) continue;
97
+ if (path.startsWith("~")) continue; // the reader's own home
98
+ if (docs.includes(path)) continue; // the user documentation shows it
99
+ out.push({ kind: "path", match: path });
100
+ }
101
+ for (const m of spanless.matchAll(FUNCTION_RE)) out.push({ kind: "function name", match: m[0] });
102
+ return out;
103
+ }
@@ -52,6 +52,21 @@
52
52
  // for two changes is no evidence they are the right two — and it would push people to write filler to
53
53
  // reach a number, which is the failure this repository has already paid for once. The remaining half is
54
54
  // a person reading the diff and asking what a lawyer would want told.
55
+ //
56
+ // ── A DECLINATION ANSWERS FOR ITS OWN COMMIT, NOT FOR THE RANGE ──────────────────────────────────────
57
+ //
58
+ // This read one `Release-note: none` anywhere in the range as the answer for all of it, so a pack of
59
+ // fifteen commits, fourteen internal and each honestly declining, carried the fifteenth, the one a client
60
+ // would notice, through with no note. Measured on three packs merged 2026-09-09 and 2026-09-10: twelve
61
+ // client-visible changes reached main that way. So the question is asked per COMMIT. A commit that ships
62
+ // code is answered by a note the range adds, or by its own `Release-note: none` with its own reason. A
63
+ // declination written by one commit says nothing about another.
64
+ //
65
+ // A `Release-note:` LINE WITH A SENTENCE IN IT IS REFUSED, unless the same commit adds a note. Every one of
66
+ // the twelve was written by somebody who believed they had written a release note, and none of them had:
67
+ // the releases page is built from `.changeset/*.md`, and a line in a commit message reaches no reader. A
68
+ // line may instead NAME the note that answers for it, `Release-note: .changeset/<name>.md`, and that is read
69
+ // as an answer, provided the range adds that note.
55
70
  import { execFileSync } from "node:child_process";
56
71
  import { readFileSync } from "node:fs";
57
72
  import { join, dirname } from "node:path";
@@ -60,7 +75,9 @@ import { fileURLToPath } from "node:url";
60
75
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
61
76
 
62
77
  /** The declaration a commit makes when the change genuinely has nothing to tell a reader. */
63
- export const NO_NOTE = /^\s*Release-note:\s*none\b\s*[—:-]?\s*(?<reason>.*\S)?\s*$/im;
78
+ // Horizontal space only. `\s` crosses a line break, so a bare `none` with any line after it, a trailer or
79
+ // another paragraph, read that line as its reason and passed as a declared decision.
80
+ export const NO_NOTE = /^[ \t]*Release-note:[ \t]*none\b[ \t]*[—:.-]?[ \t]*(?<reason>.*\S)?[ \t]*$/im;
64
81
 
65
82
  /**
66
83
  * Does this path ship, per the package's own `files` list?
@@ -108,6 +125,65 @@ function matches(path, glob) {
108
125
  /** Paths that ship as code but say nothing to a reader on their own. */
109
126
  export const NEVER_A_NOTE = [/(^|\/)CHANGELOG\.md$/, /(^|\/)package(-lock)?\.json$/, /^\.changeset\//];
110
127
 
128
+ /** Every `Release-note:` line in a message, as its value. */
129
+ const NOTE_LINE = /^\s*Release-note:[ \t]*(?<value>.*?)[ \t]*$/gim;
130
+ /** A note named in a `Release-note:` line: `.changeset/<name>.md`, or the bare `<name>.md`. */
131
+ const NAMED_NOTE = /^(?:\.changeset\/)?(?<name>[\w.-]+\.md)$/;
132
+
133
+ const isNotePath = (p) => p.startsWith(".changeset/") && p.endsWith(".md") && !p.endsWith("README.md");
134
+ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) => re.test(p)) && shipsCode(p, files));
135
+
136
+ // A NOTE COUNTS WHEN ITS COMMIT ADDS IT AND THE HEAD STILL HAS IT. A commit's paths come from `diff-tree`,
137
+ // which lists what it deletes and edits beside what it adds. Read as notes, a range whose only note was
138
+ // one it DELETED answered for every code commit in it, and a commit that edited another change's note was
139
+ // taken as carrying its own. Neither reaches a reader of this range: the release assembles the notes the
140
+ // head carries, and an edited note still describes the change that wrote it. Found in review, 2026-09-10,
141
+ // by driving both shapes; the range-wide rule this replaced had the same gap.
142
+ /**
143
+ * PURE. What each commit in a range owes, decided per commit (see the header).
144
+ *
145
+ * @param {object} o
146
+ * @param {Array<{sha:string, subject:string, message:string, paths:string[], added?:string[]}>} o.commits non-merge,
147
+ * oldest first; `added` is the paths the commit adds, when the caller read them
148
+ * @param {string[]} o.files the package's own `files` list
149
+ * @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
150
+ * @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
151
+ * owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note", paths?:string[], text?:string}>}}
152
+ */
153
+ export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
154
+ // `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
155
+ // reading of every changed path, which is what a pure table of paths means.
156
+ const present = atHead ? new Set(atHead) : null;
157
+ const notesOf = (c) => (c.added ?? c.paths).filter((p) => isNotePath(p) && (!present || present.has(p)));
158
+ const notes = [...new Set(commits.flatMap(notesOf))];
159
+ const noteNames = new Set(notes.map((p) => p.split("/").pop()));
160
+ const visible = [...new Set(commits.flatMap((c) => shipped(c.paths, files)))];
161
+ const declined = [], owed = [];
162
+ for (const c of commits) {
163
+ const ships = shipped(c.paths, files);
164
+ if (!ships.length) continue; // nothing a reader could see in this commit
165
+ const addsNote = notesOf(c).length > 0;
166
+ const values = [...String(c.message ?? "").matchAll(NOTE_LINE)].map((m) => m.groups.value);
167
+ const named = values.map((v) => NAMED_NOTE.exec(v)?.groups.name).filter(Boolean);
168
+ const prose = values.filter((v) => !/^none\b/i.test(v) && !NAMED_NOTE.test(v));
169
+ const at = { sha: c.sha, subject: c.subject };
170
+ if (prose.length && !addsNote) { owed.push({ ...at, why: "prose", text: prose[0] }); continue; }
171
+ const none = NO_NOTE.exec(String(c.message ?? ""));
172
+ if (none) {
173
+ const reason = none.groups?.reason;
174
+ if (reason) declined.push({ ...at, reason }); else owed.push({ ...at, why: "bare-none" });
175
+ continue;
176
+ }
177
+ if (addsNote) continue; // it carries its own note
178
+ const missing = named.filter((n) => !noteNames.has(n));
179
+ if (missing.length) { owed.push({ ...at, why: "names-a-missing-note", text: missing[0] }); continue; }
180
+ if (named.length) continue; // it names a note the range adds
181
+ if (notes.length) continue; // a note in the range answers for it
182
+ owed.push({ ...at, why: "no-note", paths: ships });
183
+ }
184
+ return { visible, notes, declined, owed };
185
+ }
186
+
111
187
  const argAfter = (flag) => {
112
188
  const i = process.argv.indexOf(flag);
113
189
  return i === -1 ? null : process.argv[i + 1];
@@ -124,10 +200,26 @@ function main() {
124
200
  // have failed, and would have reported the acceptance met for the life of the branch.
125
201
  const head = argAfter("--head") || "HEAD";
126
202
  const git = (...a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 1 << 28 });
127
- let changed, log;
203
+ let changed, commits, atHead;
128
204
  try {
129
205
  changed = git("diff", "--name-only", `${base}...${head}`).split("\n").filter(Boolean);
130
- log = git("log", "--format=%B", `${base}..${head}`);
206
+ // THE BRANCH'S OWN COMMITS. On a pull request the range can reach commits main already carries, from
207
+ // main merged into the branch; each of those was asked when it merged, so it is not asked again here.
208
+ // Only when `--head` is not given: a named range is a replay, and asks about exactly what it names.
209
+ let exclude = [];
210
+ if (head === "HEAD") {
211
+ try { git("rev-parse", "--verify", "-q", "origin/main^{commit}"); exclude = ["--not", "origin/main"]; }
212
+ catch { /* no origin/main in this clone: the range as given */ }
213
+ }
214
+ const shas = git("rev-list", "--no-merges", "--reverse", `${base}..${head}`, ...exclude).split("\n").filter(Boolean);
215
+ commits = shas.map((sha) => ({
216
+ sha,
217
+ subject: git("log", "-1", "--format=%s", sha).trim(),
218
+ message: git("log", "-1", "--format=%B", sha),
219
+ paths: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha).split("\n").filter(Boolean),
220
+ added: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "--diff-filter=A", sha).split("\n").filter(Boolean),
221
+ }));
222
+ atHead = git("ls-tree", "-r", "--name-only", head, "--", ".changeset/").split("\n").filter(Boolean);
131
223
  } catch (e) {
132
224
  // COULD NOT LOOK, never a pass — an unresolvable base is the shape this repository cares about.
133
225
  console.error(`release-note-required: cannot read the range against ${base}: ${e.message.split("\n")[0]}`);
@@ -139,33 +231,35 @@ function main() {
139
231
  catch (e) { console.error(`release-note-required: cannot read the shipped file list: ${e.message}`); process.exit(2); }
140
232
  if (!files.length) { console.error("release-note-required: package.json names no shipped files, so this cannot look"); process.exit(2); }
141
233
 
142
- const visible = changed.filter((p) => !NEVER_A_NOTE.some((re) => re.test(p)) && shipsCode(p, files));
143
- const notes = changed.filter((p) => p.startsWith(".changeset/") && p.endsWith(".md") && !p.endsWith("README.md"));
144
- const declined = NO_NOTE.exec(log);
145
-
234
+ const { visible, notes, declined, owed } = commitVerdicts({ commits, files, atHead });
146
235
  console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
147
236
  + `${head === "HEAD" ? "" : ` (head ${head})`}; `
148
- + `${visible.length} ship as code; ${notes.length} release note(s) in the range`);
237
+ + `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`);
238
+ // PER COMMIT, SAID AS PER COMMIT. "no note, declared on purpose" read as a verdict on the range, and beside
239
+ // a range that carries a note it told a reader skimming the output that none went out. Found in review.
240
+ for (const d of declined) console.log(` ${d.sha.slice(0, 7)} declines a note of its own, on purpose: ${d.reason}`);
241
+ if (!owed.length) return;
149
242
 
150
- if (!visible.length) return; // nothing a reader could see
151
- if (notes.length) return; // asked and answered
152
- if (declined) {
153
- const reason = declined.groups?.reason;
154
- if (!reason) {
155
- console.error("\nA commit says `Release-note: none` and gives no reason. The reason is the whole "
156
- + "point of the line — it is what a later reader uses to tell a considered decision from a\nskipped step. Write it on the same line.");
157
- process.exit(1);
243
+ console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
244
+ for (const o of owed) {
245
+ console.error(` ${o.sha.slice(0, 7)} ${o.subject}`);
246
+ if (o.why === "no-note") {
247
+ for (const p of o.paths.slice(0, 6)) console.error(` ${p}`);
248
+ if (o.paths.length > 6) console.error(` … and ${o.paths.length - 6} more`);
249
+ console.error(" ships as code, this range adds no release note, and this commit does not decline one.");
250
+ } else if (o.why === "prose") {
251
+ console.error(` says "Release-note: ${o.text.slice(0, 100)}". A sentence in a commit message reaches no`
252
+ + "\n reader: the releases page is written from .changeset/*.md. Put it in a note, or decline here.");
253
+ } else if (o.why === "bare-none") {
254
+ console.error(" says `Release-note: none` and gives no reason. The reason is the whole point of the"
255
+ + "\n line — it is what a later reader uses to tell a considered decision from a skipped step.");
256
+ } else {
257
+ console.error(` names ${o.text} as its note, and this range adds no note by that name.`);
158
258
  }
159
- console.log(` no note, declared on purpose: ${reason}`);
160
- return;
161
259
  }
162
-
163
- console.error(`\n${visible.length} file(s) that ship as code changed, and this range adds no release note:\n`);
164
- for (const p of visible.slice(0, 12)) console.error(` ${p}`);
165
- if (visible.length > 12) console.error(` … and ${visible.length - 12} more`);
166
260
  console.error("\nSomebody installing Clearotron reads the releases page to decide whether to upgrade. Add a"
167
- + "\nnote — `npx changeset`, one plain sentence about what is different for them — or, if this change"
168
- + "\ngenuinely has nothing to tell them, say so in a commit message:"
261
+ + "\nnote — `npx changeset`, one plain sentence about what is different for them — or, if a commit"
262
+ + "\ngenuinely has nothing to tell them, say so in THAT commit's message:"
169
263
  + "\n\n Release-note: none — <why a reader would see no difference>"
170
264
  + "\n\nThe contract and its examples are in .changeset/README.md.");
171
265
  process.exit(1);
@@ -21,6 +21,7 @@ import { readFileSync, readdirSync, existsSync } from "node:fs";
21
21
  import { join, dirname, basename } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
24
+ import { lineFindings, sourceDirectories, userDocs } from "./plain-language-rules.mjs";
24
25
 
25
26
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
26
27
 
@@ -44,23 +45,17 @@ export const INTERNAL_WORDS = [
44
45
  /** Said instead of saying what happens. */
45
46
  export const EMPTY_PHRASES = ["now correctly", "as expected", "properly"];
46
47
 
47
- /**
48
- * The repository's own top-level source directories.
49
- *
50
- * A path whose first segment is one of these is a SOURCE-TREE path — `driver/test/…`, `scripts/…` — and
51
- * means nothing to somebody who has never opened this repository. A path that starts at the reader's own
52
- * home does not: `~/.config/clearotron/` is where THEIR settings are, and telling them is the note's job.
53
- * Ruling 2026-09-05, narrowing the contract's flat ban on "file paths": what a reader types or
54
- * opens is allowed; our tree is not.
55
- *
56
- * Derived from the tree rather than typed, so a directory added next month is covered without anybody
57
- * remembering to add it here.
58
- */
59
- export function sourceDirectories(root = ROOT) {
60
- return new Set(readdirSync(root, { withFileTypes: true })
61
- .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules")
62
- .map((d) => d.name));
63
- }
48
+ // `sourceDirectories` moved to plain-language-rules.mjs with the path rule that reads it, and is
49
+ // re-exported here, as `userDocs` is below, for the callers that import them from this file.
50
+ export { sourceDirectories, userDocs } from "./plain-language-rules.mjs";
51
+
52
+ /** What each shared line rule says to a note's author (see plain-language-rules.mjs). */
53
+ const LINE_RULE = {
54
+ "file name": "a file name means nothing to somebody who has never opened this repository",
55
+ "path": "a file path that is ours rather than the reader's means nothing to somebody who has never opened this repository",
56
+ "jargon word": "the changelog refuses this word — say what changed for the reader instead",
57
+ "function name": "a function name means nothing to somebody who has never opened this repository",
58
+ };
64
59
 
65
60
  /** Every command-line flag a ROOT DOCUMENT shows a user. The contract bans the ones that are not. */
66
61
  export function documentedFlags(root = ROOT) {
@@ -71,14 +66,6 @@ export function documentedFlags(root = ROOT) {
71
66
  return out;
72
67
  }
73
68
 
74
- /** Everything the user documentation shows a reader, as one blob, for "is this documented" questions. */
75
- export function userDocs(root = ROOT) {
76
- return readdirSync(root)
77
- .filter((n) => n.endsWith(".md"))
78
- .map((n) => readFileSync(join(root, n), "utf8"))
79
- .join("\n");
80
- }
81
-
82
69
  /**
83
70
  * Every package name `changeset version` will accept, read off the workspace list.
84
71
  *
@@ -185,26 +172,18 @@ export function findings(text, {
185
172
 
186
173
  if (/#\d+/.test(line)) add(i, "a reader has no issue tracker to look a number up in", /#\d+/.exec(line)[0]);
187
174
  if (/\btracker issue\b/i.test(line)) add(i, "a reader has no issue tracker to look a number up in", "tracker issue");
175
+ // A MODULE NAME OF OURS IS REFUSED EVEN IN A CODE SPAN here, which is stricter than the release: a
176
+ // note may show a reader a command to type, but never one of our own files to type it on.
177
+ const ours = new Set();
188
178
  for (const m of line.matchAll(/\b[\w.-]+\.(mjs|ts|tsx|json|yml|yaml)\b/g)) {
189
- add(i, "a file name means nothing to somebody who has never opened this repository", m[0]);
179
+ ours.add(m[0]);
180
+ add(i, LINE_RULE["file name"], m[0]);
190
181
  }
191
- // A path, judged by WHOSE it is. The reader's own — `~/.config/clearotron/` — is the note's job to
192
- // give them. Ours is not, and "ours" is wider than this repository's directory names: an absolute
193
- // path into a server's filesystem is our deployment, not their machine.
194
- //
195
- // WHAT COUNTS AS A PATH AT ALL is deliberately narrow, because `and/or` is not one. A token qualifies
196
- // when it opens with `~`, `/`, `./` or `../`, when its first segment is one of our own directories,
197
- // or when a segment carries a dot. Ordinary prose with a slash in it does not.
198
- for (const m of line.matchAll(/(?:^|[\s`(])((?:~|\.{1,2})?\/?[\w.~-]+(?:\/[\w.~-]+)+\/?)/g)) {
199
- const path = m[1];
200
- if (/^https?:/.test(path)) continue;
201
- const segments = path.replace(/^[~./]+/, "").split("/").filter(Boolean);
202
- const looksLikePath = /^[~./]/.test(path) || sourceDirs.has(segments[0]) || segments.some((x) => x.includes("."));
203
- if (!looksLikePath) continue;
204
- if (path.startsWith("~")) continue; // the reader's own home
205
- if (docs.includes(path)) continue; // the user documentation shows it
206
- add(i, "a file path that is ours rather than the reader's means nothing to somebody who has never "
207
- + "opened this repository", path);
182
+ // THE LINE RULES THE RELEASE HOLDS THE CHANGELOG TO, applied to the note that will become it: file
183
+ // names, paths into our tree, jargon and function names, from one list (plain-language-rules.mjs).
184
+ for (const f of lineFindings(line, { sourceDirs, docs })) {
185
+ if (f.kind === "file name" && ours.has(f.match)) continue; // already refused just above
186
+ add(i, LINE_RULE[f.kind], f.match);
208
187
  }
209
188
  for (const m of line.matchAll(/--[a-z][a-z0-9-]+/g)) {
210
189
  if (!flags.has(m[0])) add(i, "a flag the user documentation never shows is a flag the reader cannot use", m[0]);
@@ -24,6 +24,7 @@ import { execFileSync } from "node:child_process";
24
24
  import { join, dirname } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { findings, sentences } from "./changelog-plain-language.mjs";
27
+ import { tagsHere } from "./release-cut-decision.mjs";
27
28
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
28
29
 
29
30
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -32,6 +33,30 @@ const GROUP = ["driver", "mcp-server", "portal-ui", "providers/oauth-mcp-bridge"
32
33
  export const GROUPS = ["New", "Fixed", "For operators"];
33
34
  const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
34
35
 
36
+ /** PURE. Where `version` is already out: tagged in this checkout, published on the registry, both, or neither. */
37
+ export function alreadyOut({ version, tags = [], published = false }) {
38
+ const where = [];
39
+ if (tags.includes(`v${version}`)) where.push(`tagged here as v${version}`);
40
+ if (published === true) where.push("published on the registry");
41
+ return where;
42
+ }
43
+
44
+ /**
45
+ * Whether the registry already carries `name@version`: true, false, or null when it could not be asked.
46
+ *
47
+ * npm answers a version it does not have with exit 1 and `E404`, "No match found for version", so that one
48
+ * failure is the answer no. Any other failure is a failure to look, and the caller refuses on it.
49
+ */
50
+ export function registryHas(name, version, {
51
+ run = (args) => execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
52
+ } = {}) {
53
+ try {
54
+ return String(run(["view", `${name}@${version}`, "version"]) ?? "").trim() === version;
55
+ } catch (e) {
56
+ return /\bE404\b|No match found for version/.test(`${e?.stderr ?? ""}${e?.message ?? ""}`) ? false : null;
57
+ }
58
+ }
59
+
35
60
  /** The one version the fixed group now carries — and a refusal if they disagree. */
36
61
  export function groupVersion(root = ROOT) {
37
62
  const seen = new Map();
@@ -204,6 +229,40 @@ function main() {
204
229
 
205
230
  run("version", ...args);
206
231
  const version = groupVersion();
232
+
233
+ // ── A VERSION ALREADY OUT IS REFUSED HERE, BEFORE ANYTHING IS STAMPED ─────────────────────────────
234
+ //
235
+ // The pre-release line keeps no record of its last number. `.changeset/pre.json` holds its mode and tag
236
+ // and nothing else, and a stable release deletes it, so `changeset version` counts again from
237
+ // package.json. Measured 2026-09-10: entering beta on a tree at 0.2.4 computed 0.3.0-beta.0, which was
238
+ // already tagged and on the registry. A cut from there would stamp main with it, consume every pending
239
+ // note, and fail only at publish, leaving both to be unwound by hand. The tags and the registry are the
240
+ // record, so the number is checked against both, and a failure to read either is a refusal too.
241
+ const tags = tagsHere();
242
+ if (!tags.length) {
243
+ console.error(`release-version: this checkout has no tags, so whether ${version} is already out cannot `
244
+ + "be read. Refusing rather than stamping a number that may already be published.");
245
+ process.exitCode = 2;
246
+ return;
247
+ }
248
+ const name = readJson(join(ROOT, "package.json")).name;
249
+ const published = registryHas(name, version);
250
+ if (published === null) {
251
+ console.error(`release-version: the registry could not be asked whether ${name}@${version} exists. `
252
+ + "Refusing rather than stamping a number that may already be published.");
253
+ process.exitCode = 2;
254
+ return;
255
+ }
256
+ const out = alreadyOut({ version, tags, published });
257
+ if (out.length) {
258
+ console.error(`release-version: this cut computed ${version}, and that version is already out: `
259
+ + `${out.join(" and ")}. Publishing it would fail after main is stamped and the notes are consumed.\n`
260
+ + " The pre-release line keeps no record of its last number: .changeset/pre.json holds only its mode "
261
+ + "and tag, and a stable release deletes it, so this was counted again from package.json. Put the "
262
+ + "line back where its last published version left it, then cut again.");
263
+ process.exitCode = 1;
264
+ return;
265
+ }
207
266
  const pkgPath = join(ROOT, "package.json");
208
267
  const pkg = readJson(pkgPath);
209
268
  const was = pkg.version;
@@ -68,6 +68,7 @@ import { spawn } from 'node:child_process'
68
68
  import { tmpdir } from 'node:os'
69
69
  import { reapOnExit } from '../shared/reap-on-exit.mjs' // — a detached group dies with this script
70
70
  import { browserRun } from "../shared/browser-temp-root.mjs";
71
+ import { textDifference } from './text-difference.mjs' // — a text pair that differs says where, not only that
71
72
 
72
73
  const HERE = dirname(fileURLToPath(import.meta.url))
73
74
  const DIST = join(HERE, '..', 'portal-ui', 'dist')
@@ -345,8 +346,11 @@ const twice = async (name, enter, expectPath, away = 'Home') => {
345
346
  // otherwise sail through a count comparison as "identical".
346
347
  say((t1 || '').trim().length > 0, `${name}: visit 1 rendered text`)
347
348
  say((t2 || '').trim().length > 0, `${name}: visit 2 rendered text`)
348
- say((t2 || '').trim().length > 0 && (t1 || '').trim().length > 0 && t1 === t2,
349
- `${name}: the screen renders the same text on both visits`)
349
+ // On a FAIL this line carries where the two texts first differ and a window of each side, because a red
350
+ // that does not reproduce leaves nothing else to read. The ok line is unchanged, so two passing runs
351
+ // still diff to nothing.
352
+ const sameText = (t2 || '').trim().length > 0 && (t1 || '').trim().length > 0 && t1 === t2
353
+ say(sameText, `${name}: the screen renders the same text on both visits${sameText ? '' : ` — ${textDifference(t1, t2) ?? 'neither visit rendered any text'}`}`)
350
354
 
351
355
  // The unmount half. Equal counts mean the listener added on visit 1 was removed when the screen was
352
356
  // left, rather than accumulating one per visit.
@@ -0,0 +1,22 @@
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
+ // Where two texts first differ, and a window of each side there, so a check that finds them unequal can
4
+ // say how and not only that.
5
+ //
6
+ // A pair of lengths is not enough: two texts of one length that differ in one word would print alike.
7
+ // Each window is JSON-quoted, so a newline or a trailing space in either shows on the one line printed.
8
+
9
+ /**
10
+ * `null` when the texts are equal. Otherwise one line: the offset of the first differing character, both
11
+ * lengths, and up to `span` characters of each side starting `lead` characters before that offset.
12
+ */
13
+ export function textDifference(a, b, { span = 48, lead = 16, labels = ['visit 1', 'visit 2'] } = {}) {
14
+ const x = String(a ?? '')
15
+ const y = String(b ?? '')
16
+ if (x === y) return null
17
+ let at = 0
18
+ while (at < x.length && at < y.length && x[at] === y[at]) at++
19
+ const from = Math.max(0, at - lead)
20
+ const window = (s) => JSON.stringify(`${from > 0 ? '…' : ''}${s.slice(from, from + span)}${from + span < s.length ? '…' : ''}`)
21
+ return `first differs at character ${at} (lengths ${x.length} and ${y.length}): ${labels[0]} ${window(x)} · ${labels[1]} ${window(y)}`
22
+ }
@@ -414,7 +414,7 @@ export function activeEnvPath({ repoRoot = REPO_ROOT, home = homedir(), location
414
414
  * logged: this file is where the credentials are.
415
415
  */
416
416
  export function loadEnvLocal({ env = process.env, repoRoot = REPO_ROOT, note = defaultNote,
417
- home = homedir(), location = ENV_LOCAL_LOCATION, file = null } = {}) {
417
+ home = homedir(), location = ENV_LOCAL_LOCATION, file = null, demo = false } = {}) {
418
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
@@ -443,6 +443,12 @@ export function loadEnvLocal({ env = process.env, repoRoot = REPO_ROOT, note = d
443
443
  const path = file ?? activeEnvPath({ repoRoot, home, location });
444
444
  const fromLegacy = path !== inForce;
445
445
  if (optedOut(env)) return { path, applied: [], skipped: [], reason: "opted-out" };
446
+ // A DEMO TAKES NOTHING FROM IT (`isDemoEntry`, below). Said only when there is a file being left
447
+ // alone, so a reader with a real install can see the demo did not take its settings.
448
+ if (demo) {
449
+ if (existsSync(path)) note(`[env-local] not reading ${path}: \`clearotron demo\` runs on its own data and settings, never an install's\n`);
450
+ return { path, applied: [], skipped: [], reason: "demo" };
451
+ }
446
452
  if (serviceManaged(env)) {
447
453
  // Loud ONLY when there is a file to ignore. A service with no .env beside it is the normal case on
448
454
  // every box and says nothing; a service that HAS one is somebody's surprise waiting to happen, and
@@ -497,10 +503,27 @@ export function loadEnvLocal({ env = process.env, repoRoot = REPO_ROOT, note = d
497
503
 
498
504
  function defaultNote(line) { try { process.stderr.write(line); } catch { /* a closed stderr must never fail a run */ } }
499
505
 
506
+ /**
507
+ * Is this process `clearotron demo`'s supervisor, `bin/start.mjs --demo`?
508
+ *
509
+ * THE ONE CLI ENTRY THAT MUST NOT READ THE FILE. What `.env` configures is the reader's real install:
510
+ * its config store, its pool, its grants. 0.3.0-beta.1's demo read it before `--demo` was parsed, so it
511
+ * served the real store's roster (no demo company, no Generic) and seeded its example reports into the
512
+ * real archive (measured on a reader's machine, 2026-09-10).
513
+ *
514
+ * `bin/clearotron.mjs` and `bin/example.mjs`, the two processes above it, are not CLI entries and read
515
+ * nothing, so this one is the whole of the gap. Its own children run with CLEAROTRON_NO_ENV_FILE=1.
516
+ */
517
+ export function isDemoEntry(argv = process.argv, repoRoot = REPO_ROOT) {
518
+ if (!argv?.[1] || !argv.slice(2).includes("--demo")) return false;
519
+ const entry = realOrSelf(isAbsolute(argv[1]) ? argv[1] : resolve(process.cwd(), argv[1]));
520
+ return entry === realOrSelf(join(repoRoot, "bin", "start.mjs"));
521
+ }
522
+
500
523
  // ── the gate ─────────────────────────────────────────────────────────────────────────────────────────
501
524
  // ESM caches by resolved URL, so this runs exactly once per process however many entries import it
502
525
  // (mcp-server/http-server.mjs imports mcp-server/server.mjs; both are entries).
503
- export const loaded = isCliEntry(process.argv[1]) ? loadEnvLocal() : null;
526
+ export const loaded = isCliEntry(process.argv[1]) ? loadEnvLocal({ demo: isDemoEntry(process.argv) }) : null;
504
527
 
505
528
  /**
506
529
  * The env file THIS process took its configuration from, or null when it took none.
@@ -171,6 +171,7 @@ export const NAMES_IN_FORCE = Object.freeze([
171
171
  "CLEAROTRON_TURN_CAP",
172
172
  "CLEAROTRON_TURN_LOCK_POLL_MS",
173
173
  "CLEAROTRON_UNREACHABLE_SENIOR",
174
+ "CLEAROTRON_UPDATER_STAMP",
174
175
  "CLEAROTRON_WALL_RESCUE",
175
176
  "CLEAROTRON_WARM_RETRY",
176
177
  "CLEAROTRON_WATCH_",
@@ -59,26 +59,39 @@ const groups = new Set();
59
59
  const dirs = new Set();
60
60
  let installed = false;
61
61
 
62
- function reapAll() {
63
- for (const pid of groups) {
64
- // The GROUP first — that is what detaching bought, and it is what reaches the child's children.
65
- // Falling back to the bare pid keeps a non-detached caller honest rather than silently reaping
66
- // nothing; both are best-effort, because a reaper that throws on the way out of a crashing script
67
- // replaces one problem with a worse one.
68
- try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } }
69
- }
70
- groups.clear();
62
+ // The GROUP first — that is what detaching bought, and it is what reaches the child's children.
63
+ // Falling back to the bare pid keeps a non-detached caller honest rather than silently reaping
64
+ // nothing; both are best-effort, because a reaper that throws on the way out of a crashing script
65
+ // replaces one problem with a worse one.
66
+ function killGroup(pid) {
67
+ try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } }
68
+ }
69
+ function removeDir(dir) {
70
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* a stray temp root is not worth failing an exit over */ }
71
+ }
72
+
73
+ /**
74
+ * Signal every watched group, then remove every watched directory — in that order, which is the whole
75
+ * point of this function (see the note above).
76
+ *
77
+ * THE TWO EFFECTS AND THE STATE ARE PARAMETERS so a test can record the order the calls HAPPEN in. The
78
+ * arm that held this before read the order the lines were written in, and passed on edits that reversed
79
+ * the effect while keeping the lines: a removal moved into a helper called above the kill, or a second
80
+ * removal added earlier. The defaults are the real calls and this module's own sets.
81
+ */
82
+ export function reapAll({ kill = killGroup, remove = removeDir } = {}, state = { groups, dirs }) {
83
+ for (const pid of state.groups) kill(pid);
84
+ state.groups.clear();
71
85
  // ONLY NOW, with the groups signalled, is the temp root removable. See the note above.
72
- for (const dir of dirs) {
73
- try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* a stray temp root is not worth failing an exit over */ }
74
- }
75
- dirs.clear();
86
+ for (const dir of state.dirs) remove(dir);
87
+ state.dirs.clear();
76
88
  }
77
89
 
78
90
  function install() {
79
91
  if (installed) return;
80
92
  installed = true;
81
- process.on("exit", reapAll);
93
+ // A wrapper, not the function: "exit" hands its listener the exit code, which reapAll would read as its options.
94
+ process.on("exit", () => reapAll());
82
95
  // 128 + signo is the shell's convention for "died by this signal", and preserving it matters: CI
83
96
  // reads the exit code to tell a cancellation from a failure, and a reaper that exits 0 on SIGTERM
84
97
  // would turn every cancelled job green.