geneseed 3.1.2 → 3.2.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 (120) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/docs/web/exclusions.md +1 -1
  8. package/docs/web/footprint.md +1 -1
  9. package/docs/web/foreman-mode.md +2 -2
  10. package/docs/web/themes-author.md +1 -1
  11. package/docs/web-ui.md +41 -29
  12. package/harness.config.json +1 -1
  13. package/js/README.md +1 -10
  14. package/js/build/emit-claude.mjs +4 -5
  15. package/js/build/emit-common.mjs +63 -114
  16. package/js/build/emit-opencode.mjs +74 -66
  17. package/js/build/generate.mjs +4 -6
  18. package/js/build/render.mjs +13 -13
  19. package/js/build/source.mjs +7 -6
  20. package/js/build/stubs.mjs +25 -33
  21. package/js/build/themes.mjs +1 -1
  22. package/js/build/version.mjs +1 -3
  23. package/js/hosts/hooks.mjs +3 -3
  24. package/js/hosts/hosts.mjs +2 -4
  25. package/js/hosts/installs.mjs +35 -28
  26. package/js/hosts/link.mjs +1 -1
  27. package/js/hosts/native.mjs +37 -19
  28. package/js/hosts/opencode.mjs +39 -28
  29. package/js/hosts/settings.mjs +33 -110
  30. package/js/inspect/checks-authoring.mjs +5 -4
  31. package/js/inspect/checks-build.mjs +1 -1
  32. package/js/inspect/diff.mjs +1 -3
  33. package/js/inspect/doctor.mjs +59 -44
  34. package/js/inspect/excludes.mjs +4 -10
  35. package/js/inspect/inventory.mjs +37 -55
  36. package/js/inspect/registry.mjs +4 -7
  37. package/js/inspect/scan.mjs +8 -15
  38. package/js/inspect/status.mjs +1 -1
  39. package/js/lib/fs.mjs +34 -0
  40. package/js/lib/json.mjs +28 -1
  41. package/js/lib/paths.mjs +0 -10
  42. package/js/lib/udiff.mjs +11 -16
  43. package/js/maintain/memory.mjs +2 -4
  44. package/js/maintain/migrate.mjs +3 -3
  45. package/js/maintain/uninstall.mjs +90 -78
  46. package/js/maintain/update.mjs +2 -2
  47. package/js/ui/anim.mjs +1 -1
  48. package/js/ui/menu.mjs +1 -1
  49. package/js/ui/tui.mjs +3 -20
  50. package/js/web/actions.mjs +230 -217
  51. package/js/web/activity.mjs +71 -89
  52. package/js/web/api.mjs +210 -220
  53. package/js/web/daemon.mjs +92 -110
  54. package/js/web/docs.mjs +66 -111
  55. package/js/web/graph.mjs +0 -0
  56. package/js/web/handler.mjs +78 -97
  57. package/js/web/jobs.mjs +124 -165
  58. package/js/web/routes.mjs +79 -136
  59. package/js/web/server.mjs +47 -82
  60. package/package.json +1 -1
  61. package/src/agents/explorer.md +2 -1
  62. package/src/doctrines/process.md +4 -2
  63. package/src/laws/universal.md +55 -43
  64. package/src/skills/clarify.md +1 -1
  65. package/src/skills/forge-mcp.md +2 -1
  66. package/src/skills/fresh-eyes.md +1 -1
  67. package/src/skills/ingest.md +2 -1
  68. package/src/skills/parallel-agents.md +1 -1
  69. package/src/skills/ponytail.md +2 -2
  70. package/src/skills/research.md +4 -2
  71. package/themes/_TEMPLATE.json +2 -0
  72. package/themes/biker.json +2 -0
  73. package/themes/commentator.json +2 -0
  74. package/themes/cyberpunk.json +2 -0
  75. package/themes/gamer.json +2 -0
  76. package/themes/imperial.json +2 -0
  77. package/themes/joker.json +2 -0
  78. package/themes/marvin.json +2 -0
  79. package/themes/mean.json +2 -0
  80. package/themes/military.json +2 -0
  81. package/themes/neutral.json +2 -0
  82. package/themes/pirate.json +2 -0
  83. package/themes/sports.json +2 -0
  84. package/themes/verstappen.json +2 -0
  85. package/themes/wizard.json +2 -0
  86. package/web/dist/assets/{Activity-z0uXdS7J.js → Activity-BTCG2gMl.js} +1 -1
  87. package/web/dist/assets/{ActivityDetail-CEj5IJpF.js → ActivityDetail-julsmPDB.js} +1 -1
  88. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  89. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  90. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  91. package/web/dist/assets/{Graph-BV6a6t2l.js → Graph-CS4tA7YS.js} +1 -1
  92. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  93. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  94. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  95. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  96. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  97. package/web/dist/assets/{Rules-DobVPd80.js → Rules-CsC6hfqk.js} +1 -1
  98. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  99. package/web/dist/assets/{StatusBadge-BhJ3HhdI.js → StatusBadge-gRrak0nO.js} +1 -1
  100. package/web/dist/assets/{activity-D9mfv7pY.js → activity-B0-FXZCj.js} +1 -1
  101. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  102. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  103. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  104. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  105. package/web/dist/index.html +2 -2
  106. package/web/src/pages/Laws.jsx +46 -89
  107. package/web/dist/assets/About-CPvIFZGh.js +0 -1
  108. package/web/dist/assets/Diff-BMR8fPuG.js +0 -5
  109. package/web/dist/assets/Doctor-Bz5qE4r9.js +0 -1
  110. package/web/dist/assets/Harnesses-C5Ag5PaQ.js +0 -1
  111. package/web/dist/assets/Laws-BK07NKw9.js +0 -3
  112. package/web/dist/assets/Library-CiXN9sC3.js +0 -1
  113. package/web/dist/assets/Markdown-DG6FjUz-.js +0 -1
  114. package/web/dist/assets/Profile-B2oSvdxH.js +0 -1
  115. package/web/dist/assets/Skills-DQPsvsn9.js +0 -1
  116. package/web/dist/assets/Themes-BP38FBU9.js +0 -2
  117. package/web/dist/assets/index-BE226Gv6.css +0 -1
  118. package/web/dist/assets/index-BZ7cPLMI.js +0 -1
  119. package/web/dist/assets/index-BgIs5eIa.js +0 -12
  120. package/web/dist/assets/index-DGM5j2hB.js +0 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,131 @@ labels in `harness.config.json`. The canonical identity of an *installed* harnes
