geneseed 4.8.0 → 4.9.1

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 (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/claude-code/README.md +3 -1
  4. package/adapters/opencode/plugins/geneseed-context.js +17 -1
  5. package/adapters/opencode/plugins/geneseed-guard.js +138 -32
  6. package/bin/geneseed-hook.mjs +9 -4
  7. package/docs/concepts/enforcement-table.md +2 -2
  8. package/docs/concepts/exclusions.md +18 -1
  9. package/docs/concepts/hooks.md +2 -2
  10. package/docs/reference/plugin-context.md +1 -1
  11. package/docs/reference/plugin-guard.md +1 -1
  12. package/docs/reference/troubleshoot-doctor.md +2 -1
  13. package/docs/web-ui.md +10 -8
  14. package/harness.config.json +1 -1
  15. package/js/build/args.mjs +3 -0
  16. package/js/build/driver.mjs +14 -6
  17. package/js/build/emit-claude.mjs +3 -3
  18. package/js/build/generate.mjs +16 -12
  19. package/js/build/render.mjs +7 -5
  20. package/js/build/source.mjs +19 -1
  21. package/js/cli-table.json +75 -0
  22. package/js/hosts/hooks-done.mjs +4 -0
  23. package/js/hosts/hooks-learn.mjs +3 -1
  24. package/js/hosts/hooks.mjs +14 -8
  25. package/js/hosts/installs.mjs +20 -11
  26. package/js/hosts/loss.mjs +124 -18
  27. package/js/hosts/settings.mjs +17 -8
  28. package/js/inspect/diff.mjs +4 -4
  29. package/js/inspect/doctor.mjs +4 -3
  30. package/js/inspect/status.mjs +4 -3
  31. package/js/inspect/validate.mjs +9 -4
  32. package/js/maintain/uninstall.mjs +7 -5
  33. package/js/maintain/update.mjs +1 -1
  34. package/js/web/actions.mjs +22 -17
  35. package/js/web/api.mjs +13 -11
  36. package/js/web/handler.mjs +1 -1
  37. package/js/web/routes.mjs +3 -1
  38. package/js/web/server.mjs +3 -1
  39. package/js/web/state.mjs +32 -8
  40. package/package.json +1 -1
  41. package/src/AGENT.md.tmpl +1 -1
  42. package/web/dist/assets/{Activity-B-XbKlzu.js → Activity-GQGAZHIE.js} +1 -1
  43. package/web/dist/assets/{ActivityDetail-8oq0fycD.js → ActivityDetail-DktWyxim.js} +1 -1
  44. package/web/dist/assets/Dashboard-CmSn6JfK.js +2 -0
  45. package/web/dist/assets/Docs-CsWYLnN1.js +5 -0
  46. package/web/dist/assets/FilterInput-zLFXoSt6.js +1 -0
  47. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +2 -0
  48. package/web/dist/assets/{Installs-DrqPaLDM.js → Installs-CWuUU4I3.js} +2 -2
  49. package/web/dist/assets/JournalView-ClbZj_YF.js +1 -0
  50. package/web/dist/assets/Laws-R5zZCmya.js +3 -0
  51. package/web/dist/assets/{Library-BCaqPf61.js → Library-u36V7oSD.js} +1 -1
  52. package/web/dist/assets/{Loops-Bx3HnlUA.js → Loops-BUBOeM0C.js} +2 -2
  53. package/web/dist/assets/{Markdown-BK38dn4x.js → Markdown-iHvBrpRN.js} +1 -1
  54. package/web/dist/assets/{OperatorHudView-ChQPM8JE.js → OperatorHudView-BJuCYsjf.js} +1 -1
  55. package/web/dist/assets/{Personal-DELm61DF.js → Personal-DWQuSAUz.js} +1 -1
  56. package/web/dist/assets/Tabs-GPQlocTw.js +1 -0
  57. package/web/dist/assets/{activity-D4w9t8fE.js → activity-DvPxoS4Y.js} +1 -1
  58. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  59. package/web/dist/assets/drawable-CE-arUnF.js +1 -0
  60. package/web/dist/assets/{index-r2jRgf6l.js → index-3lnPT_1J.js} +2 -2
  61. package/web/dist/assets/{index-CHpydQJg.css → index-BCJz4_8C.css} +1 -1
  62. package/web/dist/assets/{react-Ci4wQD4a.js → react-BOF6yL40.js} +1 -1
  63. package/web/dist/assets/{sections-GalMS0Zv.js → sections-BET3c1WT.js} +1 -1
  64. package/web/dist/assets/{usePoll-BcywUo55.js → usePoll-NSTzLOF4.js} +1 -1
  65. package/web/dist/index.html +5 -5
  66. package/web/src/pages/Laws.jsx +52 -25
  67. package/web/dist/assets/Dashboard-C4EMz5mf.js +0 -2
  68. package/web/dist/assets/Docs-mq4XDQK_.js +0 -5
  69. package/web/dist/assets/FilterInput-Ikvi-pS9.js +0 -1
  70. package/web/dist/assets/GreenhouseView-D1WOtq3n.js +0 -2
  71. package/web/dist/assets/JournalView-BYpASU-L.js +0 -1
  72. package/web/dist/assets/Laws-A3PFwiPD.js +0 -3
  73. package/web/dist/assets/Tabs-DcMq5IvC.js +0 -1
  74. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
  75. package/web/dist/assets/drawable-BI2_tF3o.js +0 -1
package/js/hosts/loss.mjs CHANGED
@@ -18,14 +18,18 @@
18
18
  * `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
19
19
  * holds — the same forms on a feature branch pass;
20
20
  * - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
21
- * - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of anything git
22
- * cannot bring back: untracked, ignored or modified files, a repository's own `.git`, or
23
- * any path outside a git repository.
21
+ * - a file removal (`rm`, `rmdir`, `unlink`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
22
+ * anything git cannot bring back: untracked, ignored or modified files, a repository's own
23
+ * `.git`, or any path outside a git repository.
24
24
  *
25
25
  * FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
26
- * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`) carrying a removal,
27
- * a git that fails, times out or is missing — asks, and the reason says it could not verify.
28
- * "Not proven safe" is an ask; only "proven safe" passes.
26
+ * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`, `find -delete`)
27
+ * carrying a removal, a removal that names no path (`… | Remove-Item` takes its paths from the
28
+ * pipe), a wrapper flag whose arity it does not know, ANY program it does not read (`setsid`,
29
+ * `ForEach-Object`, `case`) with a removal named unquoted after it, a git that fails, times out
30
+ * or is missing — asks, and the reason says it could not verify. A command substitution
31
+ * (`$(…)`, backticks) is judged as a command of its own. "Not proven safe" is an ask; only
32
+ * "proven safe" passes.
29
33
  *
30
34
  * THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
31
35
  * files are untracked, and whether a branch's commits are reachable elsewhere are not readable
@@ -60,16 +64,31 @@ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitr
60
64
  * a superset of what the parse acts on.
61
65
  */
62
66
  export const LOSS_CANDIDATE_RE =
63
- /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
67
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
64
68
 
65
69
  class Unverified extends Error {}
66
70
  const unverified = (why) => { throw new Unverified(why); };
67
71
 
68
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
72
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
69
73
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
70
74
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
71
75
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
72
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
76
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
77
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
78
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
79
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
80
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
81
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
82
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
83
+ '--non-interactive', '--ignore-environment', '--null']);
84
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
85
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
86
+ // The only programs this reader does not parse that may still name a removal word and pass: their
87
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
88
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
89
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
90
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
91
+ 'get-help']);
73
92
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
74
93
  // not on this command line in a form the parse can read, so a removal word inside one asks.
75
94
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -434,6 +453,8 @@ function rmAct(prog, args, dir) {
434
453
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
435
454
  targets.push(a);
436
455
  }
456
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
457
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
437
458
  const groups = new Map();
438
459
  for (const t of targets) {
439
460
  const shown = t.replace(OPAQUE, '');
@@ -475,6 +496,69 @@ function rmAct(prog, args, dir) {
475
496
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
476
497
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
477
498
 
499
+ /**
500
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
501
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
502
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
503
+ * shares it, so `(find / …)` is a scan.
504
+ */
505
+ export function stripStructure(raw) {
506
+ const t = raw.slice();
507
+ while (t.length) {
508
+ if (STRUCTURE.has(t[0])) t.shift();
509
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
510
+ else break;
511
+ }
512
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
513
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
514
+ return t;
515
+ }
516
+
517
+ /**
518
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
519
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
520
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
521
+ */
522
+ function substitutions(text) {
523
+ const out = [];
524
+ let sq = false; let dq = false;
525
+ for (let i = 0; i < text.length; i++) {
526
+ const c = text[i];
527
+ if (sq) { if (c === "'") sq = false; continue; }
528
+ if (c === "'" && !dq) { sq = true; continue; }
529
+ if (c === '"') { dq = !dq; continue; }
530
+ if (c === '`') {
531
+ const end = text.indexOf('`', i + 1);
532
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
533
+ i = end < 0 ? text.length : end;
534
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
535
+ let depth = 1; let j = i + 2;
536
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
537
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
538
+ i = j - 1;
539
+ }
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
545
+ function pastWrapper(name, w) {
546
+ const [takes, plain] = WRAPPERS.get(name).split('|');
547
+ let i = 0;
548
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
549
+ const a = w[i];
550
+ if (a === '--') { i += 1; break; }
551
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
552
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
553
+ for (let j = 1; j < a.length; j++) {
554
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
555
+ if (!plain.includes(a[j])) return null;
556
+ }
557
+ }
558
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
559
+ return w.slice(i);
560
+ }
561
+
478
562
  /**
479
563
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
480
564
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -485,19 +569,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
485
569
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
486
570
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
487
571
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
572
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
573
+ * splitter); without it, a substitution that names a removal asks.
574
+ * @param {string[][]} segments
575
+ * @param {string} cwd
576
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
488
577
  */
