geneseed 3.1.3 → 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 (113) hide show
  1. package/CHANGELOG.md +70 -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/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,76 @@ 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
+
9
79
  ## [3.1.3] — 2026-08-24
10
80
 
11
81
  ### Added
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.3**.
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
 
@@ -56,6 +56,7 @@ import { cmdExclude } from '../js/inspect/excludes.mjs';
56
56
  import { cmdBuild, cmdPrompt, cmdRebuildAll, cmdTheme } from '../js/build/generate.mjs';
57
57
  import { cmdMigrate } from '../js/maintain/migrate.mjs';
58
58
  import { parseIntStrict } from '../js/lib/text.mjs';
59
+ import { printErr } from '../js/lib/fs.mjs';
59
60
  import { cmdLink, cmdUnlink } from '../js/hosts/link.mjs';
60
61
  import { cmdMcp } from '../js/hosts/mcp.mjs';
61
62
  import { cmdMemory } from '../js/maintain/memory.mjs';
@@ -83,56 +84,31 @@ import { cmdWeb } from '../js/web/server.mjs';
83
84
  * `tests/test_hook_cli_parity.py` scrapes `const VERBS = {` for its three matrix gates, and
84
85
  * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER, which `harness_golden` cells
85
86
  * assert verbatim — the order is the old table's and is not alphabetical.
87
+ *
88
+ * FLATTENED TO `verb: fn` since Task 5: every row was a single-field `{ fn: cmdX }` object,
89
+ * the one field this table has left after `positionals`/`options`/`flags`/etc moved to
90
+ * `js/cli-table.json` above. `main` reads `VERBS[verb]` directly rather than
91
+ * `VERBS[verb].fn` now; the scraping regexes in `tests/unit/{cli_table,hook_cli,docs}.test.mjs`
92
+ * match on the KEY and the colon only; the literal string `const VERBS = {` and the key
93
+ * order neither changed.
86
94
  */
