geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -1,103 +1,33 @@
1
1
  /**
2
- * The web console's WRITES — `rituals/_web_actions.py`'s mutating endpoints plus the two
3
- * GETs that had to cross with them.
4
- *
5
- * WHAT THE PLAN'S TABLE GOT WRONG, measured by reading `js/` rather than by trusting it.
6
- * Three of its five "already ported" claims were false and one endpoint was out by an order
7
- * of magnitude:
8
- *
9
- * * `_memory_drop_index` — "check whether P5's `learn` ported it". It did not; nothing in
10
- * `js/` referenced it. It lands in `js/hosts/hooks.mjs`, beside the other memory-store
11
- * primitives, because that is where its Python siblings' twins already live.
12
- * * `_mcp_load` — "already in js/ (P6c)". What crossed in P6c is `readJsonc` and a
13
- * five-line no-host reader inside `js/web/api.mjs` for `wiki.jsonc`. The host fork and
14
- * the other twelve MCP functions are 168 lines of new `js/hosts/mcp.mjs`.
15
- * * `settings.mjs`'s atomic write — a DIFFERENT Python function from `_mcp_save`, with a
16
- * different temp suffix and no parent-directory creation. See `js/hosts/mcp.mjs`.
17
- * * `api_install_toggle` — "~25 new; `_move_tree`, `_prune_empty_ancestors` and the stash
18
- * ARE in `js/maintain/uninstall.mjs` (P5h)". `DISABLED_STASH`, `installKind` and `installState`
19
- * are. `_move_tree`, `_prune_empty_ancestors`, `_install_move_list`, `_install_relive`,
20
- * `_install_readd_entry`, `_install_deactivate`, `_install_reactivate`,
21
- * `_claude_deactivate` and `_claude_reactivate` are not — 293 lines of unported
22
- * move-and-roll-back engine, scored at zero. It is deferred; see below.
2
+ * The web console's WRITES — the mutating endpoints, plus the two GETs that belong beside
3
+ * them.
23
4
  *
24
- * ---------------------------------------------------------------------------------------
25
- * THE ARGV-IN-A-RESPONSE-BODY TRAP, SETTLED — AND ITS PREMISE IS FALSE.
26
- *
27
- * The plan says `api_install_cmd` and `api_deploy_cmd` "return the interpreter and the
28
- * generator's path — a Python argv, as DATA, in a response body", and offers two
29
- * options: (a) the Node twin returns its own argv head and the harness normalises it, or
30
- * (b) the endpoint returns the ACTION and its arguments and the job runner resolves the
31
- * command — "the better design", at the cost of changing the reference and rebuilding the
32
- * tracked `web/dist`.
33
- *
34
- * MEASURED FIRST, as the phase's own rule says. Two measurements decide it:
35
- *
36
- * 1. `grep` over `web/src/` finds NO consumer of either response. The React client's only
37
- * calls into that area are `pickFolder`; nothing reads a `cmd` field.
38
- * 2. `rituals/_web_server.py` explains why. `_post_routes` calls `api_install_cmd`, and
39
- * the ONLY thing it does with the result is `if "error" in plan: send_json(plan, 409)`
40
- * / `jm.start("install", plan["cmd"], …)`. The argv is handed to the job runner in the
41
- * SAME PROCESS. `{"cmd": [...]}` never reaches the wire; the client sees a `job_id` at
42
- * 202 or an `error`.
43
- *
44
- * So there is no argv in a response body. `{"cmd": [...]}` is a function's return TYPE, not
45
- * an endpoint's response, and the byte gate was never going to see it. That collapses the
46
- * choice:
47
- *
48
- * * Option (b) buys nothing. Its whole value was making two implementations return the
49
- * same wire bytes; they already do, because neither returns these bytes at all. The
50
- * resolver and the runner are already in one process, so "let the runner resolve it" is
51
- * a rename. It would change the reference and force a `web/dist` rebuild for zero
52
- * observable difference — the definition of work this port refuses.
53
- * * Option (a) is what remains, minus the harness change: the Node twin returns
54
- * `[process.execPath, bin/geneseed-cli.mjs, …]`, which is the honest command for the
55
- * runtime it will run on, and naming a Python interpreter there would put Python back into
56
- * a "no Python needed" install for the one operation the console exists to run.
57
- *
58
- * `web/dist` therefore does NOT need rebuilding, and it was not rebuilt.
59
- *
60
- * WHAT GATES IT, since no cell can. The value never reaches a response, so a request-level
61
- * corpus is structurally blind to it — exactly the shape a function-level gate answers and a
62
- * cell cannot. That gate is `tests/unit/web_jobs.test.mjs`: it asserts the argv HEAD
63
- * absolutely — `process.execPath` plus `bin/build-driver.mjs`, and no interpreter name
64
- * anywhere in it — and pins the TAIL, every argument after the head, against a frozen
65
- * literal. The tail is where a real bug would live, and asserting it absolutely rather than
66
- * by comparison is what let it outlive the implementation it was once compared against.
67
- *
68
- * THE CODE IS P6g's, THE DECISION IS THIS PHASE's. `api_install_cmd`, `api_deploy_cmd` and
69
- * `api_restore` all sit behind `/api/actions/<x>`, which needs the `JobManager` to route at
70
- * all — so porting them here would produce three functions with no call site, no cell and
71
- * no route, which is the "declaration that is not a dispatch" this project keeps being
72
- * bitten by. P6g wires the prefix and brings them across with the gate described above.
5
+ * `{cmd: [...]}`, returned by `apiInstallCmd` and `apiDeployCmd` below, IS A FUNCTION'S
6
+ * RETURN TYPE, NOT A RESPONSE BODY: the dispatcher hands it straight to the job runner in
7
+ * the same process, and the client only ever sees a `job_id` at 202 or an `error`. That is
8
+ * why the argv head is `process.execPath` + `bin/build-driver.mjs` — the honest command for
9
+ * the runtime this actually runs on.
73
10
  *
74
11
  * ---------------------------------------------------------------------------------------
75
- * `api_pick_folder` DOES NOT CROSS, and that is permanent.
76
- *
77
- * It opens an OS-NATIVE folder chooser on the daemon host: `osascript` on macOS, a one-shot
78
- * `tkinter` subprocess elsewhere. There is no Node twin that is not a new GUI dependency —
79
- * and the `child_process` ban would make it a sixth `_ALLOWED_SPAWNS` row for a modal
80
- * dialog. So it is DECLINED rather than deferred, and `js/web/server.mjs` declares it in
81
- * `DECLINED_POST` — a set separate from `NOT_PORTED_POST`, because the two mean different
82
- * things and a phase that empties the second must not silently empty the first. The UI
83
- * already falls back to its editable path field when this endpoint errors, which is the
84
- * behaviour a 501 produces.
12
+ * `apiPickFolder` CROSSED 2026-08-27 — a user override of the permanent decline this section
13
+ * used to record (why a folder dialog looked permanently un-portable lives in git history,
14
+ * under `DECLINED_POST` in `js/web/routes.mjs`). See `apiPickFolder`'s own docblock, below,
15
+ * for the argv, the async-spawn reasoning, and why it dispatches inline from
16
+ * `js/web/handler.mjs` rather than through `POST_ROUTES`.
85
17
  *
86
18
  * ---------------------------------------------------------------------------------------
87
- * `api_install_toggle` CROSSED IN P6i, AND THE ENGINE CAME WITH IT.
19
+ * `apiInstallToggle` is a thin endpoint over an engine that lives elsewhere on purpose.
88
20
  *
89
- * The endpoint is 27 lines over three engine calls, and the reason it waited is worth
90
- * keeping: two of the three pull in 293 lines of all-or-nothing tree moves with rollback, a
21
+ * Two of its three actions pull in 293 lines of all-or-nothing tree moves with rollback, a
91
22
  * stash layout, an `instructions`-entry unmerge, a re-emit-while-disabled guard and a host
92
- * fork. None of that is web code, and porting it inside a web phase would have put the
93
- * largest engine block behind the weakest available gate — a request script rather than a
94
- * file-move fixture. So it landed where it belongs: `installDeactivate` and
95
- * `installReactivate` live in `js/maintain/uninstall.mjs` beside `installUninstall`, the
96
- * reversible siblings of the same owned-file walk, and `apiInstallToggle` below is the thin
97
- * endpoint over them.
23
+ * fork. None of that is web code, and putting the largest engine block behind a web request
24
+ * script would give it the weakest available gate instead of a file-move fixture. So it
25
+ * lives in `js/maintain/uninstall.mjs`: `installDeactivate` and `installReactivate` beside
26
+ * `installUninstall`, the reversible siblings of the same owned-file walk.
98
27
  */
28
+ import { spawn } from 'node:child_process';
99
29
  import {
100
- accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, statSync, unlinkSync,
30
+ accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, unlinkSync,
101
31
  } from 'node:fs';
102
32
  import os from 'node:os';
103
33
  import path from 'node:path';
@@ -122,8 +52,9 @@ import {
122
52
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
123
53
  mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
124
54
  } from '../hosts/mcp.mjs';
125
- import { readText, writeText } from '../lib/fs.mjs';
55
+ import { readText, writeText, isFile, isDir } from '../lib/fs.mjs';
126
56
  import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
57
+ import { NO_WINDOW } from '../lib/proc.mjs';
127
58
  import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
128
59
  import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
129
60
  import { splitLines } from '../lib/udiff.mjs';
@@ -131,14 +62,9 @@ import {
131
62
  NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
132
63
  } from './api.mjs';
133
64
 
134
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
135
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
136
-
137
- /** `dict.get(key, default)` over a `json.loads` result. */
138
65
  const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
139
66
  ? body[key] : dflt);
140
67
 
141
- /** `str(x or "")` — Python's truthiness, then Python's `str`. */
142
68
  const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
143
69
 
144
70
  /**
@@ -197,22 +123,21 @@ function bodyExcludeRules(body) {
197
123
  return known.filter((id) => ids.includes(id));
198
124
  }
199
125
 
200
- /** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
126
+ /** Splits on runs of `WHITESPACE` (not `\s`), dropping empty strings. */
201
127
  const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
202
128
 
203
- /** `datetime.date.today().isoformat()` — LOCAL, as the reference's naive `date.today()` is. */
129
+ /** Today's date, LOCAL (not UTC) — this is a user-facing date, not a wire timestamp. */
204
130
  export function todayIso(d = new Date()) {
205
131
  const p2 = (n) => String(n).padStart(2, '0');
206
132
  return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
207
133
  }
208
134
 
209
135
  /**
210
- * `today` and `today + timedelta(days=n)` from ONE sample of the clock.
136
+ * `today` and `today + n days`, from ONE sample of the clock.
211
137
  *
212
- * The reference binds `today = datetime.date.today()` once and derives both dates from it.
213
- * Two `new Date()` calls would put the provenance line and the expiry on either side of a
214
- * midnight that fell between them — a one-run-in-86400 disagreement, which is the worst kind
215
- * to debug.
138
+ * Two separate `new Date()` calls would let the provenance line and the expiry land on
139
+ * either side of a midnight that fell between them — a one-run-in-86400 disagreement, which
140
+ * is the worst kind to debug.
216
141
  */
217
142
  function promoteDates(days) {
218
143
  const d = new Date();
@@ -222,46 +147,148 @@ function promoteDates(days) {
222
147
  }
223
148
 
224
149
  /**
225
- * `body.get(k, "") != <a str>` — Python's `!=`, where a non-string NEVER equals a string.
226
- *
227
- * `str(...)` would be wrong here and measurably so: the reference compares the RAW body
228
- * value, so a client sending `"fingerprint": 0` against an ABSENT file (whose fingerprint is
229
- * `""`) gets a conflict there, where a coerced comparison would find `"" == ""` and write.
150
+ * Strict equality on the RAW body value, not run through `strOr` first. `strOr(0)` folds to
151
+ * `''` (0 is falsy), which would wrongly equal an absent file's `''` fingerprint and let a
152
+ * client send `"fingerprint": 0` and overwrite regardless of what is actually on disk.
230
153
  */
231
154
  const fpMatches = (got, want) => typeof got === 'string' && got === want;
232
155
 
156
+ // ---- OS-native folder picker ---------------------------------------------------------------
157
+
158
+ /**
159
+ * ms before an open dialog is abandoned. The Python original's 300 s, kept as a named
160
+ * constant rather than a literal in the spawn call. `GENESEED_PICK_TIMEOUT_MS` is a TEST HOOK
161
+ * ONLY — it overrides the constant so the timeout path can be proven in seconds instead of
162
+ * five minutes (nobody is at the dialog to cancel it in an automated run); the 300 s default
163
+ * ships untouched for a real daemon.
164
+ */
165
+ // `Number.isFinite`, not `||`: the latter would ignore an explicit `GENESEED_PICK_TIMEOUT_MS=0`
166
+ // (falsy) and fall back to the 300 s default, which defeats a test that wants a timeout of 0.
167
+ const pickTimeoutOverride = Number(process.env.GENESEED_PICK_TIMEOUT_MS);
168
+ const PICK_TIMEOUT_MS = Number.isFinite(pickTimeoutOverride) ? pickTimeoutOverride : 300_000;
169
+
170
+ // A SINGLE STABLE LITERAL, never built from a request: `apiPickFolder` takes no client input
171
+ // at all (the endpoint's whole POST body is ignored), so there is nothing to interpolate into
172
+ // this command line in the first place — and `tests/unit/hook_cli.test.mjs`'s spawn allow-list
173
+ // asserts this exact string, which only holds while that stays true. `-STA`: a WinForms dialog
174
+ // needs the single-threaded apartment; `-NoProfile`: skip the user's `$PROFILE` script, which
175
+ // this one-shot invocation has no business running.
176
+ const PICK_SCRIPT_WIN = 'Add-Type -AssemblyName System.Windows.Forms; '
177
+ + '$d = New-Object System.Windows.Forms.FolderBrowserDialog; '
178
+ + 'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) '
179
+ + '{ Write-Output $d.SelectedPath }';
180
+
181
+ // Ported VERBATIM from the deleted Python (`git show 8860c72:rituals/_web_actions.py`,
182
+ // `api_pick_folder`) — same prompt text, same AppleScript shape. `choose folder` is a
183
+ // StandardAdditions command that runs in-process inside `osascript`, so it needs no
184
+ // automation-consent prompt the way scripting another app would.
185
+ const PICK_SCRIPT_MAC = 'set f to POSIX path of (choose folder with prompt '
186
+ + '"Choose a folder to deploy the harness into")\nreturn f';
187
+
188
+ /**
189
+ * Spawns `cmd args`, collects stdout/stderr, and calls `resultOf(exitCode, stdout, stderr)` to
190
+ * build the response once the child exits (or is killed on timeout, or never starts at all).
191
+ * `done` fires EXACTLY ONCE regardless of which of those three ends it — timeout, `error`
192
+ * (spawn itself failed, e.g. the binary is not on PATH), or `close` (the normal exit) all race
193
+ * for the same `settled` guard.
194
+ */
195
+ function runPicker(cmd, args, done, resultOf) {
196
+ let child;
197
+ try {
198
+ // `...NO_WINDOW`: this call captures stdout/stderr below rather than inheriting them, and
199
+ // `tests/unit/spawn_hygiene.test.mjs` gates every such call site in `js/`/`bin/` on it.
200
+ // Hiding the PowerShell/osascript CONSOLE window is exactly what is wanted here — the GUI
201
+ // dialog itself (`FolderBrowserDialog`, `choose folder`) is a separate Win32/Carbon window
202
+ // neither process's own console visibility controls.
203
+ child = spawn(cmd, args, { ...NO_WINDOW });
204
+ } catch (e) {
205
+ return done({ error: `folder picker unavailable: ${e.message}` });
206
+ }
207
+ let out = '';
208
+ let err = '';
209
+ let settled = false;
210
+ const finish = (result) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ done(result);
215
+ };
216
+ const timer = setTimeout(() => { child.kill(); finish({ error: 'folder picker timed out' }); },
217
+ PICK_TIMEOUT_MS);
218
+ child.stdout.on('data', (c) => { out += c; });
219
+ child.stderr.on('data', (c) => { err += c; });
220
+ child.on('error', (e) => finish({ error: `folder picker unavailable: ${e.message}` }));
221
+ child.on('close', (code) => finish(resultOf(code, out, err)));
222
+ }
223
+
224
+ /**
225
+ * `/api/pick-folder` — the OS-NATIVE folder chooser, opened ON THE DAEMON HOST. `done` is
226
+ * called with `{path}` on OK, `{cancelled: true}` on cancel, or `{error}` — the shape the
227
+ * client's `pickFolder()` (in `web/src/api/index.js`) already expects, unchanged since the
228
+ * endpoint was DECLINED (see this file's header for that history, overridden by the user
229
+ * 2026-08-27).
230
+ *
231
+ * ASYNC, NEVER `spawnSync`: a folder dialog stays open until a human answers it — seconds to
232
+ * minutes — and a synchronous spawn would block this single-threaded server's event loop for
233
+ * exactly that long, so no OTHER request could be served while the dialog is up. That is also
234
+ * why `js/web/handler.mjs`'s `doPost` calls this INLINE rather than through `POST_ROUTES`:
235
+ * that table's dispatch is `fn(state, body) -> object`, sent to the client the moment `fn`
236
+ * returns — a shape with no room for "answer later," which is exactly what this endpoint does.
237
+ *
238
+ * The daemon must be running IN A DESKTOP SESSION for a dialog to actually appear; headless
239
+ * (no GUI session, or a platform with no branch below), the process still answers — an
240
+ * `{error}` — never a hang, because `runPicker`'s timeout and `error` handlers both fire
241
+ * `done` on their own.
242
+ */
243
+ export function apiPickFolder(done) {
244
+ if (process.platform === 'win32') {
245
+ return runPicker('powershell', ['-NoProfile', '-STA', '-Command', PICK_SCRIPT_WIN], done,
246
+ (code, out, err) => {
247
+ if (code !== 0) return { error: (err || out).trim() || 'folder picker failed' };
248
+ const p = out.trim();
249
+ return p ? { path: p } : { cancelled: true };
250
+ });
251
+ }
252
+ if (process.platform === 'darwin') {
253
+ return runPicker('osascript', ['-e', PICK_SCRIPT_MAC], done, (code, out, err) => {
254
+ if (code === 0) return { path: out.trim() };
255
+ if (err.includes('-128') || err.includes('User canceled')) return { cancelled: true };
256
+ return { error: err.trim() || 'folder picker failed' };
257
+ });
258
+ }
259
+ return done({ error: 'no native folder dialog on this platform' });
260
+ }
261
+
233
262
  // ---- user rules (user-rules.md) ----------------------------------------------------------
234
263
 
235
- /** `build.RULES_FILE`. */
236
264
  export const RULES_FILE = 'user-rules.md';
237
265
  const rulesPath = (state) => path.join(state.target, RULES_FILE);
238
266
 
239
267
  /**
240
- * `_web_catalog.RULE_HEAD_RE` — `## R<n> — Title`, anchored at column 0 so a rule body's
241
- * fenced code and the stub's indented format example never parse as rules.
242
- *
243
- * The whitespace classes are `WHITESPACE`, not `\s`: P6d measured the two apart and this
244
- * pattern runs over a HAND-EDITED file, which is where a non-breaking space actually turns
245
- * up. `\S` is its complement for the same reason.
246
- *
247
- * `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight. Python's
248
- * `\d` on a `str` pattern is `\p{Nd}`, so `## R١ — …` parses there and not here. Matching it
249
- * would only move the problem one line down: the reference then calls `int()` on the captured
250
- * text, and `int()` accepts every Unicode decimal digit where `Number()` accepts none — so
251
- * closing the gap needs `int`'s digit table, for a heading no theme seeds, no build writes
252
- * and the web editor cannot produce (it writes `R<n>` itself from `max(ids) + 1`).
268
+ * `## R<n> — Title`, anchored at column 0 so a rule body's fenced code and the stub's
269
+ * indented format example never parse as rules.
270
+ *
271
+ * The whitespace classes are `WHITESPACE`, not `\s`: this pattern runs over a HAND-EDITED
272
+ * file, which is where a non-breaking space actually turns up. `\S` is its complement for
273
+ * the same reason.
274
+ *
275
+ * `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight, unlike
276
+ * `ruleFields` below which uses `\p{Nd}`. No theme seeds a non-ASCII rule id, no build
277
+ * writes one, and the web editor cannot produce one either — it writes `R<n>` itself from
278
+ * `max(ids) + 1`. Widening this to match would only move the gap one line down, to whatever
279
+ * parses the captured digits back into a number.
253
280
  */
254
281
  export const RULE_HEAD_RE = new RegExp(
255
282
  `^##[${WHITESPACE}]+R(\\d+)[${WHITESPACE}]*[—–-]+[${WHITESPACE}]*`
256
283
  + `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
257
284
  );
258
285
 
259
- /** `_web_catalog.RULES_BUDGET` — advisory; nothing blocks past it. */
286
+ /** Advisory only; nothing blocks past it. */
260
287
  export const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
261
288
 
262
289
  const META_RE = new RegExp(`^\\((.+)\\)[${WHITESPACE}]*$`);
263
290
 
264
- /** `_web_catalog._parse_rule_meta` — `{}` when the line is body rather than metadata. */
291
+ /** `{}` when the line is body rather than metadata. */
265
292
  function parseRuleMeta(line) {
266
293
  const m = META_RE.exec(stripWhitespace(line));
267
294
  if (!m) return {};
@@ -278,7 +305,7 @@ function parseRuleMeta(line) {
278
305
  }
279
306
 
280
307
  /**
281
- * `_web_catalog.parse_rules` — user-rules.md to `[rules, warnings]`.
308
+ * `user-rules.md` parsed to `[rules, warnings]`.
282
309
  *
283
310
  * Every rule carries its `start`/`end` LINE INDICES, which is what lets `apiRulesMutate`
284
311
  * splice exactly one block and leave every other byte of the user's file — prose,
@@ -317,7 +344,6 @@ export function parseRules(text) {
317
344
  return [rules, warnings];
318
345
  }
319
346
 
320
- /** `_web_catalog.api_rules` — the Rules page payload. */
321
347
  export function apiRules(state) {
322
348
  const p = rulesPath(state);
323
349
  if (!isFile(p)) {
@@ -335,19 +361,17 @@ export function apiRules(state) {
335
361
  body: r.body,
336
362
  }));
337
363
  return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
338
- // `len(text) // 4` — CODE POINTS. `String.length` counts UTF-16 units, so an emoji in a
339
- // rule body would put the two token estimates two apart.
364
+ // CODE POINTS, not `String.length` (UTF-16 units) — an emoji in a rule body would
365
+ // otherwise put the token estimate off.
340
366
  stats: { rules: rules.length, lines: splitLines(text).length,
341
367
  tokens: Math.floor(codePointLength(text) / 4), ...RULES_BUDGET } };
342
368
  }
343
369
 
344
- /** `_web_actions._rules_read`. */
345
370
  function rulesRead(state) {
346
371
  const p = rulesPath(state);
347
372
  return [p, isFile(p) ? (readMaybe(p) ?? '') : ''];
348
373
  }
349
374
 
350
- /** `_web_actions._rule_block`. */
351
375
  function ruleBlock(rid, title, scope, source, trialUntil, body) {
352
376
  const meta = [`scope: ${scope}`];
353
377
  if (source) meta.push(`source: ${source}`);
@@ -356,12 +380,12 @@ function ruleBlock(rid, title, scope, source, trialUntil, body) {
356
380
  }
357
381
 
358
382
  /**
359
- * `_web_actions._rule_fields` — validate and normalise a rule's writable fields.
383
+ * Validate and normalise a rule's writable fields. Throws (→ the shell's JSON 500 carrying
384
+ * the message) on an unusable rule.
360
385
  *
361
- * Throws (→ the shell's JSON 500 carrying the message) on an unusable rule. `\p{Nd}` here and
362
- * not `\d`, unlike `RULE_HEAD_RE`: this one is a pure predicate over a string that is stored
363
- * as a string, so matching Python's Unicode-aware `\d` exactly costs nothing and needs no
364
- * `int()`.
386
+ * `\p{Nd}` here and not `\d`, unlike `RULE_HEAD_RE` above: this is a pure predicate over a
387
+ * string that stays a string, so accepting any Unicode decimal digit costs nothing and
388
+ * needs no numeric parse afterward.
365
389
  */
366
390
  function ruleFields(body) {
367
391
  const title = splitWords(strOr(bget(body, 'title'))).join(' ');
@@ -379,11 +403,10 @@ function ruleFields(body) {
379
403
  }
380
404
 
381
405
  /**
382
- * `int(body.get("id"))`, with both of its failures folded into `NotFound("rule id")`.
383
- *
384
- * Python's `int()` takes an int, a bool (which IS an int there), a float (truncating) and a
385
- * decimal string with surrounding whitespace; everything else is a TypeError or ValueError,
386
- * and the reference catches both.
406
+ * Coerces whatever `id` came out of the parsed body into an integer, or throws
407
+ * `NotFound('rule id')`. Handles a decimal string (surrounding whitespace stripped), a
408
+ * boolean, a bare number (truncated), and a `JsonNumber`-shaped value via its `valueOf()` —
409
+ * whatever `parseJson` could have produced for this field.
387
410
  */
388
411
  function ruleId(v) {
389
412
  if (typeof v === 'string') {
@@ -401,7 +424,7 @@ function ruleId(v) {
401
424
  }
402
425
 
403
426
  /**
404
- * `_web_actions.api_rules_mutate` — add / update / delete on user-rules.md.
427
+ * Add / update / delete on user-rules.md.
405
428
  *
406
429
  * EVERY MUTATION REQUIRES THE FINGERPRINT OF THE CONTENT THE CLIENT LAST READ. An agent
407
430
  * session may be editing the same file mid-flight, so a stale write returns `ok: false`
@@ -451,11 +474,9 @@ export function apiRulesMutate(state, body) {
451
474
  }
452
475
 
453
476
  /**
454
- * `_web_actions.api_rules_promote` — one memory fact into a trial rule.
455
- *
456
- * The provenance line and the month of probation are the rule this endpoint owns, and both
457
- * are DATES read from the clock. `tests/web_golden.py` gates them from its own shared
458
- * per-cell clock rather than destamping them — a destamp would erase exactly the thing.
477
+ * One memory fact promoted into a trial rule. The provenance line and the month of
478
+ * probation are the rule this endpoint owns, and both are DATES read from the real clock —
479
+ * not destamped, since destamping would erase exactly the thing being recorded.
459
480
  */
460
481
  export function apiRulesPromote(state, body) {
461
482
  const name = strOr(bget(body, 'name'));
@@ -490,7 +511,7 @@ export function apiRulesPromote(state, body) {
490
511
 
491
512
  // ---- PROFILE.md --------------------------------------------------------------------------
492
513
 
493
- /** `_web_actions.api_profile_save` — the whole file, fingerprint-guarded like the rules. */
514
+ /** The whole file, fingerprint-guarded like the rules above. */
494
515
  export function apiProfileSave(state, body) {
495
516
  const p = path.join(state.target, 'PROFILE.md');
496
517
  const cur = isFile(p) ? (readMaybe(p) ?? '') : '';
@@ -508,11 +529,11 @@ export function apiProfileSave(state, body) {
508
529
 
509
530
  // ---- memory ------------------------------------------------------------------------------
510
531
 
511
- /** `_web_catalog._memory_dir` — always `<target>/memory`, never the CWD-scanning resolver. */
532
+ /** Always `<target>/memory` — never the CWD-scanning resolver. */
512
533
  const memoryDir = (state) => path.join(state.target, 'memory');
513
534
 
514
535
  /**
515
- * `_web_actions.api_memory_delete` — one fact file, and its line in the MEMORY.md index.
536
+ * One fact file, and its line in the MEMORY.md index.
516
537
  *
517
538
  * `name` is a BARE SLUG: a path separator, or one of the two reserved names, is refused, so
518
539
  * this can only ever remove a fact inside the resolved memory dir. MEMORY.md is the index the
@@ -538,8 +559,8 @@ export function apiMemoryDelete(state, name) {
538
559
  // ---- sovereign-repo exclusions -------------------------------------------------------------
539
560
 
540
561
  /**
541
- * `_web_actions.api_excludes_mutate` — this endpoint owns no exclusion logic of its own; it
542
- * validates the body and hands off to the same engine `harness exclude add|remove` calls.
562
+ * This endpoint owns no exclusion logic of its own; it validates the body and hands off to
563
+ * the same engine `harness exclude add|remove` calls.
543
564
  *
544
565
  * A malformed body is rejected with `ok: false` (→ 409) rather than reaching `excludeAdd`,
545
566
  * which assumes a real path string. That arm is also where a body that never parsed lands:
@@ -557,7 +578,6 @@ export function apiExcludesMutate(state, body) {
557
578
 
558
579
  // ---- MCP ----------------------------------------------------------------------------------
559
580
 
560
- /** `_web_actions.api_mcp` — MCP servers per ACTIVE install, host-aware. */
561
581
  export function apiMcp() {
562
582
  const out = [];
563
583
  for (const [label, p, host, scope, root] of mcpInstallTargets()) {
@@ -577,20 +597,6 @@ export function apiMcp() {
577
597
  return { targets: out, default: 0 };
578
598
  }
579
599
 
580
- /**
581
- * `_web_actions.api_mcp_toggle` — enable / disable / first-add one server.
582
- *
583
- * THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
584
- * endpoint: `known` is built from the detected install targets and an unlisted path is a 404
585
- * before anything is read or written. Without it this is "write arbitrary JSON to an
586
- * arbitrary file", behind a CSRF token and nothing else.
587
- *
588
- * The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
589
- * rewritten — so the safety check and the value saved come from the same read, with no
590
- * time-of-check/time-of-use gap and no comment-stripper touching a string that contains
591
- * `,]`. A file that will not parse is refused rather than clobbered: it may be
592
- * `~/.claude.json`, which holds projects and history far beyond MCP wiring.
593
- */
594
600
  /**
595
601
  * The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
596
602
  *
@@ -610,6 +616,20 @@ export function mcpTargetPaths() {
610
616
  return known;
611
617
  }
612
618
 
619
+ /**
620
+ * Enable / disable / first-add one server.
621
+ *
622
+ * THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
623
+ * endpoint: `known` is built from the detected install targets and an unlisted path is a 404
624
+ * before anything is read or written. Without it this is "write arbitrary JSON to an
625
+ * arbitrary file", behind a CSRF token and nothing else.
626
+ *
627
+ * The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
628
+ * rewritten — so the safety check and the value saved come from the same read, with no
629
+ * time-of-check/time-of-use gap and no comment-stripper touching a string that contains
630
+ * `,]`. A file that will not parse is refused rather than clobbered: it may be
631
+ * `~/.claude.json`, which holds projects and history far beyond MCP wiring.
632
+ */
613
633
  export function apiMcpToggle(state, body) {
614
634
  const name = strOr(bget(body, 'name'));
615
635
  const want = isTruthy(bget(body, 'enabled'));
@@ -660,7 +680,7 @@ export function apiMcpToggle(state, body) {
660
680
  // ---- the harness selector ------------------------------------------------------------------
661
681
 
662
682
  /**
663
- * `_web_actions.api_select_view` — re-point the whole console at a detected install.
683
+ * Re-point the whole console at a detected install.
664
684
  *
665
685
  * The (host, path) PAIR must be one of the detected targets, and the ROOT is threaded through
666
686
  * beside the data dir: markers and sigils live at the install root, not in the data dir, and
@@ -684,14 +704,14 @@ export function apiSelectView(state, body) {
684
704
  return { ok: true, target: state.target, theme: state.theme, emit: state.emit };
685
705
  }
686
706
 
687
- // ---- P6g — restore, and the two build-command resolvers -------------------------------------
707
+ // ---- restore, and the two build-command resolvers ---------------------------------------
688
708
 
689
709
  /**
690
- * `_web_actions._build_override` — (theme, emit) for a Build POST.
710
+ * (theme, emit) for a Build POST.
691
711
  *
692
712
  * A valid override in the body wins; anything missing or unrecognised falls back to the
693
713
  * DETECTED install, so a bogus body value can never reach the build argv. That is the same
694
- * allowlist shape `api_install_cmd` uses, and it is the reason neither endpoint needs to
714
+ * allowlist shape `apiInstallCmd` uses, and it is the reason neither endpoint needs to
695
715
  * sanitise a string: an unknown one is simply not used.
696
716
  */
697
717
  export function buildOverride(state, body) {
@@ -703,13 +723,12 @@ export function buildOverride(state, body) {
703
723
  }
704
724
 
705
725
  /**
706
- * `_web_actions._global_emitter_for` — the global emit matching a deployed install's
707
- * `.geneseed-emit`, so the EXPECTED render uses the install's own host dialect.
726
+ * The global emit matching a deployed install's `.geneseed-emit`, so the EXPECTED render
727
+ * uses the install's own host dialect.
708
728
  *
709
- * Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is how the twin
710
- * of `build.HOSTS[host]["emit_global"]` is already spelt (`js/inspect/diff.mjs` calls it that way).
711
- * An unknown or missing marker falls back to OpenCode, exactly as the reference's two
712
- * `.get(..., default)` calls do.
729
+ * Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is the shape
730
+ * `js/inspect/diff.mjs` already calls it with. An unknown or missing marker falls back to
731
+ * OpenCode.
713
732
  */
714
733
  export function globalEmitHostFor(emit) {
715
734
  const host = (EMIT_HOST_SCOPE.get(emit || '') ?? ['opencode', 'global'])[0];
@@ -717,22 +736,21 @@ export function globalEmitHostFor(emit) {
717
736
  }
718
737
 
719
738
  /**
720
- * `_web_actions.api_restore` — restore selected drifted files from the SOURCE render.
739
+ * Restore selected drifted files from the SOURCE render.
721
740
  *
722
741
  * Source wins and local edits are discarded (the inverse, keeping them, is Export
723
- * improvements). Renders the expected copy exactly as `_diff_collect` does, then per rel:
742
+ * improvements). Renders the expected copy exactly as `diffCollect` does, then per rel:
724
743
  * expected present -> overwrite/create the deployed copy; expected absent but deployed present
725
744
  * (an 'added' file) -> delete it; neither -> an error and nothing touched.
726
745
  *
727
746
  * SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
728
- * structured result rather than a job id — which is why `_post_routes` answers it before it
747
+ * structured result rather than a job id — which is why the dispatcher answers it before it
729
748
  * ever consults the action table.
730
749
  *
731
- * THE EMIT AND THE FOOTPRINT ARE READ OFF THE DEPLOYMENT, and the reference's comment says
732
- * what each one costs: a silently-OpenCode `expected` would overwrite a Claude install's
733
- * agents with the wrong frontmatter, and a full-footprint `expected` on a lean install
734
- * rewrites AGENT.md with the inlined laws and DELETES `laws/universal.md`, which only the lean
735
- * emit writes.
750
+ * THE EMIT AND THE FOOTPRINT ARE READ OFF THE DEPLOYMENT: a silently-OpenCode `expected`
751
+ * would overwrite a Claude install's agents with the wrong frontmatter, and a full-footprint
752
+ * `expected` on a lean install rewrites AGENT.md with the inlined laws and DELETES
753
+ * `laws/universal.md`, which only the lean emit writes.
736
754
  *
737
755
  * THE POSTURE AND THE MODE cost the most of the four, because this verb WRITES. Rendering
738
756
  * `expected` at `peer`/`direct` made restoring AGENT.md silently revert the user's chosen
@@ -775,8 +793,8 @@ export function apiRestore(state, files) {
775
793
  errors.push(`${rel}: outside the deployed tree`);
776
794
  } else if (isFile(src)) {
777
795
  mkdirSync(path.dirname(dst), { recursive: true });
778
- // `shutil.copyfile` — a BYTE copy, so the restored file keeps the render's own line
779
- // endings rather than the platform's (the `writeText`/`copy2` rule, one file over).
796
+ // A BYTE copy, so the restored file keeps the render's own line endings rather than
797
+ // the platform's (the `writeText`/`copy2` rule, one file over).
780
798
  copyFileSync(src, dst);
781
799
  restored.push(rel);
782
800
  } else if (isFile(dst)) {
@@ -787,14 +805,13 @@ export function apiRestore(state, files) {
787
805
  }
788
806
  }
789
807
  } finally {
790
- // `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
791
808
  rmSync(tmp, { recursive: true, force: true });
792
809
  }
793
810
  state.refresh();
794
811
  return { restored, deleted, errors };
795
812
  }
796
813
 
797
- /** `_web_actions._EMIT_FOR` — (host, scope) -> the emit name that installs it. */
814
+ /** (host, scope) -> the emit name that installs it. */
798
815
  const EMIT_FOR = new Map([
799
816
  ['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
800
817
  ['claude global', 'claude-global'], ['claude project', 'claude'],
@@ -803,21 +820,17 @@ const EMIT_FOR = new Map([
803
820
  ]);
804
821
 
805
822
  /**
806
- * `_web_actions.api_install_cmd` — the build command that installs Geneseed into a DETECTED
807
- * location, or re-themes an already-active one (an in-place re-emit, the same command either
808
- * way).
823
+ * The build command that installs Geneseed into a DETECTED location, or re-themes an
824
+ * already-active one (an in-place re-emit, the same command either way).
809
825
  *
810
826
  * The (host, path) pair MUST be one of the detected targets — the same allowlist
811
- * `api_select_view` uses, and the reason the target is never built from raw body input — and
827
+ * `apiSelectView` uses, and the reason the target is never built from raw body input — and
812
828
  * it must not be `disabled` (reactivate first). Every other field follows the same rule: a
813
829
  * VALID picked value wins, else the install's own, so a re-theme never silently flips the
814
830
  * footprint, the register or the mode, and a bogus body value cannot reach the argv.
815
831
  *
816
- * `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE. `_post_routes` hands it straight to the job
817
- * runner in the same process; it never reaches the wire. That is what collapsed P6f's
818
- * "argv-in-a-response-body" question — see this file's header — and why the twin's head is
819
- * `process.execPath` + `bin/build-driver.mjs` rather than a Python argv it would then have to
820
- * pretend to run.
832
+ * `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE — see this file's header for why that
833
+ * matters and what the argv head is.
821
834
  */
822
835
  export function apiInstallCmd(state, body) {
823
836
  const known = new Map();
@@ -864,28 +877,17 @@ export function apiInstallCmd(state, body) {
864
877
  }
865
878
 
866
879
  /**
867
- * `_web_actions.api_deploy_cmd` — the build command that deploys a FRESH per-repo harness into
868
- * an arbitrary folder the user chose.
869
- *
870
- * The open-ended sibling of `api_install_cmd`, which only rebuilds a pre-detected target from a
871
- * tight allowlist. Scope is always `project`: a global lands in the host's config dir, never a
872
- * chosen folder. THIS ENDPOINT TAKES A RAW PATH, so it is the trust boundary — the path is
873
- * validated here as an existing, writable directory that is not a host's own global config dir
874
- * (deploying a `project` emit there would mislabel as the global row and collide on dedup).
875
- */
876
- /**
877
- * `_web_actions.api_install_toggle` — deactivate, reactivate, or REMOVE one install.
880
+ * Deactivate, reactivate, or REMOVE one install.
878
881
  *
879
882
  * KEYED ON THE (host, path) PAIR, and the pair must be one of the DETECTED installs. A cwd can
880
883
  * carry both an OpenCode and a Claude install at the same path, so a path alone is ambiguous;
881
884
  * and this endpoint moves and deletes whole trees, so the root is never built from raw body
882
885
  * input. An unknown pair is a `NotFound`, which the shell answers 404.
883
886
  *
884
- * THE ENGINE IS `js/maintain/uninstall.mjs`'s, and P6i is what brought it. `installDeactivate` /
885
- * `installReactivate` are the reversible siblings of `installUninstall` — the same owned-file
886
- * walk and ancestor prune with `move` where the reversal has `unlink` — which is why they live
887
- * beside it rather than in the web tree. `remove` is `installUninstall`, which crossed in P5h
888
- * and reaches the wire for the first time here.
887
+ * THE ENGINE IS `js/maintain/uninstall.mjs`'s: `installDeactivate` / `installReactivate` are
888
+ * the reversible siblings of `installUninstall` — the same owned-file walk and ancestor prune
889
+ * with `move` where the reversal has `unlink` — which is why they live beside it rather than
890
+ * in the web tree.
889
891
  */
890
892
  export function apiInstallToggle(state, body) {
891
893
  const known = new Map();
@@ -910,14 +912,25 @@ export function apiInstallToggle(state, body) {
910
912
  const mem = bget(body, 'memory');
911
913
  res = installUninstall(root, host, scope, isTruthy(mem) ? mem : 'keep');
912
914
  } else {
913
- // `{action!r}` — Python's repr, SINGLE-quoted. `JSON.stringify` would double-quote it
914
- // inside a JSON string body and the two sides would differ on every typo.
915
+ // `formatRepr`, not `JSON.stringify` — SINGLE-quoted, consistent with every other error
916
+ // message in this file that echoes an unrecognised value.
915
917
  res = { ok: false, error: `unknown action ${formatRepr(action)}` };
916
918
  }
917
919
  state.refresh();
918
920
  return res;
919
921
  }
920
922
 
923
+ /**
924
+ * The build command that deploys a FRESH per-repo harness into an arbitrary folder the user
925
+ * chose.
926
+ *
927
+ * The open-ended sibling of `apiInstallCmd`, which only rebuilds a pre-detected target from
928
+ * a tight allowlist. Scope is always `project`: a global lands in the host's config dir,
929
+ * never a chosen folder. THIS ENDPOINT TAKES A RAW PATH, so it is the trust boundary — the
930
+ * path is validated here as an existing, writable directory that is not a host's own global
931
+ * config dir (deploying a `project` emit there would mislabel as the global row and collide
932
+ * on dedup).
933
+ */
921
934
  export function apiDeployCmd(state, body) {
922
935
  const host = stripWhitespace(strOr(bget(body, 'host')));
923
936
  if (!HOSTS.some((h) => h.host === host)) {
@@ -933,7 +946,7 @@ export function apiDeployCmd(state, body) {
933
946
  }
934
947
  if (!isDir(root)) return { error: `not a folder: ${root}` };
935
948
  try {
936
- accessSync(root, constants.W_OK); // `os.access(root, os.W_OK)`
949
+ accessSync(root, constants.W_OK);
937
950
  } catch {
938
951
  return { error: `folder not writable: ${root}` };
939
952
  }
@@ -941,7 +954,7 @@ export function apiDeployCmd(state, body) {
941
954
  for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir, copilotConfigDir]) {
942
955
  try {
943
956
  cfgdirs.add(resolvePath(fn()));
944
- } catch { /* the reference's bare `except: pass` */ }
957
+ } catch { /* not every host has a resolvable config dir; skip it */ }
945
958
  }
946
959
  if (cfgdirs.has(root)) {
947
960
  return { error: "that's a host global config dir — use its existing row to build a global install" };