geneseed 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/web/routes.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Eight exported sets and one map. They are not documentation: `tests/unit/web_server.mjs` reads
5
5
  * them as a TWO-SIDED PARTITION and probes the real handler against them, because a declaration is
6
- * not a dispatcher — mutation M23 exists precisely because a route can be declared in one and
7
- * missing from the other, and nothing but a probe notices.
6
+ * not a dispatcher — a route can be declared in one and missing from the other, and nothing but
7
+ * a probe notices.
8
8
  *
9
9
  * Split out of `server.mjs` because it is the half a reader consults to answer "does the console
10
10
  * expose X?", which has nothing to do with sockets, daemons or gzip.
@@ -14,103 +14,64 @@ import { apiExcludesMutate, apiInstallToggle, apiMcpToggle, apiMemoryDelete, api
14
14
  import { apiActivityToggle } from './activity.mjs';
15
15
 
16
16
  /**
17
- * The API paths this phase does not answer yet.
18
- *
19
- * Without it every one of them would fall through to `serveStatic`, which answers ANY
20
- * unknown path with index.html and a 200 — the SPA fallback. An unported endpoint would
21
- * therefore return a plausible HTML page instead of failing, and the first cell written
22
- * against it in P6b would be the thing that discovered the route was missing. A 501
23
- * naming the phase costs one lookup and cannot be mistaken for a working endpoint.
24
- *
25
- * It shrank to empty in P6g and the DECLARATION STAYS. Every GET the reference answers is now
26
- * answered here, so the two sets hold nothing — but they are half of a partition
27
- * (`test_every_get_route_is_either_ported_or_declared_unported`), and deleting them would let
28
- * the next GET route added to the reference fall through to the SPA with nothing to say so.
29
- * An empty declaration is the partition asserting that there is nothing left to declare.
30
- */
31
- /**
32
- * SPLIT BY VERB SINCE P6b, and the split is the point rather than a tidy-up. Five paths
33
- * answer BOTH verbs with different bodies — `/api/mcp`, `/api/excludes`, `/api/rules`,
34
- * `/api/profile`, `/api/activity` — and P6b ports the GET half of two of them. One set
35
- * keyed on path alone would have taken `/api/excludes` out of the unported list the
36
- * moment its GET landed, and its POST would then have answered `{"error": "not found"}`
37
- * with a 404: a plausible-looking response where the reference returns 200 or 409, which
38
- * is exactly the failure the 501 exists to prevent.
17
+ * The API paths this daemon does not answer — currently empty, but the DECLARATION STAYS:
18
+ * without it, an unmatched GET falls through to `serveStatic`'s SPA fallback (200 +
19
+ * index.html) instead of a clear failure. Half of a partition test, so an empty set still
20
+ * asserts there is nothing left to declare.
21
+ *
22
+ * Declared separately from the POST sets below because five paths — `/api/mcp`,
23
+ * `/api/excludes`, `/api/rules`, `/api/profile`, `/api/activity` — answer both verbs with
24
+ * different bodies, so one set keyed on path alone could not track GET and POST status
25
+ * independently.
39
26
  */
40
- /** EMPTY SINCE P6g — every GET the reference answers is answered here. */
41
27
  export const NOT_PORTED = new Set([]);
42
28
  export const NOT_PORTED_PREFIXES = [];
43
29
 
44
30
  /**
45
- * The POSTs that have not crossed YET — and EMPTY SINCE P6i, which took `/api/install`.
46
- *
47
- * IT STAYS DECLARED, for the same reason the GET pair above stays and the prefix lists have
48
- * stayed since P6g: this set is half of a partition
49
- * (`test_every_post_route_is_either_ported_or_declared_unported`), so an empty set is the
50
- * partition ASSERTING that there is nothing left to declare. Delete it and the next POST route
51
- * added to the reference falls through to `{"error": "not found"}` at 404 — a plausible-looking
52
- * answer where the reference returns 200 or 409 — with nothing to say so.
53
- *
54
- * THE DISPATCHER PROBE NEEDS A TARGET, which an empty set no longer supplies.
55
- * `tests/test_web_server.py` retired its `unportedGet` probe when `NOT_PORTED` emptied in P6g;
56
- * `unportedPost` is retired here for the same reason, and `declinedPost` (`/api/pick-folder`,
57
- * which never crosses) is what keeps the 501 arm of the dispatcher under a live probe.
31
+ * The POSTs that have not crossed yet — currently empty, for the same reason the GET pair
32
+ * above stays declared: deleting it would let an unrouted POST fall through to a
33
+ * plausible-looking 404 instead of the 501 this set exists to produce.
58
34
  */
59
35
  export const NOT_PORTED_POST = new Set([]);
60
36
  export const NOT_PORTED_POST_PREFIXES = [];
61
37
 
62
38
  /**
63
- * The POSTs that will NEVER cross, which is a different claim from the set above.
64
- *
65
- * `/api/pick-folder` opens an OS-NATIVE folder chooser on the daemon host — `osascript` on
66
- * macOS, a one-shot `tkinter` subprocess elsewhere. There is no Node twin that is not a new
67
- * GUI dependency, and reaching for one would also be a sixth `_ALLOWED_SPAWNS` row for a
68
- * modal dialog. The UI already falls back to its editable path field when this endpoint
69
- * fails, which is what a 501 gives it.
70
- *
71
- * SEPARATE FROM `NOT_PORTED_POST` ON PURPOSE. That set is a to-do list every later phase
72
- * shrinks, and folding a permanent decline into it would make the list wrong in the one
73
- * direction nobody checks: a phase that emptied it would have to either port a GUI dialog
74
- * or quietly delete a declaration. Both sets are unioned into the partition the reference's
75
- * routes are cross-checked against, so neither can drift.
39
+ * The POSTs that will NEVER cross, which is a different claim from the set above — currently
40
+ * EMPTY. `/api/pick-folder` was its one member (an OS-native folder chooser, "declined rather
41
+ * than deferred" because there was no Node twin that was not a new GUI dependency) until the
42
+ * user overrode that decision 2026-08-27; it now dispatches inline from `js/web/handler.mjs`'s
43
+ * `doPost` — see `apiPickFolder` in `js/web/actions.mjs`.
44
+ *
45
+ * KEPT DECLARED AND EMPTY FOR THE SAME REASON `NOT_PORTED_POST` STAYS DECLARED WHEN IT EMPTIES
46
+ * (see that set, just above): an empty set here is the partition asserting there is nothing
47
+ * PERMANENTLY refused, not an omission a later reader has to double-check. `notPortedPost`
48
+ * below still consults it, so a genuinely permanent decline — if one is ever added again —
49
+ * needs no dispatcher change, only a member here.
76
50
  */
77
- export const DECLINED_POST = new Set([
78
- '/api/pick-folder',
79
- ]);
51
+ export const DECLINED_POST = new Set([]);
80
52
 
81
53
  /**
82
- * The POSTs P6f answers — and this table IS the dispatch, not a declaration beside it.
83
- *
84
- * M23's lesson, applied one verb over: `test_the_declared_partition_is_the_one_the_dispatcher_uses`
85
- * exists because a hand-kept list can agree with the reference while the running handler
86
- * does something else. `doPost` looks routes up HERE, and `PORTED_POST` is this table's own
87
- * keys, so the two cannot drift — the same reason `STATE_ROUTES` is a table on the GET side.
88
- *
89
- * THE SECOND COLUMN IS THE 409 CONVENTION, and it is a column because it is not uniform.
90
- * `ok: false` means the write did not happen — a stale fingerprint, a body that never
91
- * parsed, nothing to remove — and the client must re-fetch rather than retry, which is what
92
- * 409 says and 200 does not. Five routes map it. `/api/activity` does NOT: its flag write
93
- * answers 200 whatever `ok` says, and a port that unified the rule would be wrong exactly
94
- * there. `/api/memory/delete` carries no `ok` at all — it deletes, or it raises `NotFound`
95
- * and the outer catch answers 404 — so it is `false` here for a third reason again.
96
- *
97
- * `NotFound` IS NOT CAUGHT PER ROUTE. The reference wraps six of these calls in their own
98
- * `except NotFound` AND wraps the whole of `_post_routes` in another inside `do_POST`. The
99
- * inner six are redundant — every one produces the same `{"error": "not found: …"}` at 404
100
- * — so the twin keeps the outer one only, in `handler`, where `do_POST`'s is.
54
+ * The POSTs this daemon answers — and this table IS the dispatch, not a declaration beside
55
+ * it: `doPost` looks routes up HERE, so a hand-kept declaration and the running dispatcher
56
+ * cannot drift.
57
+ *
58
+ * THE SECOND COLUMN IS THE 409 CONVENTION: `ok: false` means the write did not happen, and
59
+ * the client must re-fetch rather than retry — which 409 says and 200 does not. Two
60
+ * exceptions: `/api/activity` answers 200 whatever `ok` says (unifying the rule would be
61
+ * wrong there), and `/api/memory/delete` carries no `ok` at all (it deletes, or raises
62
+ * `NotFound` for a 404).
63
+ *
64
+ * `NotFound` is caught once, in `handler`, not per route — repeating the catch here would
65
+ * only duplicate it.
101
66
  */
102
67
  export const POST_ROUTES = new Map([
103
68
  ['/api/mcp', [apiMcpToggle, true]],
104
- // P6i. `_post_routes` wraps this one in its own `except NotFound` and then maps
105
- // `ok: false` to 409 — the same shape as `/api/mcp` beside it, so it fits the table's
106
- // second column exactly and needs no third inline dispatch. The inner catch is redundant
107
- // (see this table's docblock); `handler`'s outer one answers identically.
108
69
  ['/api/install', [apiInstallToggle, true]],
109
70
  ['/api/excludes', [apiExcludesMutate, true]],
110
71
  ['/api/view', [apiSelectView, true]],
111
72
  ['/api/activity', [apiActivityToggle, false]],
112
- // `(self._read_json_body().get("name") or "")` — the SHELL resolves the name, and the
113
- // endpoint takes a bare slug rather than a body.
73
+ // The dispatch wrapper resolves `name` from the body itself; `apiMemoryDelete` takes a bare
74
+ // slug string, not the body object, unlike the other routes here.
114
75
  ['/api/memory/delete',
115
76
  [(state, b) => apiMemoryDelete(state, isTruthy(b.name) ? formatValue(b.name) : ''), false]],
116
77
  ['/api/rules', [apiRulesMutate, true]],
@@ -121,87 +82,56 @@ export const POST_ROUTES = new Map([
121
82
  export const PORTED_POST = [...POST_ROUTES.keys()];
122
83
 
123
84
  /**
124
- * The 409 column, exported so it can be cross-checked against the reference's own `200 if
125
- * res.get("ok") else 409` conditionals by `ast`.
126
- *
127
- * A MUTATION IS WHY THIS EXISTS. Giving `/api/activity` the 409 treatment survived the whole
128
- * cell matrix: every toggle a cell can perform succeeds, and reaching the `ok: false` arm
129
- * needs the flag write to raise — which the two runtimes word differently, so no byte
130
- * comparison could hold it. The column is the thing under test and no cell can see it, so
131
- * the gate reads it out of both implementations instead.
85
+ * The 409 column, exported so a test can assert it directly. Giving `/api/activity` the 409
86
+ * treatment is the invisible mutation: nothing in normal use can force the `ok: false` arm
87
+ * to raise, so the convention has to be checked on its own rather than inferred from
88
+ * behaviour.
132
89
  */
133
90
  export const POST_ROUTES_CONVENTION = Object.fromEntries(
134
91
  [...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
135
92
  );
136
93
 
137
94
  /**
138
- * The GET paths the dispatcher answers OUTSIDE the two tables, declared so the partition
139
- * cross-check can see them: `/api/ping` is the shell's own, and the two Docs routes take
140
- * the `?harness=` query param, which a `(state)` table entry has no way to receive.
141
- *
142
- * A declaration is not a dispatcher — M23 — so
143
- * `test_the_declared_partition_is_the_one_the_dispatcher_uses` probes all three against
144
- * the running handler as well.
95
+ * The GET paths answered OUTSIDE the two tables, declared so the partition cross-check can
96
+ * see them: `/api/ping` is the shell's own, and the two Docs routes take the `?harness=`
97
+ * query param, which a `(state)` table entry has no way to receive. A probe checks all
98
+ * three against the running handler too, not merely against this list.
145
99
  */
146
100
  export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
147
101
  '/api/jobs', '/api/jobs/'];
148
102
 
149
103
  /**
150
- * The POSTs P6g answers outside `POST_ROUTES`, and they are outside it for a reason the
151
- * table's SECOND COLUMN makes plain.
152
- *
153
- * `POST_ROUTES` maps a route to `(fn, okIs409)` — one handler taking `(state, body)`, one
154
- * boolean deciding 200 against 409. The jobs routes fit neither half. `/api/jobs/<id>/cancel`
155
- * answers 200 or 404 on a lookup, not on an `ok` field; `/api/actions/<x>` answers 202 with a
156
- * job id, 409 when the runner is busy, 404 for an action the table does not name, 501 for one
157
- * that has not crossed, 400 or 409 for a resolver's refusal, and 200 for `restore` — six
158
- * statuses over one prefix, none of them read off `ok`. Bending the column to fit would make
159
- * it lie about the five routes it currently describes exactly, so these dispatch beside the
160
- * table and `tests/test_web_jobs.py` reads them out of `PORTED_POST_INLINE` instead.
104
+ * The POSTs that answer outside `POST_ROUTES`, because they don't fit its shape:
105
+ * `/api/jobs/<id>/cancel` answers 200 or 404 on a lookup, not an `ok` field, and
106
+ * `/api/actions/<x>` spans six statuses (202, 409, 404, 501, 400, 200) over one prefix, none
107
+ * read off `ok`. Bending the 409 column to fit either would make it lie about the routes it
108
+ * already describes correctly.
109
+ *
110
+ * `/api/pick-folder` joined 2026-08-27 for a THIRD reason: `POST_ROUTES`'s `fn(state, body)
111
+ * -> object` shape answers the instant `fn` returns, and this endpoint cannot — it spawns a
112
+ * native folder dialog and answers only once a human (or a timeout) closes it. See
113
+ * `apiPickFolder` in `js/web/actions.mjs`, dispatched inline from `js/web/handler.mjs`'s
114
+ * `doPost` the same way the other three rows here are.
161
115
  */
162
116
  export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
163
- '/api/actions/'];
117
+ '/api/actions/', '/api/pick-folder'];
164
118
 
165
119
  /**
166
- * POST routes THAT NEVER EXISTED ON THE REFERENCE — the first of them, and the reason this set
167
- * has to exist at all.
168
- *
169
- * `test_every_post_route_is_either_ported_or_declared_unported` asserts SET EQUALITY against
170
- * `REF_POST`, a frozen literal of the paths the Python daemon answered. That was exactly right
171
- * while the port was in flight: a route on one side and not the other was a defect either way.
172
- * The port is finished and the reference is deleted, so the assertion now says something else —
173
- * "this daemon may never grow a route" — which is a rule nobody chose.
174
- *
175
- * SO THE PARTITION GAINS A FOURTH PART RATHER THAN LOSING ITS EQUALITY. Widening `REF_POST` would
176
- * have been the cheap edit and it would have been a lie: that list is a RECORD of what the
177
- * reference answered, and a route added in 2026 was never in it. Keeping the record honest and
178
- * declaring the additions beside it keeps both claims checkable — the reference's surface is
179
- * still pinned, and everything past it is enumerated here instead of merely appearing.
180
- *
181
- * A route belongs here only once it is dispatched below; the dispatcher probe is what keeps this
182
- * from becoming a declaration nobody honours.
120
+ * POST routes added after the ported reference surface was frozen. Kept separate from
121
+ * `NOT_PORTED_POST` so that frozen record stays honest, while a new route is still
122
+ * enumerated rather than appearing unchecked. Belongs here only once dispatched below — a
123
+ * probe, not just this declaration, is what keeps that true.
183
124
  */
184
125
  export const POST_BEYOND_REF = new Set([
185
126
  '/api/reveal',
186
127
  ]);
187
128
 
188
129
  /**
189
- * GET routes THAT NEVER EXISTED ON THE REFERENCE — the same fourth part of the partition, one
190
- * verb over, and it exists for the reason `POST_BEYOND_REF` above spells out in full.
191
- *
192
- * `test_every_get_route_is_either_ported_or_declared_unported` asserts SET EQUALITY against
193
- * `REF_GET`, a frozen literal of the paths the Python daemon answered. Widening that list to
194
- * admit a route added in 2026 would forge the record; leaving the equality unqualified would
195
- * mean this daemon may never grow a GET. So additions are enumerated here instead, and the
196
- * gate compares the covered set against the reference's surface PLUS this one.
197
- *
198
- * `/api/recent` is the first, and it is a genuine gap rather than a convenience: no catalog row
199
- * on the whole surface carries a date, and a browser cannot stat the `source` path it is handed
200
- * — so "what changed lately" had no answer that did not involve the server looking. See
201
- * `apiRecent` in `api.mjs` for what it will and will not date.
202
- *
203
- * A route belongs here only once it is dispatched; the dispatcher probe is what keeps this from
204
- * becoming a declaration nobody honours.
130
+ * GET routes added after the ported reference surface was frozen — the same reason
131
+ * `POST_BEYOND_REF` above exists, one verb over. `/api/recent` is a genuine gap: no catalog
132
+ * row carries a date and a browser cannot stat the `source` path it is handed, so "what
133
+ * changed lately" needed a server that looks. See `apiRecent` in `api.mjs` for what it will
134
+ * and will not date.
205
135
  */
206
136
  export const GET_BEYOND_REF = new Set([
207
137
  '/api/recent',
@@ -217,21 +147,12 @@ export function notPortedPost(path) {
217
147
  }
218
148
 
219
149
  /**
220
- * `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
221
- *
222
- * P6a retained the drain with a note that it was measured indistinguishable and stayed
223
- * because "`_read_json_body` needs the bytes from P6f on". This is that caller.
224
- *
225
- * `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
226
- * 404 from the endpoint's own validation rather than a 500 from a parser. That matters
227
- * beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
228
- * and V8 word them differently AND report different offsets — so a 500 carrying a parse
229
- * error is a response the two implementations can never agree on.
150
+ * The drained request body as a plain object, `{}` for ANYTHING else — never a raise, so a
151
+ * malformed body becomes a 409/404 from the endpoint's own validation, not a 500.
230
152
  *
231
153
  * `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
232
- * (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
233
- * where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
234
- * same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
154
+ * (`apiProfileSave`'s `text`, `apiRulesMutate`'s `title`), and `parseJson` keeps the
155
+ * int/float distinction plain `JSON.parse` loses (see `lib/json.mjs`).
235
156
  */
236
157
  export function readJsonBody(buf) {
237
158
  let obj;
package/js/web/server.mjs CHANGED
@@ -4,44 +4,23 @@
4
4
  * declarations in `routes.mjs`, the daemon record and lifecycle in `daemon.mjs`; this file
5
5
  * binds a socket and wires the three together.
6
6
  *
7
- * WHY THE SHELL FIRST, AND ALONE. `_web_server.py` is 654 lines and none of it is an API
8
- * function: it is routing, two security guards, gzip negotiation, the static route with
9
- * its per-request token injection, and the daemon record. Every one of those is invisible
10
- * to a test that calls `api_X(state)` in process — which is how `tests/test_web.py`
11
- * reaches all 136 of its assertions — so the shell is both the part with no existing
12
- * coverage and the part every later sub-phase builds on. `tests/web_golden.py` is its
13
- * gate and was written before this file.
14
- *
15
- * THE SERIALISER, WHICH TOUCHES ALL 29 PATHS. `_send_json` is a bare `json.dumps(obj)`:
16
- * Python's DEFAULT separators, `(', ', ': ')`, and `ensure_ascii=True`. That is exactly
17
- * `jsonDumpsCompact` — whose name says "compact" meaning "no indent", not "no spaces";
18
- * read its body, it joins on `', '`. So the twin needs no new helper and the response
19
- * bodies stay byte-comparable.
20
- *
21
- * Its one condition is the int/float distinction. `formatValue` refuses a BARE JS number
22
- * because `json.loads` tells `20` from `1.0` and `JSON.parse` does not — but the numbers
23
- * in a response body are not parsed from JSON, they are computed here (counts, a port, a
24
- * pid, a unix second), and every one of them is a Python `int`, which renders identically
25
- * in both languages. `bareInts` says that out loud rather than wrapping several dozen
26
- * call sites in a factory. A Python float would be the counter-example — `1.0` against
27
- * JS's `1` — and the byte gate over every endpoint body is what would catch one.
28
- *
29
- * THE DAEMON LIFECYCLE MOVED OUT, AND THE PREDICTED CYCLE DID NOT MATERIALISE. This
30
- * docblock used to argue that splitting the handler from the daemon would make `serve()`
31
- * (which needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other.
32
- * They do not: the dependency runs ONE WAY, `server → daemon`, because `npmBuild` stayed
33
- * here with `serve` and only the record readers went. `probe`, `liveDaemon`,
7
+ * MODULE BOUNDARY: the dependency runs one way, `server → daemon`. `probe`, `liveDaemon`,
34
8
  * `spawnDetached`, `start|stop|status|restartDaemon`, `openUrl` and `requestRestart` are in
35
- * `daemon.mjs`; `npmBuild`, `buildPlan`, `serve` and `cmdWeb` are here.
9
+ * `daemon.mjs`; `npmBuild`, `buildPlan`, `serve` and `cmdWeb` are here — `npmBuild` stays with
10
+ * `serve` rather than moving with the rest of the daemon lifecycle, which is why the split
11
+ * does not produce a two-way import.
36
12
  *
37
13
  * `bin/geneseed-cli.mjs` imports `cmdWeb` statically, which is what put this tree on the
38
- * CLI's import graph and moved `js/web/jobs.mjs`'s spawn row to `entry: "cli"` — and it is
39
- * why a runtime dependency reached from here would load on EVERY `geneseed` invocation.
14
+ * CLI's import graph and moved `js/web/jobs.mjs`'s `ALLOWED_SPAWNS` row to `entry: "cli"` —
15
+ * and it is why a runtime dependency reached from here would load on EVERY `geneseed`
16
+ * invocation.
40
17
  *
41
- * WHAT IS STILL NOT HERE, SINCE P6i: `/api/pick-folder` alone, and it is DECLINED rather than
42
- * deferred — an OS-native folder chooser has no Node twin that is not a new GUI dependency.
43
- * `NOT_PORTED_POST` is empty and stays declared, because an empty half of a partition is the
44
- * partition asserting there is nothing left; `DECLINED_POST` is what still has a member.
18
+ * WHAT WAS STILL NOT HERE, SINCE P6i UNTIL 2026-08-27: `/api/pick-folder`, DECLINED rather
19
+ * than deferred — an OS-native folder chooser had no Node twin that was not a new GUI
20
+ * dependency. The user overrode that decline; the endpoint now dispatches inline from
21
+ * `js/web/handler.mjs`'s `doPost` (see `apiPickFolder` in `js/web/actions.mjs`). Both
22
+ * `NOT_PORTED_POST` and `DECLINED_POST` are empty now and stay declared regardless, because an
23
+ * empty half of a partition is the partition asserting there is nothing left, not an omission.
45
24
  */
46
25
  import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
47
26
  import { printOut } from '../lib/fs.mjs';
@@ -59,7 +38,7 @@ import { createServer } from 'node:http';
59
38
  import { join, resolve as pathResolve } from 'node:path';
60
39
  import { fileURLToPath } from 'node:url';
61
40
 
62
- const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
41
+ const ROOT = pathResolve(import.meta.dirname, '..', '..');
63
42
 
64
43
  // Re-exported so the console's state lives in one place and its importers are unchanged.
65
44
  export { webState };
@@ -67,16 +46,10 @@ export { webState };
67
46
  // ---- serve -----------------------------------------------------------------
68
47
 
69
48
  /**
70
- * Pure: what `serve()` should do about the UI bundle. Ported ahead of the rest of the
71
- * verb because it decides whether a server starts at all, and because it is the second
72
- * interactive prompt in this port — `interactive` is an ARGUMENT, which is what makes it
73
- * gateable by a corpus rather than only by a cell.
74
- *
75
- * P6h GAVE IT BOTH KINDS OF GATE AND FOUND THE CLAIM ABOVE HAD NEVER BEEN PAID. It had a
76
- * Python unit test (`tests/test_web.py`) and nothing cross-implementation until now: the
77
- * corpus in `tests/test_pure_function_parity.py` covers all five answers, and two
78
- * `harness_golden` cells cover the two arms that TERMINATE — which are also the only two
79
- * that let a `web` cell exist at all, since every other arm of `serve` binds a socket.
49
+ * Pure: what `serve()` should do about the UI bundle, decided before anything else since it
50
+ * decides whether a server starts at all. `interactive` is a plain argument rather than a
51
+ * read of `process.stdin.isTTY` inside this function, which is what keeps it callable from a
52
+ * test without mocking stdin.
80
53
  */
81
54
  export function buildPlan(dist, webDir, npm, interactive) {
82
55
  if (isFile(join(dist, 'index.html'))) return 'serve';
@@ -87,15 +60,14 @@ export function buildPlan(dist, webDir, npm, interactive) {
87
60
  }
88
61
 
89
62
  /**
90
- * `npm install` then `npm run build` in `web/`, streams inherited — the third spawn this
63
+ * `npm install` then `npm run build` in `web/`, streams inherited — one of the spawns this
91
64
  * module declares, and the one NO test reaches.
92
65
  *
93
- * THAT IS A DECLARED PARTITION, not an omission. `web/dist/index.html` is TRACKED (asserted
94
- * by `tests/test_web_daemon.py` against `git ls-files`, so the claim cannot rot), which
95
- * means `buildPlan` answers `serve` in every checkout a cell can build and the `ask` arm is
96
- * reachable only from a partial checkout on an interactive terminal. There is no npm
97
- * library to call instead; a Node twin that reimplemented `npm install` is not a thing that
98
- * exists.
66
+ * THAT IS A DECLARED GAP, not an omission. `web/dist/index.html` is TRACKED (asserted by
67
+ * `tests/unit/web_daemon.test.mjs` against `git ls-files`, so the claim cannot rot), which
68
+ * means `buildPlan` answers `serve` in any checkout a test can build against, and the `ask`
69
+ * arm is reachable only from a partial checkout on an interactive terminal. There is no npm
70
+ * library to call instead of the real thing.
99
71
  *
100
72
  * `shell` ON WINDOWS, and it is required rather than stylistic: `which('npm')` resolves
101
73
  * to `npm.CMD` through PATHEXT, and Node refuses to `spawn` a `.cmd` directly. The path is
@@ -123,8 +95,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
123
95
  const dist = join(ROOT, 'web', 'dist');
124
96
  const webDir = join(ROOT, 'web');
125
97
  const manual = ' cd web && npm install && npm run build';
126
- // `sys.stdin.isatty()`. `process.stdin.isTTY` is `undefined` rather than false on a pipe,
127
- // which is the same trap `cmdSetup` documents.
98
+ // `process.stdin.isTTY` is `undefined` rather than false on a pipe — the same trap
99
+ // `cmdSetup` documents.
128
100
  const plan = buildPlan(dist, webDir, which('npm'), Boolean(process.stdin.isTTY));
129
101
  if (plan === 'no-source') {
130
102
  printOut(`[web] web/ sources are missing from ${ROOT}.\n`);
@@ -144,11 +116,10 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
144
116
  return 1;
145
117
  }
146
118
  if (plan === 'ask') {
147
- // `except (EOFError, KeyboardInterrupt): answer = "n"`. EOF is the arm that matters and
148
- // it is the one a naive port gets backwards: `input()` RAISES at EOF, so the reference
149
- // reads it as "no", while a read that returned `''` would fall into the empty-answer
150
- // arm and start an npm install nobody asked for. `promptLine` returns null for exactly
151
- // that case — see its docblock in js/maintain/setup.mjs.
119
+ // EOF is the arm that matters: treated as an empty string it would fall into the
120
+ // empty-answer arm below (which defaults to yes) and start an npm install nobody asked
121
+ // for. `promptLine` returns null for exactly that case — see its docblock in
122
+ // js/maintain/setup.mjs — which is why `null` maps to `'n'` here.
152
123
  const line = promptLine('[web] UI not built — run npm install && npm run build now? [Y/n] ');
153
124
  const answer = line === null ? 'n' : line;
154
125
  if (['', 'y', 'yes'].includes(answer.trim().toLowerCase())) {
@@ -167,14 +138,14 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
167
138
  }
168
139
  const token = randomBytes(24).toString('base64url');
169
140
  const holder = {};
170
- // `JobManager(history_path=state.target / ".geneseed-web-runs.json")` — the console's job
171
- // list survives a reload and a restart because it is a FILE, not a process's memory.
141
+ // The console's job list survives a reload and a restart because it is a FILE on disk,
142
+ // not held in process memory.
172
143
  const jm = new JobManager(join(state.target, '.geneseed-web-runs.json'));
173
144
  const srv = createServer(makeHandler(state, jm, token, dist, holder));
174
145
  holder.srv = srv;
175
146
  return new Promise((done) => {
176
147
  srv.on('error', () => {
177
- // The reference retries on port 0; the same fallback, one level in.
148
+ // On a listen error, retry with port 0 so the OS picks a free one.
178
149
  srv.listen(0, '127.0.0.1', () => ready());
179
150
  });
180
151
  srv.listen(port, '127.0.0.1', () => ready());
@@ -191,10 +162,9 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
191
162
  printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
192
163
  printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
193
164
  if (openBrowser) openUrl(url);
194
- // `except KeyboardInterrupt: print("\n[web] stopped.")` around `serve_forever`. Node
195
- // kills on SIGINT by default, which would skip the message AND the record cleanup in
196
- // the `finally` below — a foreground `--daemon-internal` server stopped with Ctrl-C
197
- // would leave a record pointing at a dead port.
165
+ // Node kills on SIGINT by default, which would skip this message and the record
166
+ // cleanup in `srv.on('close', ...)` below — a foreground `--daemon-internal` server
167
+ // stopped with Ctrl-C would otherwise leave a record pointing at a dead port.
198
168
  process.on('SIGINT', () => {
199
169
  printOut('\n[web] stopped.\n');
200
170
  srv.close();
@@ -212,20 +182,18 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
212
182
  }
213
183
 
214
184
  /**
215
- * `cmd_web` — the verb `bin/geneseed-cli.mjs` dispatches to, and the reason this module is
185
+ * `cmdWeb` — the verb `bin/geneseed-cli.mjs` dispatches to, and the reason this module is
216
186
  * on the CLI's import graph at all (which is what moved `js/web/jobs.mjs`'s
217
- * `_ALLOWED_SPAWNS` row from `entry: "web"` to `entry: "cli"`).
187
+ * `ALLOWED_SPAWNS` row from `entry: "web"` to `entry: "cli"`).
218
188
  *
219
- * The reference's `cmd_web` does `import web` INSIDE the function; the ESM equivalent would
220
- * be a dynamic `import()`, and it is deliberately NOT used — a static import is what lets
221
- * `test_the_cli_reaches_child_process_only_where_it_is_declared` walk the CLI's graph and
222
- * find the two spawning modules under `js/web/`. The cost is parsing the web tree on every
223
- * CLI invocation; `bin/geneseed-cli.mjs` is user-invoked and carries no latency budget (the
224
- * hook entry, which does, is a different binary for exactly this reason).
189
+ * The import above is static rather than dynamic on purpose: a static import is what lets the
190
+ * spawn-graph walk find the two spawning modules under `js/web/`. The cost is parsing the web
191
+ * tree on every CLI invocation; `bin/geneseed-cli.mjs` is user-invoked and carries no latency
192
+ * budget (the hook entry, which does, is a different binary for exactly this reason).
225
193
  */
226
194
  export async function cmdWeb(args) {
227
- // `--port` is `type=int` on the reference, and `bin/geneseed-cli.mjs`'s `ints` column
228
- // refuses a non-integer before this runs — so `parseIntStrict` cannot be null here.
195
+ // `bin/geneseed-cli.mjs`'s `ints` column refuses a non-integer before this runs — so
196
+ // `parseIntStrict` cannot be null here.
229
197
  const port = args.port === null ? 4747 : parseIntStrict(args.port);
230
198
  const openBrowser = !args.noBrowser;
231
199
  if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
@@ -237,12 +205,9 @@ export async function cmdWeb(args) {
237
205
 
238
206
  // ---- entry -----------------------------------------------------------------
239
207
  //
240
- // KEPT AFTER P6h, and it is not a duplicate of the verb above. `tests/web_golden.py` drove
241
- // this module directly for all of P6a–P6g, and its 95 cells are what license the MOVE: the
242
- // same matrix now runs against `node bin/geneseed-cli.mjs web` as well, and a byte gate
243
- // that passed against one command proves nothing about the other until it is re-aimed. The
244
- // module entry is also what a debugging session reaches for when the CLI's argument layer
245
- // is the thing under suspicion.
208
+ // Not a duplicate of `cmdWeb` above: this lets `node js/web/server.mjs` be run directly,
209
+ // which is what a debugging session reaches for when the CLI's own argument layer is the
210
+ // thing under suspicion.
246
211
 
247
212
  async function main(argv) {
248
213
  let theme = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.1.3",
4
+ "version": "3.2.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
@@ -31,7 +31,8 @@
31
31
  - A concise answer: the conclusion, the key `file:line` references that support
32
32
  it, and any open questions. Never the full contents of what was read.
33
33
  - If the search comes up empty, report what was searched and where, and answer
34
- not-found — a confident wrong location costs more than an honest blank.
34
+ not-found — a confident wrong location costs more than an honest blank
35
+ ({{LAW}} XI).
35
36
 
36
37
  ## Pipeline role
37
38
 
@@ -83,5 +83,7 @@ the session — `D` decisions, `O` options, `F` findings, `R` risks, `Q` questio
83
83
  one within each kind, invent a new letter for a kind the list omits, and never
84
84
  renumber a code once issued — the user will cite it back, and a moved code makes
85
85
  their reply mean something you did not say. A code is an address, not an ornament:
86
- it lets a long exchange be answered by reference ({{ONTOLOGY}}: {{ONT_CONDUCT}}), and
87
- a decision taken on turn three still be named on turn thirty.
86
+ it lets a long exchange be answered by reference, and a decision taken on turn
87
+ three still be named on turn thirty. Where the {{ONTOLOGY}}'s {{ONT_CONDUCT}}
88
+ governs how an answer speaks, this is only the addressing convention — a practice
89
+ a repository adopts or drops like any other rule in these {{DOCTRINES}}.