489
- export function lossReason(segments, cwd, { always = true } = {}) {
490
- let dir = cwd;
491
- try {
492
- for (const raw of segments) {
578
+ export function lossReason(segments, cwd, { always = true, split } = {}) {
579
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
580
+ const judge = (segs, start, depth = 0) => {
581
+ let dir = start;
582
+ for (const raw of segs.map(stripStructure)) {
583
+ const text = raw.join(' ');
584
+ const named = LOSS_CANDIDATE_RE.test(text);
585
+ for (const body of named ? substitutions(text) : []) {
586
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
587
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
588
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
589
+ const v = judge(split(body), dir, depth + 1);
590
+ if (v) return v;
591
+ }
493
592
  let w = words(raw);
494
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
593
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
495
594
  const wrapper = progOf(w[0]);
496
- w = w.slice(1);
497
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
498
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
595
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
596
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
499
597
  }
500
- if (!w.length) continue;
598
+ if (!w || !w.length) continue;
501
599
  const prog = progOf(w[0]);
502
600
  const args = w.slice(1);
503
601
  if (CD_PROGS.has(prog)) {
@@ -510,10 +608,18 @@ export function lossReason(segments, cwd, { always = true } = {}) {
510
608
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
511
609
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
512
610
  unverified(`\`${prog}\` runs a command the gate cannot read`);
611
+ } else if (named && !INERT.has(prog)
612
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
613
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
614
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
615
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
513
616
  }
514
617
  if (verdict) return verdict;
515
618
  }
516
619
  return null;
620
+ };
621
+ try {
622
+ return judge(segments, cwd);
517
623
  } catch (e) {
518
624
  if (!(e instanceof Unverified)) throw e;
519
625
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -726,7 +726,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
726
726
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
727
727
  */
728
728
  export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
729
- deletionGate = true) {
729
+ deletionGate = true, { secretsGate = true, doneGate = true } = {}) {
730
730
  const run = hookPrefix(hookOpts);
731
731
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
732
732
  // --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
@@ -760,20 +760,25 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
760
760
  // `--no-deletion-gate` only when the install switched Law IV's gate off, so an armed
761
761
  // install's command stays byte-identical (and an older shim never sees the flag).
762
762
  const dg = deletionGate === false ? ' --no-deletion-gate' : '';
763
+ // Law I's secret scan and Law III's done gate, under the same contract (user decision
764
+ // 2026-10-18): each flag only when that gate is off.
765
+ const sg = secretsGate === false ? ' --no-secrets-gate' : '';
766
+ const dn = doneGate === false ? ' --no-done-gate' : '';
763
767
  return {
764
768
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
765
769
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
766
770
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
767
- // `tool-gate` only ever EXITS 2 for Laws I and IV (IV unless built `--no-deletion-gate`), and for a
771
+ // `tool-gate` only ever EXITS 2 for Laws I and IV (unless built `--no-secrets-gate` /
772
+ // `--no-deletion-gate`), and for a
768
773
  // write under a project's own `.geneseed/protected-checks.txt` — the project opted in —
769
774
  // and for a recursive scan rooted at a whole filesystem (ops 2, the root-scan check).
770
- PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${dg}` }] }],
775
+ PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${sg}${dg}` }] }],
771
776
  // Bob's Stop payload never carries `transcript_path` (I2) — `learn` reads `--host bob`
772
777
  // and returns immediately rather than wasting a model call on the bare envelope. KEPT
773
778
  // rather than DROPPED: the stand-down costs nothing (no transcript read, no spawn), and
774
779
  // if Bob ever starts sending one, learning on Bob starts working with no emit change —
775
780
  // dropping the group would need a second one re-added later for the same reason.
776
- Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem}${b} || exit 0` }] }],
781
+ Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem}${b}${dn} || exit 0` }] }],
777
782
  };
