geneseed 4.9.1 → 4.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +7 -1
  4. package/adapters/opencode/plugins/geneseed-guard.js +123 -12
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/enforcement-table.md +1 -1
  7. package/docs/concepts/exclusions.md +1 -2
  8. package/docs/concepts/hooks.md +3 -2
  9. package/docs/guides/upgrade.md +1 -1
  10. package/harness.config.json +1 -1
  11. package/js/README.md +30 -9
  12. package/js/build/args.mjs +9 -8
  13. package/js/build/driver.mjs +21 -21
  14. package/js/build/emit-claude.mjs +3 -3
  15. package/js/build/generate.mjs +4 -5
  16. package/js/build/source.mjs +18 -22
  17. package/js/hosts/gitref.mjs +5 -3
  18. package/js/hosts/hooks-done.mjs +21 -9
  19. package/js/hosts/hooks-learn.mjs +23 -10
  20. package/js/hosts/hooks-prims.mjs +25 -0
  21. package/js/hosts/hooks.mjs +19 -4
  22. package/js/hosts/installs.mjs +5 -4
  23. package/js/hosts/loss.mjs +98 -0
  24. package/js/hosts/native.mjs +3 -1
  25. package/js/hosts/owned.mjs +34 -6
  26. package/js/hosts/settings.mjs +6 -5
  27. package/js/inspect/checks-probe.mjs +1 -1
  28. package/js/inspect/cost.mjs +37 -5
  29. package/js/inspect/diff.mjs +154 -64
  30. package/js/inspect/validate.mjs +2 -2
  31. package/js/lib/fs.mjs +73 -2
  32. package/js/maintain/setup.mjs +31 -7
  33. package/js/maintain/uninstall.mjs +1 -1
  34. package/js/maintain/update.mjs +1 -1
  35. package/js/web/actions.mjs +36 -3
  36. package/js/web/api.mjs +17 -6
  37. package/js/web/handler.mjs +4 -16
  38. package/js/web/jobs.mjs +5 -3
  39. package/js/web/state.mjs +3 -0
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-C8sZVIUU.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-Cc6vWUS4.js} +1 -1
  43. package/web/dist/assets/Dashboard-C5ATa3ow.js +2 -0
  44. package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-tDW7i7mo.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-zk2Uzc3A.js +2 -0
  46. package/web/dist/assets/Installs-CB_rvbBP.js +3 -0
  47. package/web/dist/assets/JournalView-Bfj27XHC.js +1 -0
  48. package/web/dist/assets/Laws-BA3CzUf5.js +3 -0
  49. package/web/dist/assets/Library-BWwiy9Vb.js +1 -0
  50. package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-DaPk-ckL.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-CyiGhiHk.js} +1 -1
  52. package/web/dist/assets/Personal-Bwd27CmT.js +1 -0
  53. package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-BOD7Kaz1.js} +1 -1
  54. package/web/dist/assets/drawable-CyLe1gtW.js +1 -0
  55. package/web/dist/assets/index-DQXG_mqo.js +2 -0
  56. package/web/dist/index.html +1 -1
  57. package/web/src/pages/Laws.jsx +60 -353
  58. package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
  59. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
  60. package/web/dist/assets/Installs-CWuUU4I3.js +0 -2
  61. package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
  62. package/web/dist/assets/Laws-R5zZCmya.js +0 -3
  63. package/web/dist/assets/Library-u36V7oSD.js +0 -1
  64. package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
  65. package/web/dist/assets/drawable-CE-arUnF.js +0 -1
  66. package/web/dist/assets/index-3lnPT_1J.js +0 -2
