geneseed 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
@@ -42,35 +42,15 @@ import {
42
42
  } from 'node:fs';
43
43
  import path from 'node:path';
44
44
  import os from 'node:os';
45
- import { printErr, readText, writeText } from '../lib/fs.mjs';
45
+ import { readText, writeText, isOsError } from '../lib/fs.mjs';
46
46
  import {
47
47
  jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
48
- indexOfDeepEqual,
48
+ indexOfDeepEqual, get, has, isDict,
49
49
  } from '../lib/json.mjs';
50
+ import { expanduser } from './hosts.mjs';
51
+ import { stripWhitespace, stripWhitespaceEnd } from '../lib/text.mjs';
50
52
 
51
- export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
52
-
53
- /** `dict.get(key)` with Python's semantics — OWN properties only.
54
- *
55
- * `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
56
- * member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
57
- * The same hazard `js/hosts/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
58
- function get(obj, key) {
59
- return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
60
- }
61
-
62
- function has(obj, key) {
63
- return Object.prototype.hasOwnProperty.call(obj, key);
64
- }
65
-
66
- /** `except OSError` — an fs failure carries a `code`; anything else is a bug, so rethrow. */
67
- function isOsError(e) {
68
- return Boolean(e) && typeof e.code === 'string';
69
- }
70
-
71
- function isDict(v) {
72
- return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
73
- }
53
+ const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
74
54
 
75
55
  /**
76
56
  * Is the `process` doctrine pack active for the install being emitted?
@@ -114,7 +94,7 @@ const consentRuleOn = (d, excluded = []) =>
114
94
  * Law IV's territory — an always-on invariant — not the process pack's, and the guard
115
95
  * plugin's own force-push arm is unconditional for the same reason.
116
96
  */
117
- export function defaultPermission(doctrines = null, excluded = []) {
97
+ function defaultPermission(doctrines = null, excluded = []) {
118
98
  const bash = { 'rm -rf *': 'ask' };
119
99
  if (consentRuleOn(doctrines, excluded)) {
120
100
  bash['git commit*'] = 'ask';
@@ -205,22 +185,10 @@ function reconcileOpencodePermission(perm, want) {
205
185
  return [changed, stranded, null];
206
186
  }
207
187
 
208
- /**
209
- * `Path.with_suffix(suffix)` for the one shape this module needs.
210
- *
211
- * `PurePath.suffix` and `path.extname` disagree on leading-dot and trailing-dot names
212
- * (`.bashrc`, `x.`); the only caller passes `…/opencode.json`, and the emit's own callers
213
- * pass a literal. Spelled off `path.extname` with the empty-extension case handled so a
214
- * future caller with a suffix-less path appends rather than truncates.
215
- */
216
- function withSuffix(p, suffix) {
217
- const ext = path.extname(p);
218
- return (ext ? p.slice(0, p.length - ext.length) : p) + suffix;
219
- }
220
-
221
- /** `_build_settings._opencode_target`. */
188
+ /** `_build_settings._opencode_target`. Only caller of a `.json` -> `.jsonc` suffix swap. */
222
189
  export function opencodeTarget(jsonPath) {
223
- const jsonc = withSuffix(jsonPath, '.jsonc');
190
+ const ext = path.extname(jsonPath);
191
+ const jsonc = (ext ? jsonPath.slice(0, jsonPath.length - ext.length) : jsonPath) + '.jsonc';
224
192
  return existsSync(jsonc) ? jsonc : jsonPath;
225
193
  }
226
194
 
@@ -305,7 +273,7 @@ export function readJsonc(text) {
305
273
  * already has a `permission` key to add one is not saying so. Any other truthy value is read as
306
274
  * `'add'`, which is what the boolean callers this replaced meant.
307
275
  */
308
- export function warnCommentedJsonc(target, agentPath, permission,
276
+ function warnCommentedJsonc(target, agentPath, permission,
309
277
  includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
310
278
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
311
279
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
@@ -354,7 +322,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
354
322
  raw = readText(target);
355
323
  } catch (e) {
356
324
  if (!isOsError(e)) throw e;
357
- process.stderr.write(`[geneseed] WARN: could not read ${target} (${asOsError(e)}) `
325
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${e.message}) `
358
326
  + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
359
327
  + "hand once it's readable again.\n");
360
328
  return target;
@@ -433,25 +401,18 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
433
401
  atomicWriteJson(target, config);
434
402
  } catch (e) {
435
403
  if (!isOsError(e)) throw e;
436
- process.stderr.write(`[geneseed] WARN: could not write ${target} (${asOsError(e)}) — `
404
+ process.stderr.write(`[geneseed] WARN: could not write ${target} (${e.message}) — `
437
405
  + `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
438
406
  + 'to its "instructions" array by hand.\n');
439
407
  }
440
408
  return target;
441
409
  }
442
410
 
443
- /**
444
- * `str(OSError)` — what Python interpolates for `except OSError as e: f"({e})"`.
445
- *
446
- * Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
447
- * `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the
448
- * parity gate does not drive a cell through a real fs failure — it exercises the branch
449
- * with an injectable failure instead and compares everything except the OS's own wording.
450
- * Named here so the gap is a decision rather than a surprise at GA.
451
- */
452
- function asOsError(e) {
453
- return e.message;
454
- }
411
+ // `except OSError as e: f"({e})"` is inlined as `e.message` at each call site above and below.
412
+ // Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
413
+ // `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the parity
414
+ // gate does not drive a cell through a real fs failure — it exercises the branch with an
415
+ // injectable failure instead and compares everything except the OS's own wording.
455
416
 
456
417
  // ---- the hook shim ----------------------------------------------------------------
457
418
  // `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
@@ -499,32 +460,18 @@ export function shimRel(platform = process.platform) {
499
460
  return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
500
461
  }
501
462
 
502
- /** `_build_settings._shim_home`. */
503
- export function shimHome() {
504
- const env = process.env.GENESEED_HOME;
505
- return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
506
- }
507
-
508
463
  /**
509
- * `Path(x).expanduser()` — private twin of `js/hosts/hosts.mjs`'s `expanduser`, same guard.
464
+ * `_build_settings._shim_home`.
510
465
  *
511
- * `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
512
- * has the identical failure mode (a hook shim written under a literal `~user` directory) and
513
- * gets the identical fix — refuse rather than pass through. See that file's docblock for why
514
- * the refusal lives IN the primitive rather than at `shimHome`'s caller.
466
+ * `expanduser` is `js/hosts/hosts.mjs`'s — GENESEED_HOME is user-set, same as any config-dir
467
+ * env var there, so a `~user` value here has the identical failure mode (a hook shim written
468
+ * under a literal `~user` directory) and gets the identical refusal. This module used to carry
469
+ * a private "same guard" twin; the two had drifted (this one's refusal message dropped the
470
+ * parenthetical explaining WHY), which is exactly the kind of divergence one owner prevents.
515
471
  */
516
- function expanduser(p) {
517
- if (p === '~') return os.homedir();
518
- if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
519
- if (p.startsWith('~')) {
520
- const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
521
- + 'absolute path, or "~" / "~/…" for your own home directory';
522
- printErr(`${msg}\n`);
523
- const e = new Error(msg);
524
- e.exitCode = 1;
525
- throw e;
526
- }
527
- return p;
472
+ export function shimHome() {
473
+ const env = process.env.GENESEED_HOME;
474
+ return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
528
475
  }
529
476
 
530
477
  /** `_build_settings._hook_shim_path`. */
@@ -918,7 +865,7 @@ export function wireClaudeExcludes(p, excludes) {
918
865
  atomicWriteJson(p, config);
919
866
  } catch (e) {
920
867
  if (!isOsError(e)) throw e;
921
- process.stderr.write(`[geneseed] WARN: could not write ${p} (${asOsError(e)}) — `
868
+ process.stderr.write(`[geneseed] WARN: could not write ${p} (${e.message}) — `
922
869
  + 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
923
870
  + `${jsonDumpsCompact(added)}\n`);
924
871
  return [];
@@ -973,7 +920,7 @@ export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
973
920
  // 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
974
921
  // call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
975
922
  // exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
976
- export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
923
+ const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
977
924
 
978
925
  /**
979
926
  * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
@@ -1028,7 +975,7 @@ export function autostartStale(text, root) {
1028
975
  }
1029
976
 
1030
977
  /** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
1031
- export function settingsHookGroups(loaded) {
978
+ function settingsHookGroups(loaded) {
1032
979
  const hooks = get(loaded, 'hooks');
1033
980
  if (!isDict(hooks)) return [];
1034
981
  const out = [];
@@ -1068,7 +1015,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1068
1015
  [loaded] = readJsonc(readText(p));
1069
1016
  } catch (e) {
1070
1017
  if (!isOsError(e)) throw e;
1071
- problems.push(`${p}: could not read the file (${asOsError(e)})`);
1018
+ problems.push(`${p}: could not read the file (${e.message})`);
1072
1019
  return flush();
1073
1020
  }
1074
1021
  if (!isDict(loaded)) {
@@ -1128,12 +1075,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1128
1075
  return flush();
1129
1076
  }
1130
1077
 
1131
- const BLOCK_BEGIN = '<!-- BEGIN {id} -->';
1132
- const BLOCK_END = '<!-- END {id} -->';
1133
-
1134
- function delimiters(blockId) {
1135
- return [BLOCK_BEGIN.replace('{id}', blockId), BLOCK_END.replace('{id}', blockId)];
1136
- }
1078
+ const delimiters = (blockId) => [`<!-- BEGIN ${blockId} -->`, `<!-- END ${blockId} -->`];
1137
1079
 
1138
1080
  /**
1139
1081
  * `_build_settings._managed_block_write` — 'created' | 'updated' | 'merged'.
@@ -1141,7 +1083,7 @@ function delimiters(blockId) {
1141
1083
  */
1142
1084
  export function managedBlockWrite(p, content, blockId = 'GENESEED') {
1143
1085
  const [begin, end] = delimiters(blockId);
1144
- const block = `${begin}\n${stripEnd(content)}\n${end}\n`;
1086
+ const block = `${begin}\n${stripWhitespaceEnd(content)}\n${end}\n`;
1145
1087
  if (!existsSync(p)) {
1146
1088
  mkdirSync(path.dirname(p), { recursive: true });
1147
1089
  writeText(p, block);
@@ -1171,7 +1113,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
1171
1113
  if (!existing.includes(begin) || !existing.includes(end)) return;
1172
1114
  const pre = existing.split(begin)[0];
1173
1115
  const post = existing.slice(existing.indexOf(end) + end.length);
1174
- const rest = stripEnds(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1116
+ const rest = stripWhitespace(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
1175
1117
  if (rest) writeText(p, `${rest}\n`);
1176
1118
  else unlinkSync(p);
1177
1119
  }
@@ -1186,25 +1128,6 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
1186
1128
  return stripNewlines(inner.slice(0, inner.indexOf(end)));
1187
1129
  }
1188
1130
 
1189
- // `str.strip()` and friends. JS's `trim()` is NOT `strip()`, and the difference runs both
1190
- // ways: `trim()` eats U+FEFF, which Python does not strip, and Python strips U+0085 and
1191
- // U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
1192
- // Geneseed shares with the user, so the set is spelled out rather than approximated — and
1193
- // spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
1194
- const WHITESPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
1195
- + '\u2028\u2029\u202f\u205f\u3000]';
1196
-
1197
- const RSTRIP_RE = new RegExp(`${WHITESPACE}+$`);
1198
- const LSTRIP_RE = new RegExp(`^${WHITESPACE}+`);
1199
-
1200
- function stripEnd(s) {
1201
- return s.replace(RSTRIP_RE, '');
1202
- }
1203
-
1204
- function stripEnds(s) {
1205
- return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
1206
- }
1207
-
1208
1131
  function lstripNewlines(s) {
1209
1132
  return s.replace(/^\n+/, '');
1210
1133
  }
@@ -411,7 +411,8 @@ export function constitutionProblems() {
411
411
  try { text = readText(path.join(dir, `${pack}.md`)); } catch { continue; }
412
412
  for (const [, key] of text.matchAll(DOC_TOKEN_RE)) wanted.add(key);
413
413
  }
414
- const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'].map((r) => `LEX_${r}`);
414
+ const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI']
415
+ .map((r) => `LEX_${r}`);
415
416
  // `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
416
417
  // stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
417
418
  const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
@@ -443,11 +444,11 @@ export function constitutionProblems() {
443
444
  const stale = lex.filter((k) => !lexWant.includes(k));
444
445
  const missing = lexWant.filter((k) => !lex.includes(k));
445
446
  for (const k of stale) {
446
- problems.push(`[authoring] ${name} still defines {${k}} — the invariants are I..IX and `
447
- + 'nothing renders this key, so it is a title for a rule that no longer exists');
447
+ problems.push(`[authoring] ${name} still defines {${k}} — no law heading carries this `
448
+ + 'numeral and nothing renders this key, so it is a title for a rule that no longer exists');
448
449
  }
449
450
  for (const k of missing) {
450
- problems.push(`[authoring] ${name} has no {${k}} — one of the nine invariants would `
451
+ problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
451
452
  + 'render with an empty title');
452
453
  }
453
454
  // M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
@@ -217,7 +217,7 @@ export function renderedProblems(bundle) {
217
217
  // The OpenCode emits de-link AGENT.md's per-row table entries after rendering, so the
218
218
  // bundle on disk legitimately differs from `renderAll`'s output for this one file.
219
219
  const text = host === 'opencode' && outRel === 'AGENT.md'
220
- ? stripCapabilityLinks(cfg, rendered) : rendered;
220
+ ? stripCapabilityLinks(rendered) : rendered;
221
221
  if (readText(dest) !== text) {
222
222
  problems.push(`[rendered] ${name}/${outRel} stale (differs from a fresh render) `
223
223
  + '— rebuild');
@@ -47,11 +47,9 @@ import {
47
47
  readJsonMaybe, readMaybe, themeOfDir,
48
48
  } from '../hosts/installs.mjs';
49
49
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
50
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
+ import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
51
51
  import { toPlatformPath } from '../lib/paths.mjs';
52
52
 
53
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
54
-
55
53
  /**
56
54
  * `_harness_core._T0` — this process's start, in epoch seconds.
57
55
  *
@@ -58,6 +58,17 @@ export function themesToCheck(theme, allThemes, detected, available) {
58
58
  return [...available].sort();
59
59
  }
60
60
 
61
+ /**
62
+ * `swallowStdout` and `captureBoth` below are the same operation over a different stream
63
+ * list: replace each stream's `write` with a no-op for the duration of `fn`, then restore
64
+ * every original in `finally` regardless of how `fn` exits.
65
+ */
66
+ function withSwallowed(streams, fn) {
67
+ const saved = streams.map((s) => [s, s.write.bind(s)]);
68
+ for (const [s] of saved) s.write = () => true;
69
+ try { return fn(); } finally { for (const [s, w] of saved) s.write = w; }
70
+ }
71
+
61
72
  /**
62
73
  * `contextlib.redirect_stdout(io.StringIO())` — swallow an emit's log, and ONLY stdout.
63
74
  *
@@ -68,44 +79,60 @@ export function themesToCheck(theme, allThemes, detected, available) {
68
79
  * installed at call time, so it translates into this buffer and the bytes never leave.
69
80
  */
70
81
  function swallowStdout(fn) {
71
- const real = process.stdout.write.bind(process.stdout);
72
- process.stdout.write = () => true;
73
- try { return fn(); } finally { process.stdout.write = real; }
82
+ return withSwallowed([process.stdout], fn);
74
83
  }
75
84
 
76
85
  /**
77
- * `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
78
- * footprints.
79
- *
80
- * The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
81
- * were checked and the global layout never was. The two footprints produce genuinely
82
- * different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
83
- * ships a standalone laws file the full emit does not write — so checking one leaves the
84
- * other's links and tokens unvalidated, and lean being the default made that the shape most
85
- * installs actually run.
86
+ * The loop `globalEmitProblems`/`claudeBobEmitProblems` each wrote out: render one item into
87
+ * its own fresh temp dir with stdout swallowed, then `checkBuild` the result — a build
88
+ * failure and a check finding both prefixed with the SAME per-cell label. `makeCell(tmp,
89
+ * item)` returns `{ label, dir, run, vendored }`; `vendored` is passed straight through to
90
+ * `checkBuild`, which defaults it exactly as an omitted third argument always did.
86
91
  */
87
- export function globalEmitProblems(themeName) {
92
+ function emitProblemsOver(items, makeCell) {
88
93
  const problems = [];
89
- for (const footprint of ['lean', 'full']) {
94
+ for (const item of items) {
90
95
  withTempDir((tmp) => {
91
- const cfgDir = path.join(tmp, 'cfg');
96
+ const { label, dir, run, vendored } = makeCell(tmp, item);
92
97
  try {
93
- swallowStdout(() => emitGlobalInto('opencode', {
94
- theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
95
- }));
98
+ swallowStdout(run);
96
99
  } catch (e) {
97
100
  if (e && e.exitCode !== undefined) {
98
- problems.push(`[${themeName} global/${footprint}] build failed`);
101
+ problems.push(`[${label}] build failed`);
99
102
  return;
100
103
  }
101
104
  throw e;
102
105
  }
103
- problems.push(...checkBuild(`${themeName} global/${footprint}`, cfgDir));
106
+ problems.push(...checkBuild(label, dir, vendored));
104
107
  });
105
108
  }
106
109
  return problems;
107
110
  }
108
111
 
112
+ /**
113
+ * `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
114
+ * footprints.
115
+ *
116
+ * The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
117
+ * were checked and the global layout never was. The two footprints produce genuinely
118
+ * different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
119
+ * ships a standalone laws file the full emit does not write — so checking one leaves the
120
+ * other's links and tokens unvalidated, and lean being the default made that the shape most
121
+ * installs actually run.
122
+ */
123
+ export function globalEmitProblems(themeName) {
124
+ return emitProblemsOver(['lean', 'full'], (tmp, footprint) => {
125
+ const cfgDir = path.join(tmp, 'cfg');
126
+ return {
127
+ label: `${themeName} global/${footprint}`,
128
+ dir: cfgDir,
129
+ run: () => emitGlobalInto('opencode', {
130
+ theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
131
+ }),
132
+ };
133
+ });
134
+ }
135
+
109
136
  /**
110
137
  * `_harness_build._claude_bob_emit_problems` — validate the claude/bob/copilot PER-REPO
111
138
  * emits, which were never checked before.
@@ -116,25 +143,17 @@ export function globalEmitProblems(themeName) {
116
143
  * skills one level deeper than a bundle does.
117
144
  */
118
145
  export function claudeBobEmitProblems(themeName) {
119
- const problems = [];
120
- for (const label of ['claude', 'bob', 'copilot']) {
121
- withTempDir((tmp) => {
122
- const root = path.join(tmp, 'root');
123
- try {
124
- swallowStdout(() => emitProjectInto(label, {
125
- theme: themeName, out: path.join(root, 'bundle'), root,
126
- }));
127
- } catch (e) {
128
- if (e && e.exitCode !== undefined) {
129
- problems.push(`[${themeName} ${label}] build failed`);
130
- return;
131
- }
132
- throw e;
133
- }
134
- problems.push(...checkBuild(`${themeName} ${label}`, root, validateIsVendored));
135
- });
136
- }
137
- return problems;
146
+ return emitProblemsOver(['claude', 'bob', 'copilot'], (tmp, label) => {
147
+ const root = path.join(tmp, 'root');
148
+ return {
149
+ label: `${themeName} ${label}`,
150
+ dir: root,
151
+ run: () => emitProjectInto(label, {
152
+ theme: themeName, out: path.join(root, 'bundle'), root,
153
+ }),
154
+ vendored: validateIsVendored,
155
+ };
156
+ });
138
157
  }
139
158
 
140
159
  /**
@@ -219,11 +238,7 @@ export function doctorCollect({
219
238
 
220
239
  /** `run(..., capture_output=True)` — both streams into the void, the return code out. */
221
240
  function captureBoth(fn) {
222
- const out = process.stdout.write.bind(process.stdout);
223
- const err = process.stderr.write.bind(process.stderr);
224
- process.stdout.write = () => true;
225
- process.stderr.write = () => true;
226
- try { return fn(); } finally { process.stdout.write = out; process.stderr.write = err; }
241
+ return withSwallowed([process.stdout, process.stderr], fn);
227
242
  }
228
243
 
229
244
  /**
@@ -18,24 +18,18 @@
18
18
  * Every path a hook takes on this file is READ-only, so nothing here is on the PreToolUse
19
19
  * latency budget — which is why it lives beside the CLI and not in `js/hosts/hooks.mjs`.
20
20
  */
21
- import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync }
21
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync }
22
22
  from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { EXCLUDES_FILE, EXCLUDES_STUB, BOB_RULES_STUB } from '../build/stubs.mjs';
25
25
  import { wireClaudeExcludes, unwireClaudeExcludes } from '../hosts/settings.mjs';
26
26
  import { GLOBAL_MANIFEST, HOSTS, resolvePath } from '../hosts/hosts.mjs';
27
- import { readText, writeText, printOut, printErr } from '../lib/fs.mjs';
28
- import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
27
+ import { readText, writeText, printOut, printErr, isDir, isFile, isOsError } from '../lib/fs.mjs';
28
+ import { parseJson, jsonDumpsIndent, get, isDict } from '../lib/json.mjs';
29
29
  import { normcase, toPlatformPath } from '../lib/paths.mjs';
30
30
 
31
- const get = (o, k) => (o && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
32
- const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
33
- const isOsError = (e) => Boolean(e) && typeof e.code === 'string';
34
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
35
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
36
-
37
31
  /** `_global_installs` — every host whose GLOBAL config dir carries a Geneseed manifest. */
38
- export function globalInstalls() {
32
+ function globalInstalls() {
39
33
  const out = [];
40
34
  for (const { host, configDir } of HOSTS) {
41
35
  let cfg;
@@ -62,7 +62,7 @@ export const DOCTRINE_HEADING_RE = /^###\s+\S+\s+([a-z]+)\s+(\d+)\s+[—-]\s+(.+
62
62
  * from `STRUCTURE` rather than from a theme, so the id below is stable across all fourteen
63
63
  * voices and is safe to put in a deep link.
64
64
  */
65
- export const ONTOLOGY_HEADING_RE = /^####\s+(.+?)\s*$/;
65
+ const ONTOLOGY_HEADING_RE = /^####\s+(.+?)\s*$/;
66
66
 
67
67
  /** The lead line of a pack file — `**Craft** — how code is written.` */
68
68
  const PACK_LEAD_RE = /^\*\*(.+?)\*\*\s+[—-]\s+(.+?)\s*$/;
@@ -70,7 +70,8 @@ const PACK_LEAD_RE = /^\*\*(.+?)\*\*\s+[—-]\s+(.+?)\s*$/;
70
70
  /** `_harness_tui.LAW_CLASS` — each rule's class, keyed by Roman numeral. */
71
71
  export const LAW_CLASS = {
72
72
  I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'verify',
73
- VI: 'security', VII: 'security', VIII: 'craft', IX: 'security',
73
+ VI: 'security', VII: 'security', VIII: 'craft', IX: 'security', X: 'verify',
74
+ XI: 'verify',
74
75
  };
75
76
 
76
77
  /** `_harness_tui.ENTITY_STATUSES` — the lifecycle statuses `registry.json` may carry. */
@@ -110,41 +111,46 @@ export const SKILL_CLASS = {
110
111
  const has = (obj, k) => Object.hasOwn(obj, k);
111
112
 
112
113
  /**
113
- * `_harness_tui._parse_laws` — the rendered laws file split into
114
- * `{num, title, klass, body}`.
114
+ * The one accumulator `_parse_laws`, the doctrine-pack parser and the ontology parser each
115
+ * wrote out separately: split `text` on its heading regex, opening a new record — everything
116
+ * `makeRecord(match)` returns, plus a `body` — at every match, and appending every other line
117
+ * (including a blank one) to the CURRENT record's body. Text before the first heading is
118
+ * dropped on the floor because `cur` is still null.
115
119
  *
116
- * The accumulator appends `line + "\n"` to every non-heading line INCLUDING the ones
117
- * before the first heading, which are discarded because `cur` is still None — and then
118
- * strips each body at the end. Reproduced literally: a body's internal blank lines are
119
- * kept and only its ends are trimmed.
120
+ * `str.strip()`, applied at the end to every body. `trim()`'s whitespace set differs from
121
+ * Python's only at U+FEFF, which is the standing item this port has carried since P4 — a
122
+ * rendered body cannot end in a BOM, and a fourth primitive to say so would cost more than
123
+ * it gates.
120
124
  */
121
- export function parseLaws(text) {
122
- const laws = [];
125
+ function parseSections(text, re, makeRecord) {
126
+ const out = [];
123
127
  let cur = null;
124
128
  for (const line of text.split('\n')) {
125
- const m = LAW_HEADING_RE.exec(line);
129
+ const m = re.exec(line);
126
130
  if (m) {
127
- if (cur) laws.push(cur);
128
- cur = { num: m[1], title: m[2], klass: has(LAW_CLASS, m[1]) ? LAW_CLASS[m[1]] : 'craft', body: '' };
131
+ if (cur) out.push(cur);
132
+ cur = { ...makeRecord(m), body: '' };
129
133
  } else if (cur !== null) {
130
134
  cur.body += `${line}\n`;
131
135
  }
132
136
  }
133
- if (cur) laws.push(cur);
134
- // `str.strip()`. `trim()`'s whitespace set differs from Python's only at U+FEFF, which
135
- // is the standing item this port has carried since P4 — a rendered law body cannot end
136
- // in a BOM, and a fourth primitive to say so would cost more than it gates.
137
- for (const law of laws) law.body = law.body.trim();
138
- return laws;
137
+ if (cur) out.push(cur);
138
+ for (const item of out) item.body = item.body.trim();
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * `_harness_tui._parse_laws` — the rendered laws file split into
144
+ * `{num, title, klass, body}`.
145
+ */
146
+ function parseLaws(text) {
147
+ return parseSections(text, LAW_HEADING_RE, (m) => (
148
+ { num: m[1], title: m[2], klass: has(LAW_CLASS, m[1]) ? LAW_CLASS[m[1]] : 'craft' }));
139
149
  }
140
150
 
141
151
  /**
142
152
  * One rendered doctrine pack file split into `{pack, n, title, klass, body}` rows.
143
153
  *
144
- * Same accumulator as `parseLaws`, and deliberately so: text before the first heading — the
145
- * pack's own lead line — is dropped on the floor because `cur` is still null, and each body is
146
- * trimmed at its ends only.
147
- *
148
154
  * `klass` IS THE PACK ID, not one of `LAW_CLASSES`. A doctrine rule is grouped by the pack it
149
155
  * belongs to and there is no second taxonomy over it; the six-class chip is the invariants'.
150
156
  * The collision with the class names `craft` and `process` is real and harmless — the two
@@ -155,20 +161,8 @@ export function parseLaws(text) {
155
161
  * two; this function just reports what the text says.
156
162
  */
157
163
  export function parseDoctrines(text) {
158
- const rules = [];
159
- let cur = null;
160
- for (const line of text.split('\n')) {
161
- const m = DOCTRINE_HEADING_RE.exec(line);
162
- if (m) {
163
- if (cur) rules.push(cur);
164
- cur = { pack: m[1], n: Number(m[2]), title: m[3], klass: m[1], body: '' };
165
- } else if (cur !== null) {
166
- cur.body += `${line}\n`;
167
- }
168
- }
169
- if (cur) rules.push(cur);
170
- for (const rule of rules) rule.body = rule.body.trim();
171
- return rules;
164
+ return parseSections(text, DOCTRINE_HEADING_RE, (m) => (
165
+ { pack: m[1], n: Number(m[2]), title: m[3], klass: m[1] }));
172
166
  }
173
167
 
174
168
  /**
@@ -179,25 +173,13 @@ export function parseDoctrines(text) {
179
173
  * come from `STRUCTURE` in `js/build/render.mjs` and not from any theme file — the same reason a
180
174
  * citation can spell itself `({{ONTOLOGY}}: {{ONT_TELOS}})` with a token on both sides.
181
175
  *
182
- * The preamble above the first `####` is dropped, as in the two parsers above. It is the
183
- * paragraph that states the tier order, and it renders into AGENT.md whole — the catalogue
184
- * lists sections, and the preamble is not one.
176
+ * The preamble above the first `####` is dropped, as `parseSections` drops it for the two
177
+ * parsers above. It is the paragraph that states the tier order, and it renders into AGENT.md
178
+ * whole — the catalogue lists sections, and the preamble is not one.
185
179
  */
186
- export function parseOntology(text) {
187
- const sections = [];
188
- let cur = null;
189
- for (const line of text.split('\n')) {
190
- const m = ONTOLOGY_HEADING_RE.exec(line);
191
- if (m) {
192
- if (cur) sections.push(cur);
193
- cur = { id: m[1].toLowerCase().replace(/\s+/g, '-'), title: m[1], body: '' };
194
- } else if (cur !== null) {
195
- cur.body += `${line}\n`;
196
- }
197
- }
198
- if (cur) sections.push(cur);
199
- for (const s of sections) s.body = s.body.trim();
200
- return sections;
180
+ function parseOntology(text) {
181
+ return parseSections(text, ONTOLOGY_HEADING_RE, (m) => (
182
+ { id: m[1].toLowerCase().replace(/\s+/g, '-'), title: m[1] }));
201
183
  }
202
184
 
203
185
  /**