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