geneseed 3.1.0 → 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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -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 +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -7,15 +7,15 @@
7
7
  * of magnitude:
8
8
  *
9
9
  * * `_memory_drop_index` — "check whether P5's `learn` ported it". It did not; nothing in
10
- * `js/` referenced it. It lands in `js/hooks.mjs`, beside the other memory-store
10
+ * `js/` referenced it. It lands in `js/hosts/hooks.mjs`, beside the other memory-store
11
11
  * primitives, because that is where its Python siblings' twins already live.
12
12
  * * `_mcp_load` — "already in js/ (P6c)". What crossed in P6c is `readJsonc` and a
13
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/mcp.mjs`.
14
+ * the other twelve MCP functions are 168 lines of new `js/hosts/mcp.mjs`.
15
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/mcp.mjs`.
16
+ * different temp suffix and no parent-directory creation. See `js/hosts/mcp.mjs`.
17
17
  * * `api_install_toggle` — "~25 new; `_move_tree`, `_prune_empty_ancestors` and the stash
18
- * ARE in `js/uninstall.mjs` (P5h)". `DISABLED_STASH`, `installKind` and `installState`
18
+ * ARE in `js/maintain/uninstall.mjs` (P5h)". `DISABLED_STASH`, `installKind` and `installState`
19
19
  * are. `_move_tree`, `_prune_empty_ancestors`, `_install_move_list`, `_install_relive`,
20
20
  * `_install_readd_entry`, `_install_deactivate`, `_install_reactivate`,
21
21
  * `_claude_deactivate` and `_claude_reactivate` are not — 293 lines of unported
@@ -57,15 +57,13 @@
57
57
  *
58
58
  * `web/dist` therefore does NOT need rebuilding, and it was not rebuilt.
59
59
  *
60
- * WHAT GATES IT, since no cell can. The value never reaches a response, so
61
- * `tests/web_golden.py` is structurally blind to it — which is exactly the shape a corpus
62
- * answers and a cell cannot (the phase rule: a cell runs the PROGRAM, a corpus runs the
63
- * FUNCTION). The gate is a unit test that calls BOTH implementations and asserts three
64
- * things: the reference's argv head is its own interpreter and `build.py`, absolutely; the
65
- * twin's head is `process.execPath` and `bin/geneseed-cli.mjs`, absolutely; and the TAIL —
66
- * every argument after the head, which is where a real port bug would live — is identical.
67
- * That is the absolute-assertion-per-side discipline a tolerant comparison owes, applied to
68
- * a value the byte gate cannot reach.
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.
69
67
  *
70
68
  * THE CODE IS P6g's, THE DECISION IS THIS PHASE's. `api_install_cmd`, `api_deploy_cmd` and
71
69
  * `api_restore` all sit behind `/api/actions/<x>`, which needs the `JobManager` to route at
@@ -86,16 +84,17 @@
86
84
  * behaviour a 501 produces.
87
85
  *
88
86
  * ---------------------------------------------------------------------------------------
89
- * `api_install_toggle` IS DEFERRED, with its measurement.
90
- *
91
- * The endpoint is 27 lines over three engine calls. One of the three (`_install_uninstall`)
92
- * crossed in P5h. The other two pull in 293 lines of all-or-nothing tree moves with
93
- * rollback, a stash layout, an `instructions`-entry unmerge, a re-emit-while-disabled guard
94
- * and a host fork into `_claude_deactivate`/`_claude_reactivate`. None of it is web code and
95
- * none of it has a `harness` VERB — which is why P5 never reached it — and its gate is a
96
- * file-move fixture of the kind `harness_golden` is built for, not a request script. Porting
97
- * it inside a web phase would put the largest unported engine block behind the weakest
98
- * available gate. `/api/install` stays in `NOT_PORTED_POST`.
87
+ * `api_install_toggle` CROSSED IN P6i, AND THE ENGINE CAME WITH IT.
88
+ *
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
91
+ * 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.
99
98
  */
100
99
  import {
101
100
  accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, statSync, unlinkSync,
@@ -103,29 +102,31 @@ import {
103
102
  import os from 'node:os';
104
103
  import path from 'node:path';
105
104
 
106
- import { emitGlobalInto } from '../../bin/geneseed.mjs';
107
- import { ROOT, discoverNames, PACK_ORDER } from '../checkout.mjs';
108
- import { withStdoutSwallowed } from '../diff.mjs';
109
- import { excludeAdd, excludeRemove } from '../excludes.mjs';
110
- import { setupBuildArgs } from '../generate.mjs';
111
- import { frontmatter, memoryDropIndex } from '../hooks.mjs';
105
+ import { emitGlobalInto } from '../../bin/build-driver.mjs';
106
+ import {
107
+ ROOT, discoverNames, knownRuleIds, PACK_ORDER,
108
+ } from '../build/source.mjs';
109
+ import { withStdoutSwallowed } from '../inspect/diff.mjs';
110
+ import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
111
+ import { setupBuildArgs } from '../build/generate.mjs';
112
+ import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
112
113
  import {
113
114
  HOSTS, bobConfigDir, claudeConfigDir, copilotConfigDir, expanduser, opencodeConfigDir,
114
- pyResolve,
115
- } from '../hosts.mjs';
115
+ resolvePath,
116
+ } from '../hosts/hosts.mjs';
116
117
  import {
117
- EMIT_HOST_SCOPE, doctrinesForBuild, footprintOfDir, installState, installTargets, modeOfDir,
118
- postureOfDir, readMaybe,
119
- } from '../installs.mjs';
118
+ EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
119
+ installTargets, modeOfDir, postureOfDir, readMaybe,
120
+ } from '../hosts/installs.mjs';
120
121
  import {
121
122
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
122
123
  mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
123
- } from '../mcp.mjs';
124
- import {
125
- PY_SPACE, parseJson, pyLen, pyRepr, pyStr, pyStripSpace, pyTruthy, readText, writeText,
126
- } from '../lib/fs.mjs';
127
- import { installDeactivate, installReactivate, installUninstall } from '../uninstall.mjs';
128
- import { pySplitLines } from '../lib/udiff.mjs';
124
+ } from '../hosts/mcp.mjs';
125
+ import { readText, writeText } from '../lib/fs.mjs';
126
+ import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
127
+ import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
128
+ import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
129
+ import { splitLines } from '../lib/udiff.mjs';
129
130
  import {
130
131
  NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
131
132
  } from './api.mjs';
@@ -138,7 +139,7 @@ const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, ke
138
139
  ? body[key] : dflt);
139
140
 
140
141
  /** `str(x or "")` — Python's truthiness, then Python's `str`. */
141
- const strOr = (v) => (pyTruthy(v) ? pyStr(v) : '');
142
+ const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
142
143
 
143
144
  /**
144
145
  * A request body's `doctrines` -> a normalised pack list, or `null` for "said nothing usable".
@@ -170,8 +171,34 @@ function bodyDoctrines(body) {
170
171
  return PACK_ORDER.filter((p) => names.includes(p));
171
172
  }
172
173
 
174
+ /**
175
+ * The SECOND doctrine axis across the same trust boundary — which individual rules to drop.
176
+ *
177
+ * Same shape and same reasons as `bodyDoctrines` above: a list closed member by member
178
+ * against what this checkout actually ships (`knownRuleIds`, the one enumerator the CLI flag
179
+ * uses too), re-ordered canonically because the `Excluded rules:` line it produces is a
180
+ * marker a later reader parses back out, and `null` on any rejection so the caller decides
181
+ * what "unspecified" means.
182
+ *
183
+ * Accepts `process 7` or `process.7` — the console sends the dotted address, and a curl by
184
+ * hand naturally writes the spelling the marker shows. `none`/`[]` is the deliberate empty
185
+ * selection: "exclude nothing", which is a real answer and NOT the same as not saying.
186
+ */
187
+ function bodyExcludeRules(body) {
188
+ const raw = bget(body, 'excludeRules');
189
+ if (Array.isArray(raw) && !raw.length) return [];
190
+ const names = Array.isArray(raw) ? raw
191
+ : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
192
+ if (names === null || !names.length) return null;
193
+ if (names.length === 1 && names[0] === 'none') return [];
194
+ const known = knownRuleIds();
195
+ const ids = names.map((n) => (typeof n === 'string' ? n.trim().replace(/[ \t]+/, '.') : n));
196
+ if (!ids.every((n) => typeof n === 'string' && known.includes(n))) return null;
197
+ return known.filter((id) => ids.includes(id));
198
+ }
199
+
173
200
  /** `str.split()` with no argument — runs of PYTHON whitespace, no empties. */
174
- const pyWords = (s) => s.split(new RegExp(`[${PY_SPACE}]+`)).filter(Boolean);
201
+ const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
175
202
 
176
203
  /** `datetime.date.today().isoformat()` — LOCAL, as the reference's naive `date.today()` is. */
177
204
  export function todayIso(d = new Date()) {
@@ -213,7 +240,7 @@ const rulesPath = (state) => path.join(state.target, RULES_FILE);
213
240
  * `_web_catalog.RULE_HEAD_RE` — `## R<n> — Title`, anchored at column 0 so a rule body's
214
241
  * fenced code and the stub's indented format example never parse as rules.
215
242
  *
216
- * The whitespace classes are `PY_SPACE`, not `\s`: P6d measured the two apart and this
243
+ * The whitespace classes are `WHITESPACE`, not `\s`: P6d measured the two apart and this
217
244
  * pattern runs over a HAND-EDITED file, which is where a non-breaking space actually turns
218
245
  * up. `\S` is its complement for the same reason.
219
246
  *
@@ -225,26 +252,26 @@ const rulesPath = (state) => path.join(state.target, RULES_FILE);
225
252
  * and the web editor cannot produce (it writes `R<n>` itself from `max(ids) + 1`).
226
253
  */
227
254
  export const RULE_HEAD_RE = new RegExp(
228
- `^##[${PY_SPACE}]+R(\\d+)[${PY_SPACE}]*[—–-]+[${PY_SPACE}]*`
229
- + `([^${PY_SPACE}].*?)[${PY_SPACE}]*$`,
255
+ `^##[${WHITESPACE}]+R(\\d+)[${WHITESPACE}]*[—–-]+[${WHITESPACE}]*`
256
+ + `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
230
257
  );
231
258
 
232
259
  /** `_web_catalog.RULES_BUDGET` — advisory; nothing blocks past it. */
233
260
  export const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
234
261
 
235
- const META_RE = new RegExp(`^\\((.+)\\)[${PY_SPACE}]*$`);
262
+ const META_RE = new RegExp(`^\\((.+)\\)[${WHITESPACE}]*$`);
236
263
 
237
264
  /** `_web_catalog._parse_rule_meta` — `{}` when the line is body rather than metadata. */
238
265
  function parseRuleMeta(line) {
239
- const m = META_RE.exec(pyStripSpace(line));
266
+ const m = META_RE.exec(stripWhitespace(line));
240
267
  if (!m) return {};
241
268
  const meta = {};
242
269
  for (const part of m[1].split('|')) {
243
270
  const at = part.indexOf(':');
244
271
  if (at < 0) continue; // `partition` with no separator — not a pair
245
- const key = pyStripSpace(part.slice(0, at)).toLowerCase().replaceAll(' ', '_');
272
+ const key = stripWhitespace(part.slice(0, at)).toLowerCase().replaceAll(' ', '_');
246
273
  if (['scope', 'source', 'trial_until'].includes(key)) {
247
- meta[key] = pyStripSpace(part.slice(at + 1));
274
+ meta[key] = stripWhitespace(part.slice(at + 1));
248
275
  }
249
276
  }
250
277
  return meta;
@@ -258,7 +285,7 @@ function parseRuleMeta(line) {
258
285
  * formatting, hand-written sections — untouched.
259
286
  */
260
287
  export function parseRules(text) {
261
- const lines = pySplitLines(text);
288
+ const lines = splitLines(text);
262
289
  const rules = [];
263
290
  const warnings = [];
264
291
  const seen = new Set();
@@ -273,7 +300,7 @@ export function parseRules(text) {
273
300
  if (seen.has(rid)) warnings.push(`duplicate rule id R${rid}`);
274
301
  seen.add(rid);
275
302
  let k = idx + 1;
276
- while (k < end && !pyStripSpace(lines[k])) k += 1;
303
+ while (k < end && !stripWhitespace(lines[k])) k += 1;
277
304
  const meta = k < end ? parseRuleMeta(lines[k]) : {};
278
305
  if (Object.keys(meta).length) k += 1;
279
306
  rules.push({
@@ -282,7 +309,7 @@ export function parseRules(text) {
282
309
  scope: Object.hasOwn(meta, 'scope') ? meta.scope : 'project',
283
310
  source: Object.hasOwn(meta, 'source') ? meta.source : '',
284
311
  trial_until: Object.hasOwn(meta, 'trial_until') ? meta.trial_until : '',
285
- body: pyStripSpace(lines.slice(k, end).join('\n')),
312
+ body: stripWhitespace(lines.slice(k, end).join('\n')),
286
313
  start: idx,
287
314
  end,
288
315
  });
@@ -310,8 +337,8 @@ export function apiRules(state) {
310
337
  return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
311
338
  // `len(text) // 4` — CODE POINTS. `String.length` counts UTF-16 units, so an emoji in a
312
339
  // rule body would put the two token estimates two apart.
313
- stats: { rules: rules.length, lines: pySplitLines(text).length,
314
- tokens: Math.floor(pyLen(text) / 4), ...RULES_BUDGET } };
340
+ stats: { rules: rules.length, lines: splitLines(text).length,
341
+ tokens: Math.floor(codePointLength(text) / 4), ...RULES_BUDGET } };
315
342
  }
316
343
 
317
344
  /** `_web_actions._rules_read`. */
@@ -325,7 +352,7 @@ function ruleBlock(rid, title, scope, source, trialUntil, body) {
325
352
  const meta = [`scope: ${scope}`];
326
353
  if (source) meta.push(`source: ${source}`);
327
354
  if (trialUntil) meta.push(`trial until: ${trialUntil}`);
328
- return `## R${rid} — ${title}\n(${meta.join(' | ')})\n${pyStripSpace(body)}\n`;
355
+ return `## R${rid} — ${title}\n(${meta.join(' | ')})\n${stripWhitespace(body)}\n`;
329
356
  }
330
357
 
331
358
  /**
@@ -337,14 +364,14 @@ function ruleBlock(rid, title, scope, source, trialUntil, body) {
337
364
  * `int()`.
338
365
  */
339
366
  function ruleFields(body) {
340
- const title = pyWords(strOr(bget(body, 'title'))).join(' ');
341
- const text = pyStripSpace(strOr(bget(body, 'body')));
367
+ const title = splitWords(strOr(bget(body, 'title'))).join(' ');
368
+ const text = stripWhitespace(strOr(bget(body, 'body')));
342
369
  if (!title) throw new Error('a rule needs a title');
343
370
  if (!text) throw new Error('a rule needs body text');
344
371
  let scope = bget(body, 'scope');
345
372
  if (scope !== 'user' && scope !== 'project') scope = 'project';
346
- const source = pyWords(strOr(bget(body, 'source'))).join(' ');
347
- const trial = pyStripSpace(strOr(bget(body, 'trial_until')));
373
+ const source = splitWords(strOr(bget(body, 'source'))).join(' ');
374
+ const trial = stripWhitespace(strOr(bget(body, 'trial_until')));
348
375
  if (trial && !/^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u.test(trial)) {
349
376
  throw new Error('trial until must be YYYY-MM-DD');
350
377
  }
@@ -360,7 +387,7 @@ function ruleFields(body) {
360
387
  */
361
388
  function ruleId(v) {
362
389
  if (typeof v === 'string') {
363
- const s = pyStripSpace(v);
390
+ const s = stripWhitespace(v);
364
391
  if (!/^[+-]?\d+$/.test(s)) throw new NotFound('rule id');
365
392
  return Number(s);
366
393
  }
@@ -384,7 +411,7 @@ function ruleId(v) {
384
411
  export function apiRulesMutate(state, body) {
385
412
  const op = bget(body, 'op');
386
413
  if (op !== 'add' && op !== 'update' && op !== 'delete') {
387
- throw new NotFound(`rules op ${pyRepr(op)}`);
414
+ throw new NotFound(`rules op ${formatRepr(op)}`);
388
415
  }
389
416
  const [p, original] = rulesRead(state);
390
417
  let text = original;
@@ -404,17 +431,17 @@ export function apiRulesMutate(state, body) {
404
431
  rid = ruleId(bget(body, 'id'));
405
432
  const target = rules.find((r) => r.id === rid);
406
433
  if (target === undefined) throw new NotFound(`rule R${rid}`);
407
- const lines = pySplitLines(text);
434
+ const lines = splitLines(text);
408
435
  if (op === 'update') {
409
436
  const [title, rtext, scope, source, trial] = ruleFields(body);
410
437
  const block = ruleBlock(rid, title, scope, source, trial, rtext);
411
438
  lines.splice(target.start, target.end - target.start,
412
- ...pySplitLines(block.replace(/\n+$/, '')));
439
+ ...splitLines(block.replace(/\n+$/, '')));
413
440
  } else {
414
441
  // delete — also swallow ONE preceding blank separator line, or repeated deletes leave
415
442
  // a growing run of blank lines in the user's file.
416
443
  let start = target.start;
417
- if (start > 0 && !pyStripSpace(lines[start - 1])) start -= 1;
444
+ if (start > 0 && !stripWhitespace(lines[start - 1])) start -= 1;
418
445
  lines.splice(start, target.end - start);
419
446
  }
420
447
  next = `${lines.join('\n').replace(/\n+$/, '')}\n`;
@@ -447,14 +474,14 @@ export function apiRulesPromote(state, body) {
447
474
  const res = apiRulesMutate(state, {
448
475
  op: 'add',
449
476
  fingerprint: bget(body, 'fingerprint', ''),
450
- title: pyTruthy(bget(body, 'title')) ? bget(body, 'title') : fmName,
451
- body: pyTruthy(bget(body, 'body')) ? bget(body, 'body')
452
- : (pyStripSpace(memBody) || fmDesc),
477
+ title: isTruthy(bget(body, 'title')) ? bget(body, 'title') : fmName,
478
+ body: isTruthy(bget(body, 'body')) ? bget(body, 'body')
479
+ : (stripWhitespace(memBody) || fmDesc),
453
480
  scope: bget(body, 'scope'),
454
481
  source: `memory ${name}, promoted ${today}`,
455
482
  trial_until: trial,
456
483
  });
457
- if (pyTruthy(res.ok) && pyTruthy(bget(body, 'delete_memory'))) {
484
+ if (isTruthy(res.ok) && isTruthy(bget(body, 'delete_memory'))) {
458
485
  apiMemoryDelete(state, name);
459
486
  res.deleted_memory = name;
460
487
  }
@@ -520,7 +547,7 @@ export function apiMemoryDelete(state, name) {
520
547
  */
521
548
  export function apiExcludesMutate(state, body) {
522
549
  const action = bget(body, 'action');
523
- const p = pyStripSpace(strOr(bget(body, 'path')));
550
+ const p = stripWhitespace(strOr(bget(body, 'path')));
524
551
  if ((action !== 'add' && action !== 'remove') || !p) {
525
552
  return { ok: false, path: p,
526
553
  messages: ['body must be {action: add|remove, path: <folder>}'] };
@@ -585,7 +612,7 @@ export function mcpTargetPaths() {
585
612
 
586
613
  export function apiMcpToggle(state, body) {
587
614
  const name = strOr(bget(body, 'name'));
588
- const want = pyTruthy(bget(body, 'enabled'));
615
+ const want = isTruthy(bget(body, 'enabled'));
589
616
  const pathArg = strOr(bget(body, 'path'));
590
617
  const known = mcpTargetPaths();
591
618
  const hit = known.get(pathArg);
@@ -680,7 +707,7 @@ export function buildOverride(state, body) {
680
707
  * `.geneseed-emit`, so the EXPECTED render uses the install's own host dialect.
681
708
  *
682
709
  * Returns the HOST rather than a function, because `emitGlobalInto(host, …)` is how the twin
683
- * of `build.HOSTS[host]["emit_global"]` is already spelt (`js/diff.mjs` calls it that way).
710
+ * of `build.HOSTS[host]["emit_global"]` is already spelt (`js/inspect/diff.mjs` calls it that way).
684
711
  * An unknown or missing marker falls back to OpenCode, exactly as the reference's two
685
712
  * `.get(..., default)` calls do.
686
713
  */
@@ -721,10 +748,10 @@ export function apiRestore(state, files) {
721
748
  const restored = [];
722
749
  const deleted = [];
723
750
  const errors = [];
724
- const target = pyResolve(state.target);
751
+ const target = resolvePath(state.target);
725
752
  const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
726
753
  try {
727
- const expected = pyResolve(path.join(tmp, 'expected'));
754
+ const expected = resolvePath(path.join(tmp, 'expected'));
728
755
  withStdoutSwallowed(() => emitGlobalInto(globalEmitHostFor(state.emit), {
729
756
  theme: state.theme,
730
757
  out: path.join(tmp, 'bundle'),
@@ -740,10 +767,10 @@ export function apiRestore(state, files) {
740
767
  // read-side hole `diffCollect` had (and the reason the panel offered the file at all).
741
768
  doctrines: doctrinesForBuild(target),
742
769
  }));
743
- for (const raw of (pyTruthy(files) ? files : [])) {
744
- const rel = pyStripSpace(pyStr(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
745
- const dst = pyResolve(path.join(target, rel));
746
- const src = pyResolve(path.join(expected, rel));
770
+ for (const raw of (isTruthy(files) ? files : [])) {
771
+ const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
772
+ const dst = resolvePath(path.join(target, rel));
773
+ const src = resolvePath(path.join(expected, rel));
747
774
  if (!rel || !within(dst, target) || !within(src, expected)) {
748
775
  errors.push(`${rel}: outside the deployed tree`);
749
776
  } else if (isFile(src)) {
@@ -789,7 +816,7 @@ const EMIT_FOR = new Map([
789
816
  * `{cmd: [...]}` IS A RETURN TYPE, NOT A RESPONSE. `_post_routes` hands it straight to the job
790
817
  * runner in the same process; it never reaches the wire. That is what collapsed P6f's
791
818
  * "argv-in-a-response-body" question — see this file's header — and why the twin's head is
792
- * `process.execPath` + `bin/geneseed.mjs` rather than a Python argv it would then have to
819
+ * `process.execPath` + `bin/build-driver.mjs` rather than a Python argv it would then have to
793
820
  * pretend to run.
794
821
  */
795
822
  export function apiInstallCmd(state, body) {
@@ -825,9 +852,15 @@ export function apiInstallCmd(state, body) {
825
852
  // `null` elided the flag, and the generator's config fallback then narrowed the install and
826
853
  // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
827
854
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
855
+ // The per-rule axis needs NO `doctrinesForBuild`-style rescue: the marker it reads is only
856
+ // written when something is excluded, so its absence is a statement ("nothing") rather than
857
+ // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
858
+ // own answer, and a pre-marker install answers the empty list — which is what it has.
859
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
828
860
  const out = scope === 'global' ? null : String(root);
829
- const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines);
830
- return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
861
+ const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
862
+ PACK_ORDER, excludeRules);
863
+ return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
831
864
  }
832
865
 
833
866
  /**
@@ -848,7 +881,7 @@ export function apiInstallCmd(state, body) {
848
881
  * and this endpoint moves and deletes whole trees, so the root is never built from raw body
849
882
  * input. An unknown pair is a `NotFound`, which the shell answers 404.
850
883
  *
851
- * THE ENGINE IS `js/uninstall.mjs`'s, and P6i is what brought it. `installDeactivate` /
884
+ * THE ENGINE IS `js/maintain/uninstall.mjs`'s, and P6i is what brought it. `installDeactivate` /
852
885
  * `installReactivate` are the reversible siblings of `installUninstall` — the same owned-file
853
886
  * walk and ancestor prune with `move` where the reversal has `unlink` — which is why they live
854
887
  * beside it rather than in the web tree. `remove` is `installUninstall`, which crossed in P5h
@@ -875,26 +908,26 @@ export function apiInstallToggle(state, body) {
875
908
  // is validated IN THE ENGINE — an unknown value falls back to `keep`, never a surprise
876
909
  // delete, which is why a bogus body value is passed through rather than rejected here.
877
910
  const mem = bget(body, 'memory');
878
- res = installUninstall(root, host, scope, pyTruthy(mem) ? mem : 'keep');
911
+ res = installUninstall(root, host, scope, isTruthy(mem) ? mem : 'keep');
879
912
  } else {
880
913
  // `{action!r}` — Python's repr, SINGLE-quoted. `JSON.stringify` would double-quote it
881
914
  // inside a JSON string body and the two sides would differ on every typo.
882
- res = { ok: false, error: `unknown action ${pyRepr(action)}` };
915
+ res = { ok: false, error: `unknown action ${formatRepr(action)}` };
883
916
  }
884
917
  state.refresh();
885
918
  return res;
886
919
  }
887
920
 
888
921
  export function apiDeployCmd(state, body) {
889
- const host = pyStripSpace(strOr(bget(body, 'host')));
922
+ const host = stripWhitespace(strOr(bget(body, 'host')));
890
923
  if (!HOSTS.some((h) => h.host === host)) {
891
924
  return { error: `unknown host: ${host || '(none)'}` };
892
925
  }
893
- const raw = pyStripSpace(strOr(bget(body, 'path')));
926
+ const raw = stripWhitespace(strOr(bget(body, 'path')));
894
927
  if (!raw) return { error: 'no folder given' };
895
928
  let root;
896
929
  try {
897
- root = pyResolve(expanduser(raw));
930
+ root = resolvePath(expanduser(raw));
898
931
  } catch {
899
932
  return { error: `bad path: ${raw}` };
900
933
  }
@@ -907,7 +940,7 @@ export function apiDeployCmd(state, body) {
907
940
  const cfgdirs = new Set();
908
941
  for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir, copilotConfigDir]) {
909
942
  try {
910
- cfgdirs.add(pyResolve(fn()));
943
+ cfgdirs.add(resolvePath(fn()));
911
944
  } catch { /* the reference's bare `except: pass` */ }
912
945
  }
913
946
  if (cfgdirs.has(root)) {
@@ -929,7 +962,9 @@ export function apiDeployCmd(state, body) {
929
962
  // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
930
963
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
931
964
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
965
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
932
966
  // project-scope emit name == host name (opencode / claude / bob / copilot)
933
- const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines);
934
- return { cmd: [process.execPath, path.join(ROOT, 'bin', 'geneseed.mjs'), ...argv] };
967
+ const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
968
+ PACK_ORDER, excludeRules);
969
+ return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
935
970
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `rituals/_web_activity.py` — the live-activity surface, and the first web endpoint that
2
+ * The live-activity surface — the one web endpoint that
3
3
  * DELETES.
4
4
  *
5
5
  * It reads the per-session JSON files the `geneseed-activity` OpenCode plugin writes into
@@ -7,11 +7,11 @@
7
7
  * directory self-cleans), and returns what is left. Writer and reader share only that
8
8
  * directory: no RPC, crash-isolated, and cross-tool by construction.
9
9
  *
10
- * ONLY THE GET HALF CROSSES IN P6e. `/api/activity` answers both verbs and
11
- * `api_activity_toggle` is a WRITE, so the path stays in `NOT_PORTED_POST` and the
12
- * partition test in `tests/test_web_server.py` keeps that honest. The consequence for the
13
- * gate is that the on/off flag can be READ but never flipped, which is why its two arms are
14
- * two cells rather than two requests in one.
10
+ * BOTH VERBS CROSS. `/api/activity` answers a GET through `apiActivity` and a POST through
11
+ * `apiActivityToggle` at the bottom of this file; `NOT_PORTED_POST` is empty and still
12
+ * declared. It is also the one POST route whose result is sent at 200 whatever `ok` says,
13
+ * which is why `POST_ROUTES` in `js/web/routes.mjs` carries a 409 COLUMN rather than a rule —
14
+ * and giving this row the 409 treatment is mutation M22.
15
15
  *
16
16
  * THE PLAN GUESSED `tasklist` AND THE SOURCE SAYS OTHERWISE. `_win_pid_alive` shells
17
17
  * nothing: it goes through `ctypes` to `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` and
@@ -33,16 +33,17 @@
33
33
  * a third party and carry `cost` — a float. Python's `json.loads` types `1.0` a float and
34
34
  * `json.dumps` writes it back `1.0`; `JSON.parse` gives a bare `1` that `bareInts` then
35
35
  * renders `1`. `parseJson`'s wrapper is the only thing that survives the round trip, and
36
- * `s-live`'s `"cost": 1.0` is the cell that says so. Its companion is `pyTruthy`: an
36
+ * `s-live`'s `"cost": 1.0` is the cell that says so. Its companion is `isTruthy`: an
37
37
  * `entry.get("cost") or 0` over a wrapper object is TRUE for a wrapped zero, where Python's
38
38
  * `0.0 or 0` is the int `0` — so `s-older`'s `"cost": 0.0` must come back `0` and not `0.0`.
39
39
  */
40
40
  import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
41
41
  import path from 'node:path';
42
42
 
43
- import {
44
- normcase, parseJson, pyInt, pyStripSpace, pyTruthy, readText, writeText,
45
- } from '../lib/fs.mjs';
43
+ import { readText, writeText } from '../lib/fs.mjs';
44
+ import { parseJson, isTruthy } from '../lib/json.mjs';
45
+ import { normcase } from '../lib/paths.mjs';
46
+ import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
46
47
  import { NotFound } from './api.mjs';
47
48
 
48
49
  /**
@@ -86,7 +87,7 @@ export function activityEnabled(state) {
86
87
  } catch {
87
88
  return true;
88
89
  }
89
- return !['off', '0', 'false', 'no'].includes(pyStripSpace(raw).toLowerCase());
90
+ return !['off', '0', 'false', 'no'].includes(stripWhitespace(raw).toLowerCase());
90
91
  }
91
92
 
92
93
  /**
@@ -99,10 +100,10 @@ export function activityEnabled(state) {
99
100
  * what `int({})` and `int([])` raising looks like from here. `int(1.9)` truncates and
100
101
  * `int(True)` is 1, both of which a hand-edited snapshot can produce.
101
102
  */
102
- function pyIntOf(raw) {
103
+ function toIntOr(raw) {
103
104
  if (raw === true) return 1;
104
105
  if (raw === false) return 0;
105
- if (typeof raw === 'string') return pyInt(pyStripSpace(raw));
106
+ if (typeof raw === 'string') return parseIntStrict(stripWhitespace(raw));
106
107
  const n = (raw !== null && typeof raw === 'object' && !Array.isArray(raw))
107
108
  ? raw.valueOf() : raw;
108
109
  if (typeof n !== 'number' || !Number.isFinite(n)) return null;
@@ -111,7 +112,7 @@ function pyIntOf(raw) {
111
112
 
112
113
  /** `_pid_alive` — best-effort liveness for the writer process. See this file's header. */
113
114
  export function pidAlive(raw) {
114
- const pid = pyIntOf(raw);
115
+ const pid = toIntOr(raw);
115
116
  if (pid === null || pid <= 0) return false;
116
117
  try {
117
118
  process.kill(pid, 0);
@@ -131,7 +132,7 @@ export function pidAlive(raw) {
131
132
  */
132
133
  function normalizeEntry(entry, stem) {
133
134
  const g = (k) => (Object.hasOwn(entry, k) ? entry[k] : null);
134
- const or = (k, dflt) => (pyTruthy(g(k)) ? g(k) : dflt);
135
+ const or = (k, dflt) => (isTruthy(g(k)) ? g(k) : dflt);
135
136
  return {
136
137
  session_id: or('session_id', stem),
137
138
  agent: g('agent'),
@@ -164,7 +165,7 @@ function readEntry(p) {
164
165
 
165
166
  /** `_is_live` — the writer's pid is alive AND the snapshot is not stale. */
166
167
  function isLive(entry, now) {
167
- const updated = pyTruthy(Object.hasOwn(entry, 'updated_at') ? entry.updated_at : null)
168
+ const updated = isTruthy(Object.hasOwn(entry, 'updated_at') ? entry.updated_at : null)
168
169
  ? entry.updated_at : 0;
169
170
  return (now - Number(updated)) <= ACTIVITY_STALE_SECONDS && pidAlive(
170
171
  Object.hasOwn(entry, 'pid') ? entry.pid : null);
@@ -236,7 +237,7 @@ export function apiActivityDetail(state, sid) {
236
237
  let timeline = [];
237
238
  let full = {};
238
239
  const det = readEntry(path.join(d, `${stem}.detail.json`));
239
- if (pyTruthy(det)) {
240
+ if (isTruthy(det)) {
240
241
  const tl = Object.hasOwn(det, 'timeline') ? det.timeline : null;
241
242
  timeline = Array.isArray(tl) ? tl : [];
242
243
  full = det;
@@ -245,14 +246,14 @@ export function apiActivityDetail(state, sid) {
245
246
  // The detail file carries the uncapped lists; fall back to the snapshot's capped ones.
246
247
  // Assigning over an existing key keeps its POSITION in both languages, which is what lets
247
248
  // the response stay byte-comparable.
248
- session.files = pyTruthy(fg('files')) ? fg('files') : session.files;
249
- session.todos = pyTruthy(fg('todos')) ? fg('todos') : session.todos;
249
+ session.files = isTruthy(fg('files')) ? fg('files') : session.files;
250
+ session.todos = isTruthy(fg('todos')) ? fg('todos') : session.todos;
250
251
  // The compact transcript: an ordered list of {role, text, t} turns. Empty falls back to
251
252
  // the title as the opening user turn, because OpenCode derives the title from the first
252
253
  // prompt.
253
254
  const conv = fg('conversation');
254
255
  let conversation = Array.isArray(conv) ? conv : [];
255
- if (conversation.length === 0 && pyTruthy(session.title)) {
256
+ if (conversation.length === 0 && isTruthy(session.title)) {
256
257
  conversation = [{ role: 'user', text: session.title }];
257
258
  }
258
259
  return { session, timeline, conversation };
@@ -272,7 +273,7 @@ export function apiActivityDetail(state, sid) {
272
273
  */
273
274
  export function apiActivityToggle(state, body) {
274
275
  const raw = (body && Object.hasOwn(body, 'enabled')) ? body.enabled : true;
275
- const enabled = pyTruthy(raw);
276
+ const enabled = isTruthy(raw);
276
277
  const p = activityFlag(state);
277
278
  try {
278
279
  mkdirSync(path.dirname(p), { recursive: true });