6
6
  is the source fingerprint in `.geneseed-version` (see `geneseed version`), not this
7
7
  label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
+ ## [3.2.0] — 2026-08-27
10
+
11
+ ### Added
12
+
13
+ - **Laws X and XI — the Law III split, by append.** Law III carried four rules;
14
+ only its first survived the lean footprint. Its two buried, independently
15
+ operative sub-rules move to appended laws — **X, Echo the Intent** (state an
16
+ inferred or ambiguous goal and get agreement before building on it; stop and
17
+ ask when the ambiguity touches auth, security, production, or user data) and
18
+ **XI, Absence Is a Claim** (an empty or truncated answer carries a duty:
19
+ suspect the hidden layer, bind limits per entity, surface the cut). No
20
+ numeral moves — III keeps its verify-before-asserting core and its 40+
21
+ citations stay valid; the two citations that actually meant intent-echo
22
+ (`clarify`, `fresh-eyes`, and Law VI's body) retarget to X, and XI is born
23
+ anchored (`explorer`'s output contract, `research` step 2). Titles added to
24
+ all 14 theme voices; `LAW_CLASS`, `LAW_META`, README, SHIPPED, and the
25
+ first-free-numeral fixture (now XII) updated in step.
26
+ Spec: `docs/specs/2026-08-27-law-iii-split-design.md`.
27
+
28
+ ### Changed
29
+
30
+ - **`process 7` assumes its tier.** Its closing sentence leaned on the
31
+ ontology's Conduct section as its justification, which read as a rule living
32
+ on the wrong floor. It now carries a proper seam sentence — Conduct governs
33
+ how an answer speaks; this rule is only the addressing convention, adoptable
34
+ or droppable like any doctrine rule.
35
+ - **Lean surgery on Laws IV, VI, VIII, IX** — each law's operative clause now
36
+ lives in its first sentence (fused with the opening aphorism), because the
37
+ default `--footprint lean` keeps a law's heading plus first sentence only.
38
+ Before this, a lean install's Law IV confirmed nothing (its first sentence was
39
+ the CRUD taxonomy), Law IX kept only an aphorism, Law VIII lost its ban list,
40
+ and Law VI lost its actual rule. No renumbering, no title change; Law VI's
41
+ closing aphorism ("The web is a source to read…") cut as redundant with the
42
+ fused opener. Spec: `docs/specs/2026-08-27-lean-surgery-design.md`.
43
+
44
+ ### Fixed
45
+
46
+ - **`ponytail` cited a law that does not exist** — "`{{LAW}} on reuse`" is now
47
+ `{{DOCTRINE}} craft 4`, and "`{{LAW}} on scope`" (the only numeral-less law
48
+ citation in `src/`) is now `{{LAW}} II`.
49
+ - **Laws VI, VII and IX were cited by nothing outside the canon** — each orphan
50
+ gains one procedural anchor at the step where it bites: VI in `research`
51
+ (fetched pages) and `ingest` (converted documents), VII in `parallel-agents`
52
+ (the handoff envelope grants only the access the unit needs), IX in
53
+ `forge-mcp` (a destructive tool's guard is enforced server-side).
54
+
55
+ ## [3.1.4] — 2026-08-27
56
+
57
+ ### Added
58
+
59
+ - **`POST /api/pick-folder`** — the OS-native folder chooser behind the console's
60
+ "Exclude a folder…" and "Deploy to folder…" buttons, previously a deliberate 501. PowerShell's
61
+ `FolderBrowserDialog` on Windows, the original `osascript` prompt on macOS, `{error}` elsewhere;
62
+ async spawn with a 300 s timeout, no client input on the command line, declared argv in the
63
+ spawn allow-list. The decline's rationale (a modal dialog was not worth a spawn row) yielded to
64
+ the feature being asked for.
65
+
66
+ ### Changed
67
+
68
+ - **The ponytail refactor** (PR #103, tranches 1–7 of the 2026-08-26 over-engineering audit):
69
+ dead exports and finished-port scaffolding deleted; one owner per primitive (`js/lib/fs.mjs`,
70
+ `js/lib/json.mjs`); stdlib in place of hand-rolled walks, `Object.hasOwn`,
71
+ `import.meta.dirname` (the hook's hot path drops an import), `fetch` daemon probes;
72
+ `util.parseArgs` tokenizing under `build-driver`'s existing wording; thirteen structural twins
73
+ merged; `js/web` comment ratio 38 % → 33 % with every load-bearing *why* kept; shared
74
+ `Sel`/`Switch`/`CatalogRow` components and `d3-force` in the console. Net −339 source lines,
75
+ zero emitted bytes moved, zero runtime dependencies added, 1067 tests green. Where the audit's
76
+ premise failed, behaviour parity won: `compact()` and the docs slugger stay hand-rolled, the
77
+ cli/hook argv parsers stay put — each with its reason recorded.
78
+
79
+ ## [3.1.3] — 2026-08-24
80
+
81
+ ### Added
82
+
83
+ - **The Journal Atlas console.** A fourth dashboard lens, `journal`: growth rings per section,
84
+ the newest hand-authored entries, and the constitution drawn as a map — a hub of rules in
85
+ force, its three tiers, and dashed edges to the skills, agents, memory and wiki they govern.
86
+ Every node navigates. The map is data-driven where the design was hand-placed: circles size
87
+ to the text they hold and the pack nodes ride a fitted arc, so a harness with seven
88
+ invariants or six packs draws the same picture rather than overflowing it.
89
+ - **`atlas`, a twelfth console skin** — loam dark, sage ink, Space Grotesk, pills, in both
90
+ light and dark. Atlas and Greenhouse default to the journal lens; every other skin is
91
+ unchanged, and the lens is pickable from any of them in Settings.
92
+ - **`GET /api/recent`** — the newest file-backed entries across the harness. Nothing on the API
93
+ carried a per-entry date: every catalog row is `{name, title, desc, source}` and a browser
94
+ cannot stat a path. It answers only the four sections a person writes by hand, and drops any
95
+ section too large to stat exhaustively rather than reporting the newest of a truncated
96
+ sample. Declared in `GET_BEYOND_REF` — the GET twin of `POST_BEYOND_REF` — so the route
97
+ partition keeps its set equality against a reference surface that must not be rewritten.
98
+ - **`overview.install` now reports `build_epoch`** beside `build_time`, so a relative label is
99
+ computed rather than parsed back out of a formatted stamp.
100
+ - **`Ctrl`/`⌘`+`K` opens Spotlight**, beside `/`. The chord is the one that still works from
101
+ inside a text field, which is where `/` deliberately does not fire.
102
+
103
+ ### Changed
104
+
105
+ - **The rail regroups into Codex · Care · Setup**, with the Dashboard on its own and a vitals
106
+ card above the index — germination, drift, the doctor's verdict, and how long ago the harness
107
+ was built, on every page. Doctor badges `clean` in words; it used to badge nothing, which
108
+ reads the same as never having run.
109
+ - **Harnesses and Themes merge into one Harness page**, and About folds into Settings. No route
110
+ dies: `#/harnesses`, `#/themes` and `#/about` all still resolve. An uninstalled host now
111
+ offers one `Install…` that discloses its four choices instead of rendering them inline.
112
+ - **The voice gallery is a reference, not a second control.** Every install picks its own voice
113
+ beside its own footprint, posture and mode; a gallery at the top of the page could only ever
114
+ act on one of them. It keeps the taglines — which a `<select>` of bare names cannot show —
115
+ and gives up the buttons.
116
+ - **The rail width is responsive**, `clamp(232px, 18vw, 256px)`, between the two widths the
117
+ console has been designed at. The breakpoint overrides still win, so it still collapses.
118
+
119
+ ### Fixed
120
+
121
+ - **The Spotlight input was never wired to its own listbox.** No `role="combobox"`,
122
+ `aria-expanded`, `aria-controls`, or `aria-activedescendant`, and the rows had no ids —
123
+ so the arrow keys moved a highlight that was visual and nothing else, and a screen reader
124
+ was never told results had appeared.
125
+ - **The rail overflowed the document** on short windows once the vitals card was added: a fixed
126
+ `100vh` box with no scroller of its own produced a second scrollbar beside the page's.
127
+ - **`web/dist` is now gated on the publish path.** It ships in `files[]`, and until now nothing
128
+ on that path built it or looked at it — the freshness check ran only on pull requests, while
129
+ `workflow_dispatch` can publish any ref.
130
+ - **Two tests in `laws.test.jsx` failed roughly one run in three.** They gated on rows existing,
131
+ but the pack selection is seeded in an effect, so there is a rendered frame in which every
132
+ pack reads as off. The same race, twice, and neither was a product defect.
133
+
9
134
  ## [3.1.2] — 2026-08-24
10
135
 
11
136
  ### Changed
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  [![Themes](https://img.shields.io/badge/themes-14-9cf)](themes/)
13
13
  [![Skills](https://img.shields.io/badge/skills-47-blueviolet)](src/skills/)
14
14
  [![Agents](https://img.shields.io/badge/agents-17-orange)](src/agents/)
15
- [![Laws](https://img.shields.io/badge/laws-9-critical)](src/laws/universal.md)
15
+ [![Laws](https://img.shields.io/badge/laws-11-critical)](src/laws/universal.md)
16
16
  [![Plugins](https://img.shields.io/badge/plugins-7-teal)](adapters/opencode/plugins/)
17
17
  [![OpenCode · Claude Code · Bob · Copilot · AGENT.md](https://img.shields.io/badge/works%20with-OpenCode%20·%20Claude%20Code%20·%20Bob%20·%20Copilot%20·%20AGENT.md-1f6feb)](#-supported-harnesses)
18
18
 
@@ -153,7 +153,7 @@ The harness ships as a small set of layers, and the web console's rail is the sa
153
153
  | Layer | What it is |
154
154
  | --- | --- |
155
155
  | **🧭 Ontology** (`ontology/`) | the mind the rules govern, in four prose sections — **Telos** (the Pact: protect the user, the truth, the agent), **Evidence** (every claim graded by how it was obtained), **Decisions** (classify and tier by reversibility, show the real forks), **Conduct** (answer what was asked, once). Always in force, never toggleable |
156
- | **🛡️ Rules** (`laws/`) | 9 universal laws the agent obeys — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-assert, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, root-cause, external-gate |
156
+ | **🛡️ Rules** (`laws/`) | 11 universal laws the agent obeys — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-assert, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, root-cause, external-gate, echo-the-intent, absence-is-a-claim |
157
157
  | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all four pack files ship on disk either way, so a citation into a pack you left out still resolves |
158
158
  | **🤖 Agents** (17) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `explorer`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
159
159
  | **🛠 Skills** (47) | repeatable workflows: brainstorm · **clarify** · plan · **codebase-design** · **domain-modeling** · **wayfinder** · **tickets** · tdd · **develop** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · geneseed-code-review · **fresh-eyes** · **review-response** · commit · **ship** · **release** · **migrate** · **git-archaeology** · **git-rescue** · repo-map · document-project · **frontend-design** · **prose** · **ingest** · **research** · **learning-path** · **gap-detector** · **feynman** · **crash-course** · **drill** · **decode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **opencode-theme** · **herdr** · **pipeline** |
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical pointers, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **3.1.2**.
23
+ > string. Current label: **3.2.0**.
24
24
 
25
25
  > **Spec status audit (2026-06-15).** Every dated spec authored before today was
26
26
  > verified against the source tree and confirmed implemented (code + tests + docs);
@@ -35,8 +35,8 @@ Every row below is present in the tree today; "Spec" links the design record.
35
35
  | Capability | What it is | Spec |
36
36
  | --- | --- | --- |
37
37
  | **Themed generator** | One neutral `src/` → 14 themed bundles via `geneseed-build` (zero dependencies); structure stays theme-independent | [DESIGN.md](DESIGN.md) |
38
- | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 17 agents, 47 skills, the memory convention, and the agent's sovereign notebook | [notebook](docs/specs/2026-06-11-notebook-sovereign-space.md) · [agent-own-space](docs/specs/2026-06-10-notebook-agent-own-space.md) |
39
- | **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the nine always-on **Invariants**, and four toggleable **Doctrine** packs (craft, rigor, ops, process) addressed `<pack> <n>`. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates pack numbering, the per-rule Principle column, every `{{DOCTRINE}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | [three-tier-constitution](docs/specs/2026-08-19-three-tier-constitution-design.md) |
38
+ | **Laws / Agents / Skills / Memory / Notebook** | 11 laws, 17 agents, 47 skills, the memory convention, and the agent's sovereign notebook | [notebook](docs/specs/2026-06-11-notebook-sovereign-space.md) · [agent-own-space](docs/specs/2026-06-10-notebook-agent-own-space.md) |
39
+ | **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the eleven always-on **Invariants**, and four toggleable **Doctrine** packs (craft, rigor, ops, process) addressed `<pack> <n>`. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates pack numbering, the per-rule Principle column, every `{{DOCTRINE}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | [three-tier-constitution](docs/specs/2026-08-19-three-tier-constitution-design.md) |
40
40
  | **`doctor`** | One check: unresolved tokens, dead/non-hermetic links, theme-key parity, authoring gates, rendered-bundle drift | [theme-aware doctor/diff](docs/specs/2026-06-07-theme-aware-doctor-diff.md) |
41
41
  | **`diff` + improvements export** | Deployed-vs-source drift report; export an `improvements.md` back-port | [improvements-export](docs/specs/2026-06-12-improvements-export.md) |
42
42
  | **`status` / `version`** | Headless dashboard + fingerprint verdict (no TUI needed) | [status-command](docs/specs/2026-06-07-status-command.md) · [version-and-uninstall](docs/specs/2026-06-07-version-and-uninstall.md) |
@@ -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
45
  import { settingsIntegrityCheck } from '../js/hosts/settings.mjs';
43
- import { writeText, withPlatformNewlines } from '../js/lib/fs.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;
341
385
  }
386
+ // `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
387
+ // flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
388
+ // keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
389
+ // `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
390
+ // given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
391
+ if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
392
+ args[FLAG_BY_NAME[tok.name]] = true;
393
+ continue;
394
+ }
395
+ // Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
396
+ // always treats as a terminator but this parser never did) — refuses with the literal text
397
+ // typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
398
+ die(2, `unrecognized arguments: ${argv[tok.index]}`);
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
@@ -1123,30 +1179,22 @@ function run(argv) {
1123
1179
  // The marker directory is the emit's TARGET, which for a global emit is the config dir it
1124
1180
  // just rendered into and not `--out` at all — so the emit returns it rather than the
1125
1181
  // caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
1182
+ //
1183
+ // NINE ARMS, TWO TABLES ALREADY ON FILE: `GLOBAL_EMITS` and `PROJECT_EMITS` above are
1184
+ // exactly this dispatch's `-global` and per-repo halves, keyed by host rather than by
1185
+ // `--emit` name, so splitting the suffix reaches both without a third map.
1126
1186
  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') {
1187
+ if (args.emit.endsWith('-global')) {
1188
+ markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
1189
+ } else if (args.emit !== 'files') {
1132
1190
  // The PROJECT emits keep their markers in `out`, not in the host config dir the emit
1133
1191
  // 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);
1192
+ PROJECT_EMITS[args.emit](cfg, args, out);
1145
1193
  } 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.
1194
+ // The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
1195
+ // three-positional-argument call reproduced — `build(args.theme, out, args.footprint)`
1196
+ // leaves `native_catalog` at its signature default. It is the one signature default
1197
+ // this driver DOES inherit, and it is inherited because the Python CLI inherits it too.
1150
1198
  build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
1151
1199
  }
1152
1200