geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The COUNT gates — the hand-written tables, badges and prose that restate how many laws,
3
+ * agents, skills, themes and plugins ship, held against `src/`.
4
+ *
5
+ * Split out of `checks-authoring.mjs`, which still aggregates these (and re-exports them for
6
+ * its existing importers). `proseMirrorProblems` is pure over its inputs; `countTableProblems`
7
+ * is the reader that feeds it.
8
+ */
9
+ import path from 'node:path';
10
+ import { DOCS, DOC_FOLDERS, PLUGIN_SRC, ROOT, SRC, ruleCanon } from '../build/source.mjs';
11
+ import { themeFiles } from '../hosts/installs.mjs';
12
+ import { readText } from '../lib/fs.mjs';
13
+ import { formatRepr } from '../lib/json.mjs';
14
+ import { lawMetaProblems } from './checks-constitution.mjs';
15
+ import { LAW_CLASS, LAW_CLASSES, SKILL_CLASS } from './inventory.mjs';
16
+ import { globSorted, has, isDir, isFile, srcStems } from './scan.mjs';
17
+ import { existsSync, readdirSync } from 'node:fs';
18
+
19
+ /**
20
+ * `_harness_build._prose_mirror_problems` — the human-readable count mirrors, the ones the
21
+ * badge regex never sees.
22
+ *
23
+ * The README "What you get" table and the web onboarding copy each restate the law / agent /
24
+ * skill counts in prose, and the README enumerates the skills by name. Nothing renders these,
25
+ * so they drift silently — they already had. Pure over its inputs, so the corpus can feed it
26
+ * crafted drift without touching the tree.
27
+ */
28
+ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '') {
29
+ const problems = [];
30
+ const { laws, agents, skills, plugins } = counts;
31
+
32
+ for (const m of readme.matchAll(/(\d+) universal laws/g)) {
33
+ if (Number(m[1]) !== laws) {
34
+ problems.push(`[authoring] README prose says '${m[1]} universal laws' but src has ${laws}`);
35
+ }
36
+ }
37
+ // The README plugin count. It had drifted — the overview sentence said six while seven
38
+ // shipped — because the project's other plugin count ({N_PLUGINS} in the web docs) reads
39
+ // a DEPLOYED plugins dir that a markdown file in this repository never has. `plugins` is
40
+ // optional in `counts` (Python's `.get`, undefined here) because the corpus predates it.
41
+ if (plugins !== undefined) {
42
+ for (const m of readme.matchAll(/(\d+) \*{0,2}plugins/g)) {
43
+ if (Number(m[1]) !== plugins) {
44
+ problems.push(`[authoring] README prose says '${m[1]} plugins' but `
45
+ + `adapters/opencode/plugins has ${plugins}`);
46
+ }
47
+ }
48
+ }
49
+ for (const [label, want] of [['Agents', agents], ['Skills', skills]]) {
50
+ const m = new RegExp(`${label}\\*\\*\\s*\\((\\d+)\\)`).exec(readme);
51
+ if (m && Number(m[1]) !== want) {
52
+ problems.push(`[authoring] README '${label} (${m[1]})' but src has ${want}`);
53
+ }
54
+ }
55
+ // The README Skills row must enumerate EXACTLY the source skills — a dropped name is
56
+ // invisible to the (N) count alone, which is the off-by-one this gate forbids.
57
+ const row = readme.split('\n').find((ln) => ln.includes('🛠') && ln.includes('Skills')) ?? '';
58
+ if (row.includes('workflows:')) {
59
+ const listed = new Set(row.split('workflows:').slice(1).join('workflows:').split('·')
60
+ .map((seg) => seg.replace(/[^a-z0-9-]/g, '')));
61
+ listed.delete('');
62
+ for (const missing of [...skillStems].filter((s) => !listed.has(s)).sort()) {
63
+ problems.push(`[authoring] README skills list omits '${missing}'`);
64
+ }
65
+ for (const orphan of [...listed].filter((s) => !skillStems.has(s)).sort()) {
66
+ problems.push(`[authoring] README skills list names '${orphan}' but no `
67
+ + `skills/${orphan}.md exists`);
68
+ }
69
+ }
70
+
71
+ for (const m of web.matchAll(/(\d+) universal (?:laws|Rules)/g)) {
72
+ if (Number(m[1]) !== laws) {
73
+ problems.push(`[authoring] docs/ prose says '${m[1]} universal laws/Rules' but `
74
+ + `src has ${laws}`);
75
+ }
76
+ }
77
+ for (const m of web.matchAll(/(\d+) capability specialists/g)) {
78
+ if (Number(m[1]) !== agents) {
79
+ problems.push(`[authoring] docs/ prose says '${m[1]} capability specialists' but `
80
+ + `src has ${agents}`);
81
+ }
82
+ }
83
+ // N is a curated-subset size, not the total, so it is gated against the wikilinks it
84
+ // introduces rather than against the skill count. `[\s\S]` is Python's `re.S` dot.
85
+ const m = /(\d+) repeatable workflows the agent can invoke by name([\s\S]*?)playbook under/
86
+ .exec(web);
87
+ if (m) {
88
+ const listedN = (m[2].match(/\[\[[^\]]+\]\]/g) ?? []).length;
89
+ if (Number(m[1]) !== listedN) {
90
+ problems.push(`[authoring] docs/ says '${m[1]} repeatable workflows' `
91
+ + `but its wikilink list has ${listedN}`);
92
+ }
93
+ }
94
+
95
+ // ⚠ THE ABSENCE OF THIS TRIPLE IS CHECKED BY THE CALLER, NOT HERE. This arm is satisfiable by
96
+ // DELETING THE SENTENCE — no match, no problem, green — which is a gate failing open. The
97
+ // presence check lives in `countTableProblems`, beside the read that already knows whether
98
+ // the file could be opened at all; an empty `shipped` here means "nothing to compare".
99
+ const s = /(\d+) laws, (\d+) agents, (\d+) skills/.exec(shipped);
100
+ if (s) {
101
+ for (const [got, want, label] of [[s[1], laws, 'laws'], [s[2], agents, 'agents'],
102
+ [s[3], skills, 'skills']]) {
103
+ if (Number(got) !== want) {
104
+ problems.push(`[authoring] SHIPPED.md says '${got} ${label}' but src has ${want}`);
105
+ }
106
+ }
107
+ }
108
+ return problems;
109
+ }
110
+
111
+ /** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
112
+ const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
113
+
114
+ /**
115
+ * `_harness_build._count_table_problems` — the hand-authored AGENT.md tables and the README
116
+ * badges, against `src/`.
117
+ *
118
+ * The tables must list EXACTLY the spec files (no dead row, no orphaned spec) and each
119
+ * `agents`/`skills`/`laws`/`themes` badge must equal the real count. This is the
120
+ * authoring-time guarantee that lets tables, badges and prose stay hand-written without
121
+ * silently drifting from the source tree.
122
+ */
123
+ export function countTableProblems() {
124
+ const problems = [];
125
+ let ttext;
126
+ try { ttext = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch (e) {
127
+ return [`[authoring] AGENT.md.tmpl unreadable: ${e.message}`];
128
+ }
129
+
130
+ const linked = { agents: new Set(), skills: new Set() };
131
+ for (const m of ttext.matchAll(TMPL_SPEC_RE)) {
132
+ if (m[2] !== '_template') linked[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
133
+ }
134
+ for (const folder of ['agents', 'skills']) {
135
+ const files = srcStems(folder);
136
+ for (const missing of [...linked[folder]].filter((x) => !files.has(x)).sort()) {
137
+ problems.push(`[authoring] AGENT.md links ${folder}/${missing}.md but no such spec exists`);
138
+ }
139
+ for (const orphan of [...files].filter((x) => !linked[folder].has(x)).sort()) {
140
+ problems.push(`[authoring] ${folder}/${orphan}.md exists but the AGENT.md table omits it`);
141
+ }
142
+ }
143
+
144
+ const skillFiles = srcStems('skills');
145
+ for (const missing of [...skillFiles].filter((s) => !has(SKILL_CLASS, s)).sort()) {
146
+ problems.push(`[authoring] skills/${missing}.md has no category in SKILL_CLASS`);
147
+ }
148
+ for (const stale of Object.keys(SKILL_CLASS).filter((s) => !skillFiles.has(s)).sort()) {
149
+ problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
150
+ }
151
+
152
+ // The numerals the laws RENDER with — positional, off the canon — because LAW_CLASS and the
153
+ // console's LAW_META are keyed the way the rendered catalogue numbers them.
154
+ const lawRules = ruleCanon(SRC).rules.filter((r) => r.kind === 'LAW');
155
+ const lawNums = lawRules.map((r) => r.label);
156
+ for (const num of lawNums) {
157
+ if (!has(LAW_CLASS, num)) {
158
+ problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
159
+ }
160
+ }
161
+ // Sorted by the Roman numeral as a STRING; `doctorCollect` re-sorts every message anyway.
162
+ for (const num of Object.keys(LAW_CLASS).sort()) {
163
+ if (!LAW_CLASSES.includes(LAW_CLASS[num])) {
164
+ problems.push(`[authoring] LAW_CLASS['${num}'] = '${LAW_CLASS[num]}' is not a known `
165
+ + `class ${formatRepr(LAW_CLASSES)}`);
166
+ }
167
+ }
168
+ problems.push(...lawMetaProblems(lawRules, LAW_CLASS, LAW_CLASSES));
169
+
170
+ // WHAT SHIPS, NOT WHAT IS FLAT. A folder skill (`src/skills/<name>/SKILL.md`, vendored with
171
+ // its scripts or references) ships exactly like a flat one, so the badge, the README list and
172
+ // the SHIPPED.md triple count it. It is added HERE rather than in `srcStems`, whose other
173
+ // readers above — the AGENT.md table links and SKILL_CLASS — are about flat specs only.
174
+ const skillsDir = path.join(SRC, 'skills');
175
+ const shippedSkills = new Set([...skillFiles, ...(isDir(skillsDir) ? readdirSync(skillsDir) : [])
176
+ .filter((d) => !d.startsWith('_') && isFile(path.join(skillsDir, d, 'SKILL.md')))]);
177
+
178
+ const counts = {
179
+ agents: srcStems('agents').size,
180
+ skills: shippedSkills.size,
181
+ laws: lawNums.length,
182
+ themes: themeFiles().length,
183
+ plugins: existsSync(PLUGIN_SRC)
184
+ ? readdirSync(PLUGIN_SRC).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js')).length
185
+ : 0,
186
+ };
187
+ let readme;
188
+ try { readme = readText(path.join(ROOT, 'README.md')); } catch { return problems; }
189
+ // ⚠ PRESENCE FIRST, THEN THE VALUE — the same absence arm the SHIPPED triple needed, for the
190
+ // same reason: `if (m && …)` is green when there is no badge, so deleting a badge silences its
191
+ // own gate.
192
+ for (const [key, n] of Object.entries(counts)) {
193
+ const m = new RegExp(`badge/${key}-(\\d+)`).exec(readme);
194
+ if (!m) {
195
+ problems.push(`[authoring] README has no ${key} badge — deleting one is how this gate `
196
+ + 'goes green while the reader is told nothing');
197
+ continue;
198
+ }
199
+ if (Number(m[1]) !== n) {
200
+ problems.push(`[authoring] README ${key} badge says ${m[1]} but src has ${n}`);
201
+ }
202
+ }
203
+
204
+ // WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
205
+ // that held the web console's onboarding prose. That prose has since moved into
206
+ // `docs/<folder>/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
207
+ // — so the read was still succeeding against a file the sentences had left, all three arms
208
+ // below scored zero, and the check looked healthy while gating nothing. A templated count
209
+ // cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
210
+ // instead of the token, which is the drift that reaches a reader.
211
+ //
212
+ // `web` stays fail-soft — a missing docs tree is not an authoring fault.
213
+ let web = '';
214
+ try {
215
+ web = DOC_FOLDERS
216
+ .flatMap((f) => (isDir(path.join(DOCS, f))
217
+ ? globSorted(path.join(DOCS, f), (n) => n.endsWith('.md')) : []))
218
+ .map(readText).join('\n');
219
+ } catch { web = ''; }
220
+ // ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing docs tree
221
+ // is not an authoring fault — a checkout can legitimately lack it. SHIPPED.md is a tracked,
222
+ // shipped file whose CONTENT this function asserts, so an unreadable one was
223
+ // indistinguishable from a deleted claim: swallowing it into `''` handed the triple arm an
224
+ // empty string and turned a hard read failure into a silent pass.
225
+ //
226
+ // AND THE PRESENCE OF THE TRIPLE IS CHECKED HERE rather than inside `proseMirrorProblems`:
227
+ // this is the caller that already owns the question "could the file be read".
228
+ let shipped;
229
+ try {
230
+ shipped = readText(path.join(ROOT, 'SHIPPED.md'));
231
+ } catch (e) {
232
+ problems.push(`[authoring] SHIPPED.md is unreadable (${e.message}) — the counts it carries `
233
+ + 'cannot be checked, and a gate that cannot read its subject must say so');
234
+ shipped = '';
235
+ }
236
+ if (shipped && !/(\d+) laws, (\d+) agents, (\d+) skills/.test(shipped)) {
237
+ problems.push('[authoring] SHIPPED.md carries no \'N laws, N agents, N skills\' line — '
238
+ + 'deleting it is how that gate goes green while the count drifts');
239
+ }
240
+ problems.push(...proseMirrorProblems(readme, web, counts, shippedSkills, shipped));
241
+ return problems;
242
+ }
@@ -9,7 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import { PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC } from '../build/source.mjs';
11
11
  import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
12
- import { hookShimPath, shimDeadPaths } from '../hosts/settings.mjs';
12
+ import { hookShimPath, shimDeadPaths } from '../hosts/shim.mjs';
13
13
  import { readText } from '../lib/fs.mjs';
14
14
  import { formatRepr, formatValue } from '../lib/json.mjs';
15
15
  import { splitLines } from '../lib/udiff.mjs';
@@ -313,11 +313,20 @@ export function moduleMapProblems() {
313
313
  + 'checks-repo.mjs asserts it describes every module under js/'];
314
314
  }
315
315
 
316
- // A row is `| `name.mjs` | … |`. Reading the CODE SPAN and not the whole line keeps a module
317
- // named in a "Before editing" paragraph from counting as a row.
318
- const documented = new Set(
319
- [...text.matchAll(/^\|\s*`([a-z0-9-]+\.mjs)`\s*\|/gm)].map((m) => m[1]),
320
- );
316
+ // A row is `| `name.mjs` | … |`, and it documents `<folder>/name.mjs` for the folder whose
317
+ // `## `js/<folder>/`` heading it sits under. Keyed by basename alone, a row answered for every
318
+ // folder holding that name — catalog.mjs, cli.mjs and registry.mjs each live in two — so a
319
+ // deleted module stayed "documented" by its namesake's row. Reading the CODE SPAN and not the
320
+ // whole line keeps a module named in a "Before editing" paragraph from counting as a row.
321
+ const documented = new Set();
322
+ let folder = null;
323
+ for (const line of text.split('\n')) {
324
+ const heading = /^## `js\/([a-z0-9-]+)\/`/.exec(line);
325
+ if (heading) { folder = heading[1]; continue; }
326
+ if (line.startsWith('## ')) { folder = null; continue; }
327
+ const row = /^\|\s*`([a-z0-9-]+\.mjs)`\s*\|/.exec(line);
328
+ if (row && folder) documented.add(`${folder}/${row[1]}`);
329
+ }
321
330
 
322
331
  const onDisk = new Set();
323
332
  const jsDir = path.join(String(ROOT), 'js');
@@ -328,7 +337,7 @@ export function moduleMapProblems() {
328
337
  // that only knew `/` finds ONE part on Windows and therefore zero modules — which is what
329
338
  // the vacuity guard below caught the first time this ran.
330
339
  const parts = path.relative(jsDir, rel).replaceAll('\\', '/').split('/');
331
- if (parts.length === 2) onDisk.add(parts[1]);
340
+ if (parts.length === 2) onDisk.add(parts.join('/'));
332
341
  }
333
342
 
334
343
  if (onDisk.size < 30) {
@@ -344,7 +353,7 @@ export function moduleMapProblems() {
344
353
  }
345
354
  for (const mod of [...documented].sort()) {
346
355
  if (!onDisk.has(mod)) {
347
- problems.push(`[authoring] js/README.md has a row for ${mod}, which does not exist — the map `
356
+ problems.push(`[authoring] js/README.md has a row for js/${mod}, which does not exist — the map `
348
357
  + 'sends a reader to a file that is not there');
349
358
  }
350
359
  }
@@ -22,11 +22,10 @@
22
22
  * * **The diff algorithm.** A cell can produce ONE edited file and see one hunk. It cannot
23
23
  * produce the shapes where `difflib`'s choices differ from any other diff's — ties, the
24
24
  * recursion's alignment, and above all `autojunk`, which only engages at 200 lines and
25
- * changes the hunks on every real harness file. Gated as a corpus —
26
- * `tests/fixtures/pure_probe.mjs`'s `unified_diff` entry, replayed by
27
- * `tests/snapshot/pure_snapshot.test.mjs` against `tests/__snapshots__/primitives/` —
28
- * measured in both directions (28 of the 176 cases there disagree when `autojunk` is
29
- * switched off, so the corpus is not describing a constant).
25
+ * changes the hunks on every real harness file. The former Python suite gated it as a
26
+ * corpus, measured in both directions (28 of its 176 cases disagreed when `autojunk` was
27
+ * switched off). That corpus is gone and no Node test drives `unifiedDiff` directly, so
28
+ * beyond the one hunk the `diff/--full-…` cell sees, nothing pins these shapes today.
30
29
  * * **The timestamp.** `_write_improvements` names the file `improvements-%Y%m%d-%H%M%S.md`
31
30
  * and stamps the report with `datetime.now()`, so the two implementations cannot write the
32
31
  * same bytes across a second boundary — and a cell runs them in sequence, so the boundary
@@ -45,7 +44,7 @@ import {
45
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
46
45
  } from '../hosts/hosts.mjs';
47
46
  import {
48
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, modeOfDir, postureOfDir,
49
48
  trustOfDir,
50
49
  readJsonMaybe, readMaybe, themeOfDir,
51
50
  } from '../hosts/installs.mjs';
@@ -177,7 +176,10 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
177
176
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
178
177
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
178
  // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
- trust: trustOfDir(dir),
179
+ trust: trustOfDir(dir, host),
180
+ // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
181
+ // carrier as drift: `expected` would re-state the rule the deployment took away.
182
+ excludeRules: excludedRulesOfDir(dir),
181
183
  }));
