geneseed 3.4.0 → 3.5.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 (164) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/DESIGN.md +11 -14
  3. package/QUICKSTART.md +3 -1
  4. package/README.md +51 -42
  5. package/SETUP.md +25 -27
  6. package/SHIPPED.md +26 -32
  7. package/adapters/bob/README.md +12 -9
  8. package/adapters/copilot/README.md +26 -25
  9. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  10. package/adapters/opencode/README.md +2 -2
  11. package/adapters/opencode/plugins/geneseed-context.js +5 -3
  12. package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
  13. package/adapters/opencode/workflows/_runtime.js +97 -7
  14. package/bin/build-driver.mjs +5 -1248
  15. package/bin/geneseed-cli.mjs +11 -6
  16. package/bin/geneseed-hook.mjs +4 -2
  17. package/docs/candidate-hosts.md +1 -1
  18. package/docs/design-history.md +37 -37
  19. package/docs/extending.md +45 -24
  20. package/docs/token-footprint.md +52 -26
  21. package/docs/web/claude-hooks.md +3 -2
  22. package/docs/web/collaboration.md +2 -2
  23. package/docs/web/first-session.md +1 -1
  24. package/docs/web/footprint.md +2 -2
  25. package/docs/web/harnesses.md +1 -1
  26. package/docs/web/model.md +1 -1
  27. package/docs/web/plugin-workflow.md +1 -0
  28. package/docs/web/rules.md +3 -3
  29. package/docs/web/setup-choices.md +2 -2
  30. package/docs/web/themes-author.md +1 -1
  31. package/docs/web-ui.md +18 -10
  32. package/harness.config.json +1 -1
  33. package/js/README.md +6 -5
  34. package/js/build/driver.mjs +1191 -0
  35. package/js/build/emit-claude.mjs +17 -10
  36. package/js/build/emit-common.mjs +4 -4
  37. package/js/build/generate.mjs +1 -1
  38. package/js/build/render.mjs +48 -44
  39. package/js/build/stubs.mjs +1 -1
  40. package/js/build/themes.mjs +4 -4
  41. package/js/build/version.mjs +12 -0
  42. package/js/cli-table.json +21 -6
  43. package/js/hosts/hooks.mjs +89 -74
  44. package/js/hosts/hosts.mjs +19 -2
  45. package/js/hosts/mcp.mjs +14 -20
  46. package/js/hosts/native.mjs +23 -17
  47. package/js/hosts/settings.mjs +52 -104
  48. package/js/inspect/checks-authoring.mjs +117 -3
  49. package/js/inspect/diff.mjs +1 -1
  50. package/js/inspect/doctor.mjs +1 -1
  51. package/js/inspect/excludes.mjs +1 -1
  52. package/js/inspect/scan.mjs +3 -14
  53. package/js/inspect/status.mjs +2 -2
  54. package/js/inspect/validate.mjs +1 -1
  55. package/js/lib/fs.mjs +23 -8
  56. package/js/lib/json.mjs +7 -1
  57. package/js/lib/proc.mjs +6 -4
  58. package/js/maintain/migrate.mjs +1 -1
  59. package/js/maintain/setup.mjs +1 -1
  60. package/js/maintain/uninstall.mjs +17 -15
  61. package/js/ui/cli.mjs +19 -0
  62. package/js/ui/menu.mjs +1 -1
  63. package/js/web/actions.mjs +4 -4
  64. package/js/web/activity.mjs +4 -4
  65. package/js/web/api.mjs +14 -11
  66. package/js/web/daemon.mjs +1 -1
  67. package/js/web/docs.mjs +12 -12
  68. package/js/web/handler.mjs +12 -5
  69. package/js/web/jobs.mjs +1 -1
  70. package/package.json +1 -1
  71. package/src/AGENT.md.tmpl +69 -36
  72. package/src/agents/developer.md +2 -2
  73. package/src/agents/reviewer.md +5 -2
  74. package/src/doctrines/README.md +26 -3
  75. package/src/doctrines/craft.md +78 -5
  76. package/src/doctrines/ops.md +62 -0
  77. package/src/doctrines/process.md +91 -4
  78. package/src/doctrines/rigor.md +40 -0
  79. package/src/laws/universal.md +27 -21
  80. package/src/memory/README.md +4 -2
  81. package/src/modes/foreman.md +5 -5
  82. package/src/notebook/README.md +2 -1
  83. package/src/ontology/universal.md +71 -67
  84. package/src/postures/peer.md +3 -0
  85. package/src/skills/bruno-collection-generator.md +1 -1
  86. package/src/skills/bruno-test-writer.md +2 -2
  87. package/src/skills/daydream/LICENSE +21 -0
  88. package/src/skills/daydream/VENDOR.md +3 -1
  89. package/src/skills/geneseed-code-review.md +6 -2
  90. package/src/skills/parallel-agents.md +3 -3
  91. package/src/skills/pipeline.md +4 -4
  92. package/src/skills/react-view-transitions/VENDOR.md +7 -1
  93. package/src/skills/workflow.md +12 -4
  94. package/themes/_TEMPLATE.json +6 -4
  95. package/themes/biker.json +5 -3
  96. package/themes/commentator.json +5 -3
  97. package/themes/cyberpunk.json +5 -3
  98. package/themes/gamer.json +5 -3
  99. package/themes/imperial.json +5 -3
  100. package/themes/joker.json +5 -3
  101. package/themes/marvin.json +5 -3
  102. package/themes/mean.json +5 -3
  103. package/themes/military.json +5 -3
  104. package/themes/neutral.json +6 -4
  105. package/themes/opencode/README.md +2 -2
  106. package/themes/pirate.json +5 -3
  107. package/themes/sports.json +5 -3
  108. package/themes/verstappen.json +5 -3
  109. package/themes/wizard.json +5 -3
  110. package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
  111. package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
  112. package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
  113. package/web/dist/assets/Diff-Deisdha8.js +2 -0
  114. package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
  115. package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
  116. package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
  117. package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
  118. package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
  119. package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
  120. package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
  121. package/web/dist/assets/Library-CDOv4emM.js +1 -0
  122. package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
  123. package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
  124. package/web/dist/assets/Profile-CpP9BTne.js +1 -0
  125. package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
  126. package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
  127. package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
  128. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
  129. package/web/dist/assets/activity-iWaWVtsR.js +1 -0
  130. package/web/dist/assets/drawable-BgfrEBer.js +1 -0
  131. package/web/dist/assets/index-CAqrWhKl.js +2 -0
  132. package/web/dist/assets/index-Ruh93A3A.css +1 -0
  133. package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
  134. package/web/dist/assets/lawCats-BLgxl151.js +1 -0
  135. package/web/dist/assets/react-DbkvxWG5.js +8 -0
  136. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  137. package/web/dist/index.html +4 -3
  138. package/web/src/pages/Laws.jsx +22 -8
  139. package/src/skills/react-view-transitions/README.md +0 -42
  140. package/web/dist/assets/Activity-DMGhhjQc.js +0 -1
  141. package/web/dist/assets/ActivityDetail-CeQcDA7e.js +0 -1
  142. package/web/dist/assets/CatalogRow-CnSbDPyg.js +0 -1
  143. package/web/dist/assets/Diff-D_ksMBD1.js +0 -5
  144. package/web/dist/assets/Doctor-Brb3_oCT.js +0 -1
  145. package/web/dist/assets/FilterInput-DbRWPhtT.js +0 -1
  146. package/web/dist/assets/GreenhouseView-F3VNFr73.js +0 -2
  147. package/web/dist/assets/Harness-DJSwfYeF.js +0 -1
  148. package/web/dist/assets/JournalView-BLRYRnB3.js +0 -1
  149. package/web/dist/assets/Laws-CKYns9qe.js +0 -3
  150. package/web/dist/assets/Library-BK0YloSv.js +0 -1
  151. package/web/dist/assets/Markdown-BzTRzc7a.js +0 -46
  152. package/web/dist/assets/OperatorHudView-D3OQSGp5.js +0 -2
  153. package/web/dist/assets/Profile-CPA2u_0g.js +0 -1
  154. package/web/dist/assets/Rules-Lukel-Ru.js +0 -1
  155. package/web/dist/assets/Skills-BKRLHH7m.js +0 -1
  156. package/web/dist/assets/StatusBadge-DI3weRKd.js +0 -1
  157. package/web/dist/assets/activity-C_H2Bx1P.js +0 -1
  158. package/web/dist/assets/index-B7_Wga23.js +0 -2
  159. package/web/dist/assets/index-DzdQjvM1.css +0 -1
  160. package/web/dist/assets/index-N8dTrVro.js +0 -1
  161. package/web/dist/assets/index-Py_IQjgK.js +0 -10
  162. package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
  163. package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
  164. package/web/dist/assets/react-DOpiTn5v.js +0 -32
