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