87
95
  const VERBS = {
88
- exclude: {
89
- fn: cmdExclude,
90
- },
91
- status: {
92
- fn: cmdStatus,
93
- },
94
- version: {
95
- fn: cmdVersion,
96
- },
97
- build: {
98
- fn: cmdBuild,
99
- },
100
- prompt: {
101
- fn: cmdPrompt,
102
- },
103
- theme: {
104
- fn: cmdTheme,
105
- },
106
- diff: {
107
- fn: cmdDiff,
108
- },
109
- 'rebuild-all': {
110
- fn: cmdRebuildAll,
111
- },
112
- migrate: {
113
- fn: cmdMigrate,
114
- },
115
- doctor: {
116
- fn: cmdDoctor,
117
- },
118
- uninstall: {
119
- fn: cmdUninstall,
120
- },
121
- link: {
122
- fn: cmdLink,
123
- },
124
- unlink: {
125
- fn: cmdUnlink,
126
- },
127
- setup: {
128
- fn: cmdSetup,
129
- },
130
- web: {
131
- fn: cmdWeb,
132
- },
133
- upgrade: {
134
- fn: cmdUpgrade,
135
- },
96
+ exclude: cmdExclude,
97
+ status: cmdStatus,
98
+ version: cmdVersion,
99
+ build: cmdBuild,
100
+ prompt: cmdPrompt,
101
+ theme: cmdTheme,
102
+ diff: cmdDiff,
103
+ 'rebuild-all': cmdRebuildAll,
104
+ migrate: cmdMigrate,
105
+ doctor: cmdDoctor,
106
+ uninstall: cmdUninstall,
107
+ link: cmdLink,
108
+ unlink: cmdUnlink,
109
+ setup: cmdSetup,
110
+ web: cmdWeb,
111
+ upgrade: cmdUpgrade,
136
112
  // `up.add_parser(..., aliases=["update"])`, reproduced in P8c as a ROW OF ITS OWN rather than
137
113
  // as an `aliases` field on the row above — because a field would be a DECLARATION and the
138
114
  // three matrix gates read this table as the DISPATCH (rule 7, and M23 is where it was
@@ -144,28 +120,18 @@ const VERBS = {
144
120
  //
145
121
  // It is NOT what the web console's `update` ACTION needs: that row's argv names `upgrade`,
146
122
  // the subparser, and always did. The two were coupled only in a handoff's note.
147
- update: {
148
- fn: cmdUpgrade,
149
- },
150
- 'sync-self': {
151
- // Points at `cmdSyncSelf` rather than at `cmdUpgrade`, and that is not tidiness:
152
- // `sync-self` DROPS its `ref` before `upgrade` ever sees it, where `upgrade` both WARNS
153
- // about one and re-reads it as a theme — so the two verbs answer `sync-self cyberpunk`
154
- // and `sync-self v1.2.3` differently. See `js/maintain/update.mjs`'s `syncSelf` for both halves.
155
- fn: cmdSyncSelf,
156
- },
157
- bootstrap: {
158
- fn: cmdBootstrap,
159
- },
123
+ update: cmdUpgrade,
124
+ // Points at `cmdSyncSelf` rather than at `cmdUpgrade`, and that is not tidiness:
125
+ // `sync-self` DROPS its `ref` before `upgrade` ever sees it, where `upgrade` both WARNS
126
+ // about one and re-reads it as a theme — so the two verbs answer `sync-self cyberpunk`
127
+ // and `sync-self v1.2.3` differently. See `js/maintain/update.mjs`'s `syncSelf` for both halves.
128
+ 'sync-self': cmdSyncSelf,
129
+ bootstrap: cmdBootstrap,
160
130
  // P7a. Both are DISPATCHERS whose off-TTY arm is the whole of what a cell can reach, and
161
131
  // `menu`'s on-TTY arm falls back rather than opening a panel — `js/ui/menu.mjs`'s header
162
132
  // argues both.
163
- menu: {
164
- fn: cmdMenu,
165
- },
166
- home: {
167
- fn: cmdHome,
168
- },
133
+ menu: cmdMenu,
134
+ home: cmdHome,
169
135
  // P7b, and it is the twenty-fifth and last. `cmd_tui`'s FIRST arm is `if not sys.stdin.
170
136
  // isatty()`, so off a TTY — which is every cell there is — the verb is one line and an
171
137
  // exit code, and that arm crosses byte for byte. The panel behind it is P7c's;
@@ -173,9 +139,7 @@ const VERBS = {
173
139
  // panel-unavailable line rather than inventing a second full-screen UI, and
174
140
  // `tests/test_tui_boundary.py` asserts that the arm it declares is genuinely unreachable
175
141
  // here rather than merely untested.
176
- tui: {
177
- fn: cmdTui,
178
- },
142
+ tui: cmdTui,
179
143
  // THE THREE THAT NEVER HAD A PYTHON ORIGINAL, and they are last for that reason rather
180
144
  // than by alphabet: every row above is the twin of a subparser, and these three are not.
181
145
  // The information behind them existed only behind the web console — the catalog endpoint,
@@ -190,21 +154,15 @@ const VERBS = {
190
154
  // ABSOLUTE — `tests/snapshot/cli_help.test.mjs` splits the two populations by name and refuses a
191
155
  // verb that falls into neither, and each has its own unit gate stating what it does rather
192
156
  // than that it agrees.
193
- catalog: {
194
- fn: cmdCatalog,
195
- },
196
- mcp: {
197
- fn: cmdMcp,
198
- },
199
- memory: {
200
- fn: cmdMemory,
201
- },
157
+ catalog: cmdCatalog,
158
+ mcp: cmdMcp,
159
+ memory: cmdMemory,
202
160
  };
203
161
 
204
162
  function die(code, msg) {
205
163
  // CRLF on Windows, for the same reason `js/hosts/hooks.mjs`'s funnels translate: argparse writes
206
- // this line through `sys.stderr`, which does.
207
- process.stderr.write(`geneseed: error: ${msg}${process.platform === 'win32' ? '\r\n' : '\n'}`);
164
+ // this line through `sys.stderr`, which does. `printErr` owns the `\n`-to-`os.linesep` rule.
165
+ printErr(`geneseed: error: ${msg}\n`);
208
166
  return code;
209
167
  }
210
168
 
@@ -358,8 +316,8 @@ async function main(argv) {
358
316
  throw e;
359
317
  }
360
318
  }
361
- const spec = VERBS[verb];
362
- if (!spec) {
319
+ const fn = VERBS[verb];
320
+ if (!fn) {
363
321
  // "and every other harness subcommand is still Python — run the interpreter against the
364
322
  // old harness script" is GONE, and its removal is not cosmetic: with the four hook verbs
365
323
  // named, the two entry points now cover every subcommand there is, so the clause was not
@@ -395,7 +353,7 @@ async function main(argv) {
395
353
  // it `process.exitCode` would be assigned a PENDING PROMISE, which Node coerces to 0
396
354
  // and prints nothing about. `web status` would then report "not running" and exit 0.
397
355
  // Awaiting a number is a no-op for the ten synchronous verbs.
398
- return await spec.fn(parsed.args);
356
+ return await fn(parsed.args);
399
357
  } catch (e) {
400
358
  // `e.exitCode` is the generator's existing marker for a DELIBERATE refusal that has
401
359
  // already explained itself on stderr — `js/build/emit-claude.mjs` reads the same flag, and it is
@@ -39,6 +39,9 @@
39
39
  */
40
40
  import { cmdContext, cmdGitGate, cmdRuleGate, cmdLearn } from '../js/hosts/hooks.mjs';
41
41
  import { printHelp } from '../js/ui/cli.mjs';
42
+ // Not a new module on the hot path: `js/hosts/hooks.mjs` above already imports
43
+ // `js/lib/fs.mjs`, so this edge names a file the process has already loaded.
44
+ import { printErr } from '../js/lib/fs.mjs';
42
45
 
43
46
  const VERBS = {
44
47
  context: { fn: cmdContext, flags: { '--root': 'root' } },
@@ -54,8 +57,8 @@ const VERBS = {
54
57
 
55
58
  function die(code, msg) {
56
59
  // CRLF on Windows, for the same reason `js/hosts/hooks.mjs`'s funnels translate: argparse
57
- // writes this line through `sys.stderr`, which does.
58
- process.stderr.write(`geneseed-hook: error: ${msg}${process.platform === 'win32' ? '\r\n' : '\n'}`);
60
+ // writes this line through `sys.stderr`, which does. `printErr` owns the `\n`-to-`os.linesep` rule.
61
+ printErr(`geneseed-hook: error: ${msg}\n`);
59
62
  return code;
60
63
  }
61
64
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.1.3"
5
+ "version": "3.2.0"
6
6
  }