geneseed 2.0.0 → 3.0.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 (196) hide show
  1. package/CHANGELOG.md +217 -5
  2. package/DESIGN.md +149 -1326
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +30 -22
  5. package/SETUP.md +98 -24
  6. package/SHIPPED.md +6 -5
  7. package/adapters/bob/README.md +3 -3
  8. package/adapters/claude-code/README.md +6 -4
  9. package/adapters/claude-code/skills/geneseed/SKILL.md +10 -6
  10. package/adapters/copilot/README.md +5 -4
  11. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +11 -6
  12. package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
  13. package/adapters/opencode/README.md +15 -9
  14. package/adapters/opencode/agents/orchestrator.md +1 -1
  15. package/adapters/opencode/opencode.json +0 -9
  16. package/adapters/opencode/plugins/geneseed-context.js +7 -7
  17. package/adapters/opencode/plugins/geneseed-guard.js +6 -6
  18. package/adapters/opencode/workflows/dispatch.js +1 -1
  19. package/bin/geneseed.mjs +74 -8
  20. package/docs/declined.md +53 -2
  21. package/docs/design-history.md +1290 -0
  22. package/docs/extending.md +672 -0
  23. package/docs/limits.md +141 -82
  24. package/docs/token-footprint.md +15 -4
  25. package/docs/web/agents.md +1 -1
  26. package/docs/web/authoring.md +1 -1
  27. package/docs/web/claude-hooks.md +2 -2
  28. package/docs/web/collaboration.md +3 -3
  29. package/docs/web/first-session.md +2 -2
  30. package/docs/web/footprint.md +14 -7
  31. package/docs/web/foreman-mode.md +2 -2
  32. package/docs/web/harnesses.md +5 -3
  33. package/docs/web/install-quick.md +1 -1
  34. package/docs/web/model.md +3 -3
  35. package/docs/web/plugin-context.md +2 -2
  36. package/docs/web/plugin-guard.md +3 -3
  37. package/docs/web/plugin-ponytail.md +1 -1
  38. package/docs/web/rules.md +23 -3
  39. package/docs/web/setup-choices.md +20 -7
  40. package/docs/web/themes-author.md +13 -1
  41. package/docs/web-ui.md +36 -12
  42. package/geneseed +0 -0
  43. package/harness.config.json +1 -1
  44. package/js/catalog.mjs +29 -4
  45. package/js/checkout.mjs +24 -1
  46. package/js/cli-table.json +9 -7
  47. package/js/diff.mjs +8 -3
  48. package/js/doctor.mjs +374 -26
  49. package/js/emit.mjs +48 -11
  50. package/js/generate.mjs +39 -5
  51. package/js/hooks.mjs +8 -7
  52. package/js/installs.mjs +101 -4
  53. package/js/inventory.mjs +150 -20
  54. package/js/lib/fs.mjs +6 -2
  55. package/js/mcp.mjs +3 -15
  56. package/js/migrate.mjs +10 -4
  57. package/js/render.mjs +116 -11
  58. package/js/settings.mjs +297 -28
  59. package/js/setup.mjs +95 -5
  60. package/js/status.mjs +45 -4
  61. package/js/themes.mjs +1 -1
  62. package/js/tui.mjs +46 -3
  63. package/js/uninstall.mjs +30 -11
  64. package/js/web/actions.mjs +76 -9
  65. package/js/web/api.mjs +95 -16
  66. package/js/web/docs.mjs +33 -5
  67. package/js/web/jobs.mjs +12 -3
  68. package/js/web/server.mjs +59 -2
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +75 -65
  71. package/src/agents/_template.md +4 -4
  72. package/src/agents/advocate.md +4 -4
  73. package/src/agents/architect.md +2 -2
  74. package/src/agents/developer.md +5 -5
  75. package/src/agents/docs.md +2 -2
  76. package/src/agents/empiricist.md +3 -3
  77. package/src/agents/explorer.md +3 -3
  78. package/src/agents/framer.md +3 -3
  79. package/src/agents/historian.md +3 -3
  80. package/src/agents/operator.md +3 -3
  81. package/src/agents/pragmatist.md +3 -3
  82. package/src/agents/reviewer.md +2 -2
  83. package/src/agents/security.md +2 -2
  84. package/src/agents/skeptic.md +4 -4
  85. package/src/agents/steward.md +3 -3
  86. package/src/agents/tester.md +2 -2
  87. package/src/agents/user-advocate.md +3 -3
  88. package/src/agents/visionary.md +3 -3
  89. package/src/doctrines/README.md +41 -0
  90. package/src/doctrines/craft.md +51 -0
  91. package/src/doctrines/ops.md +61 -0
  92. package/src/doctrines/process.md +87 -0
  93. package/src/doctrines/rigor.md +52 -0
  94. package/src/laws/universal.md +17 -301
  95. package/src/memory/README.md +2 -2
  96. package/src/modes/README.md +2 -2
  97. package/src/modes/foreman.md +1 -1
  98. package/src/notebook/README.md +2 -2
  99. package/src/ontology/universal.md +89 -0
  100. package/src/postures/README.md +3 -3
  101. package/src/postures/artisan.md +3 -3
  102. package/src/postures/assistant.md +1 -1
  103. package/src/postures/expert.md +1 -1
  104. package/src/postures/mentor.md +1 -1
  105. package/src/postures/peer.md +2 -2
  106. package/src/skills/_template.md +2 -2
  107. package/src/skills/brainstorm.md +2 -2
  108. package/src/skills/clarify.md +2 -2
  109. package/src/skills/codebase-design.md +1 -1
  110. package/src/skills/commit.md +5 -5
  111. package/src/skills/council.md +4 -4
  112. package/src/skills/crash-course.md +1 -1
  113. package/src/skills/daydream/SKILL.md +1 -1
  114. package/src/skills/debug.md +2 -2
  115. package/src/skills/decode.md +1 -1
  116. package/src/skills/develop.md +6 -6
  117. package/src/skills/document-project.md +4 -4
  118. package/src/skills/domain-modeling.md +1 -1
  119. package/src/skills/drill.md +1 -1
  120. package/src/skills/feynman.md +1 -1
  121. package/src/skills/forge-mcp.md +3 -3
  122. package/src/skills/fresh-eyes.md +4 -4
  123. package/src/skills/frontend-design.md +1 -1
  124. package/src/skills/gap-detector.md +1 -1
  125. package/src/skills/geneseed-code-review.md +1 -1
  126. package/src/skills/geneseed.md +4 -4
  127. package/src/skills/git-archaeology.md +1 -1
  128. package/src/skills/git-rescue.md +3 -3
  129. package/src/skills/handoff.md +4 -4
  130. package/src/skills/herdr.md +1 -1
  131. package/src/skills/ingest.md +3 -3
  132. package/src/skills/learning-path.md +1 -1
  133. package/src/skills/migrate.md +5 -5
  134. package/src/skills/opencode-theme.md +1 -1
  135. package/src/skills/parallel-agents.md +2 -2
  136. package/src/skills/pipeline.md +2 -2
  137. package/src/skills/plan.md +2 -2
  138. package/src/skills/ponytail.md +1 -1
  139. package/src/skills/profile.md +1 -1
  140. package/src/skills/prose.md +2 -2
  141. package/src/skills/prototype.md +2 -2
  142. package/src/skills/refactor.md +2 -2
  143. package/src/skills/release.md +4 -4
  144. package/src/skills/repo-map.md +5 -5
  145. package/src/skills/research.md +6 -6
  146. package/src/skills/review-response.md +3 -3
  147. package/src/skills/roast-me.md +1 -1
  148. package/src/skills/rule.md +5 -5
  149. package/src/skills/ship.md +3 -3
  150. package/src/skills/tdd.md +2 -2
  151. package/src/skills/tickets.md +1 -1
  152. package/src/skills/wayfinder.md +1 -1
  153. package/src/skills/wiki.md +4 -4
  154. package/src/skills/workflow.md +2 -2
  155. package/themes/README.md +17 -7
  156. package/themes/_TEMPLATE.json +37 -33
  157. package/themes/biker.json +37 -33
  158. package/themes/commentator.json +37 -33
  159. package/themes/cyberpunk.json +37 -33
  160. package/themes/gamer.json +37 -33
  161. package/themes/imperial.json +37 -33
  162. package/themes/joker.json +37 -33
  163. package/themes/marvin.json +37 -33
  164. package/themes/mean.json +37 -33
  165. package/themes/military.json +37 -33
  166. package/themes/neutral.json +37 -33
  167. package/themes/pirate.json +37 -33
  168. package/themes/sports.json +37 -33
  169. package/themes/verstappen.json +37 -33
  170. package/themes/wizard.json +37 -33
  171. package/web/dist/assets/{About-DX1-VTBP.js → About-C0Mk0igT.js} +1 -1
  172. package/web/dist/assets/{Activity-Dp9NaXOn.js → Activity-C4aJJVXz.js} +1 -1
  173. package/web/dist/assets/{ActivityDetail-HpOBtrXS.js → ActivityDetail-DaQ85za0.js} +1 -1
  174. package/web/dist/assets/{Diff-Ct4G3Se8.js → Diff-B3tlO2Yj.js} +1 -1
  175. package/web/dist/assets/{Doctor-DGjkZFuA.js → Doctor-BNC-5Dmo.js} +1 -1
  176. package/web/dist/assets/{Graph-b8o-S6kv.js → Graph-us0T6ZcV.js} +1 -1
  177. package/web/dist/assets/Harnesses-DAMgyfM_.js +1 -0
  178. package/web/dist/assets/Laws-rOxegJ75.js +1 -0
  179. package/web/dist/assets/{Library-BEZscyaQ.js → Library-KmwrHA1J.js} +1 -1
  180. package/web/dist/assets/{Markdown-k_LSLBGq.js → Markdown-0eQOVGSF.js} +1 -1
  181. package/web/dist/assets/{Profile-DydukTxP.js → Profile-ZU7WV6Xl.js} +1 -1
  182. package/web/dist/assets/{Rules-CIRG4Ldt.js → Rules-CtDoPhhf.js} +1 -1
  183. package/web/dist/assets/{Skills-Buoi1IBr.js → Skills-BhoOxdkD.js} +1 -1
  184. package/web/dist/assets/{StatusBadge-CE_AJttl.js → StatusBadge-CGvYVCwa.js} +1 -1
  185. package/web/dist/assets/{Themes-CI4o3gJC.js → Themes-Dr7OOkD7.js} +1 -1
  186. package/web/dist/assets/{activity-BCW7e6Cy.js → activity-B8aq3qtl.js} +1 -1
  187. package/web/dist/assets/index-2wqPDxuN.css +1 -0
  188. package/web/dist/assets/{index-B1PIx7RM.js → index-AhMzkdbI.js} +1 -1
  189. package/web/dist/assets/index-CRftEtBX.js +12 -0
  190. package/web/dist/assets/{index-Ck2iCdaC.js → index-D0lPN_qW.js} +1 -1
  191. package/web/dist/index.html +2 -2
  192. package/web/src/pages/Laws.jsx +282 -88
  193. package/web/dist/assets/Harnesses-gr3-aHBF.js +0 -1
  194. package/web/dist/assets/Laws-DTwF5tTC.js +0 -1
  195. package/web/dist/assets/index-BnTQUSUz.js +0 -12
  196. package/web/dist/assets/index-DUCsDv7z.css +0 -1
@@ -0,0 +1,1290 @@
1
+ <div align="center">
2
+
3
+ # 🧬 Geneseed — Design history
4
+
5
+ **The Node port, P2 → P10 (2026-06 → 2026-08). A record, not a spec.**
6
+
7
+ [← Back to DESIGN.md](../DESIGN.md) · [What this tool does not prove](limits.md) · [What it costs to add one thing](extending.md)
8
+
9
+ </div>
10
+
11
+ ---
12
+
13
+ Everything below was written while this project had **two** implementations: a Python
14
+ reference (`build.py`, `rituals/*.py`) and the Node port that replaced it. The reference was
15
+ deleted in 2.0 and every recorder that drove it went with it.
16
+
17
+ So read every `.py` path here as a **record of something that existed**, never as a pointer at
18
+ something you can open. The same goes for the `tests/test_*.py` gates named throughout: they
19
+ were real, they were green, and they are gone — their Node successors are named in
20
+ `tests/ported.json`. Where a passage says "now" it means *now, at that phase*.
21
+
22
+ This annex is deliberately **not** de-Pythoned. Rewriting `_build_render.py` to `js/render.mjs`
23
+ throughout would destroy the one thing it documents — that there were two implementations, and
24
+ what it cost to keep them byte-identical. What was wrong was filing it under a header that
25
+ promised a spec someone should read before changing structure. That spec is now
26
+ [DESIGN.md](../DESIGN.md), and it is 160 lines.
27
+
28
+ Dead **links** have been repaired to point at the surviving successor, because a link is a
29
+ pointer whatever the prose around it says. Dead **prose paths** are left as written.
30
+
31
+ ---
32
+
33
+ ## ⚙️ Generator contract — the port narrative
34
+ - **The render core has a Node twin: [`js/render.mjs`](js/render.mjs).** It is a
35
+ translation of `_build_render.py`'s pure pipeline, byte-identical by test rather than by
36
+ intent — `tests/test_render_parity.py` renders both over every theme × footprint ×
37
+ catalog × laws-prefix × posture × mode and compares the written trees byte for byte,
38
+ plus the item order, which is observable and derives from a platform-dependent path
39
+ sort. `js/lib/fs.mjs` holds the primitives where the two runtimes disagree in silence;
40
+ `Path.write_text`'s `\n` → `os.linesep` translation is the load-bearing one. Zero
41
+ dependencies on that side too.
42
+ - **The host-native layer has one too: [`js/native.mjs`](js/native.mjs)** — the point where
43
+ RENDER stops being a pure function of `src/`. Its output depends on your
44
+ `agent-overrides.json`, on which files already exist in the target (claim-on-create never
45
+ overwrites a file Geneseed did not write), and on which of the three host dialects is
46
+ emitting. None of those is reachable from a bundle-emitting test: an emit always writes an
47
+ *empty* overrides stub into a *fresh* tree. So it has a gate of its own,
48
+ `tests/test_native_layer_parity.py`, which hands both implementations the same rendered
49
+ items and compares the written tree byte for byte, the returned ownership list *in order*,
50
+ and the warning stream — and asserts the Node side printed nothing at all on stdout,
51
+ because the emitted hook gates signal their verdict there and return 0 on every path.
52
+ - **The OpenCode extras are ported too: [`js/opencode.mjs`](js/opencode.mjs)** — the
53
+ branded and curated colour themes, the opt-in primary agent and slash-command layer, the
54
+ always-on `/ponytail` switch, the verbatim plugin/workflow copies, and the
55
+ `agent-overrides.json` stub with its staleness notice. Half of it sits behind
56
+ `GENESEED_PRIMARY` / `GENESEED_COMMANDS`, which are off by default — so a default emit
57
+ never writes the primary agent or any command, and neither does any golden cell.
58
+ `tests/test_opencode_extras_parity.py` drives both implementations with those flags on
59
+ and off, over every theme plus synthetic ACCENT values the shipped themes do not
60
+ contain, and compares the files, the returned ownership list, **and both output
61
+ streams** — the staleness notice goes to stdout where every native-layer warning goes to
62
+ stderr, an asymmetry inherited from the Python rather than corrected.
63
+ - **The seam is now a real process boundary: `build.py` spawns Node once per emit.**
64
+ [`js/emit.mjs`](js/emit.mjs) writes the bundle (`build`) and the OpenCode layer's RENDER
65
+ stage; Python keeps WIRE, PRUNE, MANIFEST and VERIFY, and drives — so doctor, web deploy,
66
+ setup and rebuild-all keep calling `build.emit_*` in-process with no call site changed.
67
+ Node falls back to Python when `node` is missing, silently and by design: the whole claim
68
+ is that the two are indistinguishable. `GENESEED_NO_JS=1` forces the Python path.
69
+ **The child's stdout carries the protocol document and nothing else — structurally.**
70
+ `js/emit.mjs` replaces `process.stdout.write` and `process.stderr.write` with buffers for
71
+ the whole run and restores them only to emit that document, so a stray `console.log`
72
+ cannot corrupt the handoff; it lands in the payload, Python re-prints it, and the byte
73
+ comparison fails. Python re-emits both buffers through its own `print`, which is what
74
+ keeps the bytes identical: Node writes UTF-8 unconditionally where Python writes the
75
+ console's locale encoding. `tests/test_emit_boundary.py` runs the real generator twice
76
+ over the same cell — Node driving, then `GENESEED_NO_JS=1` — and compares the tree,
77
+ **stdout, stderr and the exit code**, over cells a uniform matrix cannot reach: a
78
+ re-emit, a renamed owned dir, a *suspicious* name in `.geneseed-srcdirs.json` (the one
79
+ file-driven path into a recursive delete), user edits between two emits, and a truncated
80
+ source tree. `tests/golden.py` now compares both streams too, as `<stdout>`/`<stderr>`
81
+ pseudo-files.
82
+ - **The write-before-delete prune finally has a gate that makes it delete.**
83
+ `node tests/golden.mjs --idempotent` re-emits the *same* configuration, so the owned set is identical
84
+ on both passes and `old_owned - owned` is always empty — 259 green cells said nothing
85
+ about the prune. `--deletion` emits configuration A, then B onto A's tree, and compares
86
+ the result against a **fresh** emit of B: a working prune makes those byte-identical, a
87
+ missing one leaves A's files behind, and one widened past the difference deletes files B
88
+ still wants. Both axes were measured rather than assumed (`lean → full` drops the
89
+ single-file laws pointer, `neutral → imperial` drops the theme-named OpenCode theme), and
90
+ a cell whose two configurations stop differing is rejected instead of quietly becoming a
91
+ second `--idempotent`. The one thing it forgives is named per cell: the agent's own
92
+ memory and notebook seeds are written **once** and never re-rendered, so they keep the
93
+ pre-switch vocabulary by design — and a cell whose carve-out excuses nothing fails too.
94
+ - **All nine emits cross that seam** — and this bullet said so for two phases while eight
95
+ did. `emit_opencode_global` spawned Node **zero** times: its render half was inline
96
+ Python, `js/emit.mjs` offered no job kind for it, and `tests/test_emit_boundary.py` had no
97
+ cell for it either, which is why nothing contradicted the sentence.
98
+ `tests/test_seam_coverage.py` measures the spawn count of every mode, fails if the table
99
+ drifts, and **refuses a mode that crosses without a boundary cell** — so the count above
100
+ is a measurement, and restoring it cost the six cells that earn it. Porting the ninth was
101
+ assembly rather than translation (58 of the 65 functions in its closure were already in
102
+ `js/`), and its structural work was elsewhere: unlike its eight siblings it had no
103
+ `_render_py` / `_wire_py` pair to port into, so the pair had to be **created**. That
104
+ shape is not tidiness — `run_node` classifies as RENDER, so an emit whose two dispatch
105
+ statements are folded into one is perfectly monotone, renders, manifests, and wires
106
+ nothing the phase-order gate can see.
107
+ The largest of the nine is `_emit_claude_core`, the shared engine behind six — Claude,
108
+ Bob and Copilot at both scopes — so until it moved, two thirds of the matrix compared
109
+ Python against Python. Three things in it are not translations. `_ship_lean_laws` is the render that
110
+ used to run *after* a wiring stage, and it does two jobs: it writes the standalone laws
111
+ file and it **claims** it, so a later switch back to the full footprint prunes it.
112
+ `_global_memory` / `_global_notebook` copy arbitrary **user** files in from a legacy
113
+ bundle — the one-time migration that keeps a host switch from losing learned facts — and
114
+ the only thing making that safe is that they run at all only when the destination is
115
+ empty. And the payload has to carry something that is not a file: **`claudeMdText`**, the
116
+ text of CLAUDE.md's managed block, because computing it is a render (AGENT.md re-rendered
117
+ with every store dir prefixed) while merging it into a file the user co-owns is wiring.
118
+ `hasAgentText` travels beside it and is a *different* predicate — Bob at global scope
119
+ emits a preamble but gets no managed block.
120
+ The boundary gate grew the states that go with them: a legacy bundle to migrate from
121
+ (with the bundle in a subfolder, so `--out` and the target are not the same directory), a
122
+ `.claude/` the user got to first, and every write-once file edited *between* two emits —
123
+ the only way a byte comparison can tell "kept your store" from "re-seeded it".
124
+ - **The wiring layer has a Node twin as well: [`js/settings.mjs`](js/settings.mjs)** — the
125
+ JSONC reader, the `opencode.json` and `settings.json` merges, the hook shim, the
126
+ managed-block machinery and the settings integrity check. It was proven before it was
127
+ wired, the way every piece before it was; the emit now drives it. It was the last unit to
128
+ cross because it is the one the **runtime** drives too: eleven of its names have a
129
+ consumer outside the emit tree, ten of them in `rituals/_harness_mcp.py`, which is what
130
+ actually pins it — remerge, deactivate, reactivate, uninstall and `exclude` all edit a
131
+ real settings.json through these functions, and until now nothing compared two runtimes
132
+ across any of them. `tests/test_settings_parity.py` drives seven scripted sequences —
133
+ not seven isolated calls, because the defects here are sequence defects — over states no
134
+ emit-time gate could construct: a settings.json carrying the user's own hooks, a
135
+ commented JSONC, a recorded claim set that is no longer canonical, a settings file that
136
+ is not valid JSON, and the migration where an older install wired hooks into a different
137
+ file. **The shim is compared, not skipped.** It is the one function whose Python output
138
+ is legitimately runtime-dependent — it bakes the interpreter and the checkout — so the
139
+ Node twin takes both as arguments and the gate feeds it the two Python computed, which
140
+ makes every byte comparable, both platform branches included. What stays unproven is
141
+ which values a Node driver will pass, and that is one line at a future call site rather
142
+ than anything in the body.
143
+ - **The wiring half now runs inside the render child, and two stages deliberately do not.**
144
+ The same spawn that renders an emit also merges your `settings(.local).json`, your
145
+ CLAUDE.md managed block and your `opencode.json`. `_settings_integrity_check` stays in
146
+ Python because VERIFY runs *after* MANIFEST and MANIFEST is Python — which turns out to be
147
+ worth keeping rather than merely unavoidable. It never writes, so every Claude-shaped emit
148
+ now ends with Python re-reading the settings file Node just wrote and checking it against
149
+ the claims Node just returned. Two implementations of this layer coexist while the runtime
150
+ is Python, and that check is what makes them **interoperate** on a real file rather than
151
+ merely resemble each other under a parity harness; `tests/test_harness.py`'s uninstall
152
+ tests do the same across a whole lifecycle, wiring through Node and unwiring through
153
+ Python. `emit_opencode_global`'s own `opencode.json` merge also stays in Python, for the
154
+ duller reason that it has no child to join.
155
+ **The hook shim baked the Python interpreter and `rituals/harness.py` through P5a** even
156
+ though Node wrote the file, because the hooks it launched were still Python;
157
+ `process.execPath` was to become the right answer the day the hooks crossed, not the day
158
+ the driver did. They crossed in P5a and the substitution landed in P5b — see *the shim is
159
+ machine-wide* below. That substitution used to be invisible to every
160
+ gate — golden filters the shim out by name so a cross-revision run is not drowned in
161
+ noise, and `doctor`'s check only fires once the baked path stops existing — so the
162
+ boundary gate now compares the shim body between the two runtimes directly.
163
+ **`hookPrefix` throws rather than defaulting.** Left undefined, the runner and entry point
164
+ were baked as the literal string `"undefined"`, killing every hook in the install, and the
165
+ hooks report success on every path by design.
166
+ - **Python and JavaScript disagree about JSON, in two ways that reach emitted bytes.**
167
+ `json.dumps` escapes non-ASCII and `JSON.stringify` does not, which is 44–50
168
+ `description:` lines per theme; and `json.loads` distinguishes `20` from `1.0` where
169
+ `JSON.parse` collapses both to one double, so `temperature: 1.0` would emit as
170
+ `temperature: 1`. `js/lib/fs.mjs` settles both — the second by parsing through the
171
+ reviver's `context.source` so the literal survives, and re-serialising through
172
+ `JSON.rawJSON` so it survives a round trip. Every JSON the generator reads goes through
173
+ `parseJson`, themes included, so the rule has no exceptions to remember.
174
+ The split between the two writers is **compact vs indented**, not string vs container:
175
+ Python's default separators are `(', ', ': ')`, but passing an indent switches them to
176
+ `(',', ': ')` — exactly what `JSON.stringify(v, null, 2)` emits. So `jsonDumpsIndent`
177
+ covers every container the render half writes, while `jsonDumps` stays string-only and
178
+ throws on a container rather than silently emitting the wrong separators. The wiring
179
+ layer is where the third form finally has callers: `jsonDumpsCompact` writes a container
180
+ with no indent, which cannot delegate to `JSON.stringify` at all — patching the
181
+ separators into the serialised text would corrupt any string holding a comma or a colon
182
+ — so the walk is written out, with `sort_keys=True` as an option because the orphan scan
183
+ keys on it.
184
+ - **`_build_core` is the single owner of the generator's mutable configuration** — the
185
+ source/theme roots, the four `_<host>_config_dir` resolvers, and the build-wide
186
+ posture/mode selection, listed in its `_OWNED` tuple. The membership rule is *does
187
+ anything ever redirect this name* — a redirect that reaches some of five copies is
188
+ worse than one that reaches none, because it half-works in silence.
189
+ The process seam extended that rule: everything the Node child reads travels in one
190
+ explicit `cfg` object (`_build_core.js_cfg`), because a subprocess cannot share a
191
+ binding. That is how `STRUCTURE` joined it — it had passed the membership test by
192
+ accident, since the splice shares the dict *by reference* and a test mutating
193
+ `build.STRUCTURE` reached every reader. A child process shares nothing, so it now
194
+ travels too — and `CAPABILITY_LINK_RE` followed it for the same reason one phase later,
195
+ found not by review but by the doctor test that redirects the regex to its pre-fix form
196
+ and asserts the emit then renders dead links. It had been in `_OWNED` all along; what
197
+ changed is that the code reading it moved into another process.
198
+ `build.py` splices its four `_build_*` submodules into one shared namespace, so
199
+ every other shared name exists as a copy in each of them; the owned ones deliberately
200
+ do not. They are held back from both `_build_core.__all__` and the splice, so there is
201
+ exactly one binding: read them as `_build_core.SRC`, redirect them by writing
202
+ `_build_core.SRC`. `build.SRC` still reads (that is the runtime's surface) but refuses
203
+ writes — a write through the facade cannot be restored reliably, and a redirect that
204
+ silently outlives its test poisons every later read. A bare `SRC` left in a submodule
205
+ raises `NameError` rather than quietly reading a stale copy; that noise is the point.
206
+ - **There is a second driver: [`bin/geneseed.mjs`](bin/geneseed.mjs), and it is ADDED beside
207
+ `main()` rather than replacing it.** `build.py` is also the `import build` facade that 19
208
+ `rituals/` modules read 55 distinct names from, and roughly eleven sites spawn it as a
209
+ subprocess; none of them flip, because flipping them would make Node mandatory for the
210
+ *Python* runtime and kill the silent `js_render_available()` fallback. **All nine emits now
211
+ cross**, and they crossed in an order where each added one thing: `--emit files` is the one
212
+ whose Python dispatcher hands the whole emit to Node and returns; `--emit opencode` is the
213
+ first whose **driver body** — PRE (the manifest read), PRUNE, the atomic MANIFEST write, the
214
+ summary line — had to be ported beside the RENDER/WIRE half that already ran there; and
215
+ `--emit opencode-global` is the first **global** target, which is where the inverted
216
+ boundary rule stops being theory. The host config-dir resolvers are exactly the values a
217
+ render child must never resolve, so the driver originates them — `pyResolve` reproducing
218
+ `Path.resolve(strict=False)`, because `realpathSync` throws on the normal case where nobody
219
+ has created the config dir yet. An emit that has not crossed **refuses with exit 3** instead
220
+ of producing a partial tree; that branch is now unreachable and stays, because it is the
221
+ partition `test_the_node_driver_classifies_every_emit` asserts — a tenth emit that failed to
222
+ cross would refuse loudly rather than fall through to building a plain bundle.
223
+ `tests/node tests/golden.mjs --emits` is no longer part of the acceptance command (the full 259 run
224
+ is), but the flag stays: it is how a single emit is narrowed to while iterating, and it
225
+ carries its own wiring gate and mutation history.
226
+ **The Copilot pair crossed next, and which two was the decision.** All six remaining emits
227
+ share one engine (`_emit_claude_core`), so porting the engine looks like it should get all
228
+ six at once. Four of them write `settings.json` hooks, so they reach `hookPrefix`, which
229
+ needs the two machine-absolute values `_hook_runner_entry()` returns — `sys.executable`
230
+ and the harness entry point. **A Node driver has no `sys.executable`**: the hooks it would
231
+ be wiring are Python and `process.execPath` is node. `js/emit.mjs` gates the whole
232
+ settings path behind `!isCopilot` and skips VERIFY for the same host, so the Copilot pair
233
+ rides the shared engine without touching either — which buys the entire driver body
234
+ (manifest-with-`managed`, prune, atomic write) while leaving the interpreter question
235
+ isolated and labelled rather than answered in passing. `hookOpts` is consequently
236
+ **omitted** for a host that wires no hooks, rather than sent as `{}` or `null`: omitted, a
237
+ stray call throws the error P3b built for it; `null` raises an unrelated `TypeError`; `{}`
238
+ bakes the literal string `"undefined"` into the shim and silently kills every hook in the
239
+ install.
240
+ **Then the interpreter question had to be answered, and the answer was to refuse — until
241
+ P5b deleted the question.** (What follows describes P4e; the shim bakes Node now, and
242
+ neither the discovery nor the refusal survives. Kept because the *reasoning* is why the
243
+ flip could only land once the hook verbs had crossed. It describes the LAUNCHERS as they
244
+ then were, too: `$PYTHON`/`%PYTHON%` and the "first candidate that actually runs" probe
245
+ were their contract until the de-Python migration replaced both front doors with Node
246
+ shims, whose one knob is `$GENESEED_NODE`.) The four
247
+ hook-writing emits discovered a Python rather than inheriting one: `$PYTHON` first (the
248
+ documented contract of both front doors), then `py`, `python`, `python3` on `PATH`. When
249
+ none is found the emit **exits 4 having written nothing**, because a shim naming an absent
250
+ interpreter is a *silently disabled hook* — every Geneseed hook returns 0 and signals
251
+ through stdout, and `golden.py` excludes the shim from byte comparison by name, so nothing
252
+ in the system would report it. An install that looks complete with six dead hook groups is
253
+ worse than a build that stops, and the command the refusal names (`python build.py`) is
254
+ available exactly when the emit would have meant anything. Discovery is a **filesystem
255
+ scan, not a probe**, and that is forced: the Node driver may not import `child_process` (it
256
+ is half the proof that it is a second implementation rather than a passthrough), so
257
+ `geneseed.cmd`'s "first candidate that actually runs" test is unavailable. Measured rather
258
+ than assumed — `existsSync` returns false for the Microsoft Store alias stub, because
259
+ `statSync` on the reparse point raises `EACCES`, so the plain spelling already skips the
260
+ thing the launcher needed a subprocess to detect. (End of the superseded passage.)
261
+ **VERIFY did not need porting; it needed calling.** `settingsIntegrityCheck` has been
262
+ complete in `js/settings.mjs` since P3a with no production caller, so the "largest unported
263
+ unit left" was one call site. What changed with it is what it proves: on the Python driver
264
+ the stage runs in Python *after* a Node child did the wiring, making it a live
265
+ cross-implementation check on every build; with both halves in Node that property is gone
266
+ and only the self-check remains.
267
+ **Two hazards here are invisible to a byte comparison for opposite reasons.**
268
+ `json.dumps` defaults to `ensure_ascii=True` where `JSON.stringify` does not — a
269
+ difference the matrix catches in the manifest (its comment carries an em dash) and cannot
270
+ catch in `installs.json`, whose paths are ASCII in every cell and are not on a machine
271
+ with an accented user name. And `$OPENCODE_CONFIG_DIR` is *deliberately cleared* by
272
+ `golden.cell_env`, because leaving a relocation variable set would send ~126 global cells
273
+ into the developer's real install — so a driver that ignored it would be byte-identical in
274
+ all 259 cells while writing to the wrong directory on every machine that exports it. Both
275
+ get a hand-written cell asserting the target, and a mutation confirms each is the only
276
+ thing that catches its own bug.
277
+ **A fresh sandbox cannot reach a driver body's more interesting half.** Every one of
278
+ golden's cells emits into an empty tree, so `old_owned` is `[]`, the PRE branch never
279
+ parses a manifest and the prune never has anything to delete — the code most likely to be
280
+ wrong is invisible to the gate that is otherwise this port's acceptance test. Two gates
281
+ answer that: `golden.py --repeat N` emits N times into the same sandbox on **both** sides,
282
+ which is the re-emit path compared ACROSS implementations (`--idempotent` compares a
283
+ generator against itself and can only prove self-consistency), and the `--root` axis gets
284
+ a hand-written cell because `_argv` never passes the flag — `out == root` in all 259 cells,
285
+ so the instruction-path prefix `opencode.json` records is otherwise never exercised.
286
+ **Three gates for a driver body, and each reaches something the other two cannot** —
287
+ measured by deleting the prune and watching which one goes red. A fresh cell covers the
288
+ first emit; `--repeat` covers reading a manifest and merging into files that already
289
+ exist; only `--deletion` reaches the prune's deletion path, because it is the only one
290
+ that changes the configuration between emits and so the only one where `old_owned - owned`
291
+ is non-empty. A narrowing flag also has to be *proven wired*: `--repeat` degrades silently
292
+ into the plain parity run if `main` stops threading it through, so
293
+ `tests/test_golden_sandbox.py` intercepts `compare` and asserts the value arrived.
294
+ **The boundary rule inverts here.** Every phase since the seam became a process has asked
295
+ which values the child may resolve and which the parent must decide and send — the answer
296
+ for `cfgDir` being that a child resolving it would render into the developer's real config
297
+ dir. A Node driver is the *parent*, so the values a child must never resolve are exactly
298
+ the ones it must originate: `ROOT` and the paths under it come from the script's own
299
+ location. Two `cfg` keys are deliberately **absent**, and their absence is load-bearing:
300
+ `js_cfg()` always sends `structure` and `capabilityLinkRe` because the Python originals are
301
+ module names tests mutate, but this driver has no Python module to mutate — so
302
+ `js/render.mjs`'s `cfg.structure ?? STRUCTURE` and `js/emit.mjs`'s `capabilityLinkRe`
303
+ fallback take their right-hand branches for the first time since they were written. They
304
+ were unreachable, not untested. A second driver is what reached them.
305
+ **A byte gate cannot tell two implementations from one**, so the driver owes a process
306
+ observation and not another matrix: a `bin/geneseed.mjs` whose whole body was
307
+ `spawnSync('python', ['build.py', ...argv])` passes every golden cell perfectly, because it
308
+ *is* the Python CLI. `tests/test_node_cli_parity.py` refuses that from both sides — running
309
+ with every interpreter removed from PATH catches a spawn a source read would miss in a
310
+ disabled branch, and a source check catches the absolute-path spawn no PATH change can
311
+ observe. Each half is the only one that catches its own mutation, which is why they are two
312
+ tests. The markers go through `js/lib/fs.mjs`'s `writeText`, never `writeFileSync`: `Path.write_text`
313
+ opens in text mode with `newline=None`, so a marker written with a bare LF differs from
314
+ Python's on Windows in every cell.
315
+ **The hook path is where the matrix is weakest, because the shim is excluded from it by
316
+ name.** That exclusion is what made byte-parity reachable for a Node generator at all — the
317
+ shim body bakes the writing generator's interpreter and checkout, so it would differ in
318
+ every cell — and it leaves a hole exactly where the last four emits work. `_shim_health`
319
+ fills part of it by asserting the shim exists and names files that exist, and it used to
320
+ return "fine" when it found *no* shim: a generator that wired `settings.json` correctly
321
+ (every command naming the shim path, so every compared byte matches) but never wrote the
322
+ shim passed. It now takes the emit and requires the four hook-writing ones to produce a
323
+ shim. Two further things no byte comparison can reach get hand-written cells: *which*
324
+ interpreter the shim names (its runner is executed and required to be Python 3 — the driver
325
+ may not spawn, the gate may, and that asymmetry is the point), and whether the emitted
326
+ hooks **run** at all, by taking a command out of the settings file and running it through
327
+ the shell with its `|| exit 0` intact.
328
+ **A fifth kind of coverage hole: SILENT ON SUCCESS.** VERIFY runs on every Claude-shaped
329
+ emit and prints only on a fault, and no cell creates one — so deleting the call is
330
+ byte-identical across all 259. That is neither unreachable (it runs every time) nor
331
+ indistinguishable-by-fixture in the earlier senses; its whole *output* is conditional. The
332
+ cell that closes it plants a user-authored hook matching a sniff marker, which makes both
333
+ implementations speak so their stderr can be compared. The four earlier kinds —
334
+ fresh-sandbox, flag-never-varied, deliberately-cleared-env, empty-precondition — each
335
+ needed a different fix, and so did this one.
336
+ - **The runtime port starts with the four HOOK verbs, and with a gate rather than with
337
+ code.** `context`, `git-gate`, `rule-gate` and `learn` are the four commands an emitted
338
+ `settings.json` actually invokes, and they now have a Node twin:
339
+ [`js/hooks.mjs`](js/hooks.mjs) behind [`bin/geneseed-hook.mjs`](bin/geneseed-hook.mjs), a
340
+ **second binary** rather than a subcommand of the generator driver — `learn` must spawn
341
+ whatever `$GENESEED_LLM` names, and `bin/geneseed.mjs` is under a hard `child_process`
342
+ ban that is half the proof it is not a passthrough. The hook entry loads none of the
343
+ ~11,600 lines a Python `harness context` pulls in through `harness.py`'s
344
+ fourteen-submodule splice, which is the same reason the Python originals already refuse
345
+ to import `build` for the marker filenames they need.
346
+ **A cell is not an emit here, and that is the phase's first design question.** A verb's
347
+ observable surface is stdout, stderr, the **exit code** and whatever it wrote, so
348
+ [`tests/helpers/cli_golden.mjs`](tests/helpers/cli_golden.mjs) compares all four across 103 seeded
349
+ worlds — reusing `golden.py`'s `cell_env`, `_normalise`, `_snapshot` and `_split` rather
350
+ than growing a second set of rules about sandboxes and CRLF. `cwd` is an **input** to
351
+ three of the four (discovery, the sovereign bypass, and learn's memory search all read
352
+ it), so every cell runs from inside its own sandbox — which is why the harness resolves a
353
+ repo-relative CLI path once, up front.
354
+ - **A comparison alone would have been the wrong gate for these four**, and more obviously
355
+ than for the generator: a hook must never break a tool call, so every unreadable payload,
356
+ missing file, malformed user config and bypass exits 0 with **no output at all**. Two
357
+ implementations that both stopped gating would agree in every such cell forever. Each
358
+ cell therefore also states, absolutely, what the *reference* must do — `expect`,
359
+ `expect_absent` (the direction `expect` cannot express, and the only way to gate a filter
360
+ like `EXCLUDE_DIRS` whose whole job is leaving things out), `expect_silent` and
361
+ `expect_files` — and a cell that has stopped exercising what it names is reported as
362
+ **VACUOUS** rather than banked as a pass.
363
+ - **One line could not be ported, and it was rewritten on both sides instead.** A malformed
364
+ `context.json` used to be reported by quoting the decoder's own exception. Python's
365
+ `json` and V8 disagree on the wording *and* the offset for every common typo — a trailing
366
+ comma points at the comma in one and at the character after it in the other, and V8's
367
+ `Unexpected token 'x'` carries no offset at all — measured across twenty malformed
368
+ documents. Both now print the file and the fix, following the precedent already set for a
369
+ malformed `settings.json`, and the read failure is split out from the syntax error
370
+ because they were never the same problem. `learn` also gained `encoding="utf-8"` on the
371
+ model CLI's pipe: a bare `text=True` decodes the reply with the console code page, and
372
+ the mojibake is *written to the memory store*, so it outlives the process.
373
+ - **The shim bakes Node now, and the shim is machine-wide.** `bin/geneseed.mjs` writes
374
+ `<node> <checkout>/bin/geneseed-hook.mjs`, so an install it emits needs no Python for its
375
+ hooks at all; the interpreter discovery and the exit-4 refusal that guarded it are deleted
376
+ rather than bypassed, because `process.execPath` is the node already running and cannot
377
+ fail to exist. What makes this a decision rather than a substitution is that
378
+ `hookShimPath()` has **no per-install component** — `$GENESEED_HOME`-or-`~/.geneseed` plus
379
+ `bin/geneseed-hook[.cmd]` — so every emit on a machine rewrites one file that every
380
+ install's hooks execute. Last writer wins, and after the flip that is observable: a
381
+ machine whose last emit ran through Node has every install's hooks running under Node,
382
+ including installs `python build.py` wrote. That is correct exactly while the two entry
383
+ points answer the same verbs identically, which turns
384
+ `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` from a formality into the
385
+ gate the design rests on. A per-driver shim path was considered and rejected: the path is
386
+ baked into every already-emitted hook command, so changing it makes every existing
387
+ install's hooks stale until re-emit.
388
+ **The cell that asked which interpreter the driver discovered became a cell that runs the
389
+ shim.** Asserting the runner's identity stopped meaning anything once the answer was
390
+ "node"; the property underneath was always the chain, so the gate now pulls the git-gate
391
+ command out of the settings file the driver just wrote, runs it through the shell with a
392
+ commit payload, and requires the verdict document back — an assertion that would pass
393
+ unchanged against a Python-baked shim. Its former opposite number flipped with it: with
394
+ every Python stripped from PATH, the four hook-writing emits must now *succeed* and their
395
+ hooks must still answer.
396
+ **The pre-shim fallback is recognisable to cleanup only by a coincidence of filename**,
397
+ and that is now gated. When the shim cannot be written, `hookPrefix` emits
398
+ `"<node>" "<checkout>/bin/geneseed-hook.mjs"`, which `GENESEED_HOOK_SNIFF` matches through
399
+ `SHIM_MARK` — the marker lives in the shim's *filename*, and the Node entry happens to
400
+ share it. An unrecognised hook is one no `unlink`, `uninstall` or orphan scan can find.
401
+ **Node's hooks now translate newlines like Python's.** `sys.stdout` is a TextIOWrapper
402
+ with `newline=None`, so Python's hooks emit CRLF on Windows; `process.stdout` translates
403
+ nothing, measured at 176 bytes against 171 for one `context` run. Every cell in
404
+ `harness_golden.py` reads stdout through a universal-newline decoder, so the difference
405
+ was invisible to the entire matrix — the same shape as the shim's exclusion from
406
+ `golden.py`. Harmless while nothing baked the Node entry; the bytes a real host reads once
407
+ something does. `js/hooks.mjs`'s two output funnels translate, and one gate reads raw
408
+ bytes.
409
+ The acceptance harness is what kept the port from being dead code before any of this — it
410
+ executes `bin/geneseed-hook.mjs` as a real process against the Python one, which is the
411
+ lesson from a stage that sat complete and uncalled in `js/settings.mjs` for two phases and
412
+ was then briefed as unported.
413
+ **Adding a module that legitimately spawns opened a door the driver's own gate cannot see
414
+ through**: `test_the_driver_imports_no_child_process_module` greps `bin/geneseed.mjs`'s
415
+ source, and one `import` of `js/hooks.mjs` would put `child_process` in the driver's
416
+ process with that source still clean. `tests/test_hook_cli_parity.py` walks the driver's
417
+ transitive relative imports instead — the same assertion, one level out, added because of
418
+ this phase.
419
+ - **`status` and `version` cross, and the phase's method is a CORPUS beside the matrix.**
420
+ [`js/status.mjs`](js/status.mjs) answers both from `bin/geneseed-cli.mjs`. Two structural
421
+ things came with them.
422
+ **A closure walk counts what the callee COMPUTES; the caller consumes a fraction of it.**
423
+ An `ast` walk puts `status` at 527 LOC across 7 modules — `_tui_inventory` returns every
424
+ agent and skill with its body, purpose, source and lifecycle badge, and `_status_data`
425
+ reads three `len()`s off it. So the `SKILL_CLASS`/`LAW_CLASS`/`ENTITY_STATUSES` taxonomy
426
+ the TUI owns is in the walk and is not in the port, and neither are `_installed_defaults`'
427
+ posture and mode detectors. The exception names the rule: `_footprint_of_dir`'s return
428
+ value is unused too, and it is ported, because an unrecognised marker makes it WARN on a
429
+ stream the matrix compares.
430
+ **The fixture cannot FENCE OFF `_build_core.ROOT`, and that is a ninth kind of coverage
431
+ hole** — the inverse of the eight before it, which were all about what a fixture cannot
432
+ reach. `status` counts by rendering the real `src/`, and both `_installed_defaults` and
433
+ `version`'s fallback walk `ROOT/"Harness"`. No env var moves ROOT, every path under it is
434
+ derived at import, and the `_OWNED` redirect the suite uses is an in-process write — so
435
+ neither implementation can be pointed at a fixture tree, and copying the checkout into the
436
+ sandbox would copy the same `src/` back in. The counts are therefore gated by comparison
437
+ and asserted only by SHAPE (`expect_re`, new this phase); a cell naming `17 agents` rots
438
+ with the next content change and one naming a fingerprint rots every commit, which
439
+ `test_no_cell_hardcodes_a_source_fingerprint` refuses.
440
+ **The colour branch is unreachable from any cell, and the answer was neither of the two
441
+ the handoff offered.** `_color_enabled()` is `sys.stdout.isatty()`, every harness captures
442
+ through a pipe, so deleting every escape code would be byte-identical across the whole
443
+ matrix — ship it ungated or drop it and regress a real terminal. `_status_lines` is
444
+ documented pure, so [`tests/snapshot/pure_snapshot.test.mjs`](tests/snapshot/pure_snapshot.test.mjs)
445
+ calls it directly on both sides over a corpus of dashboards × colour × ASCII, and the tty
446
+ question never arises. Three more cell-unreachable functions ride along for the same
447
+ reason — `_version_verdict`'s up-to-date branch, `_manifest_is_claude` (only consulted for
448
+ a candidate ordered *after* the unfenceable `ROOT/"Harness"`), and `_accent_for`'s
449
+ fallback — plus `pyLen`/`pyLjust`, because a reproduction of a language primitive needs a
450
+ corpus rather than a cell.
451
+ **And the corpus found a live defect in shipped code.** `js/hooks.mjs` carried its own
452
+ `str(Path(p))` as `pyStrPath`, built on `path.normalize`; P5c had already found that
453
+ `normalize` collapses `a/../b` where `PurePath` keeps it, fixed `js/lib/fs.mjs`'s
454
+ `pyPathStr`, and gated it with a 25-path corpus — which never reached the twin, because a
455
+ corpus finds what it is pointed at and a different NAME was enough to hide it. The
456
+ consequence was live on the bypass path: a hand-edited `..` entry in `excludes.json` made
457
+ the Node hook stand down for a repo Python still gates.
458
+ `context/sovereign-bypass-does-not-match-a-dotdot-entry` is the cell, and the hook path
459
+ now has one owner for the primitive instead of two.
460
+ - **The first NON-hook verb crosses, and it needed a third binary rather than a row in an
461
+ existing table.** `harness exclude add|remove|list` — the sovereign-repo exclusions —
462
+ is now [`js/excludes.mjs`](js/excludes.mjs) behind
463
+ [`bin/geneseed-cli.mjs`](bin/geneseed-cli.mjs). The *reader* had been ported since P5a
464
+ (`sovereignBypass` runs on every hook call); this is the writer, which maintains
465
+ `excludes.json` across every global install and wires each host's native per-repo
466
+ suppression into the excluded repo.
467
+ **Where to put it was the whole question, and two of the three answers cost something
468
+ specific.** Growing `bin/geneseed-hook.mjs` would have meant relaxing
469
+ `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` from equality to a subset
470
+ relation — the gate the machine-wide shim now rests on — *and* loading `js/settings.mjs`
471
+ plus `js/emit.mjs`, ~2,100 lines, on every `PreToolUse` call, against a spec that says the
472
+ shim must exec a minimal entry. Putting it in `bin/geneseed.mjs` would have crossed two
473
+ different Python programs: that file is `build.py`'s `main()`, parses generator flags with
474
+ a partition asserted over them, and carries a `child_process` ban that `web` and `upgrade`
475
+ will eventually need broken — the ban should outlive them, not be dismantled by the first
476
+ verb that does not need it. So the two Node entries are **disjoint by assertion**, and
477
+ `tests/harness_golden.py` grew a `bin` per cell and a `--new-cli` beside `--new`.
478
+ **Supplying one candidate binary and not the other is refused, not defaulted**: an
479
+ unsupplied cell would compare the reference against itself, which always passes and reads
480
+ exactly like a ported verb.
481
+ **The ownership rule is asserted absolutely, because a comparison is blind to it.**
482
+ `exclude add` records the `claudeMdExcludes` entry it *created* so that `exclude remove`
483
+ strips only that; an entry found rather than created — a project install's own wiring, or
484
+ a hand edit — must never be claimed. Two writers that both claimed what they found would
485
+ agree in every cell of the matrix while both deleting a line out of a user's settings file,
486
+ so that one is a hand-written gate run against **both** implementations, with the
487
+ positive control (`remove` really does unwire what `add` wired) beside it, since without it
488
+ the test passes on an implementation that never unwires anything.
489
+ **Two writers for one user-owned file is a stated, temporary cost.**
490
+ `rituals/_web_actions.py` calls the Python `exclude_add`/`exclude_remove` verbatim and the
491
+ web server is a later phase, so until then both exist — held byte-equal by the matrix, and
492
+ atomic (temp + rename) on both sides, so a concurrent pair loses an edit rather than
493
+ tearing the file.
494
+ - **`build`, `prompt` and `theme` cross — the generator's own CLI face —** as
495
+ [`js/generate.mjs`](js/generate.mjs). **Ten of the 24 subcommands are now Node.** Three
496
+ things are worth recording, and the first is about measurement rather than code.
497
+ **A closure walk cannot see through a `subprocess`, and `build` is nothing but one.**
498
+ `cmd_build` measures three lines because the work is `run([sys.executable, BUILD])`. Its
499
+ Node twin does **not** reproduce that shape: `bin/geneseed-cli.mjs` is under a transitive
500
+ `child_process` ban, and spawning `node bin/geneseed.mjs` would be a Node CLI starting a
501
+ second Node process to do work it can do in its own. So `bin/geneseed.mjs`'s `main` is
502
+ exported and called directly, and the auto-run at its foot grew an entry guard — Python
503
+ needs a second process because `build.py` is a different *program*; here it is a module.
504
+ Both ways of getting the guard wrong are gated: stuck false makes the driver a silent
505
+ no-op and fails all 259 golden cells, stuck true runs the generator on the CLI's argv and
506
+ fails the three `build/*` cells.
507
+ **The cells found a live Windows bug in the reference, and the first draft of them encoded
508
+ it.** `harness build` printed nothing where the Node twin printed the generator's summary,
509
+ so the cells were written `expect_silent=True` — asserting what *is* rather than what
510
+ *should be*. `_harness_core.run` folded `CREATE_NO_WINDOW` into every spawn, and with all
511
+ three streams inherited `subprocess` never sets `STARTF_USESTDHANDLES`, so the child got a
512
+ fresh hidden console and **everything it printed was discarded whenever the parent's stdout
513
+ was not a terminal**: `geneseed build > log.txt` wrote an empty log, and the web UI's
514
+ "build all" job logged the harness's lines and none of the generator's. The flag now
515
+ applies only when the caller redirects — which is when it was ever needed, and the doctor's
516
+ per-theme sweep (the case its comment named) captures, so the Doctor page stays flash-free.
517
+ Fixed at the shared `run()` rather than reproduced, following the precedent set for
518
+ `learn`'s cp1252 pipe.
519
+ **A function can be reachable in every cell and still never VARY in one.** `_fence_for`
520
+ picks a backtick fence longer than the longest run inside a rendered file; measured over
521
+ the whole tree, the longest run in any source text is 3, so it returns the four-backtick
522
+ floor for all 96 files and a port that hardcoded four is byte-identical across the matrix.
523
+ That is a fifth distinct reason for the corpus gate, and it is a claim about *content*, so
524
+ `test_the_fence_corpus_still_describes_the_real_tree` re-derives it and fails the day a
525
+ source file grows a four-backtick run — at which point a cell becomes writable and should
526
+ be written. The corpus file is renamed to
527
+ [`tests/snapshot/pure_snapshot.test.mjs`](tests/snapshot/pure_snapshot.test.mjs) with it: it was
528
+ named for the status panel when the panel was all it held.
529
+ **Three of the six verbs the phase was scoped around did not cross, each for a measured
530
+ reason.** `sync-self` is a five-line alias whose body is `_update.sync_self` → `upgrade()`,
531
+ i.e. `git pull` + rebuild, which the self-update phase deletes rather than ports.
532
+ `link` and `unlink` install the `geneseed` front door on PATH, and that is three blockers
533
+ at once: `_win_user_path` drives **PowerShell** to edit the persistent user PATH, which the
534
+ CLI's `child_process` ban forbids; the shim they write names `sys.executable` plus
535
+ `rituals/harness.py`, so a byte-equal Node twin would have to rediscover a Python
536
+ interpreter using the discovery the shim flip deleted; and a shim naming
537
+ `bin/geneseed-cli.mjs` instead would answer three of the 24 verbs. The npm `bin` map is
538
+ what replaces them, so they belong to the publish phase and not to this one.
539
+ - **`diff` and `rebuild-all` cross** as [`js/diff.mjs`](js/diff.mjs) and `cmdRebuildAll` in
540
+ [`js/generate.mjs`](js/generate.mjs), with the install detectors extracted to
541
+ [`js/installs.mjs`](js/installs.mjs) and the registry to [`js/registry.mjs`](js/registry.mjs).
542
+ **Twelve of the 24 subcommands are now Node.**
543
+ **A closure walk cannot see through the standard library either.** An `ast` walk puts
544
+ `diff` at 252 LOC of marker and manifest reading, and the verb's *entire* user-visible
545
+ payload is `difflib.unified_diff`, which the walk counts as zero because `difflib` is not in
546
+ `rituals/`. There is no lazier answer than reproducing it: a generic JS diff produces a
547
+ correct diff and a *different* one, and what the acceptance matrix compares is bytes. So
548
+ [`js/lib/udiff.mjs`](js/lib/udiff.mjs) is `SequenceMatcher` and `unified_diff`, including
549
+ `autojunk` — the popular-line purge that engages at 200 elements and rewrites the hunks of
550
+ every real harness file, and which no fixture is large enough to reach.
551
+ **The rest of the diff is gated by cells and the algorithm by a corpus**, with the
552
+ unreachability measured in both directions: switching `autojunk` off turns 28 corpus cases
553
+ red and leaves all 189 acceptance cells green.
554
+ **Calling `main` in-process is not transparent, and `rebuild-all` is where that showed.**
555
+ Its contract is "continue past a failure so one broken install never blocks the rest",
556
+ which the Python gets for free from a subprocess return code. `bin/geneseed.mjs`'s `die`
557
+ called `process.exit`, which would have taken the loop, the CLI and every remaining install
558
+ with it — so it throws now and `main` converts every deliberate refusal to an exit code.
559
+ The first draft converted only `die`'s own, on the theory that a refusal from deeper down
560
+ should keep its stack; the cell for a bogus theme marker is what showed that was a
561
+ distinction with no principle behind it.
562
+ **A second live ordering bug in the same helper `build` found the first in.** The Python's
563
+ `[rebuild-all]` label lines arrived *after* the generator output they introduce, because
564
+ `print()` block-buffers when stdout is not a terminal while the inherited child writes
565
+ straight through — so every label named the install below it. Invisible in a terminal and
566
+ wrong in the web UI's "build all" log, the same consumer the `CREATE_NO_WINDOW` bug was
567
+ hiding output from. Fixed at the shared `run()`: flush before an inheriting spawn.
568
+ **And the driver's newlines are Python's now**, which closes an item carried since the
569
+ generator's CLI landed: its stdout was one byte short of the Python's on Windows because
570
+ `print()` translates and `process.stdout.write` does not, and no gate could see it —
571
+ `tests/golden.py` captures with `text=True`. `withPyNewlines` wraps the driver's `main`
572
+ rather than converting ~25 print sites, because those modules are *also* the body of the
573
+ seam child whose output a Python parent re-prints; translating at the site would translate
574
+ twice. The gate is a new `build` row in `test_the_two_entry_points_agree_on_stdout_BYTES`.
575
+ **`doctor` was scoped into this phase and is deliberately not in it.** Its closure is 925
576
+ LOC and it runs fifteen `_*_problems` checks that print nothing when they all pass — so
577
+ deleting any one of them is byte-identical in every clean cell, and the gate has to be one
578
+ *planted fault per check*. Measured: **ten of the fifteen read the checkout itself** — `src/`,
579
+ `themes/`, `README.md`, the web pages — and no cell can write there, because `ROOT` is
580
+ derived from the running file's own location on both sides and is the one thing the sandbox
581
+ cannot redirect. Gating `doctor` to this port's standard therefore needs a fixture kind that
582
+ does not exist yet: copy the checkout into the sandbox, plant one fault, and run *both*
583
+ binaries from the copy so their two `ROOT`s move together. That is the next phase's first
584
+ design decision rather than a corner of this one.
585
+ - **`doctor` crosses** as [`js/doctor.mjs`](js/doctor.mjs), and the fixture came first.
586
+ **Thirteen of the 24 subcommands are now Node.**
587
+ **The copy-the-checkout fixture, measured rather than feared.** `_copy_checkout` in
588
+ [`tests/helpers/cli_golden.mjs`](tests/helpers/cli_golden.mjs) gives each cell its own copy of the
589
+ working tree (`git ls-files --cached --others --exclude-standard`), plants one fault in it,
590
+ and runs *both* binaries from the copy so the two `ROOT`s move together. 511 files, 5.2 MB,
591
+ **0.31 s** against a `doctor` run of ~2.1 s — a seventh of the cell, so it is one copy per
592
+ cell per side and the obvious optimisation (one copy per cell *group*, planted and reverted)
593
+ is refused: it would buy that seventh back by making the cells order-dependent, which nothing
594
+ in this harness has ever been. Thirty cells, one planted fault per check.
595
+ **The fixture's own blind spot was the check it was built for.** `Harness/` is *gitignored*,
596
+ so a copy of the tracked set has no committed bundle at all and `_rendered_problems` returns
597
+ on its first line — every "rendered bundle in sync" a clean cell prints was a sentence about
598
+ nothing. `--bundle` and a seeded tree close it, and a two-step cell (`build`, then `doctor`
599
+ against what it wrote) is the only honest source of a bundle a fresh render *agrees* with.
600
+ **A `git ls-files` fixture cannot see the file you are writing.** The first draft copied the
601
+ *tracked* set, so every cell reported the candidate dying on `ERR_MODULE_NOT_FOUND`:
602
+ `js/doctor.mjs` was untracked until it was committed. The fixture mirrors the working tree
603
+ now, ignored paths excluded, which is also what keeps `node_modules/` out of 27 copies.
604
+ **The `child_process` ban became an allow-list, on the condition its own docstring named.**
605
+ `_authoring_problems` runs `node --check` over the OpenCode plugins and there is no
606
+ in-process equivalent — `vm.Script` compiles as a *script* and every plugin is ESM, which
607
+ `node --check` accepts through module-syntax detection. That is the opposite of `build`,
608
+ whose spawn existed only because `build.py` is a different *program*. So the gate now asserts
609
+ the property it used to infer: one binding, one call site, an argv that is `node --check`,
610
+ and a dynamic run of `doctor` with **no python anywhere on PATH**.
611
+ **Two of a check's three arms are unreachable because doctor's own consumer is stricter than
612
+ its gate.** A colour theme missing a palette role kills the opencode-global emit — which runs
613
+ *first* — with a `KeyError`, so `_color_theme_problems` never reports it. Only the arm the
614
+ emit passes through (a value present and not `#rrggbb`) can be planted.
615
+ **And the first caller in the port that CATCHES a refusal found what that costs.**
616
+ `sys.exit(msg)` attaches its message to the *exception*, so a Python caller that catches it
617
+ sees no output; this port writes at the raise site, which is identical for every caller that
618
+ lets the throw propagate and wrong for exactly this one. `renderedProblems` buffers stderr
619
+ and replays it only if the render returns.
620
+ **36 mutations, 34 fire.** The two survivors are classified (one unreachable, one
621
+ indistinguishable), and one of the mutations is aimed at the FIXTURE: with `_copy_checkout`
622
+ planting nothing, a planted-fault cell does not go red, it goes *vacuous* — which is the
623
+ absolute half of a cell doing its job one layer further out than it ever has.
624
+ - **`uninstall` crosses** as [`js/uninstall.mjs`](js/uninstall.mjs), and it is the first verb
625
+ in the port that **deletes**. **Fourteen of the 24 subcommands are now Node**; of the ten
626
+ left, `setup`/`menu`/`home` are P5's and the rest belong to P6–P10.
627
+ **A deletion needs the gate to prove what SURVIVED.** Every cell before this one asserts
628
+ what the reference produced, and a cross-implementation comparison is structurally blind to
629
+ two ports that both stopped deleting — they agree perfectly, forever. So every removal cell
630
+ seeds an *unowned* neighbour beside the owned files and names it in `expect_files`: the
631
+ difference between a manifest-driven removal and an `rmtree` is a positive control, not a
632
+ comment. P5c's ownership rule, one phase on and pointed the other way.
633
+ **The snapshot learned to see a directory.** `golden._snapshot` walks *files*, so an empty
634
+ husk was invisible in all 219 cells — and the ancestor-climb prune (`skills/<name>/…`, the
635
+ nested vendored layout) is a quarter of this verb. A `<dirs>` column in `run_cell` closes it
636
+ for the whole matrix at once, including the cells written before it existed; `cmdTheme`'s
637
+ statement order was sitting in the same hole. The *absolute* half is a different axis and is
638
+ the sixth expectation kind, **`expect_absent_files`**, which fails loudly if the column is
639
+ missing rather than letting every husk assertion pass unexamined.
640
+ **The reference's INCOMPLETE message promises a retry that cannot happen.** `uninstall` warns
641
+ that "the install marker was KEPT so you can retry" whenever a directory survives — but for a
642
+ *global* install the per-host reversal has already dropped the manifest and all four markers,
643
+ because it gates on its own `failed` list while the survivors sweep gates on `survivors`. Two
644
+ signals, one deletion, and only one of them reaches it: the promised retry reports *no
645
+ install at all*. Found by an `expect_files` written to assert the promise, and recorded as a
646
+ two-step cell both implementations must agree on rather than quietly fixed on one side.
647
+ **And a user's own file makes a clean uninstall report INCOMPLETE.** `_owned_dirs_for` holds
648
+ that *existence*, not emptiness, is the retry signal for `agents`/`skills`/`.opencode`, so any
649
+ file the manifest does not claim under one of them keeps the whole install marked
650
+ retry-worthy. The first draft of the cells put their positive controls inside those
651
+ directories and every removal cell came back INCOMPLETE — which is how the behaviour was
652
+ found. Both arms are now gated deliberately.
653
+ **`pyIsAbsolute` joins `js/lib/fs.mjs` as a single owner.** `path.isAbsolute` calls a *rootless*
654
+ `/x/AGENT.md` absolute and `Path.is_absolute` does not — Python wants a drive or a UNC root —
655
+ and that decides which `opencode.json` `instructions` entry `uninstall` unwires. Corpus-gated
656
+ for the reason P5c gave, and the corpus earned its place immediately: the first draft called a
657
+ drive-*relative* `C:x` absolute and only the two drive-relative cases said so.
658
+ **33 mutations, 30 fire.** All three survivors are the same shape — an arm with no caller in
659
+ *this* binary — and two of the reds only fired after a FIXTURE changed: a green mutation is a
660
+ question about the corpus of inputs before it is a question about the code. The control had to
661
+ be re-chosen too; the first one shared mechanism with half the mutations and reported four
662
+ over-reaches that were nothing of the kind.
663
+ - **`setup` crosses** as [`js/setup.mjs`](js/setup.mjs), and it is the first **interactive**
664
+ verb in the port. **Fifteen of the 24 subcommands are now Node**, and P5 is done: the nine
665
+ left belong to P6–P10.
666
+ **`cmd_setup` is a dispatcher, and that is the phase's first finding.** It is 21 lines: a
667
+ TTY check that refuses, `curses.wrapper(_setup_flow)` — 1,100 lines of full-screen wizard
668
+ that are **P7's** — and a line-mode fallback. Same shape as `menu` and `home`, which the
669
+ brief had already excluded from P5 for exactly that reason. The difference is that the
670
+ phases table commits GA to "the text wizard + web console", and `_setup_lines` *is* the
671
+ text wizard, so the line arm crosses and the curses arm does not. On Windows the reference
672
+ always lands in the line arm anyway (there is no `curses` to import); the one stderr line
673
+ it prints on the way is a stated divergence, and P7's to reconcile.
674
+ **No cell can reach any of it, so the corpus learned to type.** `setup` refuses when
675
+ `sys.stdin.isatty()` is false and every cell's stdin is a pipe — so the three `setup/*`
676
+ cells gate the *gate*: that it refuses, that it refuses before reading or writing anything,
677
+ and that a full script of answers does not get it to run anyway. Everything behind it is
678
+ gated by a new corpus shape in `tests/test_pure_function_parity.py`: both probes run with
679
+ **stdin redirected from a seeded file**, and their **whole stdout is compared byte for
680
+ byte** — prompts, numbered menus, `(default)` markers, the plan line and the returned
681
+ selection in one string. That is stricter than a cell would have been, because a cell
682
+ cannot vary the answers at all, and it puts the newline translation under the gate for the
683
+ first time (every earlier probe comparison went through a JSON parse, which cannot see it).
684
+ **And it found a bug in the port on its first run.** The stdin reader inherited from
685
+ `uninstall` decoded fd 0 **one byte at a time**, which turns every multi-byte character
686
+ into replacement characters. Invisible for a phase because its only caller answered `y` or
687
+ `n`; the wizard's first non-ASCII answer exposed it. The bytes are now collected and
688
+ decoded once — scanning for `\n`/`\r` a byte at a time is still correct, since no UTF-8
689
+ continuation byte is below 0x80. This is the second phase running where moving a helper to
690
+ its **second owner** is what made a latent defect observable.
691
+ **`pyInt` joins `js/lib/fs.mjs` as a single owner.** `int(s)` is not `Number(s)` in four ways that
692
+ each change a wizard answer: `Number('')` is 0, `Number('0x2')` is 2, `Number('1_0')` is
693
+ NaN and PEP 515 says 10, and `int` accepts any Unicode decimal where `Number` accepts none.
694
+ The menu's three fallback arms — an in-range index, a *parsed* out-of-range one, and an
695
+ unparseable answer matched against the option keys — are told apart by nothing else.
696
+ **The `child_process` allow-list became a table.** `_lsp_prereqs` runs `java -version` to
697
+ see whether OpenCode's `jdtls` has a JVM, so `js/setup.mjs` is the second module on this
698
+ entry allowed to spawn. The gate now carries the module, the binding, the call count and
699
+ the literal argv for each one, cross-checked against the source — the port's rule that the
700
+ second instance of anything stops being a special case.
701
+ **And the posture/mode debt came due.** `installedDefaults` had answered two keys since
702
+ P5d; the wizard pre-selects three of its five pickers from the other three, so a two-key
703
+ answer would have silently offered the *configured* default where the *deployed* one was
704
+ meant. Nothing in `status` or `doctor` moves — both read the keys they already read.
705
+ **34 mutations, 32 fire.** One survivor is indistinguishable (`_ask` trims, so dropping the
706
+ `\r` in the reader changes nothing) and one is unreachable from every gate
707
+ (`exportImprovements`' no-drift arm needs a deployed install byte-identical to a fresh
708
+ render, and the render reads `src/`). A third fired only *after* a corpus was added: eight
709
+ menu cases and not one of them was both a valid index and a valid option key, so matching
710
+ keys before parsing an index was the same function — a green mutation is a question about
711
+ the corpus of inputs before it is a question about the code.
712
+ **And a warning worth more than the phase: `< /dev/null` is a TTY to Python on Windows.**
713
+ The null device is a character device and the CRT's `_isatty` says yes, so smoke-testing
714
+ this verb's refusal from a shell runs the whole wizard and rebuilds your live install. The
715
+ cells are safe — `subprocess.run(input=…)` is a real pipe — but a gate on `isatty` cannot
716
+ be checked with a redirect.
717
+ - **The web console's HTTP shell crosses, and it needed a third acceptance harness.**
718
+ `tests/golden.py` compares the tree a generator writes; `tests/harness_golden.py` compares
719
+ one verb invocation on stdout/stderr/exit/files. An endpoint writes nothing and never
720
+ exits, so [`tests/helpers/web_fixture.mjs`](tests/helpers/web_fixture.mjs) asks a different question: one
721
+ seeded world, one **sequence of requests** against a freshly started server, compared on
722
+ the status line, the response body as bytes, the five headers the handler *chooses*, the
723
+ daemon record, and the server's own streams. **Both sides are real processes**, started on
724
+ an ephemeral port and stopped in a `finally` — a fixed 4747 would collide with the
725
+ developer's own daemon. The connection is **reused across a cell's requests**, which is
726
+ what makes `do_POST`'s drain-before-routing observable at all; a harness opening a fresh
727
+ socket per request would leave `protocol_version = "HTTP/1.1"` untested.
728
+ **Cells for the shell, corpus for the functions.** All 136 tests in `tests/test_web.py`
729
+ call `api_X(state)` in process, and none of them can see any of `_web_server.py` — not the
730
+ routing, the CSRF check, the DNS-rebinding guard, the status conventions, gzip negotiation,
731
+ keep-alive, or the token injected into `index.html` per request. That is the 654 lines the
732
+ new harness exists for.
733
+ **`_send_json` is `jsonDumpsCompact`, exactly** — "compact" there means *no indent*, not
734
+ *no spaces*, and Python's default separators are `(', ', ': ')`. So all 29 paths share one
735
+ serialiser and no new helper. Its `bareInts` flag states the one condition: a response
736
+ body's numbers are computed here rather than parsed from JSON, and Python types every one
737
+ of them `int`, which both languages render identically.
738
+ **A compressed body is the one thing that cannot be compared as bytes.** The gzip member
739
+ header carries a clock and an OS byte the two runtimes spell differently — and, measured
740
+ after a small payload matched by luck, **the DEFLATE streams differ too**: 753 bytes
741
+ against 751 for the same 1.5 kB input at the same level. That is the zlib build each
742
+ runtime links, which neither implementation chose. So the body is inflated and compared,
743
+ with the compressed length normalised to a tag so its presence stays gated. A destamp
744
+ cannot reach inside one either: the injected token compresses to a different length every
745
+ run, and the reference differed from *itself* until the gzip cells moved to a payload
746
+ carrying no per-run value.
747
+ **Fourteen mutations, twelve fire, and both survivors were findings.** One was a cell
748
+ asking the wrong question — a forged `Host` sent *with* a valid token cannot tell
749
+ host-first from token-first, since both answer `forbidden host`; only a request failing
750
+ **both** checks can. The other is honest: two mutations that stop draining the POST body
751
+ both survive, because Node's parser owns the message boundary where Python's handler reads
752
+ a raw socket. The branch is **indistinguishable on this runtime, not unreachable**, and it
753
+ stays. A third mutation found the gate itself: with no daemon record, the teardown raised
754
+ while parsing a port out of `""` — in a `finally`, *before* the kill — so a failed cell
755
+ ended the run and orphaned one server per cell, which is the exact failure the teardown
756
+ exists to prevent.
757
+ - **Eight web read endpoints cross, and six of them were a JSON face over work already
758
+ done.** `/api/overview`, `/api/themes`, `/api/doctor`, `/api/diff`, `/api/installs`,
759
+ `/api/excludes`, `/api/setup` and `/api/profile` now answer from
760
+ [`js/web/api.mjs`](js/web/api.mjs) as well as from `rituals/_web_*.py`. **No subcommand
761
+ crosses here and the count stays fifteen** — `web` the verb is P6h's, and until then the
762
+ only caller of this code is the acceptance harness. `api_setup` is `statusData()` plus
763
+ four fields; `api_diff` is `diffCollect()` reshaped; `api_excludes` is
764
+ `excludesSnapshot()` unmodified; `api_installs` is a row per `installTargets()` entry
765
+ through five detectors P5d and P5f had already ported. That is the same discovery every
766
+ P5 sub-phase made, one layer up.
767
+ **One parameter had to grow: `doctorCollect({groups})`.** `js/doctor.mjs` already carried
768
+ a `ran(check, label, probs)` whose label went nowhere, with a docblock saying it stayed
769
+ because it is the one place each check is named. `/api/doctor` renders one card per
770
+ check, so this is the phase that fills it. `on_progress=` is still absent and still P7's.
771
+ **The inventory stays the counting half, deliberately.** `state.inventory` is every agent
772
+ and skill with its body, purpose, source path, lifecycle badge and taxonomy class, and
773
+ `api_overview` reads three `len()`s off it. The badge and the class come from the ~111
774
+ lines of TUI taxonomy P7 owns and no P6b endpoint consumes either, so `specNames` is the
775
+ file-*selection* half of `_spec_entries` and P6c grows it by adding the read — not by
776
+ writing a second selector.
777
+ **`python` is the first field in this port with no honest twin.** `api_setup` reports the
778
+ interpreter running the daemon; a Node daemon has none, and answering with Node's version
779
+ under a key named `python` would be a lie the About page prints. It answers `null`. The
780
+ harness normalises the field on both sides — and, because the value is four bytes wider
781
+ on one of them, the `Content-Length` it moves, tagged the same way a compressed body's
782
+ length already was. A comparison made tolerant of a value owes an absolute assertion
783
+ about that value somewhere else, so `tests/test_web_server.py` now carries **both**
784
+ halves: that the reference reports `sys.version`, and that the Node daemon reports
785
+ `null` rather than something version-shaped that would slip through the same pattern.
786
+ **The clock needed a corpus, because the cell cannot see it.** `build_time` and
787
+ `checked_at` are both `%Y-%m-%d %H:%M` sampled while the cell runs, and the two sides run
788
+ seconds apart — so the harness normalises them, which means a twin formatting in UTC, or
789
+ padding wrong, would compare equal in every web cell. The stamp is a pure function of an
790
+ epoch second and the local zone, so it is gated in
791
+ [`tests/snapshot/pure_snapshot.test.mjs`](tests/snapshot/pure_snapshot.test.mjs) over five
792
+ instants chosen for what they break: single-digit month/day/hour/minute, both sides of a
793
+ UTC date rollover, a DST transition, and a fractional second.
794
+ **Two gates, two findings, and neither came from review.** The harness's own self-check
795
+ refused two cells before a line of the port existed: one named a `version_verdict` that
796
+ is only reachable when the checkout has no committed bundle, and one seeded a Windows
797
+ path inside a JSON string, where the backslashes made `\U` an invalid escape — the
798
+ reference then swallowed the parse error and returned the empty stub, which reads exactly
799
+ like a working exclusion list. And the first parity run found a real port bug in
800
+ `js/installs.mjs`: `readMaybe` was a bare `readFileSync` where `Path.read_text` opens in
801
+ TEXT mode and folds `\r\n` to `\n`. Every caller until now read a single-line marker and
802
+ trimmed it, so five phases never noticed; `/api/profile` is the first consumer that hands
803
+ the whole decoded text back out, and it failed on both the text and the sha256 of it.
804
+ Fixed at `readMaybe` rather than at the new call site — the four multi-line readers in
805
+ `installs.mjs` and `uninstall.mjs` all mirror a Python `read_text` too, and were wrong in
806
+ the same way.
807
+ **And the route table became a partition, cross-checked against the source.**
808
+ `tests/web_golden.py` compares the routes that ARE ported, one cell at a time; it is
809
+ structurally blind to a path the reference answers and the Node daemon declares nowhere,
810
+ which the SPA fallback would then serve as an HTML 200 where the client expects JSON. So
811
+ `tests/test_web_server.py` reads the reference's routes out of `_web_server.py` with
812
+ `ast`, asks the Node modules for their own, and requires ported ∪ unported to equal it —
813
+ **split by verb**, because five paths answer both with different bodies and porting
814
+ `/api/excludes`' GET must not take its unported POST out of the list with it.
815
+ **Fourteen mutations, and the one that survived was about that partition.** Collapsing
816
+ the two unported lists back into one — a single line, and the whole reason they are two —
817
+ left both sets exactly as declared and both tests green, while POST `/api/excludes`
818
+ began answering a plausible 404 instead of the 501 that says "unported". **A gate on a
819
+ declaration is not a gate on the dispatcher.** The fix drives the real handler over
820
+ `node:http`, one probe per branch of the partition, with a ported route beside them as
821
+ the control — without which every 501 in the list is vacuous.
822
+ - **The catalog crosses, and the taxonomy it needed was already ported.**
823
+ `/api/catalog/<section>`, `/api/item/<type>/<name>` and the wiki reader answer from
824
+ [`js/web/api.mjs`](js/web/api.mjs), over a new
825
+ [`js/inventory.mjs`](js/inventory.mjs) — `_harness_tui.py`'s catalog half. **Still
826
+ fifteen subcommands; still no call site until P6h.**
827
+ **The measurement is the finding.** `js/status.mjs` had said since P5d that "the ~111
828
+ lines of TUI taxonomy" were P7's and not ported. `LAW_CLASS`, `SKILL_CLASS`,
829
+ `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g, inside `js/doctor.mjs`,
830
+ because doctor's authoring gates are what validate them. So P6c moved the four to the
831
+ module that mirrors where Python keeps them and had doctor import them back, rather than
832
+ writing a second copy — a copy of a value under test stops being the value under test the
833
+ first time one of them is edited. Only `_parse_laws`, `load_registry` and
834
+ `entity_status` were genuinely new.
835
+ **And `inventoryCounts` folded back into the walk it was a copy of.** P5d shipped a
836
+ counting-only twin with a docblock warning that the two must not become two classifiers;
837
+ `api_catalog` consumes exactly the fields the count threw away, so this was the phase
838
+ that would have made them two. It is now three `length`s off `tuiInventory`, and the
839
+ eleven `status` cells and the catalog cells gate the same classifier from opposite ends.
840
+ **`decodeURIComponent` is not `urllib.parse.unquote`**, and the difference is a status
841
+ code. The JS builtin throws a `URIError` on a `%` that is not an escape — `a%ZZfact`, a
842
+ trailing bare `%` — which the shell would answer as a 500 where the reference answers a
843
+ 404 naming the literal text. `pyUnquote` reproduces Python's rule (a two-hex-digit table,
844
+ and anything else is left alone), gated by a 21-input corpus that also measures the two
845
+ runtimes' replacement of truncated UTF-8 rather than assuming it.
846
+ **A hand-maintained manifest is JSONC, and a cell had to say so.** `wiki.jsonc` is read
847
+ through the comment-tolerant loader on the reference; every manifest the harness seeds is
848
+ plain JSON, so a port using `JSON.parse` would have listed no wiki pages and returned an
849
+ empty `manifest` — both of which read as "the user has no wikis" rather than as a parse
850
+ failure. Found by reading the reference's loader, not by a failing cell, and closed with
851
+ the cell that would have.
852
+ **Three security branches, three cells**, because a GET carries no token: a flat name
853
+ containing `..`, a wiki entry whose relpath climbs out of its vault, and the router's
854
+ bounded `split("/", 4)` — an unbounded one hands the item route the wrong name.
855
+ **Thirteen mutations, and the two that survived were both those cells.** Each pointed at
856
+ a path with no file behind it, so the containment check could be deleted with no effect:
857
+ `is_file()` refused first, and a refusal and a miss produce the same 404. **A containment
858
+ cell whose target does not exist proves nothing.** Closed by seeding files that ARE there
859
+ and naming, in each cell, the content that must not come back.
860
+ - **The Docs pages cross for 57 of 59, and the two that do not are declared rather than
861
+ discovered.** [`js/web/docs.mjs`](js/web/docs.mjs) answers `/api/docs` and
862
+ `/api/docs/page/<id>` for the `markdown`, `concept` and `glossary` kinds — the harness-
863
+ block stripping, the slug rules, the section slicing, the live-count substitution and the
864
+ theme-aware glossary. **Still fifteen subcommands.** The measurement that scoped it is
865
+ the entry: `cli` walks `harness.build_argparser()`, **24 subparsers and 43
866
+ `add_argument` calls**, none of which a Node twin can introspect — reproducing it means
867
+ transcribing the whole CLI surface into a second table that would describe verbs
868
+ `bin/geneseed-cli.mjs` cannot run, and the real fix is to make that metadata data both
869
+ sides read, which is packaging work. `about` shells out through `_update._origin_display`,
870
+ which is P8's subject. Both answer 501, and the KIND partition is cross-checked against
871
+ the kinds the reference dispatches on — the same shape the route table took, and a table
872
+ rather than three `if`s so the gate reads what the dispatcher consults.
873
+ **`?harness=` finally has a caller**, and it is the only input these endpoints have that
874
+ is not the checkout — so it is the only thing a cell can vary, and every filtering rule
875
+ is gated by sending both hosts over one page.
876
+ **And a corpus found a divergence the port had been carrying as a footnote.** The slug
877
+ rules are compared against the frontend's, so they have to match character for character.
878
+ Python's `\s` on a `str` pattern is not JavaScript's, and the standing note read "differs
879
+ only at U+FEFF". Measured: `python \s = js \s − U+FEFF + U+001C..U+001F + U+0085`. A
880
+ heading carrying an information separator slugged differently on each side. `PY_SPACE`
881
+ in [`js/lib/fs.mjs`](js/lib/fs.mjs) is the measured set, and no cell could have found
882
+ it — nobody has written that heading.
883
+ **Twelve mutations, eleven fire, six of them on the corpus rather than a cell** — which
884
+ is what "a corpus phase" means in practice. The survivor is honest: dropping a blank
885
+ `?harness=` is `parse_qs`'s documented default and changes nothing observable, because
886
+ the empty string and a missing value reach the same fallback. Indistinguishable through
887
+ its one consumer, not unreachable, and kept for the reason the POST body drain was.
888
+ - **The capability graph and the live-activity surface cross, and the spawn the plan warned
889
+ about was never there.** [`js/web/graph.mjs`](js/web/graph.mjs) and
890
+ [`js/web/activity.mjs`](js/web/activity.mjs) answer `/api/graph`, `/api/activity` (GET)
891
+ and `/api/activity/<sid>` — **22 of 29 web paths, still fifteen subcommands.** The plan
892
+ scored the pid probe as a `tasklist` spawn needing a sixth allow-list row; the source is
893
+ `ctypes` over `OpenProcess`, so **neither implementation spawns anything** and
894
+ `process.kill(pid, 0)` is the twin.
895
+ **`api_graph`'s two halves read two different inventories** — nodes from the deployed set,
896
+ edges from the SOURCE render's bodies, because deploying a skill flattens its markdown
897
+ links to prose. A port walking one inventory for both answers a single-banded matrix on a
898
+ deployed install, and every undeployed cell still passes; the cell that separates them
899
+ deploys a skill whose deployed body carries no links at all.
900
+ **A hardcoded regex can be right for one theme and empty for another**: `Rule|Law` finds
901
+ 46 law references in the neutral render's law bodies and **zero** in the imperial one,
902
+ where the noun is "Dictate" — so the edge set is gated once per voice. Two more regex
903
+ divergences came with it: Python's word boundary is Unicode-aware where JavaScript's is
904
+ ASCII-only, and `re.escape` must not escape `-` (JavaScript reads `\-` outside a character
905
+ class as an invalid escape).
906
+ **The first web endpoint that DELETES**, which the response is completely silent about, so
907
+ `tests/web_golden.py` grew `expect_files` / `expect_absent_files` — and the clock is
908
+ **seeded rather than destamped**, one `now` per cell shared by both sides, because
909
+ normalising `updated_at` would erase the live/stale rule it exists to gate.
910
+ **`parseJson` is not optional once a third party writes the file**: activity snapshots come
911
+ from an OpenCode plugin and carry a float, and `JSON.parse` loses Python's `1.0`.
912
+ **Eighteen mutations, eighteen fire, no survivors** — two of them visible only in the file
913
+ snapshot, because the response body is byte-identical with the unlink removed.
914
+ - **The web console starts WRITING, and the argv-in-a-response-body trap had a false
915
+ premise.** [`js/web/actions.mjs`](js/web/actions.mjs) and [`js/mcp.mjs`](js/mcp.mjs) answer
916
+ the eight mutating POSTs that own their own path plus the `/api/rules` and `/api/mcp` GETs
917
+ that had to cross with them — **27 of 29 web paths, still fifteen subcommands.** The plan
918
+ called `api_install_cmd`'s `{"cmd": [sys.executable, "build.py", …]}` "a Python argv, as
919
+ DATA, in a response body" and offered two designs for it. **It is not in a response body**:
920
+ no consumer exists in `web/src/`, and `_post_routes` hands the list straight to the job
921
+ runner in the same process. So the "better design" bought nothing, `web/dist` did not need
922
+ rebuilding, and the twin simply names its own runtime — gated by a corpus rather than by a
923
+ byte gate that can never see the value. **Measure who READS a value before designing around
924
+ its shape.**
925
+ **A partition that shrinks needs a partition that does not.** `/api/pick-folder` opens an
926
+ OS-native folder dialog and will never cross, so it is declared in `DECLINED_POST` rather
927
+ than in `NOT_PORTED_POST` — folding a permanent decline into a to-do list makes the list
928
+ wrong in the one direction nobody checks.
929
+ **The 409 convention is a per-route column, not a rule about bodies**: five paths map
930
+ `ok: false` to 409, `/api/activity` answers 200 regardless, and `/api/memory/delete` carries
931
+ no `ok` at all. **`_read_json_body` finally has callers**, which is what P6a's retained
932
+ drain was for — a body that never parsed must reach the endpoint as `{}` and be refused by
933
+ its own validation, never as a 500 carrying a parser message the two runtimes word
934
+ differently.
935
+ **A cell with an upper-case filename found a live bug three phases old**: `sorted()` over
936
+ `Path` objects is case-folded on Windows, so `MEMORY.md` files under `m`, where JS's default
937
+ comparator puts `M` before `a` — every seeded memory file since P6b had been lower-case.
938
+ `comparePaths` already existed; three call sites in the catalog readers were not using it.
939
+ **Thirty mutations, thirty fire** — but two survived the first pass, and both were questions
940
+ about the CORPUS OF INPUTS. One was closed by sending a truthy non-boolean; the other could
941
+ not be closed by any cell, so the 409 column itself became the thing under test, read out of
942
+ the reference by `ast` and compared against the table `doPost` dispatches on.
943
+ - **The console runs BACKGROUND JOBS, and the port's one sanctioned spawn is argued rather
944
+ than assumed.** [`js/web/jobs.mjs`](js/web/jobs.mjs) answers the last two web paths —
945
+ **29 of 29, still fifteen subcommands** — and it is the only module in the port allowed to
946
+ start a copy of *this program*. **Isolation is the discriminator and it is a property of the
947
+ RUNTIME, not of the verb**: the reference's daemon is threaded, Node's server is
948
+ single-threaded and every ported `cmdX` is synchronous, so an in-process job would freeze the
949
+ poll the console depends on to watch it, and a `process.exit` in any callee would kill the
950
+ daemon instead of the job.
951
+ **Five of the eight actions cross and three are DECLARED**, because spawning
952
+ `python harness.py upgrade` for `update`, `link` and `unlink` would put Python back into a
953
+ "no Python needed" install for the operations that matter most. They answer **501, not the
954
+ 404 an unknown action gets** — a real endpoint that has not crossed and a typo must not give
955
+ the same answer — and the two sets are cross-checked against `action_commands`' own keys by
956
+ `ast`.
957
+ **The `$ <argv>` echo line is the first value the byte gate is asked to be tolerant of by
958
+ CONSTRUCTION**: the reference starts `python harness.py doctor` and the twin starts
959
+ `node bin/geneseed-cli.mjs doctor`, and that difference *is* the port. It is normalised the
960
+ way a timestamp is destamped, and each side's argv head is then asserted absolutely — with
961
+ the TAIL compared literally — in a corpus, because a tolerant comparison owes an absolute
962
+ assertion on every side it is tolerant of.
963
+ **Python's text mode is two ports, not one**: a chunked pipe read needs a decoder that holds
964
+ a partial UTF-8 sequence across a boundary, and it needs universal-newline folding, or the
965
+ same job's output is CRLF on one side and LF on the other.
966
+ **A destamp that bites a prefix out of a value is worse than no destamp**: the daemon
967
+ record's `"started": <int>` was replaced as a literal, a job's `started` is a float whose
968
+ integer part is the same second, and the tail it left behind only appeared when the two
969
+ clocks happened to agree. Found by the ref-vs-ref self-check, before a line of the port
970
+ existed — along with a `Content-Length` rule that named ONE field by hand where it meant
971
+ "any normalised value whose width differs".
972
+ **Twenty-five mutations, twenty-three fire, two declared survivors** — one unobservable by
973
+ construction (the env variable whose reader is P8's) and one a real race the corpus cannot
974
+ reach, because closing it needs a job whose output is still in flight and the cheap way to
975
+ get one writes a file whose NAME carries a clock. **A destamp that only reaches inside files
976
+ leaves the filename as an ungateable axis.**
977
+ - **The `web` VERB crosses — sixteen of 24 subcommands — and the TEARDOWN decided which cells
978
+ exist.** [`js/web/server.mjs`](js/web/server.mjs) grows the daemon lifecycle in one file, as
979
+ the reference keeps it: `_probe`, `_live_daemon`, `_spawn_detached`, `start|stop|status|
980
+ restart`, `_build_plan`'s consumer, `_npm_build`, the browser open, `cmd_web`, and the last
981
+ POST — `/api/restart`. `bin/geneseed-cli.mjs` finally imports the web tree, which is what
982
+ makes the CLI's spawn walk cover the job runner too.
983
+ **Three of the verb's five shapes end with a process still running**, and
984
+ `harness_golden.run_cell` has no `finally` for one: it leaves a `TemporaryDirectory`, whose
985
+ `rmtree` RAISES when a surviving daemon holds the log file open — so a failed `start` cell
986
+ would both hide its own finding and orphan a server on the developer's machine. The cell
987
+ group therefore takes the eight actions that TERMINATE (the whole stale-record protocol,
988
+ and the two `_build_plan` arms that refuse before a socket is bound), and the real-server
989
+ lifecycle stays in `web_golden`, whose teardown never raises. **Read the teardown before
990
+ deciding what a cell may do**, not after.
991
+ **The byte gate is what licenses the MOVE, and a passing run proves nothing until a
992
+ mutation says so.** All 95 `web_golden` cells now run through
993
+ `node bin/geneseed-cli.mjs web` as well as through the module — and two mutations in the
994
+ CLI's own dispatch turn the first run red while leaving the second green, which is the only
995
+ way to show the matrix is connected to the moved code rather than to where it used to live.
996
+ **A byte gate cannot tell two implementations from one, and it cannot tell two ENTRIES from
997
+ one either.**
998
+ **Putting the web tree on the CLI's graph pulled a third spawning module onto it**, through
999
+ a frontmatter parser and a memory helper that happen to live beside the `$GENESEED_LLM`
1000
+ spawn. That is P6g's "a reachability gate over a shared graph re-asks every decision that
1001
+ graph already contains", arriving from the other side — and the row DELEGATES to the test
1002
+ that already owns that argv rather than copying its literals into a second file.
1003
+ **`/api/restart` is the one route in this port that no test may reach**: every way of asking
1004
+ it a question spawns a detached daemon — in the acceptance harness's sandbox, or in the
1005
+ developer's own environment where it would bind 4747 and serve the checkout forever. It is
1006
+ declared and read from both sources instead, and the reason is written where the route is.
1007
+ **And EOF is not an empty line.** The npm prompt accepts `""` as yes, so a reader that
1008
+ returned the empty string at end-of-file would answer YES to "run npm install now?" on
1009
+ every non-interactive run; the reference's `input()` raises there. The byte reader now
1010
+ reports that case as null, gated by a stdin-seeded corpus.
1011
+ - **The install TOGGLE crosses and the web console is whole — 29 of 29 paths ANSWER, still
1012
+ sixteen subcommands.** [`js/uninstall.mjs`](js/uninstall.mjs) gains the reversible half it was
1013
+ written expecting: deactivate MOVES every owned artifact into a sibling stash and drops the
1014
+ wiring, reactivate moves the same bytes back, and the stash directory's PRESENCE is the
1015
+ disabled flag — no recorded state that could drift from the filesystem. `/api/pick-folder` is
1016
+ the one endpoint that is DECLINED rather than deferred, and `NOT_PORTED_POST` is now empty and
1017
+ still declared.
1018
+ **A deferral's measurement is a claim about another file and it rots exactly like a
1019
+ docblock.** The handoff said "293 lines over nine functions"; it is 306 over eleven, it
1020
+ counted one that had crossed three phases earlier, and it never named three others. 293 was a
1021
+ SPAN — nine bodies plus the blank lines between them.
1022
+ **`web_golden` had no `<dirs>` column, and a third of the engine lived in it.** A file
1023
+ snapshot cannot see an empty directory, so a port that moved every owned file and left
1024
+ `skills/<name>/` standing was byte-identical in all 95 cells. One pseudo-file closed it for
1025
+ the whole matrix **including every cell written before it existed** — which is what a harness
1026
+ axis can do and a per-cell expectation cannot — and it broke none of them.
1027
+ **The rollback branch is reachable, and exactly one world shape reaches it.** A deactivate is
1028
+ all-or-nothing, and the move only fails on a destination that already exists — but a
1029
+ pre-existing stash makes the install `disabled`, so the operation refuses before the loop. The
1030
+ single seedable shape is a manifest naming a file AND its parent directory. **When a defensive
1031
+ branch looks unreachable, ask what input makes the operation fail at its SECOND step**, not
1032
+ whether a cell can fail it at all.
1033
+ **One line is a security boundary rather than a translation**: pathlib's `/` REPLACES the base
1034
+ for an absolute entry, so a manifest naming `/evil.md` resolves outside the root and is
1035
+ dropped, where a `path.join` port would have folded it back inside and moved a file the
1036
+ manifest never owned.
1037
+ **And a refusal to guess is a gate that runs in production.** The settings merger will not
1038
+ default the hook shim's runner and entry, so the Claude reactivate answered with that refusal
1039
+ on its first Node run instead of quietly wiring hooks at an invented path.
1040
+ - **`upgrade` crosses — seventeen of 24 — and the phase's real work was a fixture that could
1041
+ WRITE.** [`js/update.mjs`](js/update.mjs) is `rituals/_update.py`'s upgrade half: the
1042
+ preflight, the streamed fetch, the four upstream classifications, the fast-forward, the
1043
+ doctor gate with its exact rollback, and the two rebuilds. Every other verb in this port
1044
+ READS the checkout; this one rewrites it, so its cells needed a git repository they could
1045
+ fast-forward and an origin they could fetch from — a `git clone --local` off a bare
1046
+ template the harness builds once per run (2.2 s to commit 526 files, 0.8 s to clone one).
1047
+ **The spawn ban's fourth exception, and its argument is not the job runner's.** `js/web/`
1048
+ spawns for ISOLATION; this module spawns because **the code on disk changes halfway through
1049
+ the verb**. `pullAndValidate` fast-forwards and then has to ask the PULLED source whether it
1050
+ is sound, and `rebuildBundle` has to render with the PULLED generator — an in-process
1051
+ `cmdDoctor` would validate the modules this process loaded before the merge, pass, and then
1052
+ render the old source while reporting an upgrade. `js/generate.mjs`'s `cmdBuild` calls the
1053
+ driver in-process and is right to; the discriminator is whether anything changed underneath.
1054
+ **"No cell reaches the network" is enforced by git, not by the fixture.** A fixture that
1055
+ merely points `origin` somewhere safe is a claim about the fixture. `GIT_ALLOW_PROTOCOL=file`
1056
+ is in EVERY cell's environment now, so git refuses `https` before a socket opens — and one
1057
+ cell names that refusal in its `expect`, so the day the variable stops being honoured a cell
1058
+ goes vacuous instead of going online.
1059
+ **`git add -A` drops files that are TRACKED**, which is how a fixture plants a fault by
1060
+ accident. Two source files under `src/notebook/` are in the index and also matched by the
1061
+ repo's own `.gitignore`; without `--force` the fixture's commit lost them, `doctor --all`
1062
+ on the clone reported five dead links nobody planted, and the pull's SUCCESS arm would have
1063
+ been unreachable while its rollback arm looked like the verb's only behaviour.
1064
+ **A local fetch prints NOTHING**, so the streaming half — the reader, the phase filter that
1065
+ keeps a thousand `Receiving objects: 43%` repaints out of the log, the 15 s heartbeat — is
1066
+ reachable in every cell and varied in none. The filter moved to the corpus; the heartbeat
1067
+ and the hard deadline are gated by neither, and saying so is the point.
1068
+ **P6g's `GENESEED_WEB_JOB` finally has a reader, so its mutation finally fires.** The job
1069
+ runner sets it and this verb honours it by NOT bouncing the daemon mid-job; a pair of cells
1070
+ differing only in that variable is what makes deleting either end red.
1071
+ **`export_improvements`' no-drift arm is gated at last**, and it took the first phase that
1072
+ could deploy a byte-fresh install inside a cell: step one is `rebuild-all`, step two is the
1073
+ upgrade that finds nothing to say. The drift cell beside it is the positive control — same
1074
+ fixture, one edited file, the message appears.
1075
+ **Twenty-four mutations, twenty-two fire, and the two survivors are ONE finding.** The
1076
+ credential in a tokened remote passes two independent scrubbers on one path — `_git` redacts
1077
+ its stdout, and `_parse_origin` then rebuilds the display url from host and path — so either
1078
+ alone suffices and neither is observable by itself. Removing BOTH turns the cell red, which
1079
+ is what says it is connected; each is separately corpus-gated. **A cell aimed at a defence in
1080
+ depth gates the conjunction, not the layers**, and the cell's comment now says so instead of
1081
+ naming the wrong one.
1082
+ **And P5e's `CREATE_NO_WINDOW` fix had one call site left.** `_update` keeps its own copy of
1083
+ the flag (it stays importable standalone so a stale factory can self-heal) and so kept its
1084
+ own copy of the bug: every `[rebuild-all]` line an upgrade printed on Windows was discarded,
1085
+ including the FAILED rows its error message tells you to look for. Found by a cell's
1086
+ ABSOLUTE half naming a line the reference was not printing.
1087
+ - **`sync-self` and `bootstrap` cross — nineteen of 24 — and the two shell wrappers are
1088
+ deleted.** `sync_self` is nine lines and calls `upgrade()`, so its cells gate the DISPATCH
1089
+ rather than new behaviour — and "an alias" turns out to be two properties, not one: the row
1090
+ points at `cmdSyncSelf` and not at `cmdUpgrade` because `sync-self cyberpunk` must NOT
1091
+ rebuild in cyberpunk the way `upgrade cyberpunk` does. `cmd_bootstrap` was the phase's real
1092
+ work and the plan never measured it: 30 lines over a fork, ~143 reachable and ~141 not.
1093
+ The curses progress screen is P7's, declared on exactly the terms P5i declared `cmd_setup`'s;
1094
+ `_harness_supports` and `_stale_factory_hint` are declared for a second reason — they detect
1095
+ argparse's `invalid choice` from a PARTIALLY updated install, and the Node step is a function
1096
+ this binary statically imported.
1097
+ **The step is an IMPORT, not a spawn, and this module is where that had to be argued twice.**
1098
+ P8a's rows spawn because the code on disk changes halfway through the verb; nothing has
1099
+ changed when the update step STARTS, so `cmdUpgrade()` in-process is the same program and the
1100
+ reference's own spawn is an artifact of a progress UI needing a pipe. The ARGV is still built,
1101
+ because the install log promises the user a command they can re-run — and that line cannot be
1102
+ byte-equal across runtimes, so it is normalised (the P6g `$ <argv>` shape, now in a FILE) with
1103
+ the absolute per-side assertion beside it.
1104
+ **The deletion needed a gate of its own.** `upgrade.sh` and `sync-self.sh` were an interpreter
1105
+ probe and `exec "$PY" rituals/harness.py <sub>`; no launcher invoked either, and all three
1106
+ probe `harness.py <cmd> --help` and fall back to `python rituals/_update.py <cmd>` — a stable
1107
+ contract that never went through a wrapper. Twelve tracked files still NAMED them, `bootstrap`'s
1108
+ own header comment among them, so the gate scans every tracked text file: the next stale
1109
+ pointer fails a test instead of a user.
1110
+ **Three defects, all found by a cell's absolute half.** `cmd_bootstrap` re-exec'd
1111
+ `python rituals/_harness_lifecycle.py setup` — `__file__` rather than `harness.py` — and that
1112
+ module has no `__main__` block, so `geneseed bootstrap` UPDATED AND THEN SKIPPED THE WIZARD,
1113
+ reporting success, on the front door's own default path. The Node step called `upgrade()`
1114
+ where the reference's step is the SUBCOMMAND, and `cmd_upgrade` is where the pre-update
1115
+ improvements export lives. And the first `nargs="*"` in the verb table swallowed unknown
1116
+ OPTIONS, so `geneseed bootstrap --no-setpu` would have run a full bootstrap instead of
1117
+ refusing — the most dangerous parse bug this entry point could have had.
1118
+ - **P8c pays two partition debts, and a payment needs a gate DIRECTION.** `update` leaves
1119
+ `NOT_PORTED_ACTIONS` and the docs `about` kind leaves `NOT_PORTED_KINDS` — one line each,
1120
+ which is what P6g and P6d built the partitions for. What is not one line is the observation:
1121
+ a removal seen only as the ABSENCE of a 501 is satisfied by a 404, so the dispatcher probe
1122
+ that asserted 501 now asserts the success shape for both.
1123
+ **The probe that asks for `update` can never START one.** It runs against the developer's own
1124
+ checkout, where a clean tree with an upstream would make the preflight say "ready" — so the
1125
+ probe points `GIT_DIR` at a path that does not exist, every git call in the preflight fails,
1126
+ and 422 is the only reachable answer. That is also the arm the endpoint is really about: a
1127
+ local-only gate ahead of the job, so a dirty tree gets an explanation instead of a spawn that
1128
+ fails. `unportedAction` moves to `link`, which is still declared, so the partition question
1129
+ keeps being asked by something that can still answer it.
1130
+ **`aliases=["update"]` was believed coupled to that and is not.** The action's argv names
1131
+ `upgrade`, the VERB, on both sides and always did. The alias is a separate parity fact, and it
1132
+ lands as a ROW in the verb table rather than as an `aliases` field, because the three matrix
1133
+ gates read that table as the DISPATCH — so `harness_py_subcommands()` now reads argparse's
1134
+ aliases out of the reference and an `update/` cell group proves the row reaches `cmdUpgrade`,
1135
+ the dead `ref` positional's re-read included.
1136
+ **And the `about` page's defect is this port's recurring shape.** `OriginDisplay`'s JS twin
1137
+ spells the field `githubSlug`; the wire format spells it `repo_is_github`. Reading the wire
1138
+ name off the record gives `undefined` — a perfectly plausible `false` that silently drops
1139
+ every github deep link on the page and changes nothing else.
1140
+ - **`link` and `unlink` cross — 21 of 24 — and the two defects only an INSTALL could see are
1141
+ fixed in the RENDERER.** `npm install` renames every `.gitignore` to `.npmignore` on
1142
+ extraction, and `npm pack` honours nested ignore files: so an npm-installed Geneseed emitted
1143
+ bundles with no `memory/.gitignore` and no `notebook/.gitignore` (the agent's private stores,
1144
+ committable in the user's repo, silently) and no `notebook/README.md` (loudly — 84 doctor
1145
+ problems). The manifest, the pack report and the tarball were all clean; **only the extracted
1146
+ tree disagreed.** The fix is four lines: the two source files are stored as `gitignore` and
1147
+ `dest_rel` puts the dot back, beside the `AGENT.md.tmpl` rule that was already there — so the
1148
+ emitted bytes are IDENTICAL and 259 golden cells stayed green across a change to the
1149
+ renderer. The recommended alternative (assert them from constants) would have moved the file
1150
+ COUNT in every cell; measuring is what said so. **The second call site is the one a review
1151
+ misses**: `_global_memory`/`_global_notebook` seed from the SOURCE path, not the rendered
1152
+ one, so a fix in `dest_rel` alone is right for `--emit files` and wrong for every global
1153
+ emit. `private` came off, gated by a test that runs the INSTALLED generator and diffs its
1154
+ bundle against the checkout's.
1155
+ **The verbs ship because of the WEB CONSOLE, not the CLI.** npm's own bin linking already
1156
+ puts `geneseed` on PATH, so under npm these two are near-vestigial — but the Settings page
1157
+ POSTs them in both implementations, and emptying `NOT_PORTED_ACTIONS` meant either porting
1158
+ the verbs or deleting two buttons and rebuilding the tracked `web/dist`. Deleting takes a
1159
+ working feature from checkout users, for whom `link` is the only thing that puts `geneseed`
1160
+ on PATH at all.
1161
+ **The shim is P5b's divergence again — Node bakes node** — normalised by a fifth `_STAMPS`
1162
+ entry with the debt paid absolutely per runtime; and the reference's `Path.write_text` turns
1163
+ its own `\r\n` into `\r\r\n`, which the port reproduces because `writeText` already carries
1164
+ that rule.
1165
+ **`_win_user_path` edits the registry, so the CODE was shaped to be gateable rather than the
1166
+ fixture stretched.** The PowerShell script builder is split out and compared over a corpus
1167
+ (apostrophes, UNC roots, a `;` inside the path); the cells reach both branches by handing the
1168
+ verb a `PATH` that is the sandbox bin dir and nothing else — `link` short-circuits, `unlink`
1169
+ finds no `powershell`. The SUCCESS arm stays ungated in both implementations, and says so.
1170
+ **Emptying the set cost a probe.** With no unported action left, `test_web_server.py`'s
1171
+ running 501 probe had no target and none could be borrowed — every remaining action starts a
1172
+ job or edits the real PATH — so the claim fell back to an `ast` check on the declaration,
1173
+ which is strictly weaker. Named rather than quietly dropped.
1174
+ - **The parser becomes DATA, and `migrate` becomes the 25th subcommand.** The Docs `cli`
1175
+ page walked `harness.build_argparser()`, which is the largest copy-of-a-value-under-test in
1176
+ the project and unreadable from Node. It is now **`js/cli-table.json`** — 26 command rows,
1177
+ read by both runtimes. `bin/geneseed-cli.mjs`'s `VERBS` is `{ fn }` per row now; 154 lines of
1178
+ hand-written argparse metadata are gone. It shipped in P10c as a GENERATED `cli.json` at the
1179
+ repo root, gated by `doctor` on both binaries against a sha256 of `rituals/harness.py`; the
1180
+ de-Python migration made it the OWNED document on a product path (`package.json` already
1181
+ ships `js/`), which retired both the generator and the digest — a hash of a file being
1182
+ deleted. What replaced them is stronger while the parser lives:
1183
+ `tests/test_cli_reference.py` walks `build_argparser()` and asserts it equals the table field
1184
+ for field, which also catches the hand edit a digest never could. The cost is named: the Node
1185
+ CLI cannot parse a single verb without the file.
1186
+ **`geneseed migrate` then found the premise the whole spec rested on to be wrong twice.**
1187
+ There is no `current` directory in this project and never was (npm replaces its own install
1188
+ in place, so there is no pointer to flip), and **no code in this repository has ever written
1189
+ an autostart entry** — so `migrate` detects and REPORTS a stale login item, prints the
1190
+ replacement command, and touches nothing. What it does do: survey every registered install,
1191
+ refuse rather than guess on an unrecognised emit marker, stash every settings file it will
1192
+ touch and roll the whole run back on any failure, and re-emit each root in its own theme,
1193
+ emit, footprint, posture and mode. **The shape lives in the shim BODY, not in the settings
1194
+ file** — shapes 2 and 3 are byte-identical in the host config, so a classifier reading only
1195
+ the config would call an unmigrated machine "already migrated", silently and forever.
1196
+ - **P10e — the docs, and the counts that had moved under them.** Every phase of this port
1197
+ changed a number some sentence had written down. The reconciliation is measured, not
1198
+ transcribed, and it splits three ways: **derived** where a mechanism already existed
1199
+ (`_doc_counts`' live substitution into the web Docs pages; the `howto` pages that SLICE
1200
+ `SETUP.md` by anchor, so one edit reaches the console); **gated** where the sentence has to
1201
+ stay prose
1202
+ (`doctor`'s `_prose_mirror_problems` grew the plugin count — the README said six while
1203
+ seven shipped, and the project's only derived plugin count reads a DEPLOYED directory a
1204
+ markdown file never has); and **derived-in-the-gate** for the claims `src/` cannot answer,
1205
+ in the new `tests/test_docs_claims.py`: which subcommands have no Node twin, what Python a
1206
+ bundle carries, and `N of the M subcommands`. DESIGN.md and CHANGELOG.md are deliberately
1207
+ outside that gate — they are dated logs, and rewriting a log to agree with today is how it
1208
+ stops being one.
1209
+ **The publish workflow is the one thing in this port that cannot be rehearsed.**
1210
+ `.github/workflows/publish.yml` uses npm trusted publishing (OIDC): no `NPM_TOKEN`, manual
1211
+ `workflow_dispatch` only, and the trusted publisher on the npm side is keyed on the workflow
1212
+ FILENAME — so a rename breaks publishing while the workflow still runs and still mints a
1213
+ token. The file names its own filename in the instructions it carries, and the gate asserts
1214
+ the two agree, because that self-reference is the only local symptom that exists.
1215
+ - **P7a — `menu` and `home`, and the shim the test suite had been rewriting.** Twenty-four of
1216
+ the twenty-five subcommands now run from Node. Both of these are DISPATCHERS, not screens:
1217
+ `cmd_menu` prints a five-line command list off a TTY and hands a terminal to
1218
+ `curses.wrapper(_main_menu)`, and `cmd_home` picks between the web console — already ported
1219
+ — and `cmd_menu`. **On a terminal the Node entry FALLS BACK rather than inventing a second
1220
+ menu**: the reference already has that arm (`import curses` fails on stock Windows Python,
1221
+ and it prints the same list), so the port inherits an answer instead of writing a third user
1222
+ interface that no cell could reach and P7b would delete.
1223
+ **The corpus fakes the TTY**, because every cell's stdin is a pipe and `_web_first_ok`'s
1224
+ four refusals past `isatty()` are executed and never varied — the branch is reachable and
1225
+ the input is not, which is a different kind of hole from an unreachable branch.
1226
+ Separately, and not a port matter at all: six test modules emitted with no `HOME` override
1227
+ and had been rewriting the developer's machine-wide hook shim on every suite run. It left no
1228
+ trace because `_write_hook_shim` skips an unchanged body, so nothing — not even an mtime —
1229
+ moved. `golden.sandbox_process_home()` and `tests/test_home_sandbox.py` close it, and the
1230
+ gate's first finding was a `setUpModule` shadowed by a second one further down the file.
1231
+ - **P7b — the panel runs on this machine after all, and the layout half crosses with it.**
1232
+ `tui` was the twenty-fifth and last subcommand, so **every command now runs from Node**.
1233
+ The phase opened by disproving its own brief: a bare `import curses` fails on stock Windows
1234
+ Python, but `rituals/_harness_core.py` catches that and installs `rituals/_winterm.py` under
1235
+ the name `curses` — **the shim is not a layer beside curses, on Windows it IS curses**. So
1236
+ the reference's panel opens here, `_winterm._Window` takes a stream and a pinned geometry
1237
+ and reads keys through an instance attribute, and the panel can be driven in-process. That
1238
+ made `windows-curses` the wrong answer rather than an unavailable one: installing it would
1239
+ swap PDCurses in for the 353 lines of hand-rolled VT code Geneseed actually ships to Windows,
1240
+ hiding exactly the "hard-won console behaviour" the phase was told to be careful of.
1241
+ **What crosses is the alignment contract** — display width, width-aware truncation and
1242
+ padding, the three glyph tiers, the scroll clamp, the eighths progress bar, the theme reads,
1243
+ and the inventory's two consumers. `_dwidth` is the one function with no equivalent at any
1244
+ rung: JavaScript has neither `east_asian_width` nor `combining`, `\p{Mn}|\p{Me}` disagrees
1245
+ with `combining(ch) != 0` on 897 codepoints, and no dependency may be added — so the port
1246
+ carries its own range tables and **the gate over them is the whole input space rather than a
1247
+ case list**: both sides walk every codepoint from U+0000 to U+2FFFF, run-length encode the
1248
+ widths and compare, against the live `unicodedata` rather than a copy of it.
1249
+ **The panel itself is declared, and the declaration is asserted from both directions**: the
1250
+ reference is shown drawing one, and the port is shown emitting no alt-screen escape, no
1251
+ cursor hide and no section header — plus a structural check that there is nothing in
1252
+ `js/tui.mjs` that *could* draw one. A declaration that only says "we did not" is a claim
1253
+ about one run; this says there is no screen to reach.
1254
+ The phase's own worst defect was a fixture, not a port: a scripted key list feeding a
1255
+ BLOCKING reader deadlocked the suite the moment it ran dry — and it did not hang when the
1256
+ module ran alone, because the `setUpClass` that suppressed the splash set an environment
1257
+ variable the target module had already read at import. **A fixture whose effect depends on
1258
+ import order passes in isolation and hangs in the suite.**
1259
+ - **P7c — the curses leftovers, and the four names that turned out to be one closure.** The
1260
+ last porting phase. It was handed five named leftovers and **two of them were names rather
1261
+ than code**: `_tui_inventory` has no consumer at all in `_harness_tui_views.py`, and
1262
+ `cmd_setup`'s curses arm, `cmd_bootstrap`'s curses arm and `_doctor_collect(on_progress=)`
1263
+ are one subsystem with three entrances — `_grow_flow` calls bootstrap's `_run_steps` and
1264
+ ends in `_doctor_view`, the only caller of `on_progress=` in the repo. That subsystem is the
1265
+ panel, so the panel stays declared; but the declaration needed a **fresh argument**, because
1266
+ P7b's ("a cell cannot reach it") had been dissolved by P7b itself. The new one: the port's
1267
+ `setup` and `bootstrap` have no curses arm to fall back *from* — they take the reference's
1268
+ own documented fallback unconditionally — and **no gate in this project can compare two
1269
+ panels**, since the reference is driven through Python's own `_winterm` and a Node panel
1270
+ would be graded against a hand-written expectation on both sides. *When the phase that
1271
+ inherits a declaration also dissolves its premise, the declaration has to be re-argued
1272
+ rather than re-quoted.*
1273
+ **What crosses is `theme_anim.play_line`** — the line wizard's install animation, whose only
1274
+ caller crossed in P5i. Six language primitives had to be spelled out rather than assumed:
1275
+ `dict.get` against the JavaScript prototype chain, `str.center`'s `marg & width & 1` term,
1276
+ Python's non-negative modulo, floor division into a repeat count that *throws* where Python
1277
+ empties, strict `int()` for `COLUMNS`, and `max(default=0)` against `Math.max()`. It went
1278
+ into a new module rather than `js/tui.mjs`, so that module's "contains no escape sequence"
1279
+ assertion — the thing that makes the panel's declaration a measurement — survived untouched.
1280
+ **The empty-inventory crash P7b recorded is fixed**: an inventory with no agents, skills or
1281
+ laws crashed the panel on its first frame, and the guard went at the call site rather than in
1282
+ `_detail_lines`, whose `None` return is a contract the corpus gates on both implementations.
1283
+ The phase's worst defect was again a fixture: a test drove the ported wizard with an empty
1284
+ stdin, on the reasoning that EOF is the inert input — and **every reader falls back to its
1285
+ default on EOF, the last default being `Proceed? (Y/n)` → yes**, so it ran a real 99-file
1286
+ build into the checkout. **An interactive component has no inert input; declining is an
1287
+ answer and has to be spelled.** And a second one worth keeping: a stale `.pyc` in the
1288
+ gitignored `__pycache__` executed instead of the source after a `git stash` round-trip
1289
+ restored an mtime the cache satisfied — **a test can fail, or pass, against bytecode that is
1290
+ not the source.**