geneseed 3.1.3 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- 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 +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- 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 +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- 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 +127 -88
- package/js/maintain/update.mjs +4 -4
- 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 +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- 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 +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.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/web/dist/assets/roman-DkYN0LX9.js +0 -1
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`. */
|
|
@@ -720,11 +667,34 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
|
|
|
720
667
|
* belongs in — the user's call — not how work is run, so it is not the process pack's to
|
|
721
668
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
722
669
|
*/
|
|
723
|
-
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = []) {
|
|
670
|
+
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
|
|
724
671
|
const run = hookPrefix(hookOpts);
|
|
725
672
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
726
673
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a project
|
|
727
674
|
// install of the same host sits at/above cwd (project-bypasses-global).
|
|
675
|
+
if (host === 'bob') {
|
|
676
|
+
// BOB'S OWN HOOK CONTRACT, per its hooks doc and 2.0.2 changelog (docs/reviews/
|
|
677
|
+
// bob-global-injection-2026-09.md): FIVE events — SessionStart, UserPromptSubmit,
|
|
678
|
+
// PreToolUse, PostToolUse, Stop — Claude's names, but NOT Claude's protocol. stdout is
|
|
679
|
+
// read as context on SessionStart only; on PreToolUse it is ignored and the one way to
|
|
680
|
+
// refuse a call is EXIT CODE 2. The groups this emit wrote before were Claude's whole
|
|
681
|
+
// set: `SubagentStop`/`PreCompact` are not Bob events, and the gates answered with a
|
|
682
|
+
// stdout JSON Bob never reads — permissive on every call. `--host bob` makes the gates
|
|
683
|
+
// speak exit codes (js/hosts/hooks.mjs). No matcher on PreToolUse: Bob's tool names
|
|
684
|
+
// are undocumented (its IDE and Shell differ), so `tool-gate` reads the payload's shape
|
|
685
|
+
// instead. Which tool payload fields Bob sends is equally undocumented; the gates read
|
|
686
|
+
// Claude's (`command`, `file_path`/`path`, `content`/`new_string`) and defer on anything
|
|
687
|
+
// else. Unverified live: no Bob install on the authoring machine.
|
|
688
|
+
const b = ' --host bob';
|
|
689
|
+
return {
|
|
690
|
+
SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
|
|
691
|
+
// One group, both gates: process 5's consent is a stderr line here (Bob has no ask
|
|
692
|
+
// tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
|
|
693
|
+
// `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries.
|
|
694
|
+
PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
|
|
695
|
+
Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
728
698
|
const context = `${run} context --root "${cfg}" || exit 0`;
|
|
729
699
|
const gate = `${run} git-gate --root "${cfg}"`;
|
|
730
700
|
const ruleGate = `${run} rule-gate --root "${cfg}"`;
|
|
@@ -740,7 +710,10 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
|
|
|
740
710
|
],
|
|
741
711
|
SessionStart: [
|
|
742
712
|
{ matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
|
|
743
|
-
|
|
713
|
+
// `compact` too: auto-compaction keeps the instruction file (the host re-reads it) but
|
|
714
|
+
// summarises away the eagerly injected context, memory index and notebook TOC. Re-seed
|
|
715
|
+
// them the way a resume does — the static AGENT.md is NOT re-printed.
|
|
716
|
+
{ matcher: 'resume|compact', hooks: [{ type: 'command', command: context }] },
|
|
744
717
|
],
|
|
745
718
|
// `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
|
|
746
719
|
// is not a command and the swallow-failures intent would invert into a 9009 error.
|
|
@@ -748,9 +721,151 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
|
|
|
748
721
|
// Same command as Stop: `learn` reads the payload's hook_event_name and routes a
|
|
749
722
|
// SubagentStop to the per-agent lesson path.
|
|
750
723
|
SubagentStop: [{ hooks: [{ type: 'command', command: learn }] }],
|
|
724
|
+
// And once more BEFORE compaction. `learn` distils the tail of the transcript (the newest
|
|
725
|
+
// MAX_NOTES_CHARS), so per-turn Stop is a sliding window over the session — and
|
|
726
|
+
// compaction is the one moment that window is about to be summarised away for good.
|
|
727
|
+
// The payload carries `transcript_path` like Stop's; the SessionStart `compact` matcher
|
|
728
|
+
// re-seeds context AFTER, this captures memory BEFORE. Same `|| exit 0`: never block.
|
|
729
|
+
PreCompact: [{ hooks: [{ type: 'command', command: learn }] }],
|
|
751
730
|
};
|
|
752
731
|
}
|
|
753
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Geneseed's Copilot hooks, keyed by Copilot's event names.
|
|
735
|
+
*
|
|
736
|
+
* COPILOT'S SETTINGS ARE NOT CLAUDE'S. `~/.copilot/settings.json` carries `hooks` as a map of
|
|
737
|
+
* event → ONE `{command, shell?, timeout?}` object, not event → list of matcher groups, so
|
|
738
|
+
* nothing in `claudeHookGroups`/`mergeClaudeSettings` can be reused for it: there is no
|
|
739
|
+
* array to append to, and a second Geneseed group per event is not expressible. Two hooks:
|
|
740
|
+
*
|
|
741
|
+
* * `sessionStart` → `context --host copilot`: the same project-context injection Claude
|
|
742
|
+
* gets, in Copilot's `{"additionalContext"}` envelope.
|
|
743
|
+
* * `toolCall` → `tool-gate --host copilot`: both gates behind the one command the event
|
|
744
|
+
* allows, block-only for Laws I/IV, warn for the rest (js/hosts/hooks.mjs's header).
|
|
745
|
+
*
|
|
746
|
+
* No `sessionEnd`/`agentStop` → `learn`: Copilot's payloads for those carry no
|
|
747
|
+
* `transcript_path`, so `learn` would have nothing to distil. The memory write-back stays on
|
|
748
|
+
* the preamble for this host, as before.
|
|
749
|
+
*
|
|
750
|
+
* `|| exit 0` on the non-gate, as in `claudeHookGroups` and for the same reason.
|
|
751
|
+
*/
|
|
752
|
+
export function copilotHooks(cfg, hookOpts) {
|
|
753
|
+
const run = hookPrefix(hookOpts);
|
|
754
|
+
return {
|
|
755
|
+
sessionStart: { command: `${run} context --root "${cfg}" --host copilot || exit 0` },
|
|
756
|
+
toolCall: { command: `${run} tool-gate --root "${cfg}" --host copilot` },
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Wire Copilot's hooks into `p` — returns the complete current claim set, `[{event, hook}]`.
|
|
762
|
+
*
|
|
763
|
+
* ONE SLOT PER EVENT MEANS A FOREIGN HOOK CANNOT BE JOINED, ONLY DISPLACED — and displacing
|
|
764
|
+
* a hook the user wrote is not Geneseed's to do. A slot is taken when it holds a hook that
|
|
765
|
+
* is neither canonical nor a claim recorded by a previous emit; that event is skipped with a
|
|
766
|
+
* warning and never claimed. A recorded stale claim (a moved shim) is replaced, as the Claude
|
|
767
|
+
* merge prunes stale groups. Comments in the file mean it is not rewritten, as everywhere.
|
|
768
|
+
*/
|
|
769
|
+
export function mergeCopilotSettings(p, priorHooks = null, hookOpts = {}) {
|
|
770
|
+
const prior = (priorHooks || []).filter(isDict);
|
|
771
|
+
let config = {};
|
|
772
|
+
let hadComments = false;
|
|
773
|
+
if (existsSync(p)) {
|
|
774
|
+
try {
|
|
775
|
+
const [loaded, hc] = readJsonc(readText(p));
|
|
776
|
+
hadComments = hc;
|
|
777
|
+
if (isDict(loaded)) config = loaded;
|
|
778
|
+
} catch (e) {
|
|
779
|
+
if (!isOsError(e)) throw e;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
let hooks = get(config, 'hooks');
|
|
783
|
+
if (!isDict(hooks)) hooks = {};
|
|
784
|
+
const canonical = copilotHooks(path.dirname(p), hookOpts);
|
|
785
|
+
const claims = [];
|
|
786
|
+
let changed = false;
|
|
787
|
+
for (const [event, hook] of Object.entries(canonical)) {
|
|
788
|
+
const cur = get(hooks, event);
|
|
789
|
+
const recorded = prior.find((r) => get(r, 'event') === event);
|
|
790
|
+
if (isDict(cur) && deepEquals(cur, hook)) { claims.push({ event, hook }); continue; }
|
|
791
|
+
if (isDict(cur) && !(recorded && deepEquals(recorded.hook, cur))) {
|
|
792
|
+
process.stderr.write(`[geneseed] ${path.basename(p)}: hooks.${event} is already set and `
|
|
793
|
+
+ 'is not Geneseed\'s — left alone, so this event is not wired.\n');
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
hooks[event] = hook;
|
|
797
|
+
claims.push({ event, hook });
|
|
798
|
+
changed = true;
|
|
799
|
+
}
|
|
800
|
+
// A recorded claim whose event is no longer canonical: unwire it, or it fires forever.
|
|
801
|
+
for (const rec of prior) {
|
|
802
|
+
const event = get(rec, 'event');
|
|
803
|
+
if (event in canonical) continue;
|
|
804
|
+
if (deepEquals(get(hooks, event), get(rec, 'hook'))) { delete hooks[event]; changed = true; }
|
|
805
|
+
}
|
|
806
|
+
if (!changed) return [p, claims];
|
|
807
|
+
if (hadComments) {
|
|
808
|
+
process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
|
|
809
|
+
+ "(your edits are kept). Add Geneseed's hooks by hand from adapters/copilot/README.md.\n");
|
|
810
|
+
return [p, prior];
|
|
811
|
+
}
|
|
812
|
+
if (Object.keys(hooks).length) config.hooks = hooks;
|
|
813
|
+
else delete config.hooks;
|
|
814
|
+
atomicWriteJson(p, config);
|
|
815
|
+
return [p, claims];
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Remove exactly the recorded Copilot hooks from `p`. True when the file was rewritten. */
|
|
819
|
+
export function unwireCopilotSettings(p, recorded) {
|
|
820
|
+
if (!existsSync(p) || !recorded || !recorded.length) return false;
|
|
821
|
+
let loaded;
|
|
822
|
+
let hadComments;
|
|
823
|
+
try {
|
|
824
|
+
[loaded, hadComments] = readJsonc(readText(p));
|
|
825
|
+
} catch (e) {
|
|
826
|
+
if (!isOsError(e)) throw e;
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
if (hadComments || !isDict(loaded)) return false;
|
|
830
|
+
const hooks = get(loaded, 'hooks');
|
|
831
|
+
if (!isDict(hooks)) return false;
|
|
832
|
+
for (const rec of recorded) {
|
|
833
|
+
const event = get(rec, 'event');
|
|
834
|
+
if (deepEquals(get(hooks, event), get(rec, 'hook'))) delete hooks[event];
|
|
835
|
+
}
|
|
836
|
+
if (!Object.keys(hooks).length) delete loaded.hooks;
|
|
837
|
+
try {
|
|
838
|
+
atomicWriteJson(p, loaded);
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (!isOsError(e)) throw e;
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The Copilot twin of `settingsIntegrityCheck`: every recorded hook present (or absent) in
|
|
848
|
+
* the file it was claimed against. Same contract — problems as strings, each also WARNed on
|
|
849
|
+
* stderr, empty when clean — so a merge that did not stick is loud at emit time rather than
|
|
850
|
+
* a hook that quietly never fires.
|
|
851
|
+
*/
|
|
852
|
+
export function copilotIntegrityCheck(p, recorded, expect = 'present') {
|
|
853
|
+
const problems = [];
|
|
854
|
+
const hooks = existsSync(p) ? (() => {
|
|
855
|
+
try { return get(readJsonc(readText(p))[0], 'hooks'); } catch { return null; }
|
|
856
|
+
})() : null;
|
|
857
|
+
for (const rec of (recorded || []).filter(isDict)) {
|
|
858
|
+
const hit = deepEquals(get(hooks, rec.event), rec.hook);
|
|
859
|
+
if (expect === 'present' && !hit) {
|
|
860
|
+
problems.push(`${p}: recorded Copilot hook missing — event=${rec.event}`);
|
|
861
|
+
} else if (expect === 'absent' && hit) {
|
|
862
|
+
problems.push(`${p}: recorded Copilot hook still present after unwire — event=${rec.event}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
|
|
866
|
+
return problems;
|
|
867
|
+
}
|
|
868
|
+
|
|
754
869
|
/**
|
|
755
870
|
* `_build_settings._merge_claude_settings` — returns `[target, managed]`.
|
|
756
871
|
*
|
|
@@ -763,7 +878,7 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [])
|
|
|
763
878
|
* `scope` is accepted and unused, exactly as in the Python.
|
|
764
879
|
*/
|
|
765
880
|
export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
|
|
766
|
-
doctrines = null, excluded = []) {
|
|
881
|
+
doctrines = null, excluded = [], host = 'claude') {
|
|
767
882
|
const prior = (priorHooks || []).filter(isDict);
|
|
768
883
|
let config = {};
|
|
769
884
|
let hadComments = false;
|
|
@@ -783,7 +898,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
|
|
|
783
898
|
}
|
|
784
899
|
let hooks = get(config, 'hooks');
|
|
785
900
|
if (!isDict(hooks)) hooks = {};
|
|
786
|
-
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded);
|
|
901
|
+
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded, host);
|
|
787
902
|
const canonFlat = [];
|
|
788
903
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
789
904
|
for (const g of groups) canonFlat.push({ event, group: g });
|
|
@@ -824,7 +939,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
|
|
|
824
939
|
return [p, prior];
|
|
825
940
|
}
|
|
826
941
|
// The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
|
|
827
|
-
// carries
|
|
942
|
+
// carries at least three events, so by the time control reaches here `hooks` is never empty. A
|
|
828
943
|
// mutation keeping the empty block instead of deleting it is the one of thirty-three that
|
|
829
944
|
// stays green, and it stays green because there is nothing to detect — recorded the way
|
|
830
945
|
// `themed_rel` is, so "the gate cannot see it" and "there is nothing to see" keep their
|
|
@@ -918,7 +1033,7 @@ export function wireClaudeExcludes(p, excludes) {
|
|
|
918
1033
|
atomicWriteJson(p, config);
|
|
919
1034
|
} catch (e) {
|
|
920
1035
|
if (!isOsError(e)) throw e;
|
|
921
|
-
process.stderr.write(`[geneseed] WARN: could not write ${p} (${
|
|
1036
|
+
process.stderr.write(`[geneseed] WARN: could not write ${p} (${e.message}) — `
|
|
922
1037
|
+ 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
|
|
923
1038
|
+ `${jsonDumpsCompact(added)}\n`);
|
|
924
1039
|
return [];
|
|
@@ -973,7 +1088,7 @@ export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
|
|
|
973
1088
|
// 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
|
|
974
1089
|
// call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
|
|
975
1090
|
// exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
|
|
976
|
-
|
|
1091
|
+
const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
|
|
977
1092
|
|
|
978
1093
|
/**
|
|
979
1094
|
* `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
|
|
@@ -1028,7 +1143,7 @@ export function autostartStale(text, root) {
|
|
|
1028
1143
|
}
|
|
1029
1144
|
|
|
1030
1145
|
/** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
|
|
1031
|
-
|
|
1146
|
+
function settingsHookGroups(loaded) {
|
|
1032
1147
|
const hooks = get(loaded, 'hooks');
|
|
1033
1148
|
if (!isDict(hooks)) return [];
|
|
1034
1149
|
const out = [];
|
|
@@ -1068,7 +1183,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1068
1183
|
[loaded] = readJsonc(readText(p));
|
|
1069
1184
|
} catch (e) {
|
|
1070
1185
|
if (!isOsError(e)) throw e;
|
|
1071
|
-
problems.push(`${p}: could not read the file (${
|
|
1186
|
+
problems.push(`${p}: could not read the file (${e.message})`);
|
|
1072
1187
|
return flush();
|
|
1073
1188
|
}
|
|
1074
1189
|
if (!isDict(loaded)) {
|
|
@@ -1128,12 +1243,7 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
|
|
|
1128
1243
|
return flush();
|
|
1129
1244
|
}
|
|
1130
1245
|
|
|
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
|
-
}
|
|
1246
|
+
const delimiters = (blockId) => [`<!-- BEGIN ${blockId} -->`, `<!-- END ${blockId} -->`];
|
|
1137
1247
|
|
|
1138
1248
|
/**
|
|
1139
1249
|
* `_build_settings._managed_block_write` — 'created' | 'updated' | 'merged'.
|
|
@@ -1141,7 +1251,7 @@ function delimiters(blockId) {
|
|
|
1141
1251
|
*/
|
|
1142
1252
|
export function managedBlockWrite(p, content, blockId = 'GENESEED') {
|
|
1143
1253
|
const [begin, end] = delimiters(blockId);
|
|
1144
|
-
const block = `${begin}\n${
|
|
1254
|
+
const block = `${begin}\n${stripWhitespaceEnd(content)}\n${end}\n`;
|
|
1145
1255
|
if (!existsSync(p)) {
|
|
1146
1256
|
mkdirSync(path.dirname(p), { recursive: true });
|
|
1147
1257
|
writeText(p, block);
|
|
@@ -1171,7 +1281,7 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
|
|
|
1171
1281
|
if (!existing.includes(begin) || !existing.includes(end)) return;
|
|
1172
1282
|
const pre = existing.split(begin)[0];
|
|
1173
1283
|
const post = existing.slice(existing.indexOf(end) + end.length);
|
|
1174
|
-
const rest =
|
|
1284
|
+
const rest = stripWhitespace(`${stripTrailingNewlines(pre)}\n${lstripNewlines(post)}`);
|
|
1175
1285
|
if (rest) writeText(p, `${rest}\n`);
|
|
1176
1286
|
else unlinkSync(p);
|
|
1177
1287
|
}
|
|
@@ -1186,25 +1296,6 @@ export function managedBlockRead(p, blockId = 'GENESEED') {
|
|
|
1186
1296
|
return stripNewlines(inner.slice(0, inner.indexOf(end)));
|
|
1187
1297
|
}
|
|
1188
1298
|
|
|
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
1299
|
function lstripNewlines(s) {
|
|
1209
1300
|
return s.replace(/^\n+/, '');
|
|
1210
1301
|
}
|
|
@@ -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
|
*
|