182
184
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
183
185
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -32,7 +32,7 @@
32
32
  import path from 'node:path';
33
33
  import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
34
34
  import { ROOT } from '../build/source.mjs';
35
- import { resolvePath } from '../hosts/hosts.mjs';
35
+ import { CLAUDE_STYLE, resolvePath } from '../hosts/hosts.mjs';
36
36
  import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
37
37
  import { validateIsVendored } from '../hosts/native.mjs';
38
38
  import { printOut } from '../lib/fs.mjs';
@@ -143,7 +143,7 @@ export function globalEmitProblems(themeName) {
143
143
  * skills one level deeper than a bundle does.
144
144
  */
145
145
  export function claudeBobEmitProblems(themeName) {
146
- return emitProblemsOver(['claude', 'bob', 'openclaude'], (tmp, label) => {
146
+ return emitProblemsOver(CLAUDE_STYLE, (tmp, label) => {
147
147
  const root = path.join(tmp, 'root');
148
148
  return {
149
149
  label: `${themeName} ${label}`,
@@ -78,14 +78,19 @@ const canon = (p) => resolvePath(p);
78
78
  /**
79
79
  * `_same` — is this stored entry the same folder as `repo`?
80
80
  *
81
- * `toPlatformPath` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
82
- * `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
83
- * there. Getting that backwards makes `exclude remove` unwire a repo the reference left
84
- * alone.
81
+ * Resolved like `repo` itself (`canon`: `..` collapsed, symlinks followed), the one rule the
82
+ * hook's `sovereignBypass` and the OpenCode plugins apply too — so an entry that EXCLUDES a
83
+ * folder is exactly the entry `exclude remove` can take back. It used to compare lexically,
84
+ * mirroring the former Python `Path()`, which made a hand-edited `<repo>/../repo` an entry the
85
+ * plugins honoured, the hook ignored, and `remove` could not find.
85
86
  */
86
87
  function same(stored, repo) {
87
88
  if (typeof stored !== 'string' || !stored) return false;
88
- return normcase(toPlatformPath(stored)) === normcase(repo);
89
+ try {
90
+ return normcase(canon(stored)) === normcase(repo);
91
+ } catch {
92
+ return false; // a `~user` form `expanduser` refuses names no folder
93
+ }
89
94
  }
90
95
 
91
96
  /**
@@ -34,10 +34,10 @@ import { parseJson } from '../lib/json.mjs';
34
34
  * `_harness_tui.LAW_HEADING_RE` — a heading in the RENDERED laws file, e.g.
35
35
  * `### Rule IV — Deletion Is Deliberate`.
36
36
  *
37
- * Not `js/inspect/checks-authoring.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
38
- * unrendered source. Two patterns, two inputs, two names in Python as well.
37
+ * The rendered side only: the unrendered source declares a rule as `{{LAW:<id>}} <Name>`, and
38
+ * `ruleCanon` in `js/build/source.mjs` is what reads that.
39
39
  */
40
- export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
40
+ const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
41
41
 
42
42
  /**
43
43
  * A heading in a RENDERED doctrine pack, e.g. `### Doctrine craft 1 — Automate Repetition`.
@@ -52,7 +52,7 @@ export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
52
52
  * `([a-z]+)` refuses `Rule I`'s uppercase numeral, and `([IVXLCDM]+)` refuses a lowercase pack
53
53
  * id. Measured across all fourteen themes — zero cross-matches either way.
54
54
  */
55
- export const DOCTRINE_HEADING_RE = /^###\s+\S+\s+([a-z]+)\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
55
+ const DOCTRINE_HEADING_RE = /^###\s+\S+\s+([a-z]+)\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
56
56
 
57
57
  /**
58
58
  * A section heading in the RENDERED ontology, e.g. `#### Telos`.
@@ -36,7 +36,7 @@ const FENCE_RE = /```[\s\S]*?```/g;
36
36
  const INLINE_CODE_RE = /`[^`]*`/g;
37
37
  const COMMENT_RE = /<!--[\s\S]*?-->/g;
38
38
 
39
- /** `_harness_core.strip_code` — fenced blocks, inline code and HTML comments, in that order. */
39
+ /** `_harness_core.strip_code` — fenced blocks, then HTML comments, then inline code, in that order. */
40
40
  export function stripCode(text) {
41
41
  return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
42
42
  }
@@ -34,25 +34,26 @@
34
34
  * became the second and third. Nothing about them changed in the move — the 166-cell
35
35
  * acceptance matrix is what licensed attempting it, `status`'s own eleven cells among them.
36
36
  *
37
- * WHAT NO CELL CAN REACH, and where it is gated instead. `tests/harness_golden.py`'s
38
- * status/version section carries the long form; the short form is that ROOT is not
37
+ * WHAT NO CELL CAN REACH, and where it is gated instead. The former Python matrix's
38
+ * status/version section carried the long form; the short form is that ROOT is not
39
39
  * redirectable across a process boundary, so three things here are unreachable from any
40
40
  * cell: `versionVerdict`'s "up to date" (needs a marker holding a fingerprint no cell can
41
41
  * know), `accentFor`'s cyan fallback (an unknown theme is refused upstream by
42
42
  * `effectiveTheme`), and the whole ANSI half of `statusLines` (`_color_enabled` is
43
43
  * `sys.stdout.isatty()`, and every harness captures stdout through a pipe). All three are
44
- * PURE FUNCTIONS of their arguments, so all three are gated as a corpus instead:
45
- * `tests/fixtures/pure_probe.mjs` calls them and `tests/snapshot/pure_snapshot.test.mjs`
46
- * replays that against `tests/__snapshots__/primitives/`. That was a third answer to the
47
- * colour question, which had been posed as a choice between shipping it ungated and not
48
- * shipping it.
44
+ * PURE FUNCTIONS of their arguments, so the former Python suite gated all three as a corpus
45
+ * instead — a third answer to the colour question, which had been posed as a choice between
46
+ * shipping it ungated and not shipping it. That corpus is gone: `tests/unit/lifecycle.test.mjs`
47
+ * now calls `versionVerdict` and both halves of `statusLines` directly, and nothing pins
48
+ * `accentFor`'s fallback.
49
49
  *
50
50
  * `manifestIsClaude` was a FOURTH until wave 2 of the P0/P1 review: it is only reached for
51
51
  * a candidate with no known host, and `ROOT/"Harness"` was ordered ahead of the sandbox's
52
52
  * own. Both ROOT-relative bundle candidates are gone from this walk and from
53
53
  * `installedDefaults`, so the only no-known-host candidate left is `cwd/"Harness"`, which
54
54
  * is inside the sandbox. It is reachable now; no cell seeds a manifest without an emit
55
- * marker, so it is still gated only by the corpus, and that is a gap rather than a wall.
55
+ * marker, so it is gated only by the direct calls in `tests/unit/claude.test.mjs`, and that is
56
+ * a gap rather than a wall.
56
57
  */
57
58
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
58
59
  import path from 'node:path';
@@ -66,19 +67,15 @@ import {
66
67
  } from '../hosts/hosts.mjs';
67
68
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
68
69
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
69
- // three verbs now read them. `defaultTheme`, `manifestIsClaude` and `installedDefaults` are
70
- // re-exported below because `tests/fixtures/pure_probe.mjs` names this module for two of them
71
- // and a corpus that followed the code to its new file would stop testing the caller's view.
70
+ // three verbs now read them.
72
71
  import {
73
- defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
72
+ defaultTheme, installedDefaults, installTargets, readJsonMaybe, readMaybe,
74
73
  } from '../hosts/installs.mjs';
75
74
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
- import { shimDead } from '../hosts/settings.mjs';
75
+ import { shimDead } from '../hosts/shim.mjs';
77
76
  import { printOut } from '../lib/fs.mjs';
78
77
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
79
78
 
80
- export { defaultTheme, manifestIsClaude };
81
-
82
79
  // ---- version ---------------------------------------------------------------------------
83
80
 
84
81
  /** `_harness_status._version_verdict`. */
@@ -331,13 +328,13 @@ export function statusLines(d, color = false) {
331
328
  + `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
332
329
  ['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
333
330
  ];
334
- // ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
335
- // RE-BLESSED. `statusLines` has 30 recorded cases per platform in
336
- // `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
337
- // including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
338
- // Touching `components` moves all 30; adding a row would too, since the box width is
339
- // measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
340
- // carry, exactly as `agent_md` below already is, and the recording renders unchanged.
331
+ // ⚠ A ROW, NOT A WIDER `components` LINE. When this landed, `statusLines` had 30 recorded
332
+ // cases per platform in the former Python suite's corpus, every one holding the box's exact
333
+ // bytes — including `17 agents · 47 skills · 37 laws` — and no recorder left to re-bless them.
334
+ // Touching `components` would have moved all 30; adding a row would too, since the box width
335
+ // is measured over `rows`. So the row is CONDITIONAL on a key those arguments did not carry,
336
+ // exactly as `agent_md` below already is. The corpus is gone; a caller without the key still
337
+ // gets the box it got before.
341
338
  //
342
339
  // It carries the two tiers `components` cannot: `components` counts the invariants as
343
340
  // `laws`, which is still true, and says nothing about the ontology or the packs.
@@ -9,48 +9,36 @@
9
9
  import path from 'node:path';
10
10
  import { buildInto, emitGlobalInto, emitProjectInto } from '../build/driver.mjs';
11
11
  import { resolvePath } from '../hosts/hosts.mjs';
12
+ import {
13
+ doctrinesOfDir, excludedRulesOfDir, modeOfDir, postureOfDir,
14
+ } from '../hosts/installs.mjs';
12
15
  import { validateIsVendored } from '../hosts/native.mjs';
13
- import { printOut, printErr, readText } from '../lib/fs.mjs';
16
+ import { printOut, printErr } from '../lib/fs.mjs';
14
17
  import { comparePaths } from '../lib/paths.mjs';
15
18
  import { stripWhitespace } from '../lib/text.mjs';
16
- import { linkProblems } from './checks-build.mjs';
19
+ import { checkBuild } from './checks-build.mjs';
17
20
  import { cmdDoctor } from './doctor.mjs';
18
- import { TOKEN_RE, isDir, isFile, rglob, sortedUnique, withTempDir, within } from './scan.mjs';
21
+ import { isDir, isFile, rglob, withTempDir, within } from './scan.mjs';
19
22
 
20
23
  // --------------------------------------------------------------------------------------
21
24
  // validate — the generator's `--validate-only`, on this binary because it runs the doctor
22
25
  // --------------------------------------------------------------------------------------
23
26
 
24
27
  /**
25
- * `build._validate_sandbox_problems` — the unresolved-token / dead-link / non-hermetic-link
26
- * scan over an already-rendered sandbox tree.
27
- *
28
- * A NEAR-TWIN OF `checkBuild` ABOVE, AND DELIBERATELY NOT IT, because the reference is two
29
- * functions and they differ in three observable ways: the token list is `sorted(set(...))`
30
- * here and bare `set(...)` there, the message carries no `[theme]` prefix (the caller adds
31
- * `[emit]` instead), and an unreadable file is SKIPPED rather than raised. Python duplicates
32
- * the loop because `build.py` cannot import the harness tree; here the two live in one file
33
- * and the two now sit one file apart (`checkBuild` is in `checks-build.mjs`); the
34
- * duplication is eight lines — every primitive underneath (`rglob`, `stripCode`,
35
- * `linkProblems`, `readText`) is the shared one, which is the half that could actually drift.
28
+ * `validate --help`. Its own text, because the parser it borrows is the generator's and that
29
+ * parser's `-h` prints `usage: geneseed-build …` — naming `--sync-themes`/`--config-dir`, which
30
+ * this verb refuses, under a program name the user did not type.
36
31
  */
37
- export function validateSandboxProblems(sandbox) {
38
- const out = resolvePath(sandbox);
39
- const problems = [];
40
- for (const md of rglob(out)) {
41
- if (!md.endsWith('.md') || !isFile(md)) continue;
42
- const rel = path.relative(out, md);
43
- if (validateIsVendored(rel)) continue;
44
- let text;
45
- // `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing here.
46
- try { text = readText(md); } catch { continue; }
47
- for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
48
- problems.push(`unresolved token ${tok} in ${rel}`);
49
- }
50
- problems.push(...linkProblems(md, text, out, rel));
51
- }
52
- return problems;
53
- }
32
+ export const VALIDATE_USAGE = [
33
+ 'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
+ ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
+ ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES] [-v]',
36
+ '',
37
+ "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
38
+ "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
39
+ '--config-dir.',
40
+ '',
41
+ ].join('\n');
54
42
 
55
43
  /**
56
44
  * `subprocess.run(..., capture_output=True, text=True)` around an IN-PROCESS call.
@@ -109,6 +97,17 @@ function captureStreams(fn) {
109
97
  * contract absolutely — this verb is deliberately outside the verb table, so nothing else does.
110
98
  */
111
99
  export function cmdValidate(args) {
100
+ // Two generator flags with no meaning in a dry run, refused rather than silently ignored:
101
+ // `--sync-themes` WRITES into the live themes dir, and `--config-dir` names a real install dir
102
+ // while every render here goes to a sandbox — accepting either would claim a check of
103
+ // something this verb never touches.
104
+ for (const [flag, set] of [['--sync-themes', args.syncThemes], ['--config-dir', args.cfgDir]]) {
105
+ if (set) {
106
+ printErr(`geneseed: error: validate does not take ${flag} — it renders into a sandbox `
107
+ + 'and writes nothing real; run `geneseed validate --help` for what it takes\n');
108
+ return 2;
109
+ }
110
+ }
112
111
  const problems = [];
113
112
  const scan = withTempDir((tmpRaw) => {
114
113
  // `.resolve()` mirrors `_check_build`'s: on Windows a temp dir can come back in 8.3
@@ -122,7 +121,13 @@ export function cmdValidate(args) {
122
121
  const sandbox = args.root ? path.join(root, 'bundle') : root;
123
122
  const cfgDir = path.join(tmp, 'cfg');
124
123
  const emit = args.emit;
125
- const opts = { theme: args.theme, footprint: args.footprint };
124
+ // THE FIVE RENDER AXES TRAVEL WITH THE DRY RUN. They were parsed and then dropped, so
125
+ // `validate --doctrines craft` checked an all-packs render — a pass that said nothing
126
+ // about the build the operator was about to run.
127
+ const opts = {
128
+ theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
129
+ trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
130
+ };
126
131
  let scanDirs;
127
132
  try {
128
133
  if (emit.endsWith('-global')) {
@@ -144,8 +149,8 @@ export function cmdValidate(args) {
144
149
  // which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
145
150
  // that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
146
151
  // sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
147
- // says `unknown theme 'x'` where the reference says `1`. The corpus in
148
- // `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
152
+ // says `unknown theme 'x'` where the reference says `1`. The former Python
153
+ // cross-implementation corpus is what found it; nothing else could.
149
154
  if (e && e.exitCode !== undefined) {
150
155
  printOut(`[validate-only] render/emit FAILED for theme '${args.theme}' `
151
156
  + `emit '${emit}': ${e.exitCode}\n`);
@@ -158,6 +163,14 @@ export function cmdValidate(args) {
158
163
  .sort(comparePaths);
159
164
  printOut(`[validate-only] theme=${args.theme} emit=${emit} `
160
165
  + `footprint=${args.footprint}\n`);
166
+ // READ BACK OFF THE RENDER, not echoed from `args`: this line is the proof the axes reached
167
+ // the sandbox, which an echo of the flags would print just as happily if they had not.
168
+ const at = scanDirs[0];
169
+ const packs = doctrinesOfDir(at);
170
+ const excluded = excludedRulesOfDir(at);
171
+ printOut(`[validate-only] rendered at posture=${postureOfDir(at) ?? '?'} `
172
+ + `mode=${modeOfDir(at) ?? '?'} packs=${packs ? packs.join(',') || 'none' : '?'} `
173
+ + `excluded=${excluded.map((id) => id.replace('.', ' ')).join(', ') || 'none'}\n`);
161
174
  printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
162
175
  + (args.root ? ` (root ${args.root})` : '')
163
176
  + ' — nothing was actually written (sandboxed).\n');
@@ -170,9 +183,9 @@ export function cmdValidate(args) {
170
183
  printOut(` would write: ${path.relative(base, p)}\n`);
171
184
  }
172
185
  }
173
- for (const d of scanDirs) {
174
- for (const p of validateSandboxProblems(d)) problems.push(`[${emit}] ${p}`);
175
- }
186
+ // The doctor's own per-tree scan, prefixed with the emit instead of a theme, and with the
187
+ // vendored shape every host depth can produce — a sandbox may hold a per-repo native layer.
188
+ for (const d of scanDirs) problems.push(...checkBuild(emit, d, validateIsVendored));
176
189
  return scanDirs;
177
190
  }, 'geneseed-validate-');
178
191
  if (scan === null) return 1;