778
783
  }
779
784
  // OpenClaude takes Claude's whole group set and verdicts unchanged. `context` needs the host
@@ -786,8 +791,11 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
786
791
  const context = `${run} context --root "${cfg}"${h} || exit 0`;
787
792
  const gate = `${run} git-gate --root "${cfg}"${h}${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`
788
793
  + (deletionGate === false ? ' --no-deletion-gate' : '');
789
- const ruleGate = `${run} rule-gate --root "${cfg}"`;
790
- const learn = `${run} learn ${mem}${h} || exit 0`;
794
+ // `--no-secrets-gate` skips only rule-gate's credential scan (rigor 5 and process 1 still
795
+ // run); `--no-done-gate` only learn's done check, never the distillation. One `learn` string
796
+ // feeds Stop, SubagentStop and PreCompact, and the flag sits before `|| exit 0`.
797
+ const ruleGate = `${run} rule-gate --root "${cfg}"${secretsGate === false ? ' --no-secrets-gate' : ''}`;
798
+ const learn = `${run} learn ${mem}${h}${doneGate === false ? ' --no-done-gate' : ''} || exit 0`;
791
799
  // Claude Code only (hooks.md, v2.1.295+; Task 1): a `command` hook that can't start, times
792
800
  // out, or exits anything but 0 or 2 — even one whose stdout happens to be valid JSON — now
