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
@@ -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
  }
@@ -45,7 +45,7 @@ import {
45
45
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
46
46
  } from '../hosts/hosts.mjs';
47
47
  import {
48
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
48
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, modeOfDir, postureOfDir,
49
49
  trustOfDir,
50
50
  readJsonMaybe, readMaybe, themeOfDir,
51
51
  } from '../hosts/installs.mjs';
@@ -178,6 +178,9 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
178
178
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
179
  // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
180
  trust: trustOfDir(dir),
181
+ // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
182
+ // carrier as drift: `expected` would re-state the rule the deployment took away.
183
+ excludeRules: excludedRulesOfDir(dir),
181
184
  }));
182
185
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
183
186
  // 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}`,
@@ -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
  }
@@ -73,7 +73,7 @@ import {
73
73
  defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
74
74
  } from '../hosts/installs.mjs';
75
75
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
- import { shimDead } from '../hosts/settings.mjs';
76
+ import { shimDead } from '../hosts/shim.mjs';
77
77
  import { printOut } from '../lib/fs.mjs';
78
78
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
79
79
 
@@ -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')) {
@@ -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;
@@ -19,7 +19,7 @@ import { readdirSync } from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { readText, isFile, isDir } from '../lib/fs.mjs';
22
- import { frontmatter } from '../hosts/hooks.mjs';
22
+ import { frontmatter } from '../hosts/memory-files.mjs';
23
23
  import { SRC } from '../build/source.mjs';
24
24
  import { checkGraph } from './graph.mjs';
25
25
 
