geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -383,7 +383,11 @@ behaviour** — nothing changes the machine's current agent/model unless you opt
383
383
  files (Law I), catastrophic shell like `rm -rf /` (Law IV), and any mutation under
384
384
  a declared wiki's `protected` folders (AGENT.md §8, from `wiki.jsonc`); **warns** on
385
385
  `.env` writes and force-push. `GENESEED_GUARD=off` disables it, `=warn` downgrades
386
- blocks to warnings.
386
+ blocks to warnings. Every **block** appends one line to the install's
387
+ `notebook/gates.jsonl` (rule `law-1` / `law-4` / `process-1` / `wiki`, a timestamp,
388
+ never the path or command) — the same ledger the Claude-family hooks write, so
389
+ `geneseed status` counts what the boundary caught on every host. Warnings are not
390
+ recorded.
387
391
  - **Invisible context injection** (the default). The context plugin delivers via
388
392
  `experimental.chat.messages.transform`, so the PROJECT CONTEXT block never appears in
389
393
  the conversation and survives compaction inherently; on a build without the hook it
@@ -60,6 +60,36 @@ const WARN_ONLY = MODE === "warn"
60
60
 
61
61
  function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
62
62
 
63
+ // ---- the gate ledger ------------------------------------------------------------
64
+ // The twin of `ledger()` in js/hosts/hooks.mjs: one JSON line per BLOCK into
65
+ // `<cfg>/notebook/gates.jsonl`, the rule and never the content, so `geneseed status` can
66
+ // count what the boundary actually caught on an OpenCode install too. Until this the
67
+ // recommended install was the one with no gate evidence at all. A warning is not recorded
68
+ // (nothing was refused) and neither is `GENESEED_GUARD=warn`. `notebook/` is never created
69
+ // here — an install without one (or a test importing this file) records nothing. Capped
70
+ // at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
71
+ const MAX_LEDGER_LINES = 1000
72
+ async function ledger(rule) {
73
+ try {
74
+ const bases = []
75
+ if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
76
+ bases.push(path.dirname(PLUGIN_DIR))
77
+ for (const base of bases) {
78
+ const dir = path.join(base, "notebook")
79
+ if (!(await isDir(dir))) continue
80
+ const file = path.join(dir, "gates.jsonl")
81
+ await fs.appendFile(file, `${JSON.stringify({
82
+ ts: new Date().toISOString(), verb: "guard", rule, cwd: process.cwd(),
83
+ })}\n`)
84
+ const lines = (await fs.readFile(file, "utf8")).split("\n").filter(Boolean)
85
+ if (lines.length > MAX_LEDGER_LINES) {
86
+ await fs.writeFile(file, `${lines.slice(-MAX_LEDGER_LINES).join("\n")}\n`)
87
+ }
88
+ return
89
+ }
90
+ } catch { /* the ledger never decides */ }
91
+ }
92
+
63
93
  // Files the agent must not write (secrets / private keys) → BLOCK.
64
94
  const SECRET_RE = [
65
95
  /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
@@ -119,6 +149,7 @@ const hasAny = (name, parts) => parts.some((s) => name.includes(s))
119
149
  // per wiki. Mutating anything under one is denied. Same resolution chain as the
120
150
  // context plugin: $GENESEED_WIKI -> $GENESEED_HARNESS/wiki.jsonc -> beside the install.
121
151
  async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
152
+ async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
122
153
 
123
154
  // wiki.jsonc is JSONC (the seeded stub carries a commented example): strip // and
124
155
  // /* */ comments plus trailing commas before parsing — string-aware, so quoted
@@ -243,22 +274,25 @@ export const GeneseedGuard = async () => {
243
274
  if (await sovereignBypass(process.cwd())) return
244
275
  const tool = (input?.tool || input?.name || "").toLowerCase()
245
276
  const args = output?.args || input?.args || {}
246
- const deny = (why) => {
277
+ // `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
278
+ // `law-4`, `process-1`) plus `wiki`, so status counts one vocabulary across hosts.
279
+ const deny = async (why, rule) => {
247
280
  if (WARN_ONLY) { log(`WARN (would block): ${why}`); return false }
248
281
  log(`BLOCKED: ${why}`)
282
+ await ledger(rule)
249
283
  throw new Error(`[geneseed-guard] blocked: ${why} — set GENESEED_GUARD=off to allow`)
250
284
  }
251
285
  try {
252
286
  if (hasAny(tool, WRITE_TOOLS)) {
253
287
  const p = pickPath(args)
254
- if (p && SECRET_RE.some((re) => re.test(p))) { deny(`write to secret/key file ${p} (Law I)`); return }
288
+ if (p && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Law I)`, "law-1"); return }
255
289
  if (p && SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Law I)`)
256
290
  const store = p && ruleStoreTarget(p)
257
291
  if (store && !RULE_STORE_BUMPED.has(p)) {
258
292
  RULE_STORE_BUMPED.add(p)
259
- deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
293
+ await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
260
294
  `choice is the user's (Doctrine process 1). Settle it through the rule skill, ` +
261
- `then re-issue this write`)
295
+ `then re-issue this write`, "process-1")
262
296
  return
263
297
  }
264
298
  }
@@ -268,14 +302,14 @@ export const GeneseedGuard = async () => {
268
302
  const abs = (path.isAbsolute(p) ? p : path.resolve(p)).replace(/\\/g, "/").toLowerCase()
269
303
  const hit = (await protectedPrefixes()).find(
270
304
  (x) => abs.startsWith(x.prefix) || abs === x.prefix.slice(0, -1))
271
- if (hit) { deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)`); return }
305
+ if (hit) { await deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)`, "wiki"); return }
272
306
  }
273
307
  }
274
308
  // NOT else-if: a compound tool name (e.g. exec_and_save) can match both
275
309
  // classes, and the shell check must still run after the write check.
276
310
  if (hasAny(tool, SHELL_TOOLS)) {
277
311
  const c = pickCommand(args)
278
- if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { deny(`catastrophic command (Law IV): ${c.slice(0, 80)}`); return }
312
+ if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Law IV): ${c.slice(0, 80)}`, "law-4"); return }
279
313
  if (c && SHELL_WARN_RE.some((re) => re.test(c))) log(`WARN: irreversible op — confirm intent (Law IV): ${c.slice(0, 80)}`)
280
314
  }
281
315
  } catch (err) {
@@ -16,14 +16,16 @@
16
16
  * values this file must originate. `ROOT` and the seven paths under it come from this
17
17
  * script's own location, not from an inherited `cfg`.
18
18
  *
19
- * TWO KEYS ARE DELIBERATELY ABSENT FROM `cfg`, and their absence is load-bearing.
20
- * `js_cfg()` (_build_core.py:199) always sends `structure` and `capabilityLinkRe`, because
21
- * the Python originals are module-level names that TESTS MUTATE — `_OWNED` membership
22
- * asked one level out. This driver has no Python module to mutate, so it sends neither and
23
- * `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` and `js/build/bundle.mjs`'s
24
- * `cfg.capabilityLinkRe ? ... : <default>` take their right-hand branches for the first
25
- * time. Those two fallbacks have been dead code since they were written — always
26
- * overridden by the driver that always supplies them. P4 is what makes them live.
19
+ * A KEY IS DELIBERATELY ABSENT FROM `cfg`, and its absence is load-bearing.
20
+ * `js_cfg()` (_build_core.py:199) always sent `structure` and `capabilityLinkRe`, because
21
+ * the Python originals were module-level names that TESTS MUTATE — `_OWNED` membership
22
+ * asked one level out. This driver has no Python module to mutate, so it sends neither;
23
+ * `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch for
24
+ * that reason — a fallback that was dead code until P4, always overridden by the driver
25
+ * that always supplies it. `capabilityLinkRe`'s equivalent fallback in
26
+ * `js/build/emit-common.mjs`'s `stripCapabilityLinks` was P4's other one made live, and the
27
+ * over-engineering cleanup then deleted the override branch outright: nothing had ever
28
+ * supplied one, so there was no longer a `cfg` left to be absent from.
27
29
  *
28
30
  * THE FOOTPRINT DEFAULT IS THE FLAG'S, NOT THE FUNCTION'S. `--footprint` defaults to
29
31
  * `lean` (build.py:354); every `emit_*`/`build` SIGNATURE defaults to `full`. A driver
@@ -32,15 +34,16 @@
32
34
  */
33
35
  import {
34
36
  existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync,
35
- statSync, unlinkSync,
37
+ unlinkSync,
36
38
  } from 'node:fs';
37
39
  import path from 'node:path';
38
40
  import { fileURLToPath } from 'node:url';
41
+ import { parseArgs as nodeParseArgs } from 'node:util';
39
42
  import { build, phaseLog } from '../js/build/bundle.mjs';
40
43
  import { emitClaudeRender } from '../js/build/emit-claude.mjs';
41
44
  import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
42
- import { settingsIntegrityCheck } from '../js/hosts/settings.mjs';
43
- import { writeText, withPlatformNewlines } from '../js/lib/fs.mjs';
45
+ import { copilotIntegrityCheck, settingsIntegrityCheck } from '../js/hosts/settings.mjs';
46
+ import { writeText, withPlatformNewlines, isFile } from '../js/lib/fs.mjs';
44
47
  import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
45
48
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
46
49
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
@@ -188,6 +191,26 @@ const FLAGS = {
188
191
  '-v': 'verbose', '--verbose': 'verbose',
189
192
  };
190
193
 
194
+ /**
195
+ * `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
196
+ * second time — a hand-written twin is exactly the anti-drift failure this module's docblock
197
+ * already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
198
+ * value` form is captured as one token instead of leaking `value` as an orphan positional);
199
+ * every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
200
+ * `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
201
+ */
202
+ const PARSE_OPTIONS = Object.fromEntries([
203
+ ...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
204
+ ...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
205
+ ]);
206
+ PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
207
+
208
+ /** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
209
+ const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
210
+ const FLAG_BY_NAME = Object.fromEntries(
211
+ Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
212
+ );
213
+
191
214
  /**
192
215
  * The four flags `choice` validates, as ONE table for the same anti-drift reason.
193
216
  *
@@ -307,7 +330,31 @@ function usage() {
307
330
  ].join('\n');
308
331
  }
309
332
 
310
- /** argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out`. */
333
+ /**
334
+ * argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
335
+ * SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
336
+ * decision this function made before P6 (Task 4) still made HERE, unmoved.
337
+ *
338
+ * `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
339
+ * walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
340
+ * flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
341
+ * argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
342
+ * string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
343
+ * being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
344
+ * accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
345
+ * anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
346
+ * behaviour, not a preserved one.
347
+ *
348
+ * `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
349
+ * `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
350
+ * help text for a cluster the original parser refused outright as one unrecognized argument.
351
+ * Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
352
+ * ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
353
+ * privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
354
+ * for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
355
+ * would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
356
+ * this one) refuses the whole typed token, `-bogus`, once.
357
+ */
311
358
  function parseArgs(argv, defaults) {
312
359
  const args = {
313
360
  theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
@@ -316,11 +363,12 @@ function parseArgs(argv, defaults) {
316
363
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
317
364
  syncThemes: false, validateOnly: false, verbose: false,
318
365
  };
319
- for (let i = 0; i < argv.length; i += 1) {
320
- const tok = argv[i];
321
- const eq = tok.indexOf('=');
322
- const name = eq > 0 ? tok.slice(0, eq) : tok;
323
- if (tok === '-h' || tok === '--help') {
366
+ const { tokens } = nodeParseArgs({
367
+ args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
368
+ });
369
+ for (const tok of tokens) {
370
+ if (tok.kind === 'option' && tok.value === undefined
371
+ && (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
324
372
  process.stdout.write(usage());
325
373
  // The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
326
374
  // `main`'s catch is what turns either into this process's exit status. A bare
@@ -330,15 +378,24 @@ function parseArgs(argv, defaults) {
330
378
  e.exitCode = 0;
331
379
  throw e;
332
380
  }
333
- if (VALUED[name]) {
334
- const val = eq > 0 ? tok.slice(eq + 1) : argv[i += 1];
335
- if (val === undefined) die(2, `argument ${name}: expected one argument`);
336
- args[VALUED[name]] = val;
337
- } else if (FLAGS[tok]) {
338
- args[FLAGS[tok]] = true;
339
- } else {
340
- die(2, `unrecognized arguments: ${tok}`);
381
+ if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
382
+ if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
383
+ args[VALUED_BY_NAME[tok.name]] = tok.value;
384
+ continue;
385
+ }
386
+ // `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
387
+ // flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
388
+ // keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
389
+ // `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
390
+ // given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
391
+ if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
392
+ args[FLAG_BY_NAME[tok.name]] = true;
393
+ continue;
341
394
  }
395
+ // Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
396
+ // always treats as a terminator but this parser never did) — refuses with the literal text
397
+ // typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
398
+ die(2, `unrecognized arguments: ${argv[tok.index]}`);
342
399
  }
343
400
  const choices = choicesFor();
344
401
  for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
@@ -532,10 +589,6 @@ function writeManifestAtomic(file, data) {
532
589
  renameSync(tmp, file);
533
590
  }
534
591
 
535
- function isFile(p) {
536
- try { return statSync(p).isFile(); } catch { return false; }
537
- }
538
-
539
592
  /**
540
593
  * Write-before-delete: remove only what this layer owned before and no longer produces.
541
594
  *
@@ -567,6 +620,30 @@ function pruneOwned(oc, oldOwned, owned) {
567
620
  }
568
621
  }
569
622
 
623
+ /**
624
+ * The manifest PRE-read all three emit bodies performed inline: does a manifest already
625
+ * exist at `manifestPath`, and if so, what does it parse to. `existed` is what
626
+ * `emitOpencode` alone needs (as `manifestExisted`, threaded into the render job); every
627
+ * caller reads `doc.owned`, and `emitClaudeCore` also reads `doc.managed` — `doc` is `null`
628
+ * on a missing OR a corrupt manifest, so `(doc && doc.owned) || []` covers both the same way
629
+ * the three inline try/catches did.
630
+ */
631
+ function readManifest(manifestPath) {
632
+ const existed = existsSync(manifestPath);
633
+ let doc = null;
634
+ if (existed) {
635
+ try { doc = parseJson(readFileSync(manifestPath, 'utf8')); } catch { doc = null; }
636
+ }
637
+ return { existed, doc };
638
+ }
639
+
640
+ /** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
641
+ function extrasTail(stats) {
642
+ const extras = [...(stats.primary ? ['primary agent'] : []),
643
+ ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
644
+ return extras.length ? ` + ${extras.join(', ')}` : '';
645
+ }
646
+
570
647
  /**
571
648
  * `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
572
649
  *
@@ -624,14 +701,8 @@ function emitOpencode(cfg, args, out) {
624
701
  // PRE. A missing manifest reads as "owned nothing before", which is what makes the first
625
702
  // re-emit after an upgrade treat already-existing files as the user's (claim-on-create)
626
703
  // rather than deleting them. The prune set is then empty by construction.
627
- const manifestExisted = existsSync(manifestPath);
628
- let oldOwned = [];
629
- if (manifestExisted) {
630
- try {
631
- const m = parseJson(readFileSync(manifestPath, 'utf8'));
632
- oldOwned = (m && m.owned) || [];
633
- } catch { oldOwned = []; }
634
- }
704
+ const { existed: manifestExisted, doc } = readManifest(manifestPath);
705
+ const oldOwned = (doc && doc.owned) || [];
635
706
 
636
707
  const agentPathRel = relUnder(out, root);
637
708
  const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
@@ -658,9 +729,7 @@ function emitOpencode(cfg, args, out) {
658
729
  scope: 'project',
659
730
  });
660
731
 
661
- const extras = [...(stats.primary ? ['primary agent'] : []),
662
- ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
663
- const extra = extras.length ? ` + ${extras.join(', ')}` : '';
732
+ const extra = extrasTail(stats);
664
733
  process.stdout.write(`[geneseed] opencode layer: ${stats.nAgents} subagents, `
665
734
  + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
666
735
  + `${stats.nWorkflows} workflow file(s), ${cfgName} (instructions: ${agentPath})`
@@ -687,13 +756,8 @@ function emitOpencodeGlobal(cfg, args, out) {
687
756
  const cfgDir = args.cfgDir ?? opencodeConfigDir();
688
757
  const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
689
758
 
690
- let oldOwned = [];
691
- if (existsSync(manifestPath)) {
692
- try {
693
- const m = parseJson(readFileSync(manifestPath, 'utf8'));
694
- oldOwned = (m && m.owned) || [];
695
- } catch { oldOwned = []; }
696
- }
759
+ const { doc } = readManifest(manifestPath);
760
+ const oldOwned = (doc && doc.owned) || [];
697
761
 
698
762
  const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
699
763
 
@@ -717,9 +781,7 @@ function emitOpencodeGlobal(cfg, args, out) {
717
781
  owned: [...owned].sort(),
718
782
  });
719
783
 
720
- const extras = [...(stats.primary ? ['primary agent'] : []),
721
- ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
722
- const extra = extras.length ? ` + ${extras.join(', ')}` : '';
784
+ const extra = extrasTail(stats);
723
785
  process.stdout.write(`[geneseed] opencode-global -> ${cfgDir}: ${stats.nAgents} subagents, `
724
786
  + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
725
787
  + `${stats.nWorkflows} workflow file(s), AGENT.md, ${memStatus}, ${nbStatus}, `
@@ -749,16 +811,10 @@ function emitOpencodeGlobal(cfg, args, out) {
749
811
  */
750
812
  function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
751
813
  const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
752
- let oldOwned = [];
753
- let oldManaged = {};
754
- if (existsSync(manifestPath)) {
755
- try {
756
- const data = parseJson(readFileSync(manifestPath, 'utf8'));
757
- oldOwned = data.owned || [];
758
- oldManaged = (data.managed && typeof data.managed === 'object'
759
- && !Array.isArray(data.managed)) ? data.managed : {};
760
- } catch { oldOwned = []; oldManaged = {}; }
761
- }
814
+ const { doc } = readManifest(manifestPath);
815
+ const oldOwned = (doc && doc.owned) || [];
816
+ const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
817
+ && !Array.isArray(doc.managed)) ? doc.managed : {};
762
818
  const isCopilot = host === 'copilot';
763
819
 
764
820
  // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
@@ -792,8 +848,8 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
792
848
  // VERIFY — re-read the settings file just written and match it against the claims just
793
849
  // recorded, so a merge that silently did not stick (a commented file, a mid-flight
794
850
  // external edit, a bug in the merge) is loud now instead of surfacing as hooks that
795
- // quietly never fire. Skipped for Copilot, which writes no settings file at all
796
- // (`_emit_claude_core:827`) — the other half of why Copilot could cross first.
851
+ // quietly never fire. Copilot's settings have their own shape and their own check; a
852
+ // Copilot PROJECT emit records no hooks and so checks nothing.
797
853
  //
798
854
  // On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
799
855
  // makes it a live cross-implementation check on every build. Here both halves are Node, so
@@ -804,15 +860,17 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
804
860
  // SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
805
861
  // deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
806
862
  // _geneseed_hook` plants the fault that makes it speak.
863
+ phaseLog('VERIFY');
807
864
  if (!isCopilot) {
808
- phaseLog('VERIFY');
809
865
  settingsIntegrityCheck(
810
866
  path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
867
+ } else if (managed.copilot_hooks) {
868
+ copilotIntegrityCheck(path.join(cfgDir, 'settings.json'), managed.copilot_hooks, 'present');
811
869
  }
812
870
  return {
813
871
  nAgents: stats.nAgents,
814
872
  nSkills: stats.nSkills,
815
- nHooks: (managed.settings_hooks || []).length,
873
+ nHooks: (managed.settings_hooks || managed.copilot_hooks || []).length,
816
874
  memStatus,
817
875
  nbStatus,
818
876
  };
@@ -827,7 +885,8 @@ function emitCopilot(cfg, args, out) {
827
885
  });
828
886
  process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
829
887
  + `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
830
- + `${r.nbStatus}. No settings.json/hooks (Copilot has none).\n`);
888
+ + `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
889
+ + '~/.copilot/settings.json only — the global emit wires them).\n');
831
890
  return cfgDir;
832
891
  }
833
892
 
@@ -835,14 +894,17 @@ function emitCopilot(cfg, args, out) {
835
894
  function emitCopilotGlobal(cfg, args, out) {
836
895
  const cfgDir = args.cfgDir ?? copilotConfigDir();
837
896
  warnCopilotGlobalOverProject();
897
+ // The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
898
+ // that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
899
+ const hookOpts = hookRunnerEntry();
838
900
  const r = emitClaudeCore(cfg, args, {
839
901
  cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
840
- scope: 'global', host: 'copilot', out,
902
+ scope: 'global', host: 'copilot', out, hookOpts,
841
903
  });
842
904
  process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
843
- + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}. `
844
- + 'No settings.json/hooks (Copilot has no hook mechanism — memory rides the '
845
- + "preamble's instructions); MCP servers go in mcp-config.json.\n");
905
+ + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
906
+ + `${r.nHooks} hooks (settings.json: sessionStart context, toolCall gate). Memory `
907
+ + "write-back rides the preamble's instructions; MCP servers go in mcp-config.json.\n");
846
908
  return cfgDir;
847
909
  }
848
910
 
@@ -1123,30 +1185,22 @@ function run(argv) {
1123
1185
  // The marker directory is the emit's TARGET, which for a global emit is the config dir it
1124
1186
  // just rendered into and not `--out` at all — so the emit returns it rather than the
1125
1187
  // caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
1188
+ //
1189
+ // NINE ARMS, TWO TABLES ALREADY ON FILE: `GLOBAL_EMITS` and `PROJECT_EMITS` above are
1190
+ // exactly this dispatch's `-global` and per-repo halves, keyed by host rather than by
1191
+ // `--emit` name, so splitting the suffix reaches both without a third map.
1126
1192
  let markerDir = out;
1127
- if (args.emit === 'opencode') {
1128
- emitOpencode(cfg, args, out);
1129
- } else if (args.emit === 'opencode-global') {
1130
- markerDir = emitOpencodeGlobal(cfg, args, out);
1131
- } else if (args.emit === 'copilot') {
1193
+ if (args.emit.endsWith('-global')) {
1194
+ markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
1195
+ } else if (args.emit !== 'files') {
1132
1196
  // The PROJECT emits keep their markers in `out`, not in the host config dir the emit
1133
1197
  // wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
1134
- emitCopilot(cfg, args, out);
1135
- } else if (args.emit === 'copilot-global') {
1136
- markerDir = emitCopilotGlobal(cfg, args, out);
1137
- } else if (args.emit === 'claude') {
1138
- emitClaude(cfg, args, out);
1139
- } else if (args.emit === 'claude-global') {
1140
- markerDir = emitClaudeGlobal(cfg, args, out);
1141
- } else if (args.emit === 'bob') {
1142
- emitBob(cfg, args, out);
1143
- } else if (args.emit === 'bob-global') {
1144
- markerDir = emitBobGlobal(cfg, args, out);
1198
+ PROJECT_EMITS[args.emit](cfg, args, out);
1145
1199
  } else {
1146
- // `nativeCatalog: false` is build.py:421's three-positional-argument call reproduced:
1147
- // `build(args.theme, out, args.footprint)` leaves `native_catalog` at its signature
1148
- // default. It is the one signature default this driver DOES inherit, and it is
1149
- // inherited because the Python CLI inherits it too.
1200
+ // The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
1201
+ // three-positional-argument call reproduced — `build(args.theme, out, args.footprint)`
1202
+ // leaves `native_catalog` at its signature default. It is the one signature default
1203
+ // this driver DOES inherit, and it is inherited because the Python CLI inherits it too.
1150
1204
  build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
1151
1205
  }
1152
1206