793
801
  // BLOCKS the tool call instead of sliding through as the non-blocking default. Only safe to
@@ -924,14 +932,15 @@ function readSettingsForMerge(p, consequence) {
924
932
  * global that never stood down for a project install.
925
933
  */
926
934
  export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
927
- doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true) {
935
+ doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true,
936
+ gates = {}) {
928
937
  const prior = (priorHooks || []).filter(isDict);
929
938
  const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
930
939
  if (!loaded) return [p, prior];
931
940
  const [config, hadComments] = loaded;
932
941
  let hooks = get(config, 'hooks');
933
942
  if (!isDict(hooks)) hooks = {};
934
- const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate);
943
+ const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate, gates);
935
944
  const canonFlat = [];
936
945
  for (const [event, groups] of Object.entries(canonical)) {
937
946
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -44,8 +44,8 @@ import {
44
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
45
45
  } from '../hosts/hosts.mjs';
46
46
  import {
47
- EMIT_HOST_SCOPE, defaultTheme, deletionGateOfDir, doctrinesForBuild, excludedRulesOfDir,
48
- excludedSkillsOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir,
48
+ excludedSkillsOfDir, gatesOfDir,
49
49
  footprintOfDir, modeOfDir, postureOfDir,
50
50
  trustOfDir,
51
51
  readJsonMaybe, readMaybe, themeOfDir,
@@ -184,8 +184,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
184
184
  excludeRules: excludedRulesOfDir(dir),
185
185
  // The excluded skills, or every skill the install left out reads as deleted.
186
186
  excludeSkills: excludedSkillsOfDir(dir),
187
- // Law IV's gate switch, or a disarmed install reads its `Deletion gate: off` line as drift.
188
- deletionGate: deletionGateOfDir(dir),
187
+ // The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
188
+ ...gatesOfDir(dir),
189
189
  }));
190
190
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
191
191
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -25,9 +25,10 @@
25
25
  * that this tool never shells back to the generator — is asserted directly instead.
26
26
  *
27
27
  * WHAT `doctorCollect` DOES NOT REACH, MEASURED RATHER THAN ASSUMED. It takes `groups` and
28
- * `cmdDoctor` does not pass it: that parameter is the WEB's, and `apiDoctor` in
29
- * `js/web/api.mjs` is its only caller. `ran` exists for it — four lines of accumulator that
30
- * keep the Doctor page's per-check cards a parameter rather than a rewrite.
28
+ * `cmdDoctor` does not pass it: that parameter is the WEB's, and `runDoctor` in
29
+ * `js/web/state.mjs` (the cache `apiDoctor` serves) is its only caller. `ran` exists for it —
30
+ * four lines of accumulator that keep the Doctor page's per-check cards a parameter rather
31
+ * than a rewrite.
31
32
  */
32
33
  import path from 'node:path';
33
34
  import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
@@ -58,7 +58,7 @@
58
58
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
59
59
  import path from 'node:path';
60
60
 
61
- import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
61
+ import { GATE_SWITCHES, THEMES, ROOT, makeCfg } from '../build/source.mjs';
62
62
  import { tuiInventory } from './inventory.mjs';
63
63
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
64
64
  import {
@@ -422,8 +422,9 @@ export function statusLines(d, color = false) {
422
422
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
423
423
  rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
424
424
  packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : []),
425
- // Elided when armed. Law IV's text still binds; only its tool-boundary gate is off.
426
- ...(p.deletionGate === false ? ['deletion gate: off (Law IV text still binds)'] : [])]
425
+ // Each elided when armed. The law's text still binds; only its tool-boundary gate is off.
426
+ ...GATE_SWITCHES.filter((g) => p[g.key] === false)
427
+ .map((g) => `${g.marker.toLowerCase()}: off (Law ${g.law} text still binds)`)]
427
428
  .join(` ${DOT} `) + where]);
428
429
  }
429
430
 
@@ -8,9 +8,10 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { buildInto, emitGlobalInto, emitProjectInto } from '../build/driver.mjs';
11
+ import { GATE_SWITCHES } from '../build/source.mjs';
11
12
  import { resolvePath } from '../hosts/hosts.mjs';
12
13
  import {
13
- deletionGateOfDir, doctrinesOfDir, excludedRulesOfDir, modeOfDir, postureOfDir,
14
+ doctrinesOfDir, excludedRulesOfDir, gatesOfDir, modeOfDir, postureOfDir,
14
15
  } from '../hosts/installs.mjs';
15
16
  import { validateIsVendored } from '../hosts/native.mjs';
16
17
  import { printOut, printErr } from '../lib/fs.mjs';
