geneseed 4.9.0 → 4.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +7 -1
- package/adapters/opencode/plugins/geneseed-guard.js +139 -27
- package/adapters/opencode/plugins/geneseed-learn.js +7 -1
- package/docs/concepts/hooks.md +1 -1
- package/docs/web-ui.md +3 -2
- package/harness.config.json +1 -1
- package/js/README.md +30 -9
- package/js/build/args.mjs +9 -8
- package/js/build/driver.mjs +21 -21
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +4 -5
- package/js/build/source.mjs +18 -22
- package/js/hosts/gitref.mjs +5 -3
- package/js/hosts/hooks-done.mjs +21 -9
- package/js/hosts/hooks-learn.mjs +23 -10
- package/js/hosts/hooks-prims.mjs +25 -0
- package/js/hosts/hooks.mjs +17 -8
- package/js/hosts/installs.mjs +5 -4
- package/js/hosts/loss.mjs +124 -18
- package/js/hosts/native.mjs +3 -1
- package/js/hosts/owned.mjs +34 -6
- package/js/hosts/settings.mjs +5 -4
- package/js/inspect/cost.mjs +37 -5
- package/js/inspect/diff.mjs +132 -61
- package/js/inspect/doctor.mjs +4 -3
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +73 -2
- package/js/maintain/setup.mjs +31 -7
- package/js/maintain/uninstall.mjs +1 -1
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +38 -4
- package/js/web/api.mjs +21 -13
- package/js/web/handler.mjs +5 -17
- package/js/web/jobs.mjs +5 -3
- package/js/web/routes.mjs +3 -1
- package/js/web/server.mjs +3 -1
- package/js/web/state.mjs +35 -8
- package/package.json +1 -1
- package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-D7Igwuoo.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-53rp05hx.js} +1 -1
- package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
- package/web/dist/assets/{Docs-DUxizutI.js → Docs-BM56RCT8.js} +3 -3
- package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
- package/web/dist/assets/Installs-Dh1tB1NK.js +2 -0
- package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
- package/web/dist/assets/Laws-iEheocNI.js +3 -0
- package/web/dist/assets/Library-d4_uOGmC.js +1 -0
- package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BbYeWl70.js} +2 -2
- package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-Dki9Q6W3.js} +1 -1
- package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
- package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-CAl6oFwG.js} +1 -1
- package/web/dist/assets/api-CiXlkbe0.js +1 -0
- package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
- package/web/dist/assets/index-BZgEGRbL.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +60 -353
- package/web/dist/assets/Dashboard-COLskkOJ.js +0 -2
- package/web/dist/assets/GreenhouseView-BSzVX_l7.js +0 -2
- package/web/dist/assets/Installs-Ck4YyEzs.js +0 -2
- package/web/dist/assets/JournalView-BbbACsYK.js +0 -1
- package/web/dist/assets/Laws-BZ_M86r3.js +0 -3
- package/web/dist/assets/Library-D_4LbUg2.js +0 -1
- package/web/dist/assets/Personal-C66284wd.js +0 -1
- package/web/dist/assets/api-Bp5eW_6m.js +0 -1
- package/web/dist/assets/drawable-DlrXDOJ7.js +0 -1
- package/web/dist/assets/index-DJorAjeH.js +0 -2
package/js/build/driver.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { emitClaudeRender } from './emit-claude.mjs';
|
|
|
40
40
|
import { emitOpencodeRender, emitOpencodeGlobalRender } from './emit-opencode.mjs';
|
|
41
41
|
import { settingsIntegrityCheck } from '../hosts/settings.mjs';
|
|
42
42
|
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
43
|
-
import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
|
|
43
|
+
import { writeText, withPlatformNewlines, isFile, withWriteRecord } from '../lib/fs.mjs';
|
|
44
44
|
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
45
45
|
import { relPosix } from '../lib/text.mjs';
|
|
46
46
|
// P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
// status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
|
|
59
59
|
// same cfg. golden.py's 259 cells all build one, which is what made the move safe.
|
|
60
60
|
import {
|
|
61
|
-
ROOT, makeCfg,
|
|
61
|
+
GATE_SWITCHES, ROOT, makeCfg,
|
|
62
62
|
} from './source.mjs';
|
|
63
63
|
|
|
64
64
|
// P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
|
|
@@ -75,8 +75,11 @@ import { configDefaults, die, parseArgs } from './args.mjs';
|
|
|
75
75
|
export { parseDriverArgs } from './args.mjs';
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* `_build_emit.PRIMARY_AGENT_SRC`. Exported for `diffCollect`'s render memo: a render input
|
|
80
|
+
* `sourceFingerprint` does not hash, so the memo keys on its bytes itself.
|
|
81
|
+
*/
|
|
82
|
+
export const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
|
|
80
83
|
|
|
81
84
|
/** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
|
|
82
85
|
* (not to ROOT), so the harness renders straight into any repository.
|
|
@@ -555,9 +558,9 @@ const claudeShaped = (name) => (cfg, args, out) => {
|
|
|
555
558
|
const claudeMd = path.join(configDir || carrierInLayer ? cfgDir : root, carrier);
|
|
556
559
|
const hookOpts = hookRunnerEntry();
|
|
557
560
|
before?.();
|
|
558
|
-
const r = emitClaudeCore(cfg, args, {
|
|
561
|
+
const r = withWriteRecord(() => emitClaudeCore(cfg, args, {
|
|
559
562
|
cfgDir, claudeMd, scope: configDir ? 'global' : 'project', host, out, hookOpts,
|
|
560
|
-
});
|
|
563
|
+
}));
|
|
561
564
|
process.stdout.write(summary(root ?? cfgDir, r));
|
|
562
565
|
return configDir ? cfgDir : undefined;
|
|
563
566
|
};
|
|
@@ -604,7 +607,7 @@ function writeMarkers(markerDir, emit, footprint) {
|
|
|
604
607
|
* but this argument would overwrite the live install `diff` is only reading.
|
|
605
608
|
*/
|
|
606
609
|
const GLOBAL_EMITS = {
|
|
607
|
-
opencode: emitOpencodeGlobal,
|
|
610
|
+
opencode: (...a) => withWriteRecord(() => emitOpencodeGlobal(...a)),
|
|
608
611
|
claude: claudeShaped('claude-global'),
|
|
609
612
|
bob: claudeShaped('bob-global'),
|
|
610
613
|
openclaude: claudeShaped('openclaude-global'),
|
|
@@ -633,7 +636,7 @@ const PROJECT_EMITS = {
|
|
|
633
636
|
// P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
|
|
634
637
|
// `--emit` choice into its sandbox and not only the three doctor already scans. Its call
|
|
635
638
|
// shape is the Claude-shaped emits' exactly — `(cfg, args, out)` — so the row is the whole change.
|
|
636
|
-
opencode: emitOpencode,
|
|
639
|
+
opencode: (...a) => withWriteRecord(() => emitOpencode(...a)),
|
|
637
640
|
};
|
|
638
641
|
|
|
639
642
|
/**
|
|
@@ -648,24 +651,21 @@ const PROJECT_EMITS = {
|
|
|
648
651
|
* carry straight past the parameter default.
|
|
649
652
|
*/
|
|
650
653
|
function axesCfg({
|
|
651
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
652
|
-
deletionGate = null, secretsGate = null, doneGate = null,
|
|
654
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates
|
|
653
655
|
}) {
|
|
654
656
|
return makeCfg({
|
|
655
657
|
...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
|
|
656
658
|
...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
|
|
657
659
|
...(excludeSkills ? { excludeSkills } : {}),
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
...(
|
|
661
|
-
...(doneGate === false ? { noDoneGate: true } : {}),
|
|
660
|
+
// The gate switches (`GATE_SWITCHES` keys): only an explicit `false` disarms; `null` (no
|
|
661
|
+
// opinion) keeps the gate.
|
|
662
|
+
...Object.fromEntries(GATE_SWITCHES.filter((g) => gates[g.key] === false).map((g) => [g.arg, true])),
|
|
662
663
|
});
|
|
663
664
|
}
|
|
664
665
|
|
|
665
666
|
export function emitProjectInto(host, {
|
|
666
667
|
theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
|
|
667
|
-
doctrines = null, excludeRules = null, excludeSkills = null,
|
|
668
|
-
secretsGate = null, doneGate = null,
|
|
668
|
+
doctrines = null, excludeRules = null, excludeSkills = null, ...gates
|
|
669
669
|
}) {
|
|
670
670
|
const emit = PROJECT_EMITS[host];
|
|
671
671
|
// The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
|
|
@@ -674,7 +674,7 @@ export function emitProjectInto(host, {
|
|
|
674
674
|
// checked an all-packs render nobody asked for.
|
|
675
675
|
return withPlatformNewlines(() => emit(
|
|
676
676
|
axesCfg({
|
|
677
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
677
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
678
678
|
{ theme, footprint, root }, out,
|
|
679
679
|
));
|
|
680
680
|
}
|
|
@@ -689,16 +689,16 @@ export function emitProjectInto(host, {
|
|
|
689
689
|
*/
|
|
690
690
|
export function buildInto({
|
|
691
691
|
theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
|
|
692
|
-
excludeRules = null, excludeSkills = null,
|
|
692
|
+
excludeRules = null, excludeSkills = null, ...gates
|
|
693
693
|
}) {
|
|
694
694
|
return withPlatformNewlines(() => build(axesCfg({
|
|
695
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
695
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
696
696
|
theme, out, { footprint, nativeCatalog: false }));
|
|
697
697
|
}
|
|
698
698
|
|
|
699
699
|
export function emitGlobalInto(host, {
|
|
700
700
|
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
|
|
701
|
-
excludeRules = null, excludeSkills = null,
|
|
701
|
+
excludeRules = null, excludeSkills = null, ...gates
|
|
702
702
|
}) {
|
|
703
703
|
// `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
|
|
704
704
|
// raising, because the host comes from a marker file a user can edit.
|
|
@@ -715,7 +715,7 @@ export function emitGlobalInto(host, {
|
|
|
715
715
|
// the same scar the footprint left in `diffCollect`, one axis over each time.
|
|
716
716
|
return withPlatformNewlines(() => emit(
|
|
717
717
|
axesCfg({
|
|
718
|
-
posture, mode, trust, doctrines, excludeRules, excludeSkills,
|
|
718
|
+
posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
|
|
719
719
|
{ theme, footprint, root: null, cfgDir }, out,
|
|
720
720
|
));
|
|
721
721
|
}
|
package/js/build/emit-claude.mjs
CHANGED
|
@@ -182,7 +182,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
182
182
|
// WIRE — see `claudeWire` below. One child per emit, so the two halves are two
|
|
183
183
|
// functions rather than two spawns; the seam between them is a call, not a process.
|
|
184
184
|
const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
|
|
185
|
-
cfg.excludeRules, cfg
|
|
185
|
+
cfg.excludeRules, cfg);
|
|
186
186
|
|
|
187
187
|
return {
|
|
188
188
|
owned,
|
|
@@ -216,7 +216,7 @@ export function emitClaudeRender(cfg, job) {
|
|
|
216
216
|
* always a string, so `''` is the only falsy value either language sees.
|
|
217
217
|
*/
|
|
218
218
|
function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = [],
|
|
219
|
-
|
|
219
|
+
gates = {}) {
|
|
220
220
|
phaseLog('WIRE');
|
|
221
221
|
const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
|
|
222
222
|
const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
|
|
@@ -280,7 +280,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
|
|
|
280
280
|
// DECIDES the install's packs, so the marker on disk is still the previous build's.
|
|
281
281
|
const [, managedHooks] = mergeClaudeSettings(
|
|
282
282
|
settingsPath, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
|
|
283
|
-
doctrines, excludeRules, host, cfgDir,
|
|
283
|
+
doctrines, excludeRules, host, cfgDir, gates,
|
|
284
284
|
);
|
|
285
285
|
managed.settings_hooks = managedHooks;
|
|
286
286
|
|
package/js/build/generate.mjs
CHANGED
|
@@ -141,8 +141,7 @@ export function cmdBuild(args) {
|
|
|
141
141
|
*/
|
|
142
142
|
export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
|
|
143
143
|
posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
|
|
144
|
-
excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null,
|
|
145
|
-
{ secretsGate = true, doneGate = true } = {}) {
|
|
144
|
+
excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, gates = {}) {
|
|
146
145
|
const argv = ['--theme', theme, '--emit', emit];
|
|
147
146
|
if (!emit.endsWith('-global')) {
|
|
148
147
|
if (out) argv.push('--out', out);
|
|
@@ -172,9 +171,9 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
|
|
|
172
171
|
if (excludeSkills?.length) argv.push('--exclude-skills', excludeSkills.join(','));
|
|
173
172
|
// The gate switches elide when armed: the driver's default IS armed (no config key feeds
|
|
174
173
|
// them), so only an explicit `false` speaks, and every armed argv stays the bytes it was.
|
|
174
|
+
// `gates` is one object keyed as `GATE_SWITCHES` keys it (`gatesOfDir`, a profile, a body).
|
|
175
175
|
// Law order, so a Law IV-only install still ends on `--no-deletion-gate` as it did.
|
|
176
|
-
const
|
|
177
|
-
for (const g of GATE_SWITCHES) if (gates[g.key] === false) argv.push(g.flag);
|
|
176
|
+
for (const g of GATE_SWITCHES) if (gates?.[g.key] === false) argv.push(g.flag);
|
|
178
177
|
return argv;
|
|
179
178
|
}
|
|
180
179
|
|
|
@@ -238,7 +237,7 @@ export function installProfile(host, scope, root, notify = null) {
|
|
|
238
237
|
const gates = gatesOfDir(root, host);
|
|
239
238
|
const out = scope === 'global' ? null : root;
|
|
240
239
|
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
241
|
-
PACK_ORDER, excludeRules, trust, excludeSkills, gates
|
|
240
|
+
PACK_ORDER, excludeRules, trust, excludeSkills, gates);
|
|
242
241
|
// A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
|
|
243
242
|
// honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
|
|
244
243
|
// same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
|
package/js/build/source.mjs
CHANGED
|
@@ -385,6 +385,19 @@ export function resolveSkillNames(names, {
|
|
|
385
385
|
return { ids: [...new Set(ids)].sort(), notices, unknown };
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* The laws whose hook gate an install can switch off, in law order: the cfg/profile key, the
|
|
390
|
+
* build flag, the carrier marker (`<marker>: off`, written only when off) and the law. ONE
|
|
391
|
+
* table, so the flag, the marker, the rebuild argv, the read-back and `status` cannot drift
|
|
392
|
+
* apart. Every gate is ARMED by default and the law's text binds either way. The deletion row
|
|
393
|
+
* is #319's, unchanged: existing installs carry its flag and marker.
|
|
394
|
+
*/
|
|
395
|
+
export const GATE_SWITCHES = [
|
|
396
|
+
{ key: 'secretsGate', flag: '--no-secrets-gate', arg: 'noSecretsGate', marker: 'Secrets gate', law: 'I' },
|
|
397
|
+
{ key: 'doneGate', flag: '--no-done-gate', arg: 'noDoneGate', marker: 'Done gate', law: 'III' },
|
|
398
|
+
{ key: 'deletionGate', flag: '--no-deletion-gate', arg: 'noDeletionGate', marker: 'Deletion gate', law: 'IV' },
|
|
399
|
+
];
|
|
400
|
+
|
|
388
401
|
/**
|
|
389
402
|
* `_build_core.js_cfg()`, originated rather than received.
|
|
390
403
|
*
|
|
@@ -399,8 +412,7 @@ export function resolveSkillNames(names, {
|
|
|
399
412
|
*/
|
|
400
413
|
export function makeCfg({
|
|
401
414
|
posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
|
|
402
|
-
excludeRules = [], excludeSkills = [],
|
|
403
|
-
noDoneGate = false,
|
|
415
|
+
excludeRules = [], excludeSkills = [], ...switches
|
|
404
416
|
} = {}) {
|
|
405
417
|
return {
|
|
406
418
|
root: ROOT,
|
|
@@ -428,25 +440,9 @@ export function makeCfg({
|
|
|
428
440
|
excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
|
|
429
441
|
// Skill names left out of this install — same "unspecified takes nothing away" default.
|
|
430
442
|
excludeSkills: Array.isArray(excludeSkills) ? [...excludeSkills] : [],
|
|
431
|
-
//
|
|
432
|
-
// only a literal `true` disarms, so every
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
// same contract (user decision 2026-10-18; see GATE_SWITCHES).
|
|
436
|
-
secretsGate: noSecretsGate !== true,
|
|
437
|
-
doneGate: noDoneGate !== true,
|
|
443
|
+
// The gate switches (`GATE_SWITCHES`: `secretsGate`, `doneGate`, `deletionGate`), each armed
|
|
444
|
+
// unless its `no*` arg says so in so many words: only a literal `true` disarms, so every
|
|
445
|
+
// caller that never heard of a switch keeps the gate.
|
|
446
|
+
...Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, switches[g.arg] !== true])),
|
|
438
447
|
};
|
|
439
448
|
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The laws whose hook gate an install can switch off, in law order: the cfg/profile key, the
|
|
443
|
-
* build flag, the carrier marker (`<marker>: off`, written only when off) and the law. ONE
|
|
444
|
-
* table, so the flag, the marker, the rebuild argv, the read-back and `status` cannot drift
|
|
445
|
-
* apart. Every gate is ARMED by default and the law's text binds either way. The deletion row
|
|
446
|
-
* is #319's, unchanged: existing installs carry its flag and marker.
|
|
447
|
-
*/
|
|
448
|
-
export const GATE_SWITCHES = [
|
|
449
|
-
{ key: 'secretsGate', flag: '--no-secrets-gate', arg: 'noSecretsGate', marker: 'Secrets gate', law: 'I' },
|
|
450
|
-
{ key: 'doneGate', flag: '--no-done-gate', arg: 'noDoneGate', marker: 'Done gate', law: 'III' },
|
|
451
|
-
{ key: 'deletionGate', flag: '--no-deletion-gate', arg: 'noDeletionGate', marker: 'Deletion gate', law: 'IV' },
|
|
452
|
-
];
|
package/js/hosts/gitref.mjs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
|
|
13
13
|
* import. A module that is fs/path only is importable from anywhere.
|
|
14
14
|
*
|
|
15
|
-
* The OpenCode guard plugin
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The OpenCode guard plugin (`adapters/opencode/plugins/geneseed-guard.js`) is copied whole
|
|
16
|
+
* into an install and cannot import this, so it carries twins of `gitRootOf`, `gitDirOf`,
|
|
17
|
+
* `gitCommonDirOf`, `currentBranch`, `protectedBranches` and `stashExists` for its Law IV and
|
|
18
|
+
* protected-checks gates. `tests/plugins/guard.test.mjs` compares each with the original here;
|
|
19
|
+
* change one, change both.
|
|
18
20
|
*/
|
|
19
21
|
import { existsSync, statSync, lstatSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'node:fs';
|
|
20
22
|
import path from 'node:path';
|
package/js/hosts/hooks-done.mjs
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
* result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
|
|
29
29
|
* 14 000+ real Claude Code results, 2026-10-17).
|
|
30
30
|
*
|
|
31
|
+
* READ FROM THE END (constant-cost Stop). Only the current turn matters, so the transcript is read
|
|
32
|
+
* backward in growing windows (`tailReader`) until the turn's opening prompt is inside one; a
|
|
33
|
+
* turn longer than every window falls back to the whole file. Never judged on a truncated turn:
|
|
34
|
+
* a window without a prompt answers "read more", not "no edit".
|
|
35
|
+
*
|
|
31
36
|
* At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
|
|
32
37
|
* gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
|
|
33
38
|
*/
|
|
@@ -37,7 +42,7 @@ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
|
|
|
37
42
|
import { within } from '../lib/paths.mjs';
|
|
38
43
|
import { gitRootOf } from './gitref.mjs';
|
|
39
44
|
import { DONE_LIST, recordGate } from './hooks.mjs';
|
|
40
|
-
import { splitLines } from './hooks-prims.mjs';
|
|
45
|
+
import { splitLines, tailReader } from './hooks-prims.mjs';
|
|
41
46
|
|
|
42
47
|
const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
|
|
43
48
|
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
@@ -102,18 +107,24 @@ function isPrompt(obj) {
|
|
|
102
107
|
/**
|
|
103
108
|
* The decision, over transcript TEXT: the checks that have not passed since the turn's last
|
|
104
109
|
* edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
|
|
105
|
-
* unit tier — a transcript is text no CLI cell can vary cheaply.
|
|
110
|
+
* unit tier — a transcript is text no CLI cell can vary cheaply. `whole` false means the text
|
|
111
|
+
* is only the file's tail: with no prompt in it the turn may start earlier, so the answer is
|
|
112
|
+
* null ("read more") — a turn judged from its middle could miss its edit and wrongly pass.
|
|
106
113
|
*/
|
|
107
|
-
export function doneDecide(transcript, checks, root, cwd = root) {
|
|
114
|
+
export function doneDecide(transcript, checks, root, cwd = root, whole = true) {
|
|
108
115
|
const entries = [];
|
|
109
116
|
for (const line of splitLines(transcript)) {
|
|
110
117
|
if (!line.trim()) continue;
|
|
111
118
|
try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
|
|
112
119
|
}
|
|
113
|
-
let start =
|
|
120
|
+
let start = -1;
|
|
114
121
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
115
122
|
if (isPrompt(entries[i])) { start = i + 1; break; }
|
|
116
123
|
}
|
|
124
|
+
if (start === -1) {
|
|
125
|
+
if (!whole) return null;
|
|
126
|
+
start = 0;
|
|
127
|
+
}
|
|
117
128
|
const calls = []; // tool_use blocks, in order
|
|
118
129
|
const results = new Map(); // tool_use_id -> is_error
|
|
119
130
|
for (const obj of entries.slice(start)) {
|
|
@@ -147,9 +158,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
|
|
|
147
158
|
* The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
|
|
148
159
|
* goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
|
|
149
160
|
* `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
|
|
150
|
-
* ledger lands in the same root its sovereignty check read.
|
|
161
|
+
* ledger lands in the same root its sovereignty check read. `read` is `cmdLearn`'s per-Stop
|
|
162
|
+
* transcript reader, so the distillation after an allow reuses the windows read here.
|
|
151
163
|
*/
|
|
152
|
-
export function doneGate(meta, args, install) {
|
|
164
|
+
export function doneGate(meta, args, install, read = tailReader()) {
|
|
153
165
|
try {
|
|
154
166
|
const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
|
|
155
167
|
const root = gitRootOf(cwd);
|
|
@@ -160,15 +172,15 @@ export function doneGate(meta, args, install) {
|
|
|
160
172
|
err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
|
|
161
173
|
return false;
|
|
162
174
|
}
|
|
163
|
-
let
|
|
175
|
+
let missing;
|
|
164
176
|
try {
|
|
165
|
-
|
|
177
|
+
missing = read(meta.transcript_path,
|
|
178
|
+
(text, whole) => doneDecide(text, checks, root, cwd, whole));
|
|
166
179
|
} catch {
|
|
167
180
|
// No ask tier on Stop: blocking on an unreadable transcript would trap the user.
|
|
168
181
|
err('[done-gate] transcript unreadable — turn not judged.\n');
|
|
169
182
|
return false;
|
|
170
183
|
}
|
|
171
|
-
const missing = doneDecide(transcript, checks, root, cwd);
|
|
172
184
|
if (!missing.length) return false;
|
|
173
185
|
if (meta.stop_hook_active === true) {
|
|
174
186
|
recordGate(install, 'done-gate-cap', 'law-3');
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
|
|
|
12
12
|
import {
|
|
13
13
|
globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
|
|
14
14
|
} from './hosts.mjs';
|
|
15
|
-
import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
|
|
15
|
+
import { readStdin, splitLines, splitWords, tailReader } from './hooks-prims.mjs';
|
|
16
16
|
import { doneGate } from './hooks-done.mjs';
|
|
17
17
|
import {
|
|
18
18
|
existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
|
|
@@ -94,15 +94,26 @@ function contentText(content) {
|
|
|
94
94
|
return '';
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Render a Claude-Code-style JSONL transcript into `role: text` notes, read from the END: the
|
|
99
|
+
* window grows until its notes hold twice MAX_NOTES_CHARS UTF-16 units (so at least
|
|
100
|
+
* MAX_NOTES_CHARS code points) or it is the whole file. The window's notes are a suffix of the
|
|
101
|
+
* whole file's, so the tail `cmdLearn` keeps is the one a full read would give — and a tail of
|
|
102
|
+
* tool output, which flattens to nothing, just reads further back.
|
|
103
|
+
*/
|
|
104
|
+
function flattenTranscript(p, read) {
|
|
100
105
|
try {
|
|
101
106
|
// `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
|
|
102
|
-
|
|
107
|
+
return read(p, (raw, whole) => {
|
|
108
|
+
const flat = flattenText(raw);
|
|
109
|
+
return whole || flat.length >= 2 * MAX_NOTES_CHARS ? flat : null;
|
|
110
|
+
});
|
|
103
111
|
} catch {
|
|
104
112
|
return '';
|
|
105
113
|
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function flattenText(raw) {
|
|
106
117
|
const acc = [];
|
|
107
118
|
for (let line of splitLines(raw)) {
|
|
108
119
|
line = line.trim();
|
|
@@ -152,11 +163,11 @@ function flattenTranscript(p) {
|
|
|
152
163
|
* store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
|
|
153
164
|
* reaches none of them.
|
|
154
165
|
*/
|
|
155
|
-
export function readNotes(raw, meta = hookMeta(raw)) {
|
|
166
|
+
export function readNotes(raw, meta = hookMeta(raw), read = tailReader()) {
|
|
156
167
|
if (!raw.trim()) return '';
|
|
157
168
|
const tp = meta.agent_transcript_path || meta.transcript_path;
|
|
158
169
|
if (!tp) return raw;
|
|
159
|
-
const flat = flattenTranscript(tp);
|
|
170
|
+
const flat = flattenTranscript(tp, read);
|
|
160
171
|
const lastMsg = typeof meta.last_assistant_message === 'string'
|
|
161
172
|
? meta.last_assistant_message.trim() : '';
|
|
162
173
|
if (!flat) return lastMsg || flat;
|
|
@@ -314,7 +325,9 @@ export function cmdLearn(args) {
|
|
|
314
325
|
// means the turn goes on, so there is nothing to distil yet: the next Stop will.
|
|
315
326
|
// `--no-done-gate` (the install switched Law III's gate off) skips the check, never the
|
|
316
327
|
// distillation below.
|
|
317
|
-
|
|
328
|
+
// One reader for this Stop: an allowed turn's distillation reuses the windows the gate read.
|
|
329
|
+
const read = tailReader();
|
|
330
|
+
if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install, read)) return 0;
|
|
318
331
|
|
|
319
332
|
// I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
|
|
320
333
|
// to distil — and the bare envelope itself is not notes text (that would waste a model call
|
|
@@ -334,7 +347,7 @@ export function cmdLearn(args) {
|
|
|
334
347
|
|
|
335
348
|
// B3: a real hook call with `$GENESEED_LLM` unset has nothing to send a prompt to — stdout on
|
|
336
349
|
// Stop/SubagentStop/PreCompact reaches only the debug log, never the user (docs `hooks.md`) —
|
|
337
|
-
// so reading and flattening the transcript (
|
|
350
|
+
// so reading and flattening the transcript (a 2 MB tail or more, every turn) just to print it nowhere
|
|
338
351
|
// is pure waste. Checked BEFORE `readNotes`, which is what does that read. A manual
|
|
339
352
|
// invocation keeps printing the prompt on an unset `$GENESEED_LLM` — that is the documented,
|
|
340
353
|
// deliberate behaviour (docs/reference/env-context.md) for someone running `learn` by hand to
|
|
@@ -345,7 +358,7 @@ export function cmdLearn(args) {
|
|
|
345
358
|
return 0;
|
|
346
359
|
}
|
|
347
360
|
|
|
348
|
-
let notes = readNotes(raw, meta);
|
|
361
|
+
let notes = readNotes(raw, meta, read);
|
|
349
362
|
if (!notes.trim()) {
|
|
350
363
|
err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
|
|
351
364
|
return 0;
|
package/js/hosts/hooks-prims.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { statSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { comparePaths } from '../lib/paths.mjs';
|
|
13
|
+
import { readTail } from '../lib/fs.mjs';
|
|
13
14
|
|
|
14
15
|
// ---- Python path and text primitives -------------------------------------------------
|
|
15
16
|
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
@@ -75,6 +76,30 @@ export function splitLines(text) {
|
|
|
75
76
|
return parts;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** The first window a Stop reads from a transcript's end; real transcripts reach 50 MB. */
|
|
80
|
+
export const TAIL_BYTES = 2 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A transcript reader for ONE hook invocation: `read(file, judge)` hands `judge(text, whole)`
|
|
84
|
+
* the file's last `bytes`, then a window four times larger, until `judge` answers (anything
|
|
85
|
+
* but null) or the window holds the whole file — where it must answer. Each window is read once
|
|
86
|
+
* per reader, so the done gate and `learn` share one read when both run on the same Stop.
|
|
87
|
+
* A read error (missing, unreadable) throws to the caller, uncached. Never a module-level cache:
|
|
88
|
+
* the host appends between Stops, and a test rewrites the same path.
|
|
89
|
+
*/
|
|
90
|
+
export function tailReader(bytes = TAIL_BYTES) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
return (file, judge) => {
|
|
93
|
+
for (let n = bytes; ; n *= 4) {
|
|
94
|
+
const key = `${n}:${file}`;
|
|
95
|
+
if (!seen.has(key)) seen.set(key, readTail(file, n));
|
|
96
|
+
const { text, start } = seen.get(key);
|
|
97
|
+
const answer = judge(text, start === 0);
|
|
98
|
+
if (answer !== null || start === 0) return answer;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
79
104
|
export function splitWords(s) {
|
|
80
105
|
return s.split(/\s+/).filter(Boolean);
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
|
|
59
59
|
} from './hosts.mjs';
|
|
60
60
|
import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
|
|
61
|
-
import { LOSS_CANDIDATE_RE, lossReason } from './loss.mjs';
|
|
61
|
+
import { LOSS_CANDIDATE_RE, lossReason, stripStructure } from './loss.mjs';
|
|
62
62
|
import { selfAndParents, readStdin } from './hooks-prims.mjs';
|
|
63
63
|
import { recordUse } from './usage.mjs';
|
|
64
64
|
|
|
@@ -349,13 +349,14 @@ const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-c
|
|
|
349
349
|
const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g;
|
|
350
350
|
const SCAN_WRAPPERS = new Set(['sudo', 'command', '\\builtin', 'time', 'nice']);
|
|
351
351
|
// One left-to-right pass: a comment (`#` starting a word, to the end of the line — an apostrophe
|
|
352
|
-
// in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator
|
|
352
|
+
// in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator (a lone `&`
|
|
353
|
+
// backgrounds a command and runs the next, but `2>&1`, `<&3`, `&>` and `|&` are not one), or a
|
|
353
354
|
// heredoc opener (`<<TAG`, `<<-TAG`, `<< 'TAG'`, `<<"TAG"` — never the here-string `<<<`).
|
|
354
355
|
// Every alternative consumes at least one character, and none can backtrack past its start.
|
|
355
|
-
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
|
|
356
|
+
const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
|
|
356
357
|
|
|
357
358
|
/**
|
|
358
|
-
* The command cut at
|
|
359
|
+
* The command cut at `|`/`;`/`&`/`&&`/`||`/newline OUTSIDE quotes, with every heredoc body (the
|
|
359
360
|
* lines after `<<TAG` up to the `TAG` line; `<<-` lets it be tab-indented) dropped: both are data.
|
|
360
361
|
* Without this, `git commit -F - <<'EOF'` — the mandated multi-line commit form — or a quoted
|
|
361
362
|
* `-m "fix; find / loop"` whose text merely names a root scan reads as one, which on Bob and
|
|
@@ -401,7 +402,8 @@ export function rootScan(command) {
|
|
|
401
402
|
if (!SCAN_WORD_RE.test(command)) return null;
|
|
402
403
|
for (const seg of scanSegments(command)) {
|
|
403
404
|
// Tokens keep their quotes until the root test, so a quoted `"a:"` can be told from a bare `C:`.
|
|
404
|
-
|
|
405
|
+
// `(find / …)`, `then find / …`: the grammar around the command is not its program.
|
|
406
|
+
let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t));
|
|
405
407
|
while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1);
|
|
406
408
|
if (!toks.length) continue;
|
|
407
409
|
// The program word without its path (either slash) or an `.exe` suffix: `C:\tools\rg.exe`.
|
|
@@ -427,6 +429,8 @@ export function rootScan(command) {
|
|
|
427
429
|
return null;
|
|
428
430
|
}
|
|
429
431
|
const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
|
|
432
|
+
// The segments `lossReason` reads: raw tokens, quotes still on — also how it cuts a `$(…)` body.
|
|
433
|
+
const lossSegments = (s) => scanSegments(s).map((seg) => seg.match(SCAN_TOKEN_RE) || []);
|
|
430
434
|
// A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
|
|
431
435
|
// (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
|
|
432
436
|
const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
|
|
@@ -439,8 +443,8 @@ function gitDecide(args, payload) {
|
|
|
439
443
|
if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
|
|
440
444
|
// A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
|
|
441
445
|
// a push, and process 5 below still asks for it.
|
|
442
|
-
const loss = lossReason(
|
|
443
|
-
|
|
446
|
+
const loss = lossReason(lossSegments(command), (payload && payload.cwd) || process.cwd(),
|
|
447
|
+
{ split: lossSegments });
|
|
444
448
|
if (loss) {
|
|
445
449
|
return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 ${loss}. `
|
|
446
450
|
+ 'Confirm this specific command, or leave the data in place.');
|
|
@@ -521,8 +525,13 @@ export const DONE_LIST = '.geneseed/done-checks.txt';
|
|
|
521
525
|
* not seen here; CI running the checks is the boundary that holds against that.
|
|
522
526
|
*/
|
|
523
527
|
function protectedCheck(p) {
|
|
528
|
+
return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p));
|
|
529
|
+
}
|
|
530
|
+
// One reading of the target. `protectedCheck` takes two — the path as written and the path with
|
|
531
|
+
// links followed — so a link from outside into a protected folder and a protected folder that
|
|
532
|
+
// links out of the repo are both caught: following links never loosens the gate.
|
|
533
|
+
function protectedIn(target) {
|
|
524
534
|
try {
|
|
525
|
-
const target = resolvePath(p);
|
|
526
535
|
const root = gitRootOf(path.dirname(target));
|
|
527
536
|
if (!root) return null;
|
|
528
537
|
if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -609,9 +609,6 @@ export function gatesOfDir(d, host = null) {
|
|
|
609
609
|
return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, gateOfDir(d, g.marker, host)]));
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
/** Law IV's switch alone (#319's reader, kept for its callers). */
|
|
613
|
-
export const deletionGateOfDir = (d, host = null) => gateOfDir(d, 'Deletion gate', host);
|
|
614
|
-
|
|
615
612
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
616
613
|
|
|
617
614
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
@@ -838,7 +835,11 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
|
|
|
838
835
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
839
836
|
|
|
840
837
|
/**
|
|
841
|
-
* `_harness_setup._installed_defaults` —
|
|
838
|
+
* `_harness_setup._installed_defaults` — eight keys: `theme`, `posture`, `mode`, `emit`,
|
|
839
|
+
* `footprint`, `doctrines`, `excludeRules`, `excludeSkills`. NOT the gate switches: a walk
|
|
840
|
+
* across every install on the machine would carry one host's `off` into another's re-run, and
|
|
841
|
+
* for a gate that disarms a boundary its owner never switched off — `setup` reads them off the
|
|
842
|
+
* one install it is re-running (`targetGates` in js/maintain/setup.mjs).
|
|
842
843
|
*
|
|
843
844
|
* It carried only `theme` and `emit` for four phases because those are the two `status`
|
|
844
845
|
* prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
|