@@ -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
- /** `_build_emit.PRIMARY_AGENT_SRC`. */
79
- const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
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
- // Only an explicit `false` disarms; `null` (no opinion) keeps the gate.
659
- ...(deletionGate === false ? { noDeletionGate: true } : {}),
660
- ...(secretsGate === false ? { noSecretsGate: true } : {}),
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, deletionGate = 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, deletionGate, secretsGate, doneGate }),
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, deletionGate = null, secretsGate = null, doneGate = null,
692
+ excludeRules = null, excludeSkills = null, ...gates
693
693
  }) {
694
694
  return withPlatformNewlines(() => build(axesCfg({
695
- posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate, secretsGate, doneGate }),
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, deletionGate = null, secretsGate = null, doneGate = 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, deletionGate, secretsGate, doneGate }),
718
+ posture, mode, trust, doctrines, excludeRules, excludeSkills, ...gates }),
719
719
  { theme, footprint, root: null, cfgDir }, out,
720
720
  ));
721
721
  }
@@ -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.deletionGate, { secretsGate: cfg.secretsGate, doneGate: cfg.doneGate });
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
- deletionGate = true, gates = {}) {
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, deletionGate, gates,
283
+ doctrines, excludeRules, host, cfgDir, gates,
284
284
  );
285
285
  managed.settings_hooks = managedHooks;
286
286
 
