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.
- package/CHANGELOG.md +70 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/bin/build-driver.mjs +127 -79
- package/bin/geneseed-cli.mjs +42 -84
- package/bin/geneseed-hook.mjs +5 -2
- package/harness.config.json +1 -1
- package/js/README.md +1 -10
- package/js/build/emit-claude.mjs +4 -5
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +13 -13
- package/js/build/source.mjs +7 -6
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/hosts/hooks.mjs +3 -3
- package/js/hosts/hosts.mjs +2 -4
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/native.mjs +37 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +33 -110
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +4 -10
- package/js/inspect/inventory.mjs +37 -55
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +1 -1
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +90 -78
- package/js/maintain/update.mjs +2 -2
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +133 -224
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +66 -111
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +78 -97
- package/js/web/jobs.mjs +124 -165
- package/js/web/routes.mjs +73 -152
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/process.md +4 -2
- package/src/laws/universal.md +55 -43
- package/src/skills/clarify.md +1 -1
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/ingest.md +2 -1
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
- package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
- package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
- package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
- package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
- package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
- package/web/dist/assets/Harness-vRYs11kX.js +1 -0
- package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
- package/web/dist/assets/Library-CjC7BAWN.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
- package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
- package/web/dist/assets/Skills-BvatJGfe.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
- package/web/dist/assets/index-BnRWQbZb.js +12 -0
- package/web/dist/assets/index-Bx3I7cRW.js +2 -0
- package/web/dist/assets/index-D0f9tJlA.css +1 -0
- package/web/dist/assets/index-DJOhOwwR.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +42 -71
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/hosts/settings.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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} (${
|
|
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} (${
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
* `
|
|
464
|
+
* `_build_settings._shim_home`.
|
|
510
465
|
*
|
|
511
|
-
* `
|
|
512
|
-
* has the identical failure mode (a hook shim written
|
|
513
|
-
*
|
|
514
|
-
*
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
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} (${
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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
|
|
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${
|
|
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 =
|
|
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'
|
|
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}} —
|
|
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
|
|
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(
|
|
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');
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -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
|
*
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
* `
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
92
|
+
function emitProblemsOver(items, makeCell) {
|
|
88
93
|
const problems = [];
|
|
89
|
-
for (const
|
|
94
|
+
for (const item of items) {
|
|
90
95
|
withTempDir((tmp) => {
|
|
91
|
-
const
|
|
96
|
+
const { label, dir, run, vendored } = makeCell(tmp, item);
|
|
92
97
|
try {
|
|
93
|
-
swallowStdout(
|
|
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(`[${
|
|
101
|
+
problems.push(`[${label}] build failed`);
|
|
99
102
|
return;
|
|
100
103
|
}
|
|
101
104
|
throw e;
|
|
102
105
|
}
|
|
103
|
-
problems.push(...checkBuild(
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/js/inspect/excludes.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
32
|
+
function globalInstalls() {
|
|
39
33
|
const out = [];
|
|
40
34
|
for (const { host, configDir } of HOSTS) {
|
|
41
35
|
let cfg;
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
* `
|
|
114
|
-
* `
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
122
|
-
const
|
|
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 =
|
|
129
|
+
const m = re.exec(line);
|
|
126
130
|
if (m) {
|
|
127
|
-
if (cur)
|
|
128
|
-
cur = {
|
|
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)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
183
|
-
* paragraph that states the tier order, and it renders into AGENT.md
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
/**
|