@@ -33,7 +34,8 @@ export const VALIDATE_USAGE = [
33
34
  'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
35
  ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
36
  ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
36
- ' [--exclude-skills SKILLS] [--no-deletion-gate] [-v]',
37
+ ' [--exclude-skills SKILLS] [--no-secrets-gate] [--no-done-gate]',
38
+ ' [--no-deletion-gate] [-v]',
37
39
  '',
38
40
  "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
39
41
  "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
@@ -129,6 +131,7 @@ export function cmdValidate(args) {
129
131
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
130
132
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
131
133
  excludeSkills: args.excludeSkills, deletionGate: args.noDeletionGate ? false : null,
134
+ secretsGate: args.noSecretsGate ? false : null, doneGate: args.noDoneGate ? false : null,
132
135
  };
133
136
  let scanDirs;
134
137
  try {
@@ -170,11 +173,13 @@ export function cmdValidate(args) {
170
173
  const at = scanDirs[0];
171
174
  const packs = doctrinesOfDir(at);
172
175
  const excluded = excludedRulesOfDir(at);
176
+ const gates = gatesOfDir(at);
173
177
  printOut(`[validate-only] rendered at posture=${postureOfDir(at) ?? '?'} `
174
178
  + `mode=${modeOfDir(at) ?? '?'} packs=${packs ? packs.join(',') || 'none' : '?'} `
175
179
  + `excluded=${excluded.map((id) => id.replace('.', ' ')).join(', ') || 'none'}`
176
- // Elided when armed, so an armed dry run prints the line it always did.
177
- + `${deletionGateOfDir(at) ? '' : ' deletion-gate=off'}\n`);
180
+ // Each elided when armed, so an armed dry run prints the line it always did.
181
+ + `${GATE_SWITCHES.filter((g) => gates[g.key] === false)
182
+ .map((g) => ` ${g.marker.toLowerCase().replace(' ', '-')}=off`).join('')}\n`);
178
183
  printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
179
184
  + (args.root ? ` (root ${args.root})` : '')
180
185
  + ' — nothing was actually written (sandboxed).\n');
@@ -51,7 +51,7 @@ import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
54
- deletionGateOfDir, excludedRulesOfDir, installKind, installState,
54
+ excludedRulesOfDir, gatesOfDir, installKind, installState,
55
55
  registeredTargets,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
@@ -1173,11 +1173,13 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1173
1173
  const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
1174
1174
  : excludedRulesOfDir(cfg, host);
1175
1175
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1176
- // Law IV's gate switch, the same two-carrier read: off only when a carrier says so, so a
1177
- // reactivate never disarms an install that did not ask for it.
1178
- const deletionGate = deletionGateOfDir(root, host) && deletionGateOfDir(cfg, host);
1176
+ // The gate switches, the same two-carrier read: a gate is off only when a carrier says so,
1177
+ // so a reactivate never disarms an install that did not ask for it.
1178
+ const [atRoot, atCfg] = [gatesOfDir(root, host), gatesOfDir(cfg, host)];
1179
+ const gates = Object.fromEntries(Object.keys(atRoot).map((k) => [k, atRoot[k] && atCfg[k]]));
1179
1180
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1180
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg, deletionGate);
1181
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg,
1182
+ gates.deletionGate, gates);
1181
1183
  // `and data` — a manifest that did not parse is not one to write back.
1182
1184
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1183
1185
  managed.settings_hooks = claims;
@@ -632,7 +632,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }, notif
632
632
  if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
633
633
  const p = installProfile('opencode', 'global', String(dir), notify);
634
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
635
- undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate).slice(4);
635
+ undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate, p).slice(4);
636
636
  }
637
637
 
638
638
  /**
@@ -46,8 +46,8 @@ import {
46
46
  opencodeConfigDir, resolvePath,
47
47
  } from '../hosts/hosts.mjs';
48
48
  import {
49
- EMIT_HOST_SCOPE, deletionGateOfDir, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
50
- footprintOfDir,
49
+ EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
50
+ footprintOfDir, gatesOfDir,
51
51
  installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
52
52
  } from '../hosts/installs.mjs';
53
53
  import {
@@ -131,13 +131,17 @@ function bodyExcludeRules(body) {
131
131
  }
132
132
 
133
133
  /**
134
- * Law IV's gate switch across the trust boundary: only a real JSON boolean counts. Anything
135
- * else — a string `"false"`, a number, absence — is `null`, and the caller keeps the install's
136
- * own answer, so a malformed body can never disarm the gate by accident.
134
+ * The gate switches across the trust boundary (`deletionGate`, `secretsGate`, `doneGate`), each
135
+ * on its own: only a real JSON boolean counts. Anything else — a string `"false"`, a number,
136
+ * absence — falls back to the install's own answer for THAT gate, so a malformed or partial body
137
+ * can never disarm (or re-arm) a gate it did not name.
137
138
  */
138
- function bodyDeletionGate(body) {
139
- const v = bget(body, 'deletionGate');
140
- return typeof v === 'boolean' ? v : null;
139
+ function bodyGates(body, root, host) {
140
+ const own = gatesOfDir(root, host);
141
+ return Object.fromEntries(Object.entries(own).map(([k, v]) => {
142
+ const said = bget(body, k);
143
+ return [k, typeof said === 'boolean' ? said : v];
144
+ }));
141
145
  }
142
146
 
143
147
  /**
@@ -303,7 +307,8 @@ export function apiMemoryDelete(state, name) {
303
307
  // CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
304
308
  // resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
305
309
  if (memoryDelete(d, name) === null) throw new NotFound(name);
306
- state.refresh();
310
+ // The doctor reads nothing in the memory store, so its cached verdict survives (`refresh`).
311
+ state.refresh({ doctor: false });
307
312
  return { archived: name };
308
313
  }
309
314
 
@@ -501,8 +506,8 @@ export function apiRestore(state, files) {
501
506
  // re-states a rule whose hook gate the install deliberately left unwired.
502
507
  excludeRules: excludedRulesOfDir(target),
503
508
  excludeSkills: excludedSkillsOfDir(target),
504
- // Law IV's gate switch: a restored AGENT.md must keep the install's `Deletion gate:` line.
505
- deletionGate: deletionGateOfDir(target),
509
+ // The gate switches: a restored AGENT.md must keep the install's `<gate>: off` lines.
510
+ ...gatesOfDir(target),
506
511
  }));
507
512
  for (const raw of (isTruthy(files) ? files : [])) {
508
513
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -585,12 +590,12 @@ export function apiInstallCmd(state, body) {
585
590
  // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
586
591
  // own answer, and a pre-marker install answers the empty list — which is what it has.
587
592
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
588
- // Law IV's gate switch (the Constitution page's toggle): a strict boolean in the body, else
589
- // the install's own answer — so a pack Apply, which sends none, keeps the switch as it is.
590
- const deletionGate = bodyDeletionGate(body) ?? deletionGateOfDir(root, host);
593
+ // The gate switches (the Constitution page's toggles): a strict boolean per gate in the body,
594
+ // else the install's own answer — so a pack Apply keeps every switch as it is.
595
+ const gates = bodyGates(body, root, host);
591
596
  const out = scope === 'global' ? null : String(root);
592
597
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
593
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
598
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
594
599
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
595
600
  }
596
601
 
@@ -689,9 +694,9 @@ export function apiDeployCmd(state, body) {
689
694
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
690
695
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
691
696
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
692
- const deletionGate = bodyDeletionGate(body) ?? deletionGateOfDir(root, host);
697
+ const gates = bodyGates(body, root, host);
693
698
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
694
699
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
695
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
700
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
696
701
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
697
702
  }
package/js/web/api.mjs CHANGED
@@ -16,11 +16,10 @@ import { readVersion, sourceFingerprint } from '../build/version.mjs';
16
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
17
  import { alwaysOnCost } from '../inspect/cost.mjs';
18
18
  import { diffCollect } from '../inspect/diff.mjs';
19
- import { doctorCollect } from '../inspect/doctor.mjs';
20
19
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
20
  import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
22
21
  import {
23
- deletionGateOfDir, footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
22
+ footprintOfDir, gatesOfDir, installState, installTargets, modeOfDir, trustOfDir,
24
23
  postureOfDir, readJsonMaybe, themeOfDir,
25
24
  } from '../hosts/installs.mjs';
26
25
  import { PRESETS } from '../loop/score.mjs';
@@ -79,13 +78,15 @@ export function apiSetup(state) {
79
78
  };
80
79
  }
81
80
 
82
- /** The same engine as the `doctor` verb, grouped per check. */
83
- export function apiDoctor(state) {
84
- const groups = [];
85
- const [themes, problems] = doctorCollect({ theme: state.theme, groups });
86
- state.stampDoctor(problems);
87
- return { themes, ok: !problems.length, problems, groups,
88
- checked_at: state.doctor.checked_at };
81
+ /**
82
+ * The same engine as the `doctor` verb, grouped per check — served from the state's cached run
83
+ * (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
84
+ * for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
85
+ */
86
+ export function apiDoctor(state, fresh = false) {
87
+ if (fresh) state.runDoctor();
88
+ const { ok, problems, checked_at } = state.doctor;
89
+ return { ...state._doctorRun, ok, problems, checked_at };
89
90
  }
90
91
 
91
92
  /**
@@ -247,9 +248,10 @@ export function apiOverview(state) {
247
248
  for (const [host, scope, root] of installTargets()) {
248
249
  try {
249
250
  if (samePath(viewCfg(host, scope, root), state.target)) {
250
- // `deletionGate`: Law IV's gate switch, for the Constitution page's toggle on row IV.
251
+ // `deletionGate`/`secretsGate`/`doneGate`: the gate switches, for the Constitution
252
+ // page's toggles on rows IV, I and III.
251
253
  install = { host, scope, path: root, footprint: footprintOfDir(root),
252
- deletionGate: deletionGateOfDir(root, host) };
254
+ ...gatesOfDir(root, host) };
253
255
  break;
254
256
  }
255
257
  } catch { /* an unreadable candidate just isn't a match */ }
@@ -130,7 +130,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
130
130
 
131
131
  function doGet(req, res, path, ae) {
132
132
  const route = STATE_ROUTES[path];
133
- if (route !== undefined) return sendJson(res, route(state), 200, ae);
133
+ if (route !== undefined) return sendJson(res, route(state, req.url), 200, ae);
134
134
  // `pid` is what a restarting client waits on: the OLD server keeps answering this ping for
135
135
  // a moment after `/api/restart` returns, and a reload onto it fetches the old token.
136
136
  if (path === '/api/ping') {
package/js/web/routes.mjs CHANGED
@@ -146,7 +146,9 @@ export const STATE_ROUTES = {
146
146
  '/api/recent': apiRecent,
147
147
  '/api/themes': apiThemes,
148
148
  '/api/setup': apiSetup,
149
- '/api/doctor': apiDoctor,
149
+ // The one route here that reads its query: `?fresh=1` re-runs instead of serving the cache.
150
+ '/api/doctor': (state, url) => apiDoctor(state,
151
+ new URL(url || '', 'http://x').searchParams.has('fresh')),
150
152
  '/api/installs': apiInstalls,
151
153
  '/api/fleet': apiFleet,
152
154
  '/api/update': apiUpdate,
package/js/web/server.mjs CHANGED
@@ -27,7 +27,7 @@ import { isFile, printOut } from '../lib/fs.mjs';
27
27
  import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
29
  import { promptLine } from '../lib/prompt.mjs';
30
- import { webState } from './state.mjs';
30
+ import { warmDoctor, webState } from './state.mjs';
31
31
  import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
32
  import { makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
@@ -165,6 +165,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
165
165
  printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
166
166
  printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
167
167
  if (openBrowser) openUrl(url);
168
+ // After the record and the ready line, so `web start`'s wait loop already has its URL.
169
+ warmDoctor(state);
168
170
  // Node kills on SIGINT by default, which would skip this message and the record
169
171
  // cleanup in `srv.on('close', ...)` below — a foreground `--daemon-internal` server
170
172
  // stopped with Ctrl-C would otherwise leave a record pointing at a dead port.