geneseed 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -0,0 +1,375 @@
1
+ /**
2
+ * The generator's flag surface — the defaults `harness.config.json` supplies, the parse tables,
3
+ * `-h`, and the argparse-shaped refusal (`die`). Split out of `driver.mjs`, which consumes it
4
+ * through `parseArgs`/`configDefaults` and re-exports `parseDriverArgs` for `geneseed validate`.
5
+ *
6
+ * Under the same transitive `child_process` ban as the driver: everything here reads the
7
+ * checkout and prints, nothing more.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { parseArgs as nodeParseArgs } from 'node:util';
11
+ import { withPlatformNewlines } from '../lib/fs.mjs';
12
+ import path from 'node:path';
13
+ import { CONFIG, discoverNames, PACK_ORDER, knownRuleIds } from './source.mjs';
14
+ // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
15
+ // what lets it sit inside this module's `child_process` ban.
16
+ import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
17
+
18
+ /** The nine `--emit` choices: a plain bundle, then a project and a global emit per host. */
19
+ const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
20
+ 'bob', 'bob-global', 'openclaude', 'openclaude-global'];
21
+
22
+ /**
23
+ * build.py:307-321 — the three defaults that come from `harness.config.json`.
24
+ *
25
+ * The corrupt-file branch is reproduced including its imprecision: the warning says only
26
+ * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
27
+ * Python text mattered more than fixing it here, because the two CLIs' stderr was compared
28
+ * byte-for-byte by the former Python golden harness; nothing pins the wording now.
29
+ */
30
+ export function configDefaults() {
31
+ const d = {
32
+ theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
33
+ doctrines: [...PACK_ORDER], excludeRules: [],
34
+ };
35
+ if (!existsSync(CONFIG)) return d;
36
+ try {
37
+ const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
38
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
39
+ if (data.theme !== undefined) d.theme = data.theme;
40
+ if (data.posture !== undefined) d.posture = data.posture;
41
+ if (data.mode !== undefined) d.mode = data.mode;
42
+ // `doctrines` is the only config key that is a LIST, so it is the only one that can be
43
+ // the right type and still nonsense. A bad value warns and falls back to all packs
44
+ // rather than dying: this file is hand-editable, and a typo here must not brick every
45
+ // rebuild of an install whose AGENT.md is otherwise fine. `[]` is a legitimate value
46
+ // (no packs) and must survive the check — hence an explicit Array test, not truthiness.
47
+ if (data.doctrines !== undefined) {
48
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
49
+ if (Array.isArray(data.doctrines) && data.doctrines.every((x) => known.includes(x))) {
50
+ d.doctrines = PACK_ORDER.filter((p) => data.doctrines.includes(p));
51
+ } else {
52
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "doctrines" is not a `
53
+ + 'list of known pack names — using all packs.\n');
54
+ }
55
+ }
56
+ // The second axis, read the same forgiving way and defaulting the OTHER direction: a
57
+ // bad value excludes NOTHING, because the safe answer for a list of things to take
58
+ // away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
59
+ if (data.excludeRules !== undefined) {
60
+ if (Array.isArray(data.excludeRules)
61
+ && data.excludeRules.every((x) => typeof x === 'string')) {
62
+ // ⚠ AND EVERY ID IS CHECKED, as `--exclude-rules` checks its own: an unknown id
63
+ // passed through lands in the `Excluded rules:` marker, `rebuild-all` hands it back
64
+ // as a flag, and `parseExcludeRules` dies on it — every rebuild of that install,
65
+ // forever. Unknown ids are dropped with a WARN; the known ones still bind.
66
+ const known = knownRuleIds();
67
+ const ids = data.excludeRules.map((x) => x.trim().replace(/[ \t]+/, '.'));
68
+ const unknown = ids.filter((id) => !known.includes(id));
69
+ if (unknown.length) {
70
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" names `
71
+ + `unknown rule(s) ${unknown.map((id) => `'${id.replace('.', ' ')}'`).join(', ')}`
72
+ + ' — ignoring them.\n');
73
+ }
74
+ d.excludeRules = [...new Set(ids.filter((id) => known.includes(id)))].sort();
75
+ } else {
76
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
77
+ + 'not a list of rule addresses — excluding nothing.\n');
78
+ }
79
+ }
80
+ }
81
+ } catch {
82
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
83
+ + "using theme 'neutral'.\n");
84
+ }
85
+ return d;
86
+ }
87
+
88
+ /**
89
+ * The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
90
+ * dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
91
+ * own private copy of the flags is a second source that drifts silently the first time a
92
+ * flag is added to one and not the other, and nothing downstream would say so.
93
+ */
94
+ const VALUED = {
95
+ '--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
96
+ '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
97
+ '--out': 'out', '--target': 'out', '--emit': 'emit',
98
+ '--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
99
+ };
100
+ const FLAGS = {
101
+ '--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
102
+ '-v': 'verbose', '--verbose': 'verbose',
103
+ };
104
+
105
+ /**
106
+ * `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
107
+ * second time — a hand-written twin is exactly the anti-drift failure this module's docblock
108
+ * already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
109
+ * value` form is captured as one token instead of leaking `value` as an orphan positional);
110
+ * every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
111
+ * `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
112
+ */
113
+ const PARSE_OPTIONS = Object.fromEntries([
114
+ ...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
115
+ ...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
116
+ ]);
117
+ PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
118
+
119
+ /** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
120
+ const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
121
+ const FLAG_BY_NAME = Object.fromEntries(
122
+ Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
123
+ );
124
+
125
+ /**
126
+ * The five flags `choice` validates, as ONE table for the same anti-drift reason.
127
+ *
128
+ * A function and not a constant because two of the five read the checkout: `discoverNames`
129
+ * scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
130
+ * standing in and cannot be frozen at import.
131
+ *
132
+ * `--doctrines` is DELIBERATELY not a sixth row. Every consumer of this table treats an
133
+ * entry as one value drawn from a closed list — `choice` refuses anything else, and
134
+ * `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
135
+ * LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
136
+ * advertised a metavar that lies. It is validated by `parseDoctrines` instead and prints the
137
+ * plain `DOCTRINES` metavar, exactly as `--theme` (also open-ended) already does.
138
+ */
139
+ function choicesFor() {
140
+ return {
141
+ '--emit': EMITS,
142
+ '--footprint': ['lean', 'full'],
143
+ '--posture': discoverNames('postures', 'peer'),
144
+ '--mode': discoverNames('modes', 'direct'),
145
+ '--trust': Object.keys(PRESETS),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
151
+ *
152
+ * BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
153
+ * every citation in the corpus spell a rule `process 7`; the console's deep link and the
154
+ * catalogue spell it `process.7`. A flag that took only one of them would be wrong for
155
+ * whichever half of the documentation the reader had open, so it takes either and normalises
156
+ * to the dotted form the code compares.
157
+ *
158
+ * ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
159
+ * refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
160
+ * a typo that would otherwise take away nothing in silence, which is the failure this flag
161
+ * can least afford: the user asked for a rule to STOP binding and it would keep binding.
162
+ * Sorted, deduped, for the same marker-stability reason `--doctrines` is.
163
+ */
164
+ function parseExcludeRules(value) {
165
+ const s = value.trim();
166
+ if (s === 'none' || s === '') return [];
167
+ const known = knownRuleIds();
168
+ const out = [];
169
+ for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
170
+ const id = raw.replace(/[ \t]+/, '.');
171
+ if (!known.includes(id)) {
172
+ die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
173
+ + `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
174
+ }
175
+ out.push(id);
176
+ }
177
+ return [...new Set(out)].sort();
178
+ }
179
+
180
+ /**
181
+ * `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
182
+ *
183
+ * Normalised into `PACK_ORDER` rather than kept in the order typed, which also dedupes: the
184
+ * rendered `Active packs:` line is a MARKER a later reader parses back out of a deployed
185
+ * carrier, and a marker whose contents depend on argument order is one that compares unequal
186
+ * to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
187
+ * packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
188
+ * different fault and `js/build/render.mjs` refuses the whole build for it.
189
+ */
190
+ function parseDoctrines(value) {
191
+ const s = value.trim();
192
+ const known = discoverNames('doctrines', PACK_ORDER[0]);
193
+ if (s === 'none') return [];
194
+ const names = s.split(',').map((x) => x.trim()).filter(Boolean);
195
+ if (!names.length) {
196
+ die(2, "argument --doctrines: expected a comma-separated list of packs, or 'none'");
197
+ }
198
+ for (const n of names) {
199
+ if (!known.includes(n)) {
200
+ die(2, `argument --doctrines: invalid choice: '${n}' (choose from `
201
+ + `${known.map((c) => `'${c}'`).join(', ')}, or 'none')`);
202
+ }
203
+ }
204
+ return PACK_ORDER.filter((p) => names.includes(p));
205
+ }
206
+
207
+ /**
208
+ * `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
209
+ * did not, so the two entry points disagreed: the reference printed usage and exited 0 while
210
+ * `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
211
+ *
212
+ * NO GOLDEN CELL CAN SEE IT: every cell is built out of the render flags and never passes
213
+ * `--help`, and a flag missing from one side is invisible to a gate that never passes it.
214
+ * `tests/unit/node_driver.test.mjs` is the gate, and it checks a hand-written flag table rather
215
+ * than one scraped from this file, so it fails on drift instead of agreeing by construction.
216
+ *
217
+ * The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
218
+ * a hand-written copy of the reference's output in a file no gate compares, which is the
219
+ * drift this whole docblock is about; the surface here is the npx one, in the same class as
220
+ * the `--sync-themes` and `--validate-only` refusals that already answer differently.
221
+ */
222
+ function usage() {
223
+ const choices = choicesFor();
224
+ const metavar = (flag) => (choices[flag]
225
+ ? `{${choices[flag].join(',')}}`
226
+ : VALUED[flag].toUpperCase());
227
+ const valued = Object.keys(VALUED);
228
+ const bare = Object.keys(FLAGS);
229
+ return [
230
+ `usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
231
+ ...bare.map((f) => `[${f}]`)].join(' ')}`,
232
+ '',
233
+ 'Render the Geneseed harness for a theme.',
234
+ '',
235
+ 'options:',
236
+ ' -h, --help',
237
+ ...valued.map((f) => ` ${f} ${metavar(f)}`),
238
+ ...bare.map((f) => ` ${f}`),
239
+ '',
240
+ ].join('\n');
241
+ }
242
+
243
+ /**
244
+ * argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
245
+ * SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
246
+ * decision this function made before P6 (Task 4) still made HERE, unmoved.
247
+ *
248
+ * `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
249
+ * walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
250
+ * flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
251
+ * argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
252
+ * string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
253
+ * being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
254
+ * accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
255
+ * anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
256
+ * behaviour, not a preserved one.
257
+ *
258
+ * `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
259
+ * `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
260
+ * help text for a cluster the original parser refused outright as one unrecognized argument.
261
+ * Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
262
+ * ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
263
+ * privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
264
+ * for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
265
+ * would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
266
+ * this one) refuses the whole typed token, `-bogus`, once.
267
+ */
268
+ export function parseArgs(argv, defaults) {
269
+ const args = {
270
+ theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
271
+ trust: defaults.trust,
272
+ doctrines: defaults.doctrines,
273
+ excludeRules: defaults.excludeRules,
274
+ out: 'Harness', emit: 'files', footprint: 'lean', root: null,
275
+ syncThemes: false, validateOnly: false, verbose: false,
276
+ };
277
+ const { tokens } = nodeParseArgs({
278
+ args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
279
+ });
280
+ for (const tok of tokens) {
281
+ if (tok.kind === 'option' && tok.value === undefined
282
+ && (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
283
+ process.stdout.write(usage());
284
+ // The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
285
+ // `main`'s catch is what turns either into this process's exit status. A bare
286
+ // `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
287
+ // loop with it, and skips the stdout flush the text was just written to.
288
+ const e = new Error('help');
289
+ e.exitCode = 0;
290
+ throw e;
291
+ }
292
+ if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
293
+ if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
294
+ args[VALUED_BY_NAME[tok.name]] = tok.value;
295
+ continue;
296
+ }
297
+ // `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
298
+ // flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
299
+ // keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
300
+ // `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
301
+ // given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
302
+ if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
303
+ args[FLAG_BY_NAME[tok.name]] = true;
304
+ continue;
305
+ }
306
+ // Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
307
+ // always treats as a terminator but this parser never did) — refuses with the literal text
308
+ // typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
309
+ die(2, `unrecognized arguments: ${argv[tok.index]}`);
310
+ }
311
+ const choices = choicesFor();
312
+ for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
313
+ // Only when the flag was actually PASSED is this a string; left at its default it is
314
+ // already the array `configDefaults` built, and re-parsing an array would stringify it.
315
+ if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
316
+ if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
317
+ return args;
318
+ }
319
+
320
+ /**
321
+ * The generator's OWN flag surface, for the one consumer that is not this driver.
322
+ *
323
+ * `geneseed validate` takes every render flag but `--sync-themes`/`--config-dir` (it refuses
324
+ * those two, and prints its own `-h` before this parser can print the generator's) — the reference's
325
+ * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
326
+ * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
327
+ * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
328
+ * `-h`, and `tests/unit/node_driver.test.mjs`'s '--help names every flag the parser takes' gates only
329
+ * this one.
330
+ *
331
+ * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
332
+ * they sit inside `main`'s funnel, and called from the CLI binary they would not.
333
+ */
334
+ export function parseDriverArgs(argv) {
335
+ return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
336
+ }
337
+
338
+ function choice(flag, value, allowed) {
339
+ if (!allowed.includes(value)) {
340
+ die(2, `argument ${flag}: invalid choice: '${value}' `
341
+ + `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
347
+ *
348
+ * `harness rebuild-all` re-emits every active install and its whole contract is "continue
349
+ * past a failure so one broken install never blocks the rest". The Python gets that for free:
350
+ * each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
351
+ * driver is a module in the same process, so `process.exit` would take the loop, the CLI and
352
+ * every remaining install with it — the one place where importing rather than spawning is not
353
+ * transparent, and it turns a per-install failure into a total one.
354
+ *
355
+ * The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
356
+ * use, and the first draft of this used a second name so that only `die` would be converted
357
+ * — on the argument that an incomplete source should keep its stack. That draft was wrong,
358
+ * and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
359
+ * `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
360
+ * with only `die` converted the refusal propagated out of the loop and the remaining
361
+ * installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
362
+ * turns every deliberate refusal into an exit code — the narrower rule was a distinction
363
+ * with no principle behind it.
364
+ *
365
+ * One behaviour improves as a side effect, and it is worth naming rather than discovering
366
+ * later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
367
+ * a slow pipe could lose output it had already produced. Returning through `main` lets Node
368
+ * drain normally.
369
+ */
370
+ export function die(code, msg) {
371
+ process.stderr.write(`geneseed: error: ${msg}\n`);
372
+ const e = new Error(msg);
373
+ e.exitCode = code;
374
+ throw e;
375
+ }
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import path from 'node:path';
14
14
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
- import { copyFile, readText, writeText } from '../lib/fs.mjs';
15
+ import { copyFile, isDir, isFile, readText, writeText } from '../lib/fs.mjs';
16
16
  import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
- import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
17
+ import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE } from './emit-common.mjs';
18
18
  import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
19
19
  import {
20
20
  ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
@@ -114,16 +114,9 @@ function safePriorDirName(out, priorName) {
114
114
  // build
115
115
  // ---------------------------------------------------------------------------
116
116
 
117
- /**
118
- * `_build_render.build` — render the bundle into `out`.
119
- *
120
- * `out` arrives as the STRING Python already resolved, and is echoed into the progress
121
- * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
122
- * Windows, and re-deriving it here would differ by separator in every message.
123
- */
124
117
  /**
125
118
  * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
126
- * `tests/test_emit_phase_order.py`.
119
+ * `tests/unit/emit_phase_order.test.mjs`.
127
120
  *
128
121
  * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
129
122
  * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
@@ -156,6 +149,13 @@ export function phaseLog(phase) {
156
149
  process.stderr.write(`[geneseed:phase] ${phase}\n`);
157
150
  }
158
151
 
152
+ /**
153
+ * `_build_render.build` — render the bundle into `out`.
154
+ *
155
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
156
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
157
+ * Windows, and re-deriving it here would differ by separator in every message.
158
+ */
159
159
  export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
160
160
  phaseLog('RENDER');
161
161
  const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });