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