@@ -1,1252 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The Node driver — `build.py`'s `main()`, as a second CLI beside it.
4
- *
5
- * P4 adds a driver; it does not replace one. `build.py` is ALSO the `import build` facade
6
- * that 19 `rituals/` modules read 55 distinct names from, and ~11 sites spawn
7
- * `[sys.executable, build.py, ...]`. None of those flip here: the Python CLI has to
8
- * survive this phase intact, and every gate that drives it keeps driving it.
9
- *
10
- * WHAT THIS FILE ORIGINATES, AND WHY THAT IS THE PHASE'S WHOLE POINT.
11
- * Every phase since P2d has asked one question at the process boundary: which values does
12
- * the CHILD resolve, and which does the parent decide and send? P3c's answer for `cfgDir`
13
- * was "the child must never resolve it — a child that did would render 135 files into the
14
- * developer's real ~/.config/opencode". P4 inverts that rule rather than repeating it:
15
- * this file IS the parent now, so the values a child must never resolve are exactly the
16
- * values this file must originate. `ROOT` and the seven paths under it come from this
17
- * script's own location, not from an inherited `cfg`.
18
- *
19
- * A KEY IS DELIBERATELY ABSENT FROM `cfg`, and its absence is load-bearing.
20
- * `js_cfg()` (_build_core.py:199) always sent `structure` and `capabilityLinkRe`, because
21
- * the Python originals were module-level names that TESTS MUTATE — `_OWNED` membership
22
- * asked one level out. This driver has no Python module to mutate, so it sends neither;
23
- * `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch for
24
- * that reason — a fallback that was dead code until P4, always overridden by the driver
25
- * that always supplies it. `capabilityLinkRe`'s equivalent fallback in
26
- * `js/build/emit-common.mjs`'s `stripCapabilityLinks` was P4's other one made live, and the
27
- * over-engineering cleanup then deleted the override branch outright: nothing had ever
28
- * supplied one, so there was no longer a `cfg` left to be absent from.
29
- *
30
- * THE FOOTPRINT DEFAULT IS THE FLAG'S, NOT THE FUNCTION'S. `--footprint` defaults to
31
- * `lean` (build.py:354); every `emit_*`/`build` SIGNATURE defaults to `full`. A driver
32
- * that reproduced the signature default would emit a different harness in every cell while
33
- * every gate that calls the functions directly stayed green.
3
+ * `geneseed-build` — the generator's entry. Everything it does lives in `js/build/driver.mjs`,
4
+ * which the CLI verbs import too; this file only hands it argv. Keeping the entry this thin is
5
+ * what lets a `js/` module import the driver without running it on the importer's argv.
34
6
  */
35
- import {
36
- existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync,
37
- unlinkSync,
38
- } from 'node:fs';
39
- import path from 'node:path';
40
- import { fileURLToPath } from 'node:url';
41
- import { parseArgs as nodeParseArgs } from 'node:util';
42
- import { build, phaseLog } from '../js/build/bundle.mjs';
43
- import { emitClaudeRender } from '../js/build/emit-claude.mjs';
44
- import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
45
- import { copilotIntegrityCheck, settingsIntegrityCheck } from '../js/hosts/settings.mjs';
46
- import { writeText, withPlatformNewlines, isFile } from '../js/lib/fs.mjs';
47
- import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
48
- // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
49
- // find a global install, and a resolver that decides WHERE a driver writes is the last thing
50
- // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
51
- import {
52
- GLOBAL_MANIFEST, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
53
- copilotConfigDir,
54
- } from '../js/hosts/hosts.mjs';
7
+ import { main } from '../js/build/driver.mjs';
55
8
 
56
- // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
57
- // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
58
- // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
59
- import {
60
- ROOT, CONFIG, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
61
- } from '../js/build/source.mjs';
62
-
63
- // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
64
- // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
65
- // for a reader. See js/inspect/registry.mjs.
66
- import { registryRecord, registryRoots } from '../js/inspect/registry.mjs';
67
-
68
- // P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
69
- // of textual surgery over committed files with a corpus of its own, and it is the half of the
70
- // maintainer pair that needs nothing this driver is banned from having.
71
- import { syncThemes } from '../js/build/themes.mjs';
72
-
73
- /** The nine `--emit` choices, in build.py:337-338's order. */
74
- const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
75
- 'bob', 'bob-global', 'copilot', 'copilot-global'];
76
-
77
- /**
78
- * The emits this driver can run end to end — all nine, as of P4e.
79
- *
80
- * The set is kept (rather than deleted along with the refusal branch) because it is the
81
- * thing `test_the_node_driver_classifies_every_emit` reads to assert the PARTITION: every
82
- * `--emit` choice is either here or refused with exit 3, so a tenth emit added to `EMITS`
83
- * without crossing is a failure rather than an oversight. It is also what
84
- * `test_every_relocation_var_moves_its_global_target` cross-checks its host table against.
85
- */
86
- const PORTED = new Set(EMITS);
87
-
88
- /** `_build_emit.PRIMARY_AGENT_SRC`. */
89
- const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
90
- /**
91
- * `_build_global.HOSTS[host]['native_catalog']` — does the host catalogue skills and agents
92
- * to the model itself? A DECISION the driver takes and sends, never something the render
93
- * half re-derives: `emit_opencode` passes `host_catalogs_natively('opencode')`, which is
94
- * `true`, where the plain `files` bundle passes `false`. Getting this wrong collapses (or
95
- * fails to collapse) AGENT.md's capability tables in every cell.
96
- */
97
- const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
98
-
99
- /** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
100
- * (not to ROOT), so the harness renders straight into any repository.
101
- *
102
- * EXPORTED IN P5i because `js/maintain/setup.mjs` became its second caller: `_setup_summary_lines`
103
- * names the AGENT.md a bundle emit just wrote, and it names it by the same resolution the
104
- * emit used. Copying the one line would be a second owner of the rule that a bare `Harness`
105
- * follows the invocation and not the checkout — which is what
106
- * `build/the-bundle-follows-cwd-not-the-checkout` exists to gate.
107
- *
108
- * `resolvePath` AND NOT `path.resolve`, and the difference is the whole reason `resolvePath`
109
- * exists: the reference ends in `.resolve()`, which canonicalises — 8.3 short names expanded
110
- * on Windows, symlinks followed on POSIX, the filesystem's own casing — where `path.resolve`
111
- * only normalises `.`/`..`. Nine `resolvePath` call sites had the rule and this one did not.
112
- * It is invisible on a machine whose paths are already canonical, which is every machine
113
- * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
114
- * two CLIs printed the same directory under two different names — a 70-byte difference in a
115
- * line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
116
- * argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
117
- * reference does not expand `~` here, and a bare `~` is a legal directory name. */
118
- export function resolveOut(raw) {
119
- return resolvePath(path.resolve(process.cwd(), raw));
120
- }
121
-
122
- /**
123
- * build.py:307-321 — the three defaults that come from `harness.config.json`.
124
- *
125
- * The corrupt-file branch is reproduced including its imprecision: the warning says only
126
- * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
127
- * Python text matters more than fixing it here, because the two CLIs' stderr is compared
128
- * byte-for-byte by `tests/golden.py` (:316).
129
- */
130
- function configDefaults() {
131
- const d = {
132
- theme: 'neutral', posture: 'peer', mode: 'direct', doctrines: [...PACK_ORDER],
133
- excludeRules: [],
134
- };
135
- if (!existsSync(CONFIG)) return d;
136
- try {
137
- const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
138
- if (data && typeof data === 'object' && !Array.isArray(data)) {
139
- if (data.theme !== undefined) d.theme = data.theme;
140
- if (data.posture !== undefined) d.posture = data.posture;
141
- if (data.mode !== undefined) d.mode = data.mode;
142
- // `doctrines` is the only config key that is a LIST, so it is the only one that can be
143
- // the right type and still nonsense. A bad value warns and falls back to all packs
144
- // rather than dying: this file is hand-editable, and a typo here must not brick every
145
- // rebuild of an install whose AGENT.md is otherwise fine. `[]` is a legitimate value
146
- // (no packs) and must survive the check — hence an explicit Array test, not truthiness.
147
- if (data.doctrines !== undefined) {
148
- const known = discoverNames('doctrines', PACK_ORDER[0]);
149
- if (Array.isArray(data.doctrines) && data.doctrines.every((x) => known.includes(x))) {
150
- d.doctrines = PACK_ORDER.filter((p) => data.doctrines.includes(p));
151
- } else {
152
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "doctrines" is not a `
153
- + 'list of known pack names — using all packs.\n');
154
- }
155
- }
156
- // The second axis, read the same forgiving way and defaulting the OTHER direction: a
157
- // bad value excludes NOTHING, because the safe answer for a list of things to take
158
- // away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
159
- if (data.excludeRules !== undefined) {
160
- if (Array.isArray(data.excludeRules)
161
- && data.excludeRules.every((x) => typeof x === 'string')) {
162
- d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
163
- .sort();
164
- } else {
165
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
166
- + 'not a list of rule addresses — excluding nothing.\n');
167
- }
168
- }
169
- }
170
- } catch {
171
- process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
172
- + "using theme 'neutral'.\n");
173
- }
174
- return d;
175
- }
176
-
177
- /**
178
- * The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
179
- * dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
180
- * own private copy of the flags is a second source that drifts silently the first time a
181
- * flag is added to one and not the other, and nothing downstream would say so.
182
- */
183
- const VALUED = {
184
- '--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
185
- '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
186
- '--out': 'out', '--target': 'out', '--emit': 'emit',
187
- '--footprint': 'footprint', '--root': 'root',
188
- };
189
- const FLAGS = {
190
- '--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
191
- '-v': 'verbose', '--verbose': 'verbose',
192
- };
193
-
194
- /**
195
- * `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
196
- * second time — a hand-written twin is exactly the anti-drift failure this module's docblock
197
- * already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
198
- * value` form is captured as one token instead of leaking `value` as an orphan positional);
199
- * every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
200
- * `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
201
- */
202
- const PARSE_OPTIONS = Object.fromEntries([
203
- ...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
204
- ...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
205
- ]);
206
- PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
207
-
208
- /** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
209
- const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
210
- const FLAG_BY_NAME = Object.fromEntries(
211
- Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
212
- );
213
-
214
- /**
215
- * The four flags `choice` validates, as ONE table for the same anti-drift reason.
216
- *
217
- * A function and not a constant because two of the four read the checkout: `discoverNames`
218
- * scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
219
- * standing in and cannot be frozen at import.
220
- *
221
- * `--doctrines` is DELIBERATELY not a fifth row. Every consumer of this table treats an
222
- * entry as one value drawn from a closed list — `choice` refuses anything else, and
223
- * `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
224
- * LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
225
- * advertised a metavar that lies. It is validated by `parseDoctrines` instead and prints the
226
- * plain `DOCTRINES` metavar, exactly as `--theme` (also open-ended) already does.
227
- */
228
- function choicesFor() {
229
- return {
230
- '--emit': EMITS,
231
- '--footprint': ['lean', 'full'],
232
- '--posture': discoverNames('postures', 'peer'),
233
- '--mode': discoverNames('modes', 'direct'),
234
- };
235
- }
236
-
237
- /**
238
- * `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
239
- *
240
- * Normalised into `PACK_ORDER` rather than kept in the order typed, which also dedupes: the
241
- * rendered `Active packs:` line is a MARKER a later reader parses back out of a deployed
242
- * carrier, and a marker whose contents depend on argument order is one that compares unequal
243
- * to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
244
- * packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
245
- * different fault and `js/build/render.mjs` refuses the whole build for it.
246
- */
247
- /**
248
- * `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
249
- *
250
- * BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
251
- * every citation in the corpus spell a rule `process 7`; the console's deep link and the
252
- * catalogue spell it `process.7`. A flag that took only one of them would be wrong for
253
- * whichever half of the documentation the reader had open, so it takes either and normalises
254
- * to the dotted form the code compares.
255
- *
256
- * ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
257
- * refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
258
- * a typo that would otherwise take away nothing in silence, which is the failure this flag
259
- * can least afford: the user asked for a rule to STOP binding and it would keep binding.
260
- * Sorted, deduped, for the same marker-stability reason `--doctrines` is.
261
- */
262
- function parseExcludeRules(value) {
263
- const s = value.trim();
264
- if (s === 'none' || s === '') return [];
265
- const known = knownRuleIds();
266
- const out = [];
267
- for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
268
- const id = raw.replace(/[ \t]+/, '.');
269
- if (!known.includes(id)) {
270
- die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
271
- + `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
272
- }
273
- out.push(id);
274
- }
275
- return [...new Set(out)].sort();
276
- }
277
-
278
- function parseDoctrines(value) {
279
- const s = value.trim();
280
- const known = discoverNames('doctrines', PACK_ORDER[0]);
281
- if (s === 'none') return [];
282
- const names = s.split(',').map((x) => x.trim()).filter(Boolean);
283
- if (!names.length) {
284
- die(2, "argument --doctrines: expected a comma-separated list of packs, or 'none'");
285
- }
286
- for (const n of names) {
287
- if (!known.includes(n)) {
288
- die(2, `argument --doctrines: invalid choice: '${n}' (choose from `
289
- + `${known.map((c) => `'${c}'`).join(', ')}, or 'none')`);
290
- }
291
- }
292
- return PACK_ORDER.filter((p) => names.includes(p));
293
- }
294
-
295
- /**
296
- * `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
297
- * did not, so the two entry points disagreed: the reference printed usage and exited 0 while
298
- * `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
299
- *
300
- * NO CELL COULD SEE IT. `golden.py`'s `_argv` builds every cell out of the render flags and
301
- * never emits `--help`, so the byte comparison only ever ran inputs both implementations
302
- * were built for — a flag missing from one side is invisible to a gate that never passes it.
303
- * `test_the_help_text_names_every_flag_the_reference_takes` is the gate, and it reads
304
- * `build.py`'s `add_argument` calls rather than this file, so it fails on drift from EITHER
305
- * side rather than agreeing with whichever one it was copied from.
306
- *
307
- * The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
308
- * a hand-written copy of the reference's output in a file no gate compares, which is the
309
- * drift this whole docblock is about; the surface here is the npx one, in the same class as
310
- * the `--sync-themes` and `--validate-only` refusals that already answer differently.
311
- */
312
- function usage() {
313
- const choices = choicesFor();
314
- const metavar = (flag) => (choices[flag]
315
- ? `{${choices[flag].join(',')}}`
316
- : VALUED[flag].toUpperCase());
317
- const valued = Object.keys(VALUED);
318
- const bare = Object.keys(FLAGS);
319
- return [
320
- `usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
321
- ...bare.map((f) => `[${f}]`)].join(' ')}`,
322
- '',
323
- 'Render the Geneseed harness for a theme.',
324
- '',
325
- 'options:',
326
- ' -h, --help',
327
- ...valued.map((f) => ` ${f} ${metavar(f)}`),
328
- ...bare.map((f) => ` ${f}`),
329
- '',
330
- ].join('\n');
331
- }
332
-
333
- /**
334
- * argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
335
- * SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
336
- * decision this function made before P6 (Task 4) still made HERE, unmoved.
337
- *
338
- * `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
339
- * walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
340
- * flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
341
- * argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
342
- * string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
343
- * being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
344
- * accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
345
- * anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
346
- * behaviour, not a preserved one.
347
- *
348
- * `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
349
- * `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
350
- * help text for a cluster the original parser refused outright as one unrecognized argument.
351
- * Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
352
- * ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
353
- * privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
354
- * for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
355
- * would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
356
- * this one) refuses the whole typed token, `-bogus`, once.
357
- */
358
- function parseArgs(argv, defaults) {
359
- const args = {
360
- theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
361
- doctrines: defaults.doctrines,
362
- excludeRules: defaults.excludeRules,
363
- out: 'Harness', emit: 'files', footprint: 'lean', root: null,
364
- syncThemes: false, validateOnly: false, verbose: false,
365
- };
366
- const { tokens } = nodeParseArgs({
367
- args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
368
- });
369
- for (const tok of tokens) {
370
- if (tok.kind === 'option' && tok.value === undefined
371
- && (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
372
- process.stdout.write(usage());
373
- // The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
374
- // `main`'s catch is what turns either into this process's exit status. A bare
375
- // `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
376
- // loop with it, and skips the stdout flush the text was just written to.
377
- const e = new Error('help');
378
- e.exitCode = 0;
379
- throw e;
380
- }
381
- if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
382
- if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
383
- args[VALUED_BY_NAME[tok.name]] = tok.value;
384
- continue;
385
- }
386
- // `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
387
- // flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
388
- // keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
389
- // `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
390
- // given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
391
- if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
392
- args[FLAG_BY_NAME[tok.name]] = true;
393
- continue;
394
- }
395
- // Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
396
- // always treats as a terminator but this parser never did) — refuses with the literal text
397
- // typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
398
- die(2, `unrecognized arguments: ${argv[tok.index]}`);
399
- }
400
- const choices = choicesFor();
401
- for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
402
- // Only when the flag was actually PASSED is this a string; left at its default it is
403
- // already the array `configDefaults` built, and re-parsing an array would stringify it.
404
- if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
405
- if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
406
- return args;
407
- }
408
-
409
- /**
410
- * The generator's OWN flag surface, for the one consumer that is not this driver.
411
- *
412
- * `geneseed validate` takes `--theme/--emit/--out/--root/--footprint/-v` — the reference's
413
- * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
414
- * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
415
- * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
416
- * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
417
- *
418
- * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
419
- * they sit inside `main`'s funnel, and called from the CLI binary they would not.
420
- */
421
- export function parseDriverArgs(argv) {
422
- return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
423
- }
424
-
425
- function choice(flag, value, allowed) {
426
- if (!allowed.includes(value)) {
427
- die(2, `argument ${flag}: invalid choice: '${value}' `
428
- + `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
429
- }
430
- }
431
-
432
- /**
433
- * argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
434
- *
435
- * `harness rebuild-all` re-emits every active install and its whole contract is "continue
436
- * past a failure so one broken install never blocks the rest". The Python gets that for free:
437
- * each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
438
- * driver is a module in the same process, so `process.exit` would take the loop, the CLI and
439
- * every remaining install with it — the one place where importing rather than spawning is not
440
- * transparent, and it turns a per-install failure into a total one.
441
- *
442
- * The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
443
- * use, and the first draft of this used a second name so that only `die` would be converted
444
- * — on the argument that an incomplete source should keep its stack. That draft was wrong,
445
- * and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
446
- * `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
447
- * with only `die` converted the refusal propagated out of the loop and the remaining
448
- * installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
449
- * turns every deliberate refusal into an exit code — the narrower rule was a distinction
450
- * with no principle behind it.
451
- *
452
- * One behaviour improves as a side effect, and it is worth naming rather than discovering
453
- * later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
454
- * a slow pipe could lose output it had already produced. Returning through `main` lets Node
455
- * drain normally.
456
- */
457
- function die(code, msg) {
458
- process.stderr.write(`geneseed: error: ${msg}\n`);
459
- const e = new Error(msg);
460
- e.exitCode = code;
461
- throw e;
462
- }
463
-
464
- /**
465
- * `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
466
- * P5b is NOT Python's.
467
- *
468
- * The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
469
- * interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
470
- * `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
471
- * emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
472
- * this driver emits therefore needs no Python at all for its hooks.
473
- *
474
- * WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
475
- * `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
476
- * emits with exit 4 when it found none, because writing a shim that names a nonexistent
477
- * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
478
- * `process.execPath` — the node already running this file, which by construction exists —
479
- * so there is nothing left to discover and no way for discovery to fail. P4e kept the
480
- * unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
481
- * partition it belongs to; nothing asserts a partition over this one, so keeping it would
482
- * be keeping code no test can reach for no stated reason.
483
- *
484
- * THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
485
- * `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
486
- * per-install component: every emit of every install on the machine rewrites the same file,
487
- * and every install's hooks execute it. While both drivers baked Python that was invisible,
488
- * because last-writer-wins wrote the same thing. It is observable now — a machine whose last
489
- * emit ran through this file has EVERY install's hooks running under Node, including
490
- * installs the Python driver wrote, and the reverse.
491
- *
492
- * That is correct exactly while the two entry points answer the same verbs the same way, so
493
- * the gate that used to be a formality is now load-bearing:
494
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
495
- * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
496
- * lacks is a dead hook on every install on the machine, not just this one. The alternative
497
- * considered and rejected was a per-driver shim path: the path is baked into every already
498
- * emitted hook command, so changing it makes every existing install's hooks stale until
499
- * re-emit, which is P10's migration arriving five phases early.
500
- *
501
- * EXPORTED SINCE P6i, because the emitter is no longer its only caller.
502
- * `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
503
- * Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
504
- * correctly. There is exactly one right answer on this side and it is this function, so the
505
- * second caller imports it rather than restating two lines that must never drift: a
506
- * reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
507
- * the emitter never writes.
508
- */
509
- export function hookRunnerEntry() {
510
- return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
511
- }
512
-
513
- /**
514
- * `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
515
- * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
516
- *
517
- * `_PREAMBLE_CONFIG_DIR` has exactly one key, so only a `CLAUDE.md` carrier resolves to a
518
- * value: Bob's and Copilot's `AGENTS.md`, and Copilot's `copilot-instructions.md`, get null.
519
- *
520
- * Computed HERE and not in the render child, and that is the inverted boundary rule doing
521
- * real work rather than being restated. P3b's note on the Python original: it resolves
522
- * through `_build_core._claude_config_dir`, an `_OWNED` name precisely because the suite
523
- * redirects it at a sandbox, and a redirect that stops at a subprocess half-works in
524
- * silence. The child must therefore never derive it — but this file is the PARENT, so
525
- * deriving it is exactly this file's job, and `the host emit` receives the answer.
526
- *
527
- * POSIX spelling, per the original: `claudeMdExcludes` entries are glob patterns, where a
528
- * backslash is an escape, so the Windows-native form risks never matching.
529
- */
530
- function preambleExclude(claudeMd) {
531
- if (path.basename(claudeMd) !== 'CLAUDE.md') return null;
532
- return resolvePath(path.join(claudeConfigDir(), 'CLAUDE.md')).split(path.sep).join('/');
533
- }
534
-
535
- /**
536
- * `_build_global._warn_bob_global_over_project` — informational, never auto-removes.
537
- *
538
- * Same shape as `warnCopilotGlobalOverProject` and a DIFFERENT message, because the two
539
- * hosts differ in what they can promise: Copilot simply stacks both preambles, while Bob's
540
- * workspace rules may or may not shadow the global copy depending on precedence nothing can
541
- * verify at emit time — so this text hedges where Copilot's asserts.
542
- */
543
- function warnBobGlobalOverProject() {
544
- const survivors = projectSurvivors('bob');
545
- if (!survivors.length) return;
546
- process.stderr.write(
547
- `[geneseed] WARN: ${survivors.length} project Bob install(s) already exist — `
548
- + 'emitting GLOBAL now means BOTH may auto-load together in those repos '
549
- + "(doubled context) unless Bob's workspace rules truly shadow the global "
550
- + "one there. Review and remove what you don't want:\n");
551
- for (const root of survivors) {
552
- process.stderr.write(` - ${root} -> geneseed uninstall --target "${root}"\n`);
553
- }
554
- }
555
-
556
- /**
557
- * `_build_render._rel_under` — POSIX path of `out` relative to `root`, or '' when they are
558
- * the same directory OR when `out` is not under `root` at all.
559
- *
560
- * `Path.relative_to` RAISES for a non-descendant; `path.relative` happily walks up with
561
- * `..`. Reproducing the raise is the whole content of this function: a `..` prefix here
562
- * would be written into `opencode.json`'s instruction path.
563
- */
564
- function relUnder(out, root) {
565
- const rel = path.relative(root, out);
566
- if (rel === '' || rel === '.') return '';
567
- if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
568
- return rel.split(path.sep).join('/');
569
- }
570
-
571
- /**
572
- * `_build_global._write_manifest_atomic` — temp + rename, so a torn manifest can never make
573
- * the next emit treat every owned file as the user's own.
574
- *
575
- * `writeText`, not `writeFileSync`: Python writes this through `Path.write_text`, so the
576
- * whole document is CRLF on Windows.
577
- *
578
- * `jsonDumpsIndent`, not `JSON.stringify`: `json.dumps` defaults to `ensure_ascii=True` and
579
- * escapes every non-ASCII character, where `JSON.stringify` emits it raw. This was
580
- * `JSON.stringify` for a whole phase and nothing caught it, because the only manifest being
581
- * written was the per-repo OpenCode one and its `_comment` is pure ASCII. The
582
- * `opencode-global` comment contains an em dash, and that is the character that finally
583
- * made the difference observable — a helper that is wrong everywhere but tellable in only
584
- * one place.
585
- */
586
- function writeManifestAtomic(file, data) {
587
- const tmp = `${file}.tmp`;
588
- writeText(tmp, `${jsonDumpsIndent(data)}\n`);
589
- renameSync(tmp, file);
590
- }
591
-
592
- /**
593
- * Write-before-delete: remove only what this layer owned before and no longer produces.
594
- *
595
- * Runs AFTER the whole current set is on disk, so a live file is never momentarily absent
596
- * part-way through an emit. A pre-existing file that was never in the manifest is the
597
- * user's and is not reachable from here at all.
598
- */
599
- function pruneOwned(oc, oldOwned, owned) {
600
- const now = new Set(owned);
601
- const failed = [];
602
- for (const rel of [...new Set(oldOwned)].filter((r) => !now.has(r)).sort()) {
603
- const victim = path.join(oc, rel);
604
- try {
605
- if (isFile(victim)) {
606
- unlinkSync(victim);
607
- const parent = path.dirname(victim);
608
- if (parent !== oc && readdirSync(parent).length === 0) rmdirSync(parent);
609
- }
610
- } catch (e) {
611
- // Deliberately divergent and deliberately unreachable in the gates: `str(OSError)`
612
- // ("[Errno 13] Permission denied: ...") and a Node error message ("EACCES: ...") can
613
- // never agree, and no cell drives a real filesystem failure through this text.
614
- failed.push(`${rel} (${e.message})`);
615
- }
616
- }
617
- if (failed.length) {
618
- process.stderr.write('[geneseed] WARN: could not remove stale owned file(s): '
619
- + `${failed.join(', ')}\n`);
620
- }
621
- }
622
-
623
- /**
624
- * The manifest PRE-read all three emit bodies performed inline: does a manifest already
625
- * exist at `manifestPath`, and if so, what does it parse to. `existed` is what
626
- * `emitOpencode` alone needs (as `manifestExisted`, threaded into the render job); every
627
- * caller reads `doc.owned`, and `emitClaudeCore` also reads `doc.managed` — `doc` is `null`
628
- * on a missing OR a corrupt manifest, so `(doc && doc.owned) || []` covers both the same way
629
- * the three inline try/catches did.
630
- */
631
- function readManifest(manifestPath) {
632
- const existed = existsSync(manifestPath);
633
- let doc = null;
634
- if (existed) {
635
- try { doc = parseJson(readFileSync(manifestPath, 'utf8')); } catch { doc = null; }
636
- }
637
- return { existed, doc };
638
- }
639
-
640
- /** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
641
- function extrasTail(stats) {
642
- const extras = [...(stats.primary ? ['primary agent'] : []),
643
- ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
644
- return extras.length ? ` + ${extras.join(', ')}` : '';
645
- }
646
-
647
- /**
648
- * `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
649
- *
650
- * Each candidate root's own `.geneseed-emit` marker is read and compared to the literal
651
- * emit name; an unreadable marker is skipped, never raised.
652
- */
653
- function projectSurvivors(emitName) {
654
- const out = [];
655
- for (const root of registryRoots()) {
656
- let marker;
657
- try {
658
- marker = readFileSync(path.join(root, '.geneseed-emit'), 'utf8').trim();
659
- } catch { continue; }
660
- if (marker === emitName) out.push(root);
661
- }
662
- return out;
663
- }
664
-
665
- /**
666
- * `_build_global._warn_copilot_global_over_project` — purely informational.
667
- *
668
- * Copilot documents no exclude or shadow mechanism, so a global preamble and a project
669
- * one simply stack. UNREACHABLE from every golden cell: each emits into a fresh sandbox
670
- * whose registry is empty, so `projectSurvivors` is always `[]` and this never prints.
671
- * Reaching it needs a project emit registered BEFORE a global one in the same sandbox.
672
- */
673
- function warnCopilotGlobalOverProject() {
674
- const survivors = projectSurvivors('copilot');
675
- if (!survivors.length) return;
676
- process.stderr.write(
677
- `[geneseed] WARN: ${survivors.length} project Copilot install(s) already exist — `
678
- + 'emitting GLOBAL now means BOTH preambles load together in those repos '
679
- + '(doubled context): Copilot stacks ~/.copilot/copilot-instructions.md on top '
680
- + "of a repo's own AGENTS.md. Review and remove what you don't want:\n");
681
- for (const root of survivors) {
682
- process.stderr.write(` - ${root} -> geneseed uninstall --target "${root}"\n`);
683
- }
684
- }
685
-
686
- /**
687
- * `_build_emit.emit_opencode` — the driver body, which is what this phase actually ports.
688
- *
689
- * RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
690
- * either side of them: reading the previous manifest (PRE), pruning what this layer no
691
- * longer owns, writing the manifest atomically, and the summary line. All five stages now
692
- * run in one process, in the order `test_emit_phase_order.py` pins for the Python twin:
693
- * RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
694
- * settings file of its own).
695
- */
696
- function emitOpencode(cfg, args, out) {
697
- const root = args.root ? resolveOut(args.root) : out;
698
- const oc = path.join(root, '.opencode');
699
- const manifestPath = path.join(oc, GLOBAL_MANIFEST);
700
-
701
- // PRE. A missing manifest reads as "owned nothing before", which is what makes the first
702
- // re-emit after an upgrade treat already-existing files as the user's (claim-on-create)
703
- // rather than deleting them. The prune set is then empty by construction.
704
- const { existed: manifestExisted, doc } = readManifest(manifestPath);
705
- const oldOwned = (doc && doc.owned) || [];
706
-
707
- const agentPathRel = relUnder(out, root);
708
- const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
709
-
710
- // RENDER + WIRE, one call. `primaryAgentSrc` lives in `_build_emit` and only this job
711
- // needs it, so the caller adds it rather than `makeCfg` reaching across.
712
- const rendered = emitOpencodeRender(
713
- { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
714
- {
715
- theme: args.theme, out, root, footprint: args.footprint,
716
- nativeCatalog: NATIVE_CATALOG.opencode, oldOwned, manifestExisted, agentPath,
717
- });
718
- const { owned, stats, cfgName } = rendered;
719
-
720
- phaseLog('PRUNE');
721
- pruneOwned(oc, oldOwned, owned);
722
-
723
- phaseLog('MANIFEST');
724
- writeManifestAtomic(manifestPath, {
725
- _comment: 'Files owned by Geneseed\'s per-repo OpenCode emit (--emit opencode). '
726
- + 'Do not edit; removed on re-emit. A pre-existing file not in this '
727
- + 'list is yours and is never touched.',
728
- owned: [...owned].sort(),
729
- scope: 'project',
730
- });
731
-
732
- const extra = extrasTail(stats);
733
- process.stdout.write(`[geneseed] opencode layer: ${stats.nAgents} subagents, `
734
- + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
735
- + `${stats.nWorkflows} workflow file(s), ${cfgName} (instructions: ${agentPath})`
736
- + `${extra}\n`);
737
- }
738
-
739
- /**
740
- * `_build_global.emit_opencode_global` — the driver body for the "everything global, zero
741
- * per-repo" deployment.
742
- *
743
- * Shaped like `emitOpencode` and differing in four measured ways, each of which is a value
744
- * this driver decides rather than the child re-deriving it:
745
- * - the target is the RESOLVED config dir, not `--out`;
746
- * - `out` is passed through unchanged and is the LEGACY BUNDLE a memory store is migrated
747
- * from — not the target, not the config dir's parent, not derivable from anything the
748
- * child holds;
749
- * - `agentPath` is ABSOLUTE here (`<cfg>/AGENT.md` as POSIX), where the per-repo emit
750
- * sends a path relative to the project root;
751
- * - the manifest carries no `scope` and no `managed` claim set, because the one file this
752
- * emit wires is never unwired by any teardown — so there is nothing to record and no
753
- * VERIFY stage to re-read it.
754
- */
755
- function emitOpencodeGlobal(cfg, args, out) {
756
- const cfgDir = args.cfgDir ?? opencodeConfigDir();
757
- const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
758
-
759
- const { doc } = readManifest(manifestPath);
760
- const oldOwned = (doc && doc.owned) || [];
761
-
762
- const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
763
-
764
- const rendered = emitOpencodeGlobalRender(
765
- { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
766
- {
767
- theme: args.theme, cfgDir, out: out === null ? null : out,
768
- footprint: args.footprint, nativeCatalog: NATIVE_CATALOG.opencode,
769
- oldOwned, agentPath,
770
- });
771
- const { owned, stats, memStatus, nbStatus, cfgName } = rendered;
772
-
773
- phaseLog('PRUNE');
774
- pruneOwned(cfgDir, oldOwned, owned);
775
-
776
- phaseLog('MANIFEST');
777
- writeManifestAtomic(manifestPath, {
778
- _comment: 'Files owned by Geneseed\'s --emit opencode-global. '
779
- + 'Do not edit; removed on re-emit. The memory and notebook '
780
- + 'stores are NOT listed — they are never deleted.',
781
- owned: [...owned].sort(),
782
- });
783
-
784
- const extra = extrasTail(stats);
785
- process.stdout.write(`[geneseed] opencode-global -> ${cfgDir}: ${stats.nAgents} subagents, `
786
- + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
787
- + `${stats.nWorkflows} workflow file(s), AGENT.md, ${memStatus}, ${nbStatus}, `
788
- + `${cfgName} (no context.json)${extra}. `
789
- + 'The learn plugin now finds <cfg>/memory automatically; set GENESEED_HARNESS only to '
790
- + 'override.\n');
791
- return cfgDir;
792
- }
793
-
794
- /**
795
- * `_build_global._emit_claude_core` — the shared engine behind six emits.
796
- *
797
- * Only the two Copilot ones run through it so far, and that is a deliberate split rather
798
- * than an arbitrary stopping point: `js/build/emit-claude.mjs's wire` gates the entire settings/hook path
799
- * behind `if (!isCopilot)`, so a Copilot emit never reaches `hookPrefix` and therefore never
800
- * needs `hookOpts` — the pair of machine-absolute values (`sys.executable` and the harness
801
- * entry point) that a Node driver has no way to produce. Copilot crossing first isolates
802
- * this driver body from that question entirely.
803
- *
804
- * `hookOpts` is OMITTED for a host that does not wire hooks, rather than passed as `{}` or
805
- * `null`. If a future change ever routed a Copilot emit into the hook path, `hookPrefix()`
806
- * would receive `undefined`, hit its own default and throw the error it was built to throw
807
- * — where `null` would raise an unrelated TypeError and `{}` would bake the literal string
808
- * `"undefined"` into the shim and silently kill every hook in the install. Fail loud, on
809
- * purpose. The four hook-writing hosts pass a real pair from `hookRunnerEntry()`, which
810
- * cannot fail to produce one — `process.execPath` is the node already running.
811
- */
812
- function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
813
- const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
814
- const { doc } = readManifest(manifestPath);
815
- const oldOwned = (doc && doc.owned) || [];
816
- const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
817
- && !Array.isArray(doc.managed)) ? doc.managed : {};
818
- const isCopilot = host === 'copilot';
819
-
820
- // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
821
- // but Claude — `_PREAMBLE_CONFIG_DIR` has exactly one key, `CLAUDE.md` — so neither
822
- // Copilot carrier filename (copilot-instructions.md, AGENTS.md) resolves to one, and
823
- // neither does Bob's. Spelling it as a call rather than a literal is what makes that a
824
- // measured `null` instead of an assumed one.
825
- const rendered = emitClaudeRender(cfg, {
826
- theme: args.theme, cfgDir, claudeMd, scope, host,
827
- out: out === null ? null : out,
828
- footprint: args.footprint, nativeCatalog: NATIVE_CATALOG[host] ?? false,
829
- oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd),
830
- ...(hookOpts ? { hookOpts } : {}),
831
- });
832
- const { owned, stats, memStatus, nbStatus, managed } = rendered;
833
-
834
- phaseLog('PRUNE');
835
- pruneOwned(cfgDir, oldOwned, owned);
836
-
837
- phaseLog('MANIFEST');
838
- writeManifestAtomic(manifestPath, {
839
- _comment: "Files owned by Geneseed's Claude emit. Do not edit; removed on "
840
- + 're-emit. The memory and notebook stores are NOT listed — never '
841
- + 'deleted. `managed` records the CLAUDE.md block + settings.json '
842
- + 'hooks so uninstall removes exactly those.',
843
- owned: [...owned].sort(),
844
- managed,
845
- scope,
846
- });
847
-
848
- // VERIFY — re-read the settings file just written and match it against the claims just
849
- // recorded, so a merge that silently did not stick (a commented file, a mid-flight
850
- // external edit, a bug in the merge) is loud now instead of surfacing as hooks that
851
- // quietly never fire. Copilot's settings have their own shape and their own check; a
852
- // Copilot PROJECT emit records no hooks and so checks nothing.
853
- //
854
- // On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
855
- // makes it a live cross-implementation check on every build. Here both halves are Node, so
856
- // that particular property is gone and only the self-check remains — worth stating,
857
- // because it is a real reduction in what a Claude emit proves about itself, and the thing
858
- // that replaces it is the acceptance matrix rather than anything at runtime.
859
- //
860
- // SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
861
- // deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
862
- // _geneseed_hook` plants the fault that makes it speak.
863
- phaseLog('VERIFY');
864
- if (!isCopilot) {
865
- settingsIntegrityCheck(
866
- path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
867
- } else if (managed.copilot_hooks) {
868
- copilotIntegrityCheck(path.join(cfgDir, 'settings.json'), managed.copilot_hooks, 'present');
869
- }
870
- return {
871
- nAgents: stats.nAgents,
872
- nSkills: stats.nSkills,
873
- nHooks: (managed.settings_hooks || managed.copilot_hooks || []).length,
874
- memStatus,
875
- nbStatus,
876
- };
877
- }
878
-
879
- /** `_build_global.emit_copilot` — per-repo: AGENTS.md at the repo root + a `.github/` layer. */
880
- function emitCopilot(cfg, args, out) {
881
- const root = args.root ? resolveOut(args.root) : out;
882
- const cfgDir = path.join(root, '.github');
883
- const r = emitClaudeCore(cfg, args, {
884
- cfgDir, claudeMd: path.join(root, 'AGENTS.md'), scope: 'project', host: 'copilot', out,
885
- });
886
- process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
887
- + `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
888
- + `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
889
- + '~/.copilot/settings.json only — the global emit wires them).\n');
890
- return cfgDir;
891
- }
892
-
893
- /** `_build_global.emit_copilot_global` — into Copilot's personal config dir (~/.copilot). */
894
- function emitCopilotGlobal(cfg, args, out) {
895
- const cfgDir = args.cfgDir ?? copilotConfigDir();
896
- warnCopilotGlobalOverProject();
897
- // The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
898
- // that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
899
- const hookOpts = hookRunnerEntry();
900
- const r = emitClaudeCore(cfg, args, {
901
- cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
902
- scope: 'global', host: 'copilot', out, hookOpts,
903
- });
904
- process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
905
- + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
906
- + `${r.nHooks} hooks (settings.json: sessionStart context, toolCall gate). Memory `
907
- + "write-back rides the preamble's instructions; MCP servers go in mcp-config.json.\n");
908
- return cfgDir;
909
- }
910
-
911
- /**
912
- * `_build_global.emit_claude_global` — into Claude Code's global config dir (~/.claude).
913
- *
914
- * `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse:
915
- * the shape stayed after P5b deleted the refusal so that a future value which CAN fail is
916
- * decided while nothing has been written, rather than behind a half-rendered config dir.
917
- */
918
- function emitClaudeGlobal(cfg, args, out) {
919
- const cfgDir = args.cfgDir ?? claudeConfigDir();
920
- const hookOpts = hookRunnerEntry();
921
- const r = emitClaudeCore(cfg, args, {
922
- cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'global', host: 'claude', out,
923
- hookOpts,
924
- });
925
- // "Hooks call the harness by absolute path", not `harness.py` — and this one was already
926
- // WRONG on this side rather than merely about to be: P5b made this driver bake `<node>
927
- // bin/geneseed-hook.mjs` into the hooks it writes, so the sentence named a file its own
928
- // emit does not use. The two drivers bake different entries and printed the same claim;
929
- // the neutral wording is true of both, and stays true when only one is left. It is frozen
930
- // in the `claude-global` cells' recorded stdout, which is why it moves here rather than in
931
- // the deletion phase, which may not move a recorded byte.
932
- process.stdout.write(`[geneseed] claude-global -> ${cfgDir}: ${r.nAgents} subagents, `
933
- + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
934
- + `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
935
- + '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
936
- + 'GENESEED_HARNESS only to relocate memory.\n');
937
- return cfgDir;
938
- }
939
-
940
- /** `_build_global.emit_claude` — per-repo: CLAUDE.md at the root + a `.claude/` layer. */
941
- function emitClaude(cfg, args, out) {
942
- const root = args.root ? resolveOut(args.root) : out;
943
- const hookOpts = hookRunnerEntry();
944
- const r = emitClaudeCore(cfg, args, {
945
- cfgDir: path.join(root, '.claude'), claudeMd: path.join(root, 'CLAUDE.md'),
946
- scope: 'project', host: 'claude', out, hookOpts,
947
- });
948
- process.stdout.write(`[geneseed] claude (folder) -> ${root}: CLAUDE.md + .claude/ `
949
- + `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
950
- + `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
951
- }
952
-
953
- /**
954
- * `_build_global.emit_bob_global` — into Bob's global config dir (~/.bob).
955
- *
956
- * The warning fires BEFORE the emit, matching the Python order: it is about a state this
957
- * emit is at the point of creating, so printing it afterwards would describe the situation
958
- * as though the operator had already chosen it.
959
- */
960
- function emitBobGlobal(cfg, args, out) {
961
- const cfgDir = args.cfgDir ?? bobConfigDir();
962
- const hookOpts = hookRunnerEntry();
963
- warnBobGlobalOverProject();
964
- const r = emitClaudeCore(cfg, args, {
965
- cfgDir, claudeMd: path.join(cfgDir, 'AGENTS.md'), scope: 'global', host: 'bob', out,
966
- hookOpts,
967
- });
968
- process.stdout.write(`[geneseed] bob-global -> ${cfgDir}: ${r.nAgents} subagents, `
969
- + `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
970
- + 'AGENTS.md is not auto-loaded, so none is written), '
971
- + `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`);
972
- return cfgDir;
973
- }
974
-
975
- /** `_build_global.emit_bob` — per-repo: AGENTS.md at the root + a `.bob/` layer. */
976
- function emitBob(cfg, args, out) {
977
- const root = args.root ? resolveOut(args.root) : out;
978
- const hookOpts = hookRunnerEntry();
979
- const r = emitClaudeCore(cfg, args, {
980
- cfgDir: path.join(root, '.bob'), claudeMd: path.join(root, 'AGENTS.md'),
981
- scope: 'project', host: 'bob', out, hookOpts,
982
- });
983
- process.stdout.write(`[geneseed] bob (folder) -> ${root}: AGENTS.md + .bob/ `
984
- + `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
985
- + `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
986
- }
987
-
988
- /**
989
- * build.py:437-466 — the POST stage, which writes markers and records the install and
990
- * wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
991
- * Python side; this is the same stage on this side, and it stays after the dispatch for
992
- * the same reason.
993
- *
994
- * `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
995
- * `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
996
- * with a bare LF differs from Python's in every cell on this platform.
997
- */
998
- function writeMarkers(markerDir, emit, footprint) {
999
- try {
1000
- mkdirSync(markerDir, { recursive: true });
1001
- writeText(path.join(markerDir, '.geneseed-emit'), `${emit}\n`);
1002
- // Written for EVERY emit (build.py:443, unconditional), unlike the theme marker:
1003
- // the claude/bob/copilot PROJECT installs never call build(), so this is their only
1004
- // footprint record. Read by harness._footprint_of_dir, which defaults to 'full'.
1005
- writeText(path.join(markerDir, '.geneseed-footprint'), `${footprint}\n`);
1006
- } catch { /* best-effort, exactly as build.py:444-445 — a marker hiccup never fails a build */ }
1007
- }
1008
-
1009
- /**
1010
- * `_build_global.HOSTS[host]['emit_global']` — the one column a CALLER outside the dispatch
1011
- * needs, and the reason it is a named export rather than a fifth branch in `main`.
1012
- *
1013
- * `harness diff` renders an 'expected' copy of a deployed install into a temp dir and
1014
- * compares it file by file. The Python spells that `HOSTS[host]["emit_global"](theme,
1015
- * out=..., cfg=<tmp>, footprint=...)`, where `cfg` overrides the target the emit would
1016
- * otherwise resolve. This is that call, and the override is threaded as `args.cfgDir` through
1017
- * the four `emit*Global` bodies rather than added to `parseArgs`: `build.py` has no
1018
- * `--cfg-dir` flag, and a flag on one driver and not the other is a divergence
1019
- * `tests/golden.py` compares 259 times and could never see, because it drives both from the
1020
- * same argv.
1021
- *
1022
- * WHICH SIDE OF THE BOUNDARY THIS SITS ON. P3c's rule was that a render CHILD must never
1023
- * resolve a config dir, because a child that did would write 135 files into the developer's
1024
- * real `~/.config/opencode`; P4a inverted it for the driver, which is the parent and must
1025
- * ORIGINATE what the child may not resolve. `diff` is a third position: it is a parent that
1026
- * needs the target to be somewhere other than where the resolver would put it, so it sends
1027
- * the value and the resolver is skipped entirely. An emit whose `cfgDir` came from anywhere
1028
- * but this argument would overwrite the live install `diff` is only reading.
1029
- */
1030
- const GLOBAL_EMITS = {
1031
- opencode: emitOpencodeGlobal,
1032
- claude: emitClaudeGlobal,
1033
- bob: emitBobGlobal,
1034
- copilot: emitCopilotGlobal,
1035
- };
1036
-
1037
- /**
1038
- * The PER-REPO half of the same idea, and `doctor` is its only caller.
1039
- *
1040
- * `_claude_bob_emit_problems` renders `emit_claude`, `emit_bob` and `emit_copilot` into
1041
- * throwaway sandboxes and scans each — the three emits that write CLAUDE.md/AGENTS.md
1042
- * straight into a repo, and the ones outside doctor's sweep when the skill-table dead links
1043
- * shipped.
1044
- *
1045
- * IT CALLS THE EMIT, NOT `main`, AND THAT IS THE WHOLE POINT OF THE EXPORT. Routing this
1046
- * through `driverMain(['--emit', 'claude', …])` would look like less code and would run two
1047
- * stages the Python's direct call never reaches: `writeMarkers` drops `.geneseed-emit` and
1048
- * `.geneseed-footprint` into the sandbox, and `registryRecord` writes a row into the USER'S
1049
- * install registry for a temp directory that is deleted a millisecond later. A validation
1050
- * pass must not register an install.
1051
- *
1052
- * `footprint: 'full'` is the Python signature default that `_claude_bob_emit_problems`'s
1053
- * three-positional call leaves in place, inherited here rather than re-decided.
1054
- */
1055
- const PROJECT_EMITS = {
1056
- claude: emitClaude, bob: emitBob, copilot: emitCopilot,
1057
- // P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
1058
- // `--emit` choice into its sandbox and not only the three doctor already scans. Its call
1059
- // shape is `emitClaude`'s exactly — `(cfg, args, out)` — so the row is the whole change.
1060
- opencode: emitOpencode,
1061
- };
1062
-
1063
- export function emitProjectInto(host, { theme, out, root, footprint = 'full', doctrines = null }) {
1064
- const emit = PROJECT_EMITS[host];
1065
- // `doctrines` rides the same rail posture and mode ride into `emitGlobalInto`, and for the
1066
- // same reason: a drift reader that renders `expected` at the DEFAULT pack set reports the
1067
- // whole doctrines section as edited on every narrowed install. `null` means "no opinion" and
1068
- // `makeCfg` renders all four, which is what every validation caller wants.
1069
- return withPlatformNewlines(() => emit(makeCfg(doctrines ? { doctrines } : {}),
1070
- { theme, footprint, root }, out));
1071
- }
1072
-
1073
- /**
1074
- * The ninth `--emit` choice — a plain `files` bundle — for the same caller and the same
1075
- * reason: `_validate_only`'s `else` branch is `build(args.theme, sandbox, args.footprint)`,
1076
- * a DIRECT call that reaches neither `writeMarkers` nor `registryRecord`.
1077
- *
1078
- * `nativeCatalog: false` is that three-positional call's signature default, inherited here
1079
- * exactly as `run` inherits it below.
1080
- */
1081
- export function buildInto({ theme, out, footprint = 'lean' }) {
1082
- return withPlatformNewlines(
1083
- () => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
1084
- );
1085
- }
1086
-
1087
- export function emitGlobalInto(host, {
1088
- theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null,
1089
- }) {
1090
- // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
1091
- // raising, because the host comes from a marker file a user can edit.
1092
- const emit = GLOBAL_EMITS[host] ?? GLOBAL_EMITS.opencode;
1093
- // The same funnel `main` installs, for the same reason and not because `diff` asked: this
1094
- // runs the whole render tree, whose ~25 print sites write raw `\n`. `diff` swallows the
1095
- // emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
1096
- // that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
1097
- // terminal with the wrong bytes on the one stream the caller deliberately does not hide.
1098
- // POSTURE AND MODE TRAVEL WITH THE RENDER, and the `|| default` is load-bearing:
1099
- // `postureOfDir` returns null for "no install / undetectable", which `makeCfg`'s parameter
1100
- // default would NOT catch. Callers that pass nothing keep the reference's behaviour of
1101
- // rendering at `peer`/`direct`; the drift readers pass what the deployment actually says,
1102
- // because rendering `expected` at the defaults reports AGENT.md as edited on every install
1103
- // that chose a register — the same scar the footprint left in `diffCollect`, one axis over.
1104
- return withPlatformNewlines(
1105
- () => emit(
1106
- // THE PACK SELECTION RIDES THE SAME RAIL, with one difference: `doctrinesOfDir` answers
1107
- // `null` for "no marker / undetectable", and that must render at ALL packs rather than
1108
- // at `[]`, so the key is OMITTED instead of passed through — `makeCfg`'s own default is
1109
- // the fail-closed answer and an explicit `doctrines: null` would not reach it.
1110
- makeCfg({
1111
- posture: posture || 'peer', mode: mode || 'direct', ...(doctrines ? { doctrines } : {}),
1112
- }),
1113
- { theme, footprint, root: null, cfgDir }, out,
1114
- ),
1115
- );
1116
- }
1117
-
1118
- /**
1119
- * EXPORTED because `harness build` is a passthrough to this program.
1120
- *
1121
- * `_harness_build.cmd_build` is `run([sys.executable, BUILD, *extra]).returncode` — Python
1122
- * needs a second process because `build.py` is a different PROGRAM. Here it is a module in
1123
- * the same one, and `bin/geneseed-cli.mjs` is under a transitive `child_process` ban, so
1124
- * `js/build/generate.mjs` calls this directly. The export is what makes the auto-run below need a
1125
- * guard: without one, importing this file to reach `main` would RUN the generator with the
1126
- * CLI's argv.
1127
- */
1128
- export function main(argv) {
1129
- // `withPlatformNewlines` is what makes this driver's bytes Python's bytes on Windows, for every
1130
- // print site in the whole render tree at once — see its docblock for why a funnel and not
1131
- // 25 calls to `printOut`. It wraps the `catch` as well as `run`, because `die` writes its
1132
- // line on the way out.
1133
- return withPlatformNewlines(() => {
1134
- try {
1135
- return run(argv);
1136
- } catch (e) {
1137
- // Every DELIBERATE refusal becomes this process's exit code — `die`'s, and equally
1138
- // `assertSourceComplete`'s and `effectiveTheme`'s, which have already explained
1139
- // themselves on stderr. An unmarked throw is a crash and keeps its stack, because
1140
- // Python prints a traceback for one and dressing it as a tidy refusal would hide a bug.
1141
- if (e && e.exitCode !== undefined) return e.exitCode;
1142
- throw e;
1143
- }
1144
- });
1145
- }
1146
-
1147
- function run(argv) {
1148
- const args = parseArgs(argv, configDefaults());
1149
-
1150
- // P2 crossed both of the flags this branch used to refuse, and they landed in two
1151
- // different places because the transitive `child_process` ban splits them.
1152
- //
1153
- // `--sync-themes` is HERE, because it needs nothing this driver may not have: it reads
1154
- // `themes/`, rewrites the files textually, and prints. `js/build/themes.mjs` carries it.
1155
- // Non-zero when files were CHANGED (0 == already in sync), so CI can run it as a drift
1156
- // check — build.py:393-397's mapping, and a refusal (exit 2) if the template is unreadable.
1157
- if (args.syncThemes) return syncThemes() ? 1 : 0;
1158
- // `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
1159
- // `js/inspect/checks-authoring.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
1160
- // is under a transitive ban on reaching any such module, gated twice — by a source grep in
1161
- // `tests/test_node_cli_parity.py` and by an import walk in `tests/test_hook_cli_parity.py`.
1162
- // So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
1163
- // points at it rather than silently building for real into the caller's `--out`.
1164
- if (args.validateOnly) {
1165
- die(2, '--validate-only lives on the CLI entry point, because it runs the doctor and '
1166
- + 'this generator may not start a process. Run: geneseed validate '
1167
- + '[--theme T] [--emit E] [--out O] [--root R] [--footprint F] [-v]');
1168
- }
1169
-
1170
- // Every `--emit` choice has crossed as of P4e, so this branch is now unreachable — and it
1171
- // stays, because it is the partition `test_the_node_driver_classifies_every_emit` asserts:
1172
- // a tenth emit added to `EMITS` and not to `PORTED` refuses loudly here instead of falling
1173
- // through the dispatch's `else` and silently building a plain bundle.
1174
- if (!PORTED.has(args.emit)) {
1175
- // The `Run: …` tail naming the Python driver is gone. It named the fallback for an emit
1176
- // this driver could not do, and there is no longer a second driver to fall back TO — the
1177
- // partition is now "known" vs "not a thing", and only the refusal is left to say so.
1178
- die(3, `--emit ${args.emit} is not one this driver builds (only `
1179
- + `${[...PORTED].join(', ')} are).`);
1180
- }
1181
-
1182
- const out = resolveOut(args.out);
1183
- const cfg = makeCfg(args);
1184
-
1185
- // The marker directory is the emit's TARGET, which for a global emit is the config dir it
1186
- // just rendered into and not `--out` at all — so the emit returns it rather than the
1187
- // caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
1188
- //
1189
- // NINE ARMS, TWO TABLES ALREADY ON FILE: `GLOBAL_EMITS` and `PROJECT_EMITS` above are
1190
- // exactly this dispatch's `-global` and per-repo halves, keyed by host rather than by
1191
- // `--emit` name, so splitting the suffix reaches both without a third map.
1192
- let markerDir = out;
1193
- if (args.emit.endsWith('-global')) {
1194
- markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
1195
- } else if (args.emit !== 'files') {
1196
- // The PROJECT emits keep their markers in `out`, not in the host config dir the emit
1197
- // wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
1198
- PROJECT_EMITS[args.emit](cfg, args, out);
1199
- } else {
1200
- // The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
1201
- // three-positional-argument call reproduced — `build(args.theme, out, args.footprint)`
1202
- // leaves `native_catalog` at its signature default. It is the one signature default
1203
- // this driver DOES inherit, and it is inherited because the Python CLI inherits it too.
1204
- build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
1205
- }
1206
-
1207
- writeMarkers(markerDir, args.emit, args.footprint);
1208
- // build() drops a .geneseed-theme in `out` for the emits that call it; the global emits
1209
- // render into the config dir WITHOUT calling build(), so the theme is recorded here.
1210
- // Deliberately not written for the claude/bob/copilot PROJECT emits — they carry none,
1211
- // and `_harness_setup._installed_defaults` detects those by an AGENT.md sigil scan
1212
- // instead. Writing one for them would change the emitted tree.
1213
- if (args.emit.endsWith('-global')) {
1214
- try {
1215
- writeText(path.join(markerDir, '.geneseed-theme'), `${args.theme}\n`);
1216
- } catch { /* best-effort, as build.py:449-452 */ }
1217
- }
1218
- // An ALLOW-LIST, not "everything that is not global": a plain `--emit files` dev build —
1219
- // the default — must never pollute the registry, and only the four per-repo host emits
1220
- // are ones `_EMIT_HOST_SCOPE` can map back to a row. Records `out`, where the marker is.
1221
- if (['opencode', 'claude', 'bob', 'copilot'].includes(args.emit)) registryRecord(markerDir);
1222
- return 0;
1223
- }
1224
-
1225
- /**
1226
- * Run only when this file IS the entry point, not when `js/build/generate.mjs` imports it.
1227
- *
1228
- * `import.meta.main` would say this in one word and is Node 24; the spec's `engines` floor
1229
- * is still open and this machine runs v22, which is why the comparison is spelled out rather than using it. So the
1230
- * check is the manual one, through `realpathSync` because `process.argv[1]` is whatever the
1231
- * caller typed and `import.meta.url` is always the resolved file — an `npx`-generated shim
1232
- * or a `geneseed link` symlink makes those differ as strings while naming the same file.
1233
- *
1234
- * BOTH ways of getting this wrong are gated, which is why it is a guard and not a comment,
1235
- * and both were measured rather than argued:
1236
- *
1237
- * * stuck FALSE — `node bin/build-driver.mjs` becomes a silent no-op: 259 golden cells.
1238
- * * stuck TRUE — importing this file runs the generator on the IMPORTER's argv, so it is
1239
- * not only `build` that breaks. `bin/geneseed-cli.mjs` reaches `js/build/generate.mjs` at
1240
- * module load, so every CLI verb dies in the driver's flag parser: **62 of the 166
1241
- * acceptance cells**, `exclude` and `status` and `version` among them.
1242
- */
1243
- const entry = (() => {
1244
- if (!process.argv[1]) return false;
1245
- try {
1246
- return realpathSync.native(process.argv[1]) === realpathSync.native(fileURLToPath(import.meta.url));
1247
- } catch {
1248
- return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1249
- }
1250
- })();
1251
-
1252
- if (entry) process.exitCode = main(process.argv.slice(2));
9
+ process.exitCode = main(process.argv.slice(2));