geneseed 3.13.0 → 3.14.0

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 (93) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  4. package/bin/geneseed-cli.mjs +10 -14
  5. package/bin/geneseed-hook.mjs +22 -15
  6. package/docs/extending.md +5 -5
  7. package/harness.config.json +1 -1
  8. package/js/README.md +19 -10
  9. package/js/build/args.mjs +374 -0
  10. package/js/build/driver.mjs +137 -553
  11. package/js/build/emit-claude.mjs +2 -4
  12. package/js/build/generate.mjs +6 -3
  13. package/js/build/render.mjs +5 -9
  14. package/js/build/source.mjs +3 -11
  15. package/js/hosts/hooks-context.mjs +431 -0
  16. package/js/hosts/hooks-learn.mjs +275 -0
  17. package/js/hosts/hooks-prims.mjs +108 -0
  18. package/js/hosts/hooks.mjs +50 -977
  19. package/js/hosts/hosts.mjs +12 -4
  20. package/js/hosts/installs.mjs +5 -6
  21. package/js/hosts/mcp.mjs +12 -48
  22. package/js/hosts/memory-files.mjs +181 -0
  23. package/js/hosts/settings.mjs +135 -414
  24. package/js/hosts/shim.mjs +373 -0
  25. package/js/inspect/checks-authoring.mjs +21 -828
  26. package/js/inspect/checks-build.mjs +4 -4
  27. package/js/inspect/checks-constitution.mjs +493 -0
  28. package/js/inspect/checks-counts.mjs +242 -0
  29. package/js/inspect/checks-repo.mjs +17 -8
  30. package/js/inspect/diff.mjs +4 -1
  31. package/js/inspect/doctor.mjs +2 -2
  32. package/js/inspect/inventory.mjs +4 -4
  33. package/js/inspect/scan.mjs +1 -1
  34. package/js/inspect/status.mjs +1 -1
  35. package/js/inspect/validate.mjs +48 -35
  36. package/js/loop/catalog.mjs +5 -3
  37. package/js/loop/cli.mjs +26 -12
  38. package/js/loop/graph.mjs +1 -1
  39. package/js/loop/registry.mjs +10 -15
  40. package/js/loop/score.mjs +1 -1
  41. package/js/loop/state.mjs +35 -11
  42. package/js/maintain/memory.mjs +1 -1
  43. package/js/maintain/migrate.mjs +18 -30
  44. package/js/maintain/uninstall.mjs +47 -88
  45. package/js/ui/cli.mjs +12 -12
  46. package/js/web/actions.mjs +29 -19
  47. package/js/web/api.mjs +21 -21
  48. package/js/web/docs.mjs +29 -72
  49. package/package.json +1 -1
  50. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  51. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  52. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  53. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  54. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  55. package/web/dist/assets/GreenhouseView-6KDjX8da.js +2 -0
  56. package/web/dist/assets/Installs-MWuhNjSm.js +2 -0
  57. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  58. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  59. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  60. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  61. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  62. package/web/dist/assets/OperatorHudView-BPP1dtLz.js +2 -0
  63. package/web/dist/assets/Personal-26VPYkK0.js +1 -0
  64. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  65. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  66. package/web/dist/assets/{drawable-C_x5xncq.js → drawable-zVVDbsyw.js} +1 -1
  67. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  68. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  69. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  70. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  71. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  72. package/web/dist/index.html +4 -5
  73. package/web/src/pages/Laws.jsx +0 -1
  74. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  75. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  76. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  77. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  78. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  79. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  80. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  81. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  82. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  83. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  84. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  85. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  86. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  87. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  88. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  89. package/web/dist/assets/index-CMPgezFm.js +0 -2
  90. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  91. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  92. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  93. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -17,7 +17,7 @@ import { PALETTE_ROLES, colorThemeFiles } from '../hosts/opencode.mjs';
17
17
  import { readText, withDiscardableStderr } from '../lib/fs.mjs';
18
18
  import { parseJson, formatRepr } from '../lib/json.mjs';
19
19
  import {
20
- ABS_LINK_RE, LINK_RE, TOKEN_RE, has, isDir, isFile, rglob, stemOf, stripCode, within,
20
+ ABS_LINK_RE, LINK_RE, TOKEN_RE, has, isDir, isFile, rglob, sortedUnique, stemOf, stripCode, within,
21
21
  } from './scan.mjs';
22
22
  import { existsSync, readFileSync } from 'node:fs';
23
23
 
@@ -75,9 +75,9 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
75
75
  const rel = path.relative(outAbs, md);
76
76
  if (isVendored(rel)) continue;
77
77
  const text = readText(md);
78
- // `set(TOKEN_RE.findall(text))` — Python iterates the set in ITS order, and the whole
79
- // list is sorted by `_doctor_collect` before printing, so only uniqueness travels.
80
- for (const tok of new Set(text.match(TOKEN_RE) ?? [])) {
78
+ // Sorted per file: `doctorCollect` re-sorts everything anyway, but `validate` prints these
79
+ // in the order they come back.
80
+ for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
81
81
  problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
82
82
  }
83
83
  for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
@@ -0,0 +1,493 @@
1
+ /**
2
+ * The CONSTITUTION gates — whether the laws and doctrine packs agree with themselves and with
3
+ * everything keyed off them: rule declarations and citations, the LEAN blocks, the hook-pinned
4
+ * addresses, the theme vocabulary, and the web console's per-rule Principle copy.
5
+ *
6
+ * Split out of `checks-authoring.mjs`, which still aggregates these (and re-exports them for
7
+ * its existing importers). Same contract as every check module here: an array of problem
8
+ * strings, empty when clean.
9
+ */
10
+ import path from 'node:path';
11
+ import {
12
+ CONFIG, PACK_ORDER, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles, ruleCanon, titleKey,
13
+ } from '../build/source.mjs';
14
+ import { LEAN_BLOCK_RE, splitAtLawHeadings } from '../build/render.mjs';
15
+ import { themeFiles } from '../hosts/installs.mjs';
16
+ import { readText } from '../lib/fs.mjs';
17
+ import { parseJson, formatRepr } from '../lib/json.mjs';
18
+ import { NOTE, isFile, rglob } from './scan.mjs';
19
+
20
+ /**
21
+ * The body of `const <name> = { … }` in the web Laws page — the one file out of `web/src` the
22
+ * doctor reads. Answers `[body, null]`, or `[null, problem]` when the page is unreadable or the
23
+ * literal is gone (`missing` is that problem's text).
24
+ */
25
+ function lawsPageLiteral(name, missing) {
26
+ let text;
27
+ try { text = readText(path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx')); } catch (e) {
28
+ return [null, `[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
29
+ }
30
+ const block = new RegExp(`^const ${name} = \\{$([\\s\\S]*?)^\\}$`, 'm').exec(text);
31
+ return block ? [block[1], null] : [null, missing];
32
+ }
33
+
34
+ /**
35
+ * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>', '<id>'],` in the web page.
36
+ *
37
+ * The third field PINS the row to a rule id. The key is the number the catalogue renders, and a
38
+ * number is only a position: remove a law and every later row describes its predecessor's
39
+ * neighbour, with every class still plausible. The pin is what makes that a doctor failure.
40
+ *
41
+ * Either quote style, because a principle carrying an apostrophe is double-quoted. Every
42
+ * `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
43
+ * reflowed across several lines still matches — a gate that only understood the one-line form
44
+ * silently lost it and left the law's Principle unguarded.
45
+ *
46
+ * `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
47
+ * newline (Python has no `re.S` here), which is JS's default.
48
+ */
49
+ const LAW_META_ROW =
50
+ /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*(?:,\s*(['"])(.*?)\6\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
51
+
52
+ /**
53
+ * `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
54
+ *
55
+ * `LAW_META` holds each law's one-line Principle, copy that exists nowhere else in the tree,
56
+ * and a law with no row falls back to `['craft', '']`: the rule renders with a blank
57
+ * description and the wrong class chip, while everything upstream is fine. That is exactly
58
+ * how two laws shipped description-less.
59
+ */
60
+ export function lawMetaProblems(lawRules, lawClass, lawClasses) {
61
+ const [block, refusal] = lawsPageLiteral('LAW_META', '[authoring] LAW_META literal not found '
62
+ + "in web/src/pages/Laws.jsx — the web Laws ledger's Principle column would have no gate");
63
+ if (refusal) return [refusal];
64
+ const meta = new Map();
65
+ for (const m of block.matchAll(LAW_META_ROW)) {
66
+ meta.set(Number(m[1]), [m[3], m[5], m[7]]);
67
+ }
68
+ const problems = [];
69
+ const seen = new Set();
70
+ for (const { n, label: roman, id } of lawRules) {
71
+ seen.add(n);
72
+ if (!meta.has(n)) {
73
+ problems.push(`[authoring] laws/universal.md rule ${roman} (${n}) has no row in `
74
+ + 'LAW_META (web/src/pages/Laws.jsx) — the web Laws ledger would '
75
+ + 'render it with a blank Principle');
76
+ continue;
77
+ }
78
+ const [klass, principle, pin] = meta.get(n);
79
+ if (pin !== id) {
80
+ problems.push(`[authoring] LAW_META[${n}] is pinned to '${pin ?? '(no id)'}' but rule ${roman} is `
81
+ + `'${id}' — the rows no longer follow the canon's order, so this principle `
82
+ + 'describes a different law');
83
+ }
84
+ if (!principle.trim()) {
85
+ problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
86
+ + `${roman} would render with a blank description`);
87
+ }
88
+ if (!lawClasses.includes(klass)) {
89
+ problems.push(`[authoring] LAW_META[${n}] class '${klass}' is not a known class `
90
+ + `${formatRepr(lawClasses)}`);
91
+ } else if (lawClass[roman] && klass !== lawClass[roman]) {
92
+ problems.push(`[authoring] LAW_META[${n}] classes rule ${roman} as '${klass}' but `
93
+ + `LAW_CLASS says '${lawClass[roman]}'`);
94
+ }
95
+ }
96
+ for (const n of [...meta.keys()].filter((k) => !seen.has(k)).sort((a, b) => a - b)) {
97
+ problems.push(`[authoring] LAW_META lists rule ${n} but laws/universal.md has no such rule`);
98
+ }
99
+ return problems;
100
+ }
101
+
102
+ /** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>', '<id>'],`. */
103
+ const DOCTRINE_META_ROW =
104
+ /^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*(?:,\s*(['"])(.*?)\7\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
105
+
106
+ /**
107
+ * `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
108
+ *
109
+ * SAME DEFECT, ONE TIER OVER. `DOCTRINE_META` holds copy that exists nowhere else in the tree,
110
+ * and a rule with no row falls back to an empty principle: it renders with a blank description
111
+ * while every count upstream reads correct. Two invariants shipped exactly that way before
112
+ * `lawMetaProblems` existed, and 23 new rules is a much larger surface to lose one in.
113
+ *
114
+ * ⚠ THE CLASS ARM IS DIFFERENT FROM THE INVARIANTS'. An invariant's class is checked against
115
+ * `LAW_CLASSES`, a vocabulary of six; a doctrine rule has no second taxonomy — its class IS its
116
+ * pack — so the check is an EQUALITY against the pack in its own key. That catches the copy
117
+ * error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
118
+ * the pack it came from, which would colour the row and label its chip with the wrong pack.
119
+ */
120
+ export function doctrineMetaProblems(addrs, ids = null) {
121
+ const [block, refusal] = lawsPageLiteral('DOCTRINE_META', '[authoring] DOCTRINE_META literal not '
122
+ + "found in web/src/pages/Laws.jsx — the console's doctrine rows would have no gate on their "
123
+ + 'Principle column');
124
+ if (refusal) return [refusal];
125
+ const meta = new Map();
126
+ for (const m of block.matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6], m[8]]);
127
+ const problems = [];
128
+ const seen = new Set();
129
+ for (const [i, addr] of addrs.entries()) {
130
+ seen.add(addr);
131
+ if (!meta.has(addr)) {
132
+ problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
133
+ + '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
134
+ continue;
135
+ }
136
+ const [klass, principle, pin] = meta.get(addr);
137
+ if (ids && pin !== ids[i]) {
138
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] is pinned to '${pin ?? '(no id)'}' but `
139
+ + `that address is '${ids[i]}' now — the row describes a different rule`);
140
+ }
141
+ if (!principle.trim()) {
142
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
143
+ + 'rule would render with a blank description');
144
+ }
145
+ const pack = addr.split('.')[0];
146
+ if (klass !== pack) {
147
+ problems.push(`[authoring] DOCTRINE_META['${addr}'] carries pack '${klass}' — a doctrine `
148
+ + `rule's class IS its pack, so this must read '${pack}'`);
149
+ }
150
+ }
151
+ for (const addr of [...meta.keys()].filter((k) => !seen.has(k)).sort()) {
152
+ problems.push(`[authoring] DOCTRINE_META lists ${addr} but no pack file defines it`);
153
+ }
154
+ return problems;
155
+ }
156
+
157
+ // A rule is DECLARED by its heading and CITED by its id — see `ruleCanon` in
158
+ // `js/build/source.mjs`. The gates below read the RAW source, because the render resolves ids and
159
+ // a bad one would already be gone from what it hands back.
160
+ const DECL_LINE_RE = /^### \{\{(LAW|DOCTRINE):([^}]*)\}\}[ \t]+\S/;
161
+ const ID_CITE_RE = /\{\{(LAW|DOCTRINE):([^}]*)\}\}/g;
162
+ // The pre-id spellings. Either one in src/ is a citation that names a POSITION, which is exactly
163
+ // what stops being true the next time the canon moves.
164
+ const BARE_CITE_RE = /\{\{LAW\}\}\s+[IVXLCDM]+\b|\{\{DOCTRINE\}\}\s+[a-z]+\s+\d+\b/g;
165
+ // `{{DOCTRINE}}` or `{{DOCTRINE:` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is
166
+ // legal everywhere, including in an invariant that points at the tier as a whole.
167
+ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
168
+
169
+ /**
170
+ * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
171
+ * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
172
+ * (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
173
+ * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
174
+ * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
175
+ * doctor failure instead of a gate silently guarding its neighbour.
176
+ */
177
+ export const HOOK_PINNED = {
178
+ 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
179
+ 'persist-insight': 'process 1', 'consent-before-push': 'process 5',
180
+ };
181
+
182
+ /**
183
+ * Every authored rule carries exactly one LEAN block, and both halves say something.
184
+ *
185
+ * ⚠ `resolveLean` IS A REPLACE OVER A MARKER PAIR, WHICH MAKES IT A NO-OP ON MARKERLESS TEXT.
186
+ * A rule whose block was never written, or lost to an edit, therefore ships its FULL body at
187
+ * the default footprint — silently, in every install, with every other gate in the tree green.
188
+ * That is the failure this arm exists for, and before it there was no LEAN gate anywhere in
189
+ * `js/inspect/`: not for the doctrine packs, and not for the invariants either.
190
+ *
191
+ * The other two shapes are cheaper to state than to debug. An empty lean half renders a
192
+ * heading over nothing at the footprint most installs use. Two blocks in one rule are
193
+ * ambiguous by construction — `LEAN_BLOCK_RE` is global, so both would resolve, and which
194
+ * text the reader gets would be a fact about the order they were written in.
195
+ *
196
+ * The ontology is exempt on purpose: it carries ONE file-wide block whose halves each repeat
197
+ * the four `####` section headings, which is a different shape with its own gate in
198
+ * `tests/unit/emit_smoke.test.mjs` (four distinct names, eight occurrences).
199
+ */
200
+ export function leanBlockProblems() {
201
+ const problems = [];
202
+ const files = [['laws', path.join(SRC, 'laws', 'universal.md')]];
203
+ for (const pack of PACK_ORDER) files.push(['doctrines', path.join(SRC, 'doctrines', `${pack}.md`)]);
204
+
205
+ for (const [dir, file] of files) {
206
+ const rel = `${dir}/${path.basename(file)}`;
207
+ let text;
208
+ try { text = readText(file); } catch (e) {
209
+ problems.push(`[authoring] ${rel} unreadable: ${e.message} — every rule in it would ship `
210
+ + 'its full body at the lean footprint, unannounced');
211
+ continue;
212
+ }
213
+ // `.slice(1)` drops the lead chunk — the file's preamble, which declares no rule.
214
+ for (const block of splitAtLawHeadings(text).slice(1)) {
215
+ // The declaration as the SOURCE spells it — `{{LAW:<id>}} <Name>` — because that is the
216
+ // string an author greps for, and the rendered spelling is fourteen strings.
217
+ const heading = block.slice(0, block.indexOf('\n') === -1 ? undefined : block.indexOf('\n'));
218
+ const addr = heading.replace(/^###\s+/, '').split(/\s+[—-]\s+/)[0].trim();
219
+ const found = [...block.matchAll(LEAN_BLOCK_RE)];
220
+ if (found.length === 0) {
221
+ problems.push(`[authoring] ${rel}: ${addr} has no LEAN block — resolveLean is a no-op on `
222
+ + 'markerless text, so the rule would inline its FULL body at the default footprint');
223
+ continue;
224
+ }
225
+ if (found.length > 1) {
226
+ problems.push(`[authoring] ${rel}: ${addr} has ${found.length} LEAN blocks — both resolve, `
227
+ + 'so which text a reader is handed is a fact about the order they were written in');
228
+ }
229
+ const [, full, lean] = found[0];
230
+ if (!full.trim()) {
231
+ problems.push(`[authoring] ${rel}: ${addr} has an empty full half — the rule would vanish `
232
+ + 'from a full build and from its own on-disk file');
233
+ }
234
+ if (!lean.trim()) {
235
+ problems.push(`[authoring] ${rel}: ${addr} has an empty lean half — the rule would render `
236
+ + 'as a heading over nothing at the footprint most installs use');
237
+ }
238
+ }
239
+ }
240
+ return problems;
241
+ }
242
+
243
+ /**
244
+ * The three-tier constitution's own authoring gates — the doctrine twin of the `LAW_CLASS` /
245
+ * `LAW_META` family, plus the two purity rules the tiering rests on.
246
+ *
247
+ * IT LIVES BESIDE `countTableProblems` RATHER THAN INSIDE IT, and is called from
248
+ * `authoringProblems` beside `registry`/`secret`/`vendorPin`. `countTableProblems` opens by
249
+ * reading `AGENT.md.tmpl` and HARD-RETURNS if that read fails, so an arm placed inside it
250
+ * would go quiet on exactly the tree that is most broken. Nothing here needs the template's
251
+ * tables, and `proseMirrorProblems` — the frozen recording — is untouched either way.
252
+ *
253
+ * ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
254
+ * presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
255
+ * fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
256
+ * `DOC_*` coverage, the `LEX_*` equality and the single-word tier noun each need a gate
257
+ * that reads the SOURCE and asks what the source requires.
258
+ */
259
+ export function constitutionProblems() {
260
+ const problems = [];
261
+
262
+ // ---- every rule declares an id, every id is well-formed and unique, none is retired.
263
+ //
264
+ // Numbers are positional now (`ruleCanon`), so contiguity and filing cannot go wrong; what can
265
+ // is the declaration itself. A `### ` line in a canon file that is not `{{KIND:id}} Name`
266
+ // renders nowhere as a rule — no number, no title — and a duplicated id makes every citation
267
+ // of it ambiguous.
268
+ for (const [kind, pack, file] of canonFiles(SRC)) {
269
+ const rel = path.relative(SRC, file).split(path.sep).join('/');
270
+ let text;
271
+ try { text = readText(file); } catch (e) {
272
+ problems.push(`[authoring] ${rel} is ${pack ? 'named in PACK_ORDER' : 'the canon'} but `
273
+ + `unreadable: ${e.message} — every citation into it breaks`);
274
+ continue;
275
+ }
276
+ let count = 0;
277
+ for (const line of text.split('\n').filter((l) => l.startsWith('### '))) {
278
+ const m = DECL_LINE_RE.exec(line);
279
+ if (!m || m[1] !== kind) {
280
+ problems.push(`[authoring] ${rel}: '${line}' is not a rule declaration — a rule heading `
281
+ + `here reads '### {{${kind}:<id>}} <Principle Name>'; without an id it has no number, `
282
+ + 'no title and no way to be cited');
283
+ continue;
284
+ }
285
+ count += 1;
286
+ if (!RULE_ID_RE.test(m[2])) {
287
+ problems.push(`[authoring] ${rel}: rule id '${m[2]}' is not kebab-case (a-z, 0-9, '-')`);
288
+ }
289
+ }
290
+ if (!count) {
291
+ const why = pack ? 'the pack would render as an empty section' : 'there would be no invariants';
292
+ problems.push(`[authoring] ${rel} declares no rule — ${why}`);
293
+ }
294
+ }
295
+ const canon = ruleCanon(SRC);
296
+ const seenIds = new Map();
297
+ const seenNames = new Map();
298
+ for (const r of canon.rules) {
299
+ const where = r.kind === 'LAW' ? `law ${r.label}` : `doctrine ${r.label}`;
300
+ if (seenIds.has(r.id)) {
301
+ problems.push(`[authoring] rule id '${r.id}' is declared twice (${seenIds.get(r.id)} and `
302
+ + `${where}) — ids are unique across laws and doctrines, or a citation names two rules`);
303
+ } else seenIds.set(r.id, where);
304
+ const lower = r.name.toLowerCase();
305
+ if (seenNames.has(lower)) {
306
+ problems.push(`[authoring] principle name '${r.name}' is declared twice (${seenNames.get(lower)} `
307
+ + `and ${where}) — a citation renders as the name, so two rules would read as one`);
308
+ } else seenNames.set(lower, where);
309
+ const retiredAs = RETIRED_RULE_IDS[r.id];
310
+ if (retiredAs && retiredAs.liveAs !== r.kind) {
311
+ problems.push(`[authoring] ${where} reuses the retired id '${r.id}' (${retiredAs.was}, `
312
+ + `${retiredAs.note}) — an id that meant one rule in someone's notes must never come back `
313
+ + 'meaning another; pick a new one');
314
+ }
315
+ }
316
+ const rules = new Map(canon.rules.filter((r) => r.kind === 'DOCTRINE')
317
+ .map((r) => [`${r.pack}.${r.n}`, r]));
318
+
319
+ // ---- the hook-gated rules are still at the address the hooks key them by.
320
+ for (const [id, label] of Object.entries(HOOK_PINNED)) {
321
+ const r = canon.byId.get(id);
322
+ if (!r || r.label !== label) {
323
+ problems.push(`[authoring] the hooks gate '${id}' as ${label} (js/hosts/hooks.mjs ledger keys, `
324
+ + `settings.mjs CONSENT_RULE, the console's HOOK_GATED), but the canon ${r ? `numbers it ${r.label}`
325
+ : 'no longer declares it'} — move those keys with it, or the tool boundary guards a `
326
+ + 'different rule than the prompt names');
327
+ }
328
+ }
329
+
330
+ // The console's Principle column, keyed off the rules just parsed — so the gate is driven by
331
+ // the SOURCE and a rule added to a pack file is caught the same day, not when someone
332
+ // notices a blank description in the browser.
333
+ problems.push(...doctrineMetaProblems([...rules.keys()], [...rules.values()].map((r) => r.id)));
334
+
335
+ // ---- every citation in src/ is by id, and every id resolves.
336
+ //
337
+ // The whole tree, not the template: a skill footer citing a rule that does not exist is as
338
+ // broken as a template one, and it renders into every bundle.
339
+ const cited = new Map(); // 'process.5' -> [rel, …]
340
+ for (const p of rglob(SRC)) {
341
+ if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
342
+ let text;
343
+ try { text = readText(p); } catch { continue; }
344
+ const rel = path.relative(SRC, p).split(path.sep).join('/');
345
+ // ⚠ AN ALWAYS-ON TIER MAY NEVER CITE A TOGGLEABLE ONE (D2). A `--doctrines craft` build
346
+ // ships invariants that point at rules its AGENT.md does not contain; the ontology is
347
+ // worse still, being the tier that states the order the others sit in. Doctrine->doctrine
348
+ // citations are fine and stay: the full catalogue ships in every bundle.
349
+ if ((rel.startsWith('ontology/') || rel.startsWith('laws/')) && DOCTRINE_TOKEN_RE.test(text)) {
350
+ problems.push(`[authoring] ${rel} cites {{DOCTRINE}} — an always-on tier may never `
351
+ + 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
352
+ + 'that is not there. State the point directly instead');
353
+ }
354
+ for (const [bare] of text.matchAll(BARE_CITE_RE)) {
355
+ problems.push(`[authoring] ${rel} cites '${bare.replace(/\s+/g, ' ')}' by number — numbers are `
356
+ + 'positional and move when the canon does; cite {{LAW:<id>}} / {{DOCTRINE:<id>}}');
357
+ }
358
+ for (const [whole, kind, id] of text.matchAll(ID_CITE_RE)) {
359
+ const r = canon.byId.get(id);
360
+ if (r && r.kind === kind) {
361
+ if (kind === 'DOCTRINE') {
362
+ const key = `${r.pack}.${r.n}`;
363
+ if (!cited.has(key)) cited.set(key, []);
364
+ cited.get(key).push(rel);
365
+ }
366
+ continue;
367
+ }
368
+ const retiredAs = RETIRED_RULE_IDS[id];
369
+ let why = 'which no law or doctrine declares — it would render as the raw token';
370
+ if (r) why = `but '${id}' is a ${r.kind === 'LAW' ? 'law' : 'doctrine rule'} — use {{${r.kind}:${id}}}`;
371
+ else if (retiredAs) {
372
+ why = `a retired id (${retiredAs.was}, ${retiredAs.note}) — cite the rule its text now lives in`;
373
+ }
374
+ problems.push(`[authoring] ${rel} cites ${whole}, ${why}`);
375
+ }
376
+ }
377
+
378
+ // ---- the vocabulary every theme owes the source: one title per declared rule, keyed by id.
379
+ const wanted = new Set(canon.rules.filter((r) => r.kind === 'DOCTRINE').map((r) => titleKey(r.kind, r.id)));
380
+ const lexWant = canon.rules.filter((r) => r.kind === 'LAW').map((r) => titleKey(r.kind, r.id));
381
+ // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
382
+ // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
383
+ const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
384
+ for (const f of files) {
385
+ const name = path.basename(f);
386
+ let theme;
387
+ try { theme = parseJson(readText(f)); } catch (e) {
388
+ problems.push(`[authoring] ${name} unreadable: ${e.message}`);
389
+ continue;
390
+ }
391
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) continue;
392
+ const keys = Object.keys(theme);
393
+ for (const key of [...wanted].sort()) {
394
+ if (!Object.hasOwn(theme, key)) {
395
+ problems.push(`[authoring] ${name} has no {${key}} — a doctrine rule declares it, and `
396
+ + 'theme parity cannot see a key that is missing from every theme at once');
397
+ }
398
+ }
399
+ for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
400
+ if (!wanted.has(key)) {
401
+ problems.push(`[authoring] ${name} defines {${key}}, which no doctrine rule declares — a `
402
+ + 'dead voice key ships a title for a rule that does not exist');
403
+ }
404
+ }
405
+ // I1 — an EQUALITY, not a presence check. The renumber left `LEX_XXII`, `LEX_XXIII`,
406
+ // `LEX_XXIV` and `LEX_XXXVI` behind in all fifteen files, and parity was silent because
407
+ // they were absent from nowhere.
408
+ const lex = keys.filter((k) => k.startsWith('LEX_')).sort();
409
+ const stale = lex.filter((k) => !lexWant.includes(k));
410
+ const missing = lexWant.filter((k) => !lex.includes(k));
411
+ for (const k of stale) {
412
+ problems.push(`[authoring] ${name} still defines {${k}} — no law declares this id and `
413
+ + 'nothing renders this key, so it is a title for a rule that no longer exists');
414
+ }
415
+ for (const k of missing) {
416
+ problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
417
+ + 'render with an empty title');
418
+ }
419
+ // M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
420
+ // error; the heading stops matching and the tier silently parses to nothing. `_TEMPLATE`'s
421
+ // values are descriptive placeholders, so only real voices are held to this.
422
+ if (name === '_TEMPLATE.json') continue;
423
+ for (const key of ['LAW', 'DOCTRINE']) {
424
+ const v = theme[key];
425
+ if (typeof v === 'string' && /\s/.test(v)) {
426
+ problems.push(`[authoring] ${name} spells {${key}} as ${formatRepr(v)} — the heading `
427
+ + 'parsers match the tier noun with \\S+, so a value with a space makes every '
428
+ + `${key === 'LAW' ? 'invariant' : 'doctrine rule'} parse to nothing, in silence`);
429
+ }
430
+ }
431
+ }
432
+
433
+ // ---- every `§N` in src/ addresses a section the template actually declares.
434
+ //
435
+ // ⚠ THIS GATE IS NARROW AND SAYS SO. Inserting `## 2. Doctrines` pushed every later section
436
+ // down one, and the sweep that fixed the template's own cross-references stopped at the
437
+ // template — four satellites under `src/skills/` and `src/agents/` kept pointing at the
438
+ // section that used to be there, and they RENDER INTO EVERY BUNDLE. A range check would not
439
+ // have caught those: §7 still existed, it had just stopped being the Wiki.
440
+ //
441
+ // What it does catch is the other half of a renumber — a pointer past the end, which is what
442
+ // REMOVING a section leaves behind — and it is nearly free. The half it cannot catch is a
443
+ // judgement about intent, and `docs/extending.md` carries the manual sweep for it. Naming
444
+ // that limit here is the point: a reader who assumes this gate is total will skip the sweep.
445
+ const declared = new Set();
446
+ // Read here rather than borrowing `countTableProblems`' copy: that function hard-returns when
447
+ // the template is unreadable, and this gate must not inherit a silence it did not choose.
448
+ let tmpl = '';
449
+ try { tmpl = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch { tmpl = ''; }
450
+ for (const [, n] of tmpl.matchAll(/^## (\d+)\.\s/gm)) declared.add(Number(n));
451
+ if (declared.size) {
452
+ for (const p of rglob(SRC)) {
453
+ if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
454
+ let text;
455
+ try { text = readText(p); } catch { continue; }
456
+ const rel = path.relative(SRC, p).split(path.sep).join('/');
457
+ for (const [, n] of text.matchAll(/§(\d+)/g)) {
458
+ if (declared.has(Number(n))) continue;
459
+ problems.push(`[authoring] ${rel} cites AGENT.md §${n}, and the template declares no `
460
+ + `such section ${formatRepr([...declared].sort((a, b) => a - b).map(String))} — a `
461
+ + 'renumber moved it, and this pointer renders into every bundle');
462
+ }
463
+ }
464
+ }
465
+
466
+ // ---- the build default names packs that exist.
467
+ let cfgDoctrines = null;
468
+ try {
469
+ const cfg = parseJson(readText(CONFIG));
470
+ if (cfg && typeof cfg === 'object' && Array.isArray(cfg.doctrines)) cfgDoctrines = cfg.doctrines;
471
+ } catch { /* no config, or unreadable — `configDefaults()` is forgiving here too */ }
472
+ if (cfgDoctrines) {
473
+ for (const pack of cfgDoctrines) {
474
+ if (!PACK_ORDER.includes(pack)) {
475
+ problems.push(`[authoring] harness.config.json names doctrine pack ${formatRepr(pack)}, `
476
+ + `which this checkout does not ship ${formatRepr([...PACK_ORDER])} — every build reading `
477
+ + 'that default would fail');
478
+ }
479
+ }
480
+ }
481
+
482
+ // ---- D5, a NOTE and not a problem: what a narrowed default leaves pointing at the shelf.
483
+ const on = cfgDoctrines ?? [...PACK_ORDER];
484
+ const dangling = [...cited].filter(([k]) => rules.has(k) && !on.includes(k.split('.')[0]));
485
+ if (dangling.length) {
486
+ const packs = [...new Set(dangling.map(([k]) => k.split('.')[0]))].sort();
487
+ problems.push(`${NOTE}harness.config.json builds ${formatRepr(on)}, and ${dangling.length} `
488
+ + `citation(s) in src/ point into ${packs.join(', ')}. Those rules still SHIP — every `
489
+ + 'pack file is in the bundle — so the reference resolves on disk; it is the rendered '
490
+ + 'AGENT.md that will not contain them. Legal, and recorded here so it is not silent');
491
+ }
492
+ return problems;
493
+ }