@@ -117,8 +117,10 @@ export function loadCatalog({
117
117
  return { bricks, templates, problems, overridden: overridden.sort() };
118
118
  }
119
119
 
120
- export function catalogProblems(opts = {}) {
121
- const { bricks, templates, problems } = loadCatalog(opts);
120
+ /** `catalog`: an already-loaded `loadCatalog(opts)`, for a caller that needs the catalogue
121
+ * itself too — passing it saves reading every brick and template twice. */
122
+ export function catalogProblems(opts = {}, catalog = loadCatalog(opts)) {
123
+ const { bricks, templates, problems } = catalog;
122
124
  const out = [...problems];
123
125
  for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
124
126
  out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
package/js/loop/cli.mjs CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
7
7
  * `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
8
- * stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang.
8
+ * stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang —
9
+ * and a TTY reads as `null`, "no porcelain given", never as `''`: an empty pipe is a CLEAN
10
+ * tree, and a clean tree is exactly the snapshot a read brick's check must compare against.
9
11
  *
10
12
  * NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
11
13
  * out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
@@ -27,7 +29,7 @@ import {
27
29
  import { recordLoop } from './registry.mjs';
28
30
 
29
31
  const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
30
- const stdin = () => (process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
32
+ const stdin = () => (process.stdin.isTTY ? null : readFileSync(0, 'utf8'));
31
33
  const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
32
34
 
33
35
  function requireRoot() {
@@ -58,14 +60,17 @@ function writeCommitMessage(root, gitdir, result) {
58
60
  return { ...result, message_file: file.replaceAll('\\', '/') };
59
61
  }
60
62
 
61
- function withState(fn) {
63
+ /** `write: false` for a read-only action (`next`): rewriting LOOP.md there would change
64
+ * nothing, and would race the Active tab's preset picker (`registry.mjs`'s `setLoopPreset`) —
65
+ * a preset it saved between this read and the rewrite would be silently undone. */
66
+ function withState(fn, { write = true } = {}) {
62
67
  const root = requireRoot();
63
68
  const file = path.join(root, LOOP_FILE);
64
69
  if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
65
70
  const state = parseLoopFile(readText(file));
66
71
  const { bricks } = loadCatalog({ projectRoot: root });
67
72
  const result = fn(state, bricks);
68
- writeLoopFile(file, state);
73
+ if (write) writeLoopFile(file, state);
69
74
  return result;
70
75
  }
71
76
 
@@ -79,26 +84,30 @@ function resolveGraph(spec, catalog) {
79
84
 
80
85
  const ACTIONS = {
81
86
  check(args) {
87
+ // The project level of the catalogue is `<git root>/.geneseed/`, as for `init` and `next`,
88
+ // so a check run from a subdirectory sees the same bricks and templates they will.
89
+ const root = gitRootOf(process.cwd()) ?? process.cwd();
82
90
  if (args.brick) {
83
91
  const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
84
92
  return { ok: !problems.length, problems };
85
93
  }
86
94
  if (args.graph) {
87
- const catalog = loadCatalog({ projectRoot: process.cwd() });
95
+ const catalog = loadCatalog({ projectRoot: root });
88
96
  const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
89
97
  return { ok: !problems.length, problems };
90
98
  }
91
99
  // The bare check doubles as the catalogue LISTING the loop skill composes a free graph
92
100
  // from — frontmatter only: a brick's body reaches the model through `next`, for the node
93
101
  // the engine chose, never as a menu.
94
- const problems = catalogProblems({ projectRoot: process.cwd() });
95
- const { bricks, templates, overridden } = loadCatalog({ projectRoot: process.cwd() });
102
+ const catalog = loadCatalog({ projectRoot: root });
103
+ const problems = catalogProblems({ projectRoot: root }, catalog);
104
+ const { bricks, templates, overridden } = catalog;
96
105
  const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
97
106
  return {
98
107
  ok: !problems.length, problems,
99
108
  templates: [...templates.keys()].sort(byName),
100
109
  bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
101
- .map(({ body, available, reason, ...b }) => ({ ...b, available, ...(reason ? { reason } : {}) })),
110
+ .map(({ body, reason, ...b }) => ({ ...b, ...(reason ? { reason } : {}) })),
102
111
  overridden,
103
112
  };
104
113
  },
@@ -121,14 +130,14 @@ const ACTIONS = {
121
130
  recordLoop({ root, branch: currentBranch(root), title: args.title });
122
131
  return { file, graph: graph.name, preset: state.preset, status: state.status };
123
132
  },
124
- next: () => withState((s, b) => nextStep(s, b)),
133
+ next: () => withState((s, b) => nextStep(s, b), { write: false }),
125
134
  score(args) {
126
135
  if (args.diff) {
127
136
  // X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
128
137
  // malformed `.git` file errors without advancing the loop.
129
138
  const root = requireRoot();
130
139
  const gitdir = gitDirOf(root);
131
- return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
140
+ return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin() ?? ''))));
132
141
  }
133
142
  if (!args.declared) throw new Error('score needs --declared or --diff');
134
143
  return withState((s) => scoreDeclared(s, {
@@ -150,10 +159,15 @@ const ACTIONS = {
150
159
  }
151
160
  note = raw.replace(/\r\n/g, '\n').trimEnd();
152
161
  }
153
- const card = args.card ? JSON.parse(args.card) : null;
162
+ let card = null;
163
+ if (args.card) {
164
+ try { card = JSON.parse(args.card); } catch (e) {
165
+ throw new Error(`--card is not valid JSON: ${e.message}`, { cause: e });
166
+ }
167
+ }
154
168
  const porcelain = stdin();
155
169
  return withState((s, b) => recordOutcome(s, b, args.outcome, {
156
- card, porcelain: porcelain ? porcelain.replace(/\r\n/g, '\n').trimEnd() : null, note,
170
+ card, porcelain: porcelain === null ? null : porcelain.replace(/\r\n/g, '\n').trimEnd(), note,
157
171
  }));
158
172
  },
159
173
  decide(args) {
package/js/loop/graph.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { WEIGHTS } from './score.mjs';
23
23
 
24
- export const TERMINALS = new Set(['$close', '$stop']);
24
+ const TERMINALS = new Set(['$close', '$stop']);
25
25
  export const ENGINE_MAX_ITERATIONS = 20;
26
26
  /** A template's optional `category`, in the order the console's Loops list shows them. */
27
27
  export const CATEGORIES = ['architecture', 'tests', 'development', 'refactoring', 'day-to-day'];