@@ -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, deletionGate = true,
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 gates = { secretsGate, doneGate, deletionGate };
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.deletionGate, 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
@@ -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 = [], noDeletionGate = false, noSecretsGate = false,
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
- // Law IV's deletion gate (`--no-deletion-gate`). Armed unless switched off in so many words:
432
- // only a literal `true` disarms, so every caller that never heard of the switch keeps it.
433
- deletionGate: noDeletionGate !== true,
434
- // Law I's secret scan (`--no-secrets-gate`) and Law III's done gate (`--no-done-gate`), the
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
- ];
@@ -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 once kept a standalone twin of this for its `permission.ask`
16
- * loop exemption; upstream never calls that hook, so the twin was deleted (OpenCode verdict
17
- * I-2). This module has no copy to keep in step.
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';
@@ -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 = 0;
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 transcript;
175
+ let missing;
164
176
  try {
165
- transcript = readText(meta.transcript_path);
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');
@@ -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
- /** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
98
- function flattenTranscript(p) {
99
- let raw;
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
- raw = readText(p);
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
- if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install)) return 0;
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 (up to 16 KB, every turn) just to print it nowhere
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;
@@ -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);
@@ -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, stripStructure } from './loss.mjs';
61
+ import { LOSS_CANDIDATE_RE, catastrophic, lossReason, stripStructure } from './loss.mjs';
62
62
  import { selfAndParents, readStdin } from './hooks-prims.mjs';
63
63
  import { recordUse } from './usage.mjs';
64
64
 
@@ -438,8 +438,18 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
438
438
  function gitDecide(args, payload) {
439
439
  const command = ((payload && payload.tool_input) || {}).command;
440
440
  if (typeof command !== 'string') return 0;
441
- // `--no-deletion-gate`: the install switched Law IV's boundary off (user decision,
442
- // 2026-10-17). Only this check stands down — ops 2 and process 5 below still run.
441
+ // Law IV's catastrophic class (a root, a drive, the home dir, a disk): no state makes it safe,
442
+ // so it runs BEFORE the switch below and whatever it says (user decision, 2026-10-18). On
443
+ // Claude an ask is the strongest verdict the hook has; on Bob, `law-4` exits 2.
444
+ const doomed = catastrophic(command, lossSegments);
445
+ if (doomed) {
446
+ return ask(args, 'git-gate', 'law-4', `Geneseed (Deletion Is Deliberate) \u2014 \`${doomed.slice(0, 80)}\` `
447
+ + 'would destroy a filesystem root, a drive, the home directory or a whole disk, which nothing '
448
+ + 'could restore. This check stands even with the deletion gate off.');
449
+ }
450
+ // `--no-deletion-gate`: the install switched Law IV's real-loss boundary off (user decision,
451
+ // 2026-10-17). Only this check stands down — the catastrophic class above, ops 2 and process 5
452
+ // below still run.
443
453
  if (!args.noDeletionGate && LOSS_CANDIDATE_RE.test(command)) {
444
454
  // A pass here FALLS THROUGH: a force push to a feature branch loses nothing, but it is still
445
455
  // a push, and process 5 below still asks for it.
@@ -525,8 +535,13 @@ export const DONE_LIST = '.geneseed/done-checks.txt';
525
535
  * not seen here; CI running the checks is the boundary that holds against that.
526
536
  */
527
537
  function protectedCheck(p) {
538
+ return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p));
539
+ }
540
+ // One reading of the target. `protectedCheck` takes two — the path as written and the path with
541
+ // links followed — so a link from outside into a protected folder and a protected folder that
542
+ // links out of the repo are both caught: following links never loosens the gate.
543
+ function protectedIn(target) {
528
544
  try {
529
- const target = resolvePath(p);
530
545
  const root = gitRootOf(path.dirname(target));
531
546
  if (!root) return null;
532
547
  if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
@@ -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` — all six keys.
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
package/js/hosts/loss.mjs CHANGED
@@ -625,4 +625,102 @@ export function lossReason(segments, cwd, { always = true, split } = {}) {
625
625
  return `the gate could not verify what this command would lose (${e.message})`;
626
626
  }
627
627
  }
628
+
629
+ /**
630
+ * THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
631
+ * a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
632
+ * `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
633
+ * decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
634
+ * in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
635
+ * -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
636
+ * names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
637
+ * are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
638
+ * here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
639
+ * other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
640
+ * it is tested on the whole command. `split` cuts a command into raw-token segments, as for
641
+ * `lossReason`. Returns the offending text, or null.
642
+ */
643
+ const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
644
+ const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
645
+ // `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
646
+ const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
647
+ const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
648
+ // PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
649
+ const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
650
+ const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
651
+ // The programs that run what they read on stdin as commands.
652
+ const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
653
+ // The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
654
+ // openers it finds are the ones left in the segments' tokens, in the same order.
655
+ const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
656
+ /** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
657
+ function heredocBodies(command) {
658
+ const out = [];
659
+ const tags = [];
660
+ const re = new RegExp(HEREDOC_SCAN_RE);
661
+ let m;
662
+ while ((m = re.exec(command))) {
663
+ if (m[3]) { tags.push([m[1], m[3]]); continue; }
664
+ if (m[0] !== '\n' || !tags.length) continue;
665
+ let start = re.lastIndex;
666
+ while (tags.length) {
667
+ const [dash, tag] = tags.shift();
668
+ const body = [];
669
+ while (start < command.length) {
670
+ const end = command.indexOf('\n', start);
671
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
672
+ start = end < 0 ? command.length : end + 1;
673
+ if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
674
+ body.push(line);
675
+ }
676
+ out.push(body.join('\n'));
677
+ }
678
+ re.lastIndex = start;
679
+ }
680
+ return out;
681
+ }
682
+ export function catastrophic(command, split, depth = 0) {
683
+ if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
684
+ if (FORK_BOMB_RE.test(command)) return command.trim();
685
+ const bodies = command.includes('<<') ? heredocBodies(command) : [];
686
+ let next = 0;
687
+ for (const raw of split(command).map(stripStructure)) {
688
+ const text = raw.join(' ');
689
+ // What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
690
+ const fed = [];
691
+ raw.forEach((t, i) => {
692
+ if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
693
+ else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
694
+ });
695
+ for (const body of depth < 4 ? substitutions(text) : []) {
696
+ const v = catastrophic(body, split, depth + 1);
697
+ if (v) return v;
698
+ }
699
+ let w = words(raw).map((t) => t.replace(OPAQUE, ''));
700
+ while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
701
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
702
+ }
703
+ if (!w.length) continue;
704
+ const prog = progOf(w[0]);
705
+ for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
706
+ const v = catastrophic(body, split, depth + 1);
707
+ if (v) return v;
708
+ }
709
+ const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
710
+ // cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
711
+ const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
712
+ let hit = false;
713
+ if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
714
+ else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
715
+ else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
716
+ else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
717
+ else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
718
+ else if (INDIRECT.has(prog) && depth < 4) {
719
+ const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
720
+ if (v) return v;
721
+ }
722
+ if (hit) return text.trim();
723
+ }
724
+ return null;
725
+ }
628
726
  // ---- loss twin: END