geneseed 3.1.1 → 3.1.2

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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
package/js/web/jobs.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  * the chain on the first non-zero. In-process that would be a return value threaded through
34
34
  * every verb's error paths, several of which currently end at `process.exitCode`.
35
35
  *
36
- * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/geneseed.mjs <build args>`
36
+ * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build args>`
37
37
  * for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
38
38
  * by a different entry. `process.execPath` is the running interpreter by absolute path, so
39
39
  * stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
@@ -47,15 +47,16 @@
47
47
  * primitive, not this program.
48
48
  *
49
49
  * ---------------------------------------------------------------------------------------
50
- * THE PARTITION — six of the eight action rows cross and two do not.
50
+ * THE PARTITION — all eight action rows cross, and the declaration outlives them.
51
51
  *
52
- * `doctor`, `build`, `build-all`, `export`, `uninstall` and — since P8c — `update` all name a
53
- * verb that has crossed. `link`/`unlink` run `harness.py link|unlink` (P10b). A Node runner
54
- * that spawned the Python harness for those would put Python back into a "no Python
55
- * needed" install, so they are DECLARED in `NOT_PORTED_ACTIONS` and answered 501 by the
56
- * dispatcher, and P10b ends by deleting both rows. `tests/test_web_jobs.py` cross-checks the
57
- * two sets against `action_commands`' own keys with `ast`, so a NINTH row added to the
58
- * reference cannot quietly answer "unknown action".
52
+ * `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`, `link` and `unlink` each
53
+ * name a verb `bin/geneseed-cli.mjs` runs. `link`/`unlink` were the last two: they ran
54
+ * `harness.py link|unlink` until P10b, and a runner that spawned the Python harness would
55
+ * have put Python back into a "no Python needed" install.
56
+ *
57
+ * `NOT_PORTED_ACTIONS` IS EMPTY AND STILL DECLARED, which is the point of it: a ninth action
58
+ * added later cannot quietly answer "unknown action", because `tests/unit/web_jobs.test.mjs`
59
+ * holds the two sets against `actionCommands`' own keys.
59
60
  *
60
61
  * ---------------------------------------------------------------------------------------
61
62
  * THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
@@ -64,7 +65,7 @@
64
65
  * the daemon mid-job — which killed the job's own tracking and left the console on `running`
65
66
  * forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
66
67
  * P8, and a contract written after the fact is a contract that was broken once first. P8a is
67
- * where `js/update.mjs` began honouring it, and P8c is where the `update` row that triggers
68
+ * where `js/maintain/update.mjs` began honouring it, and P8c is where the `update` row that triggers
68
69
  * it crossed — so it is a live pair now rather than a promise.
69
70
  *
70
71
  * `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
@@ -107,10 +108,11 @@ import { statSync } from 'node:fs';
107
108
  import { StringDecoder } from 'node:string_decoder';
108
109
  import path from 'node:path';
109
110
 
110
- import { ROOT, PACK_ORDER } from '../checkout.mjs';
111
- import { setupBuildArgs } from '../generate.mjs';
112
- import { isDict } from '../mcp.mjs';
113
- import { jsonDumpsCompact, parseJson, pyTruthy, readText, writeText } from '../lib/fs.mjs';
111
+ import { ROOT, PACK_ORDER } from '../build/source.mjs';
112
+ import { setupBuildArgs } from '../build/generate.mjs';
113
+ import { isDict } from '../hosts/mcp.mjs';
114
+ import { readText, writeText } from '../lib/fs.mjs';
115
+ import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
114
116
 
115
117
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
116
118
 
@@ -232,7 +234,7 @@ export class JobManager {
232
234
  return;
233
235
  }
234
236
  for (const j of Array.isArray(jobs) ? jobs : []) {
235
- if (isDict(j) && pyTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
237
+ if (isDict(j) && isTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
236
238
  }
237
239
  }
238
240
 
@@ -401,7 +403,7 @@ export class JobManager {
401
403
  * five. `update` and `export` self-resolve the deployed theme downstream and take no args.
402
404
  *
403
405
  * THE ARGV HEAD IS THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
404
- * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/geneseed.mjs`,
406
+ * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`,
405
407
  * which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
406
408
  * normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
407
409
  * each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
@@ -409,7 +411,7 @@ export class JobManager {
409
411
  */
410
412
  const NODE = () => process.execPath;
411
413
  const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
412
- const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
414
+ const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
413
415
 
414
416
  /**
415
417
  * The rows whose VERB has not crossed. **EMPTY SINCE P10b, AND STILL DECLARED.**
@@ -417,7 +419,7 @@ const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
417
419
  * P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
418
420
  * that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
419
421
  * phase that also had to decide whether those two verbs should exist under npm at all
420
- * (`js/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
422
+ * (`js/hosts/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
421
423
  * the console's Settings buttons POST here, and only a ported verb can answer them).
422
424
  *
423
425
  * WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
@@ -473,7 +475,7 @@ function actionTable({
473
475
  // P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
474
476
  // argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
475
477
  // the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
476
- // job, set by `_run` above and read by `js/update.mjs` — P6g's contract with P8, honoured.
478
+ // job, set by `_run` above and read by `js/maintain/update.mjs` — P6g's contract with P8, honoured.
477
479
  update: [[NODE(), CLI(), 'upgrade']],
478
480
  export: [[NODE(), CLI(), 'diff', '--out']],
479
481
  // Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
@@ -0,0 +1,223 @@
1
+ /**
2
+ * WHAT THE WEB API ANSWERS — as a declaration, separate from the dispatcher that honours it.
3
+ *
4
+ * Eight exported sets and one map. They are not documentation: `tests/unit/web_server.mjs` reads
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.
8
+ *
9
+ * Split out of `server.mjs` because it is the half a reader consults to answer "does the console
10
+ * expose X?", which has nothing to do with sockets, daemons or gzip.
11
+ */
12
+ import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
13
+ import { apiExcludesMutate, apiInstallToggle, apiMcpToggle, apiMemoryDelete, apiProfileSave, apiRulesMutate, apiRulesPromote, apiSelectView } from './actions.mjs';
14
+ import { apiActivityToggle } from './activity.mjs';
15
+
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.
39
+ */
40
+ /** EMPTY SINCE P6g — every GET the reference answers is answered here. */
41
+ export const NOT_PORTED = new Set([]);
42
+ export const NOT_PORTED_PREFIXES = [];
43
+
44
+ /**
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.
58
+ */
59
+ export const NOT_PORTED_POST = new Set([]);
60
+ export const NOT_PORTED_POST_PREFIXES = [];
61
+
62
+ /**
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.
76
+ */
77
+ export const DECLINED_POST = new Set([
78
+ '/api/pick-folder',
79
+ ]);
80
+
81
+ /**
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.
101
+ */
102
+ export const POST_ROUTES = new Map([
103
+ ['/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
+ ['/api/install', [apiInstallToggle, true]],
109
+ ['/api/excludes', [apiExcludesMutate, true]],
110
+ ['/api/view', [apiSelectView, true]],
111
+ ['/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.
114
+ ['/api/memory/delete',
115
+ [(state, b) => apiMemoryDelete(state, isTruthy(b.name) ? formatValue(b.name) : ''), false]],
116
+ ['/api/rules', [apiRulesMutate, true]],
117
+ ['/api/rules/promote', [apiRulesPromote, true]],
118
+ ['/api/profile', [apiProfileSave, true]],
119
+ ]);
120
+
121
+ export const PORTED_POST = [...POST_ROUTES.keys()];
122
+
123
+ /**
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.
132
+ */
133
+ export const POST_ROUTES_CONVENTION = Object.fromEntries(
134
+ [...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
135
+ );
136
+
137
+ /**
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.
145
+ */
146
+ export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
147
+ '/api/jobs', '/api/jobs/'];
148
+
149
+ /**
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.
161
+ */
162
+ export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
163
+ '/api/actions/'];
164
+
165
+ /**
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.
183
+ */
184
+ export const POST_BEYOND_REF = new Set([
185
+ '/api/reveal',
186
+ ]);
187
+
188
+ export function notPorted(path) {
189
+ return NOT_PORTED.has(path) || NOT_PORTED_PREFIXES.some((p) => path.startsWith(p));
190
+ }
191
+
192
+ export function notPortedPost(path) {
193
+ return NOT_PORTED_POST.has(path) || DECLINED_POST.has(path)
194
+ || NOT_PORTED_POST_PREFIXES.some((p) => path.startsWith(p));
195
+ }
196
+
197
+ /**
198
+ * `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
199
+ *
200
+ * P6a retained the drain with a note that it was measured indistinguishable and stayed
201
+ * because "`_read_json_body` needs the bytes from P6f on". This is that caller.
202
+ *
203
+ * `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
204
+ * 404 from the endpoint's own validation rather than a 500 from a parser. That matters
205
+ * beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
206
+ * and V8 word them differently AND report different offsets — so a 500 carrying a parse
207
+ * error is a response the two implementations can never agree on.
208
+ *
209
+ * `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
210
+ * (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
211
+ * where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
212
+ * same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
213
+ */
214
+ export function readJsonBody(buf) {
215
+ let obj;
216
+ try {
217
+ obj = parseJson((buf && buf.length ? buf : Buffer.from('{}')).toString('utf-8'));
218
+ } catch {
219
+ return {};
220
+ }
221
+ return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
222
+ }
223
+