geneseed 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -6
  3. package/SHIPPED.md +1 -1
  4. package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
  5. package/adapters/opencode/plugins/geneseed-context.js +195 -52
  6. package/adapters/opencode/plugins/geneseed-guard.js +42 -29
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/concepts/enforcement-table.md +9 -3
  9. package/docs/concepts/hooks.md +3 -0
  10. package/docs/extending.md +1 -1
  11. package/docs/guides/install-clone.md +1 -1
  12. package/docs/guides/install.md +2 -2
  13. package/docs/reference/plugin-context.md +2 -0
  14. package/docs/reference/troubleshoot-doctor.md +31 -0
  15. package/docs/reference/troubleshoot-install.md +5 -5
  16. package/docs/understand/enforced-vs-asked.md +1 -0
  17. package/harness.config.json +1 -1
  18. package/js/README.md +8 -5
  19. package/js/build/bundle.mjs +11 -2
  20. package/js/build/driver.mjs +1 -10
  21. package/js/build/emit-claude.mjs +7 -22
  22. package/js/build/emit-common.mjs +16 -9
  23. package/js/build/emit-opencode.mjs +5 -11
  24. package/js/build/generate.mjs +1 -1
  25. package/js/cli-table.json +15 -0
  26. package/js/hosts/hooks-context.mjs +1 -4
  27. package/js/hosts/hooks-done.mjs +186 -0
  28. package/js/hosts/hooks-learn.mjs +9 -4
  29. package/js/hosts/hooks.mjs +27 -8
  30. package/js/hosts/hosts.mjs +2 -0
  31. package/js/hosts/installs.mjs +47 -11
  32. package/js/hosts/native.mjs +5 -13
  33. package/js/hosts/opencode.mjs +37 -22
  34. package/js/hosts/owned.mjs +19 -5
  35. package/js/hosts/settings.mjs +17 -11
  36. package/js/hosts/shim.mjs +24 -10
  37. package/js/hosts/usage.mjs +4 -3
  38. package/js/inspect/checks-authoring.mjs +9 -7
  39. package/js/inspect/checks-constitution.mjs +2 -2
  40. package/js/inspect/checks-probe.mjs +292 -0
  41. package/js/inspect/checks-repo.mjs +13 -54
  42. package/js/inspect/cost.mjs +5 -6
  43. package/js/inspect/doctor.mjs +29 -3
  44. package/js/inspect/scan.mjs +9 -5
  45. package/js/inspect/scorecard-svg.mjs +3 -3
  46. package/js/inspect/scorecard-yaml.mjs +407 -0
  47. package/js/inspect/scorecard.mjs +7 -405
  48. package/js/inspect/status.mjs +14 -13
  49. package/js/lib/node-floor.mjs +4 -3
  50. package/js/maintain/migrate.mjs +2 -3
  51. package/js/maintain/uninstall.mjs +20 -26
  52. package/js/web/actions.mjs +2 -2
  53. package/js/web/api.mjs +36 -24
  54. package/js/web/gates-api.mjs +5 -8
  55. package/js/web/usage-api.mjs +14 -17
  56. package/package.json +2 -2
  57. package/src/skills/rule.md +11 -2
  58. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  59. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  60. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  61. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  62. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  63. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  64. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  65. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  66. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  67. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  68. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  69. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  70. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  71. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  72. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  73. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  74. package/web/dist/index.html +2 -2
  75. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  76. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  77. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  78. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  79. package/web/dist/assets/index-CdpLpPQN.js +0 -2
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The done gate — Verify Before Asserting's boundary half, at the moment a turn ends (#289).
3
+ *
4
+ * Every other gate sits before an act. This one sits at Stop: a turn that edited files under a
5
+ * project which lists its proof in `.geneseed/done-checks.txt` may not end until every listed
6
+ * check has run AND passed after the last edit. The gate never runs a check itself — the hook
7
+ * path starts no process of its own (ADR 0002) — it reads the turn's own tool calls in the transcript.
8
+ *
9
+ * WHY IT RIDES `learn` AND NOT A VERB OR A FLAG OF ITS OWN (ADR 0010). The machine-wide shim is
10
+ * last-writer-wins: an older `geneseed-hook` would refuse a new verb or flag, and the Stop
11
+ * command's `|| exit 0` would then skip learning too, silently. Arming on a project FILE keeps
12
+ * every emitted settings file byte-identical, so a stale shim only means "no done gate".
13
+ *
14
+ * WHY STDOUT JSON AND NOT EXIT 2. The emitted Stop command ends in `|| exit 0`, which swallows
15
+ * a 2. `{"decision":"block"}` on exit 0 is the one signal that survives the existing wiring.
16
+ * Allowing prints NOTHING — on Stop a stray stdout byte is read as a verdict.
17
+ *
18
+ * WHAT COUNTS (shortcut: write tools only; a shell `sed -i` or redirect is not an edit here —
19
+ * seeing those would need `git status`, a spawn. Same limit as `protected-checks.txt`):
20
+ * - an edit is a Write/Edit/NotebookEdit/MultiEdit whose target is under the project root and
21
+ * whose result is not an error (a denied write changed nothing);
22
+ * - a pass is a Bash/PowerShell call whose command contains the declared line UNPIPED (a pipe
23
+ * takes the exit code of its last stage: `npm test | tail` exits 0 on a red suite) and whose
24
+ * result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
25
+ * 14 000+ real Claude Code results, 2026-10-17).
26
+ *
27
+ * At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
28
+ * gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
29
+ */
30
+ import { existsSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
33
+ import { within } from '../lib/paths.mjs';
34
+ import { gitRootOf } from './gitref.mjs';
35
+ import { DONE_LIST, recordGate } from './hooks.mjs';
36
+ import { splitLines } from './hooks-prims.mjs';
37
+
38
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
39
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
40
+
41
+ /** The declared checks, one per line, `#` comments; null when the project has no list. */
42
+ export function readDoneChecks(root) {
43
+ const file = path.join(root, DONE_LIST);
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ return splitLines(readText(file)).map((l) => l.trim().replace(/\s+/g, ' '))
47
+ .filter((l) => l && !l.startsWith('#'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * True when `command` runs `line` as a whole command with no pipe after it in the same segment.
55
+ * A segment ends at `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. The
56
+ * check must open a segment (the start, or right after one of those separators) and be followed
57
+ * by the end, whitespace or a separator, never by more of a word (`npm run lint:fix` does not
58
+ * prove `npm run lint`); a match inside a quoted string (`-m "ran npm test"`) is text, not a run.
59
+ * Whitespace is collapsed on both sides. shortcut: the shell is not parsed — a prefix such as
60
+ * `sudo`/`time`/`env X=1`, a subshell, or a `|` inside a quoted argument after the check all read
61
+ * as "not run". Every approximation here refuses a pass, so the gate errs toward a reminder.
62
+ */
63
+ export function runsUnpiped(command, line) {
64
+ const cmd = String(command).replace(/[ \t]+/g, ' ');
65
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
66
+ const next = cmd[i + line.length];
67
+ if (next !== undefined && !/[\s;&|]/.test(next)) continue;
68
+ const head = cmd.slice(0, i);
69
+ if (!/(^|&&|\|\||;|\n) ?$/.test(head)) continue;
70
+ let quote = '', esc = false;
71
+ for (const ch of head) {
72
+ if (esc) esc = false;
73
+ else if (ch === '\\' && quote !== "'") esc = true;
74
+ else if (quote) { if (ch === quote) quote = ''; }
75
+ else if (ch === '"' || ch === "'") quote = ch;
76
+ }
77
+ if (quote) continue;
78
+ const rest = cmd.slice(i + line.length);
79
+ let piped = false;
80
+ for (let j = 0; j < rest.length; j += 1) {
81
+ const two = rest.slice(j, j + 2);
82
+ if (two === '&&' || two === '||' || rest[j] === ';' || rest[j] === '\n') break;
83
+ if (rest[j] === '|') { piped = true; break; }
84
+ }
85
+ if (!piped) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /** A user line that opens a turn: the user typed it, it is not a tool result or a meta note. */
91
+ function isPrompt(obj) {
92
+ if (obj.type !== 'user' || obj.isMeta || obj.isSidechain) return false;
93
+ const c = obj.message && obj.message.content;
94
+ if (typeof c === 'string') return true;
95
+ return Array.isArray(c) && c.length > 0 && !c.some((b) => b && b.type === 'tool_result');
96
+ }
97
+
98
+ /**
99
+ * The decision, over transcript TEXT: the checks that have not passed since the turn's last
100
+ * edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
101
+ * unit tier — a transcript is text no CLI cell can vary cheaply.
102
+ */
103
+ export function doneDecide(transcript, checks, root, cwd = root) {
104
+ const entries = [];
105
+ for (const line of splitLines(transcript)) {
106
+ if (!line.trim()) continue;
107
+ try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
108
+ }
109
+ let start = 0;
110
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
111
+ if (isPrompt(entries[i])) { start = i + 1; break; }
112
+ }
113
+ const calls = []; // tool_use blocks, in order
114
+ const results = new Map(); // tool_use_id -> is_error
115
+ for (const obj of entries.slice(start)) {
116
+ if (obj.isSidechain) continue;
117
+ const content = obj.message && obj.message.content;
118
+ if (!Array.isArray(content)) continue;
119
+ for (const b of content) {
120
+ if (!b || typeof b !== 'object') continue;
121
+ if (b.type === 'tool_use') calls.push(b);
122
+ else if (b.type === 'tool_result') results.set(b.tool_use_id, b.is_error === true);
123
+ }
124
+ }
125
+ let lastEdit = -1;
126
+ calls.forEach((c, i) => {
127
+ if (!EDIT_TOOLS.has(c.name) || results.get(c.id) === true) return;
128
+ const p = c.input && (c.input.file_path || c.input.notebook_path || c.input.path);
129
+ if (typeof p === 'string' && p && within(path.resolve(cwd, p), root)) lastEdit = i;
130
+ });
131
+ if (lastEdit === -1) return [];
132
+ const passed = new Set();
133
+ calls.slice(lastEdit + 1).forEach((c) => {
134
+ const command = SHELL_TOOLS.has(c.name) && c.input && c.input.command;
135
+ // A call with no result yet (the host has not flushed it) is not a pass.
136
+ if (typeof command !== 'string' || results.get(c.id) !== false) return;
137
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check);
138
+ });
139
+ return checks.filter((c) => !passed.has(c));
140
+ }
141
+
142
+ /**
143
+ * The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
144
+ * goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
145
+ * `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
146
+ * ledger lands in the same root its sovereignty check read.
147
+ */
148
+ export function doneGate(meta, args, install) {
149
+ try {
150
+ const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
151
+ const root = gitRootOf(cwd);
152
+ const checks = root ? readDoneChecks(root) : null;
153
+ if (!checks || !checks.length) return false;
154
+ // Bob's Stop payload carries no transcript (learn's I2): nothing to judge, and it says so.
155
+ if (args.host === 'bob' || typeof meta.transcript_path !== 'string') {
156
+ err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
157
+ return false;
158
+ }
159
+ let transcript;
160
+ try {
161
+ transcript = readText(meta.transcript_path);
162
+ } catch {
163
+ // No ask tier on Stop: blocking on an unreadable transcript would trap the user.
164
+ err('[done-gate] transcript unreadable — turn not judged.\n');
165
+ return false;
166
+ }
167
+ const missing = doneDecide(transcript, checks, root, cwd);
168
+ if (!missing.length) return false;
169
+ if (meta.stop_hook_active === true) {
170
+ recordGate(install, 'done-gate-cap', 'law-3');
171
+ err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
172
+ return false;
173
+ }
174
+ recordGate(install, 'done-gate', 'law-3');
175
+ out(`${JSON.stringify({
176
+ decision: 'block',
177
+ reason: 'Geneseed (Verify Before Asserting) — this turn edited files, and these '
178
+ + `checks from ${DONE_LIST} have not passed since the last edit: ${missing.join('; ')}. `
179
+ + 'Run them unpiped, or say plainly that they were not run and why.',
180
+ })}\n`);
181
+ return true;
182
+ } catch (e) {
183
+ err(`[done-gate] skipped: ${e && e.message}\n`);
184
+ return false;
185
+ }
186
+ }
@@ -13,6 +13,7 @@ import {
13
13
  globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
14
14
  } from './hosts.mjs';
15
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
16
+ import { doneGate } from './hooks-done.mjs';
16
17
  import {
17
18
  existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
18
19
  } from './memory-files.mjs';
@@ -292,12 +293,12 @@ export function cmdLearn(args) {
292
293
  }
293
294
 
294
295
  // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
295
- // folder the global install must not learn.
296
- if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
296
+ // folder the global install must not learn. The done gate's ledger lives in the same root.
297
+ const install = args.memory ? path.dirname(toPlatformPath(args.memory)) : null;
298
+ if (install && sovereignBypass(install)) return 0;
297
299
  // Same install root, and a project install of this host beside it learns on its own Stop: a
298
300
  // second LLM call, and the global store learning facts the project owns (Claude B4).
299
- if (args.memory && globalHookStandingDown(path.dirname(toPlatformPath(args.memory)),
300
- hookProjectDir(), args.host, 'learn')) return 0;
301
+ if (install && globalHookStandingDown(install, hookProjectDir(), args.host, 'learn')) return 0;
301
302
 
302
303
  const raw = args.file ? readText(args.file) : readStdin();
303
304
  const meta = hookMeta(raw);
@@ -309,6 +310,10 @@ export function cmdLearn(args) {
309
310
  const hookEvent = typeof meta.hook_event_name === 'string' ? meta.hook_event_name
310
311
  : (typeof meta.event === 'string' ? meta.event : null);
311
312
 
313
+ // The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
314
+ // means the turn goes on, so there is nothing to distil yet: the next Stop will.
315
+ if (hookEvent === 'Stop' && !args.file && doneGate(meta, args, install)) return 0;
316
+
312
317
  // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
313
318
  // to distil — and the bare envelope itself is not notes text (that would waste a model call
314
319
  // on `{"event":"Stop","session_id":"s"}`). Scoped to `--host bob`: every other host's Stop/
@@ -255,8 +255,12 @@ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5', 'ops-2']);
255
255
  * information a trimmed file loses. The re-read happens on the ask path only.
256
256
  */
257
257
  const MAX_LEDGER_LINES = 1000;
258
+ // Exported for the done gate (`./hooks-done.mjs`), which records from Stop, not from `ask`.
259
+ export function recordGate(root, verb, rule) { ledger(root, verb, rule); }
258
260
  function ledger(root, verb, rule) {
259
- if (!root) return;
261
+ // `doctor --probe` asks on purpose; its asks are not the user's. Read here, on the rare ask
262
+ // path only: the variable silences the RECORD, never the decision.
263
+ if (!root || process.env.GENESEED_GATE_PROBE) return;
260
264
  try {
261
265
  const file = path.join(root, GATE_LEDGER);
262
266
  appendFileSync(file, `${JSON.stringify({
@@ -441,6 +445,8 @@ export function rootScan(command) {
441
445
  const m = /^-path[=:](.+)$/i.exec(t);
442
446
  return m ? m[1] : t;
443
447
  });
448
+ // For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
449
+ // POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
444
450
  if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
445
451
  || (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
446
452
  || (ps && PS_RECURSE_RE.test(a)))) continue;
@@ -448,8 +454,6 @@ export function rootScan(command) {
448
454
  }
449
455
  return null;
450
456
  }
451
- // For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
452
- // POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
453
457
  const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
454
458
  // A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
455
459
  // (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
@@ -523,21 +527,25 @@ const SECRET_RE =
523
527
  /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
524
528
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
525
529
 
530
+ export const SENSOR_LIST = '.geneseed/protected-checks.txt';
531
+ // The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
532
+ // protected-checks list: emptying it would disarm the gate it feeds.
533
+ export const DONE_LIST = '.geneseed/done-checks.txt';
526
534
  /**
527
535
  * External Gate's boundary half: the checks an agent is judged by must sit outside what it can
528
536
  * edit, or a failing check is one edit away from a passing one. A project opts in by listing
529
537
  * them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
530
538
  * its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
531
539
  * writing into another reads that other repo's list. The list protects itself. Returns the entry
532
- * the path falls under, or null. ponytail: a write tool only — a shell `sed -i` or redirect is
540
+ * the path falls under, or null. shortcut: a write tool only — a shell `sed -i` or redirect is
533
541
  * not seen here; CI running the checks is the boundary that holds against that.
534
542
  */
535
- export const SENSOR_LIST = '.geneseed/protected-checks.txt';
536
543
  function protectedCheck(p) {
537
544
  try {
538
545
  const target = resolvePath(p);
539
546
  const root = gitRootOf(path.dirname(target));
540
547
  if (!root) return null;
548
+ if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
541
549
  const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
542
550
  // `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
543
551
  // separator of the prefix included.
@@ -555,6 +563,14 @@ function ruleGate(args) {
555
563
  return ruleDecide(args, readPayload());
556
564
  }
557
565
 
566
+ // The REPLACE halves of a Roo-style `apply_diff` (`<<<<<<< SEARCH … ======= <new> >>>>>>> REPLACE`).
567
+ // A diff without those markers is a shape we do not know, so it is returned whole: fail closed.
568
+ function diffReplaced(diff) {
569
+ if (typeof diff !== 'string') return diff;
570
+ const halves = [...diff.matchAll(/^=======\r?\n([\s\S]*?)^>>>>>>> REPLACE/gm)].map((m) => m[1]);
571
+ return halves.length ? halves.join('\n') : diff;
572
+ }
573
+
558
574
  function ruleDecide(args, payload) {
559
575
  const ti = (payload && payload.tool_input) || {};
560
576
  // NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
@@ -567,9 +583,11 @@ function ruleDecide(args, payload) {
567
583
  // cheap, harmless guard in case that ever changes.
568
584
  // `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
569
585
  // field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
570
- // required, same as every field above.
586
+ // required, same as every field above. Only WRITTEN text is scanned: `search` and a diff's
587
+ // SEARCH halves are text leaving the file (like Claude's `old_string`), so a secret there is
588
+ // being removed — blocking it would stop the agent cleaning up a leak.
571
589
  const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
572
- const body = [ti.content, ti.new_string, ti.new_source, ti.diff, ti.replace, ti.search, ...edits]
590
+ const body = [ti.content, ti.new_string, ti.new_source, diffReplaced(ti.diff), ti.replace, ...edits]
573
591
  .filter((v) => typeof v === 'string').join('\n');
574
592
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
575
593
  return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
@@ -579,7 +597,8 @@ function ruleDecide(args, payload) {
579
597
  const sensor = protectedCheck(p);
580
598
  if (sensor) {
581
599
  return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
582
- + `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
600
+ + `check (${sensor === DONE_LIST ? 'the done gate list itself'
601
+ : `${sensor}, listed in ${SENSOR_LIST}`}). A check the agent can edit is not a `
583
602
  + 'check: fix the code, not the check, unless the user asks for this edit.');
584
603
  }
585
604
  const target = ruleGateTarget(p, args.root);
@@ -55,6 +55,8 @@ export function sovereignBypass(root) {
55
55
  } catch {
56
56
  return false;
57
57
  }
58
+ // Nothing to match, so no reason to pay for the cwd and `.git` reads below on every hook call.
59
+ if (!entries.length) return false;
58
60
  let heres;
59
61
  try {
60
62
  heres = [normcase(resolvePath(process.cwd()))];
@@ -44,7 +44,7 @@ import { registryRoots } from '../inspect/registry.mjs';
44
44
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
45
45
  import { formatRepr, isDict } from '../lib/json.mjs';
46
46
  import { comparePaths } from '../lib/paths.mjs';
47
- import { claudeHookShell } from './shim.mjs';
47
+ import { claudeHookShell, hookShimPath } from './shim.mjs';
48
48
 
49
49
  /**
50
50
  * `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
@@ -567,10 +567,10 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
567
567
  * name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
568
568
  * else the install left out. Silent unless the caller passes `notify` — status, diff and the
569
569
  * console call this on every read; the replays that rebuild an install (`rebuild-all`,
570
- * `upgrade`, `migrate`) pass it. `host` is last, after `notify`, for the same reason
571
- * `trustOfDir` puts it last: every existing positional call keeps working unchanged.
570
+ * `upgrade`, `migrate`) pass it. `host` comes second, the same `(d, host)` order as its
571
+ * siblings `excludedRulesOfDir`/`doctrinesOfDir`, so the marker readers call alike.
572
572
  */
573
- export function excludedSkillsOfDir(d, notify = null, host = null) {
573
+ export function excludedSkillsOfDir(d, host = null, notify = null) {
574
574
  return firstCarrier(d, (carrierPath) => {
575
575
  const text = readMaybe(carrierPath);
576
576
  if (text === null) return undefined;
@@ -615,9 +615,6 @@ export function emitHostScopeOf(root) {
615
615
  return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
616
616
  }
617
617
 
618
- // `DISABLED_STASH` moved to `hosts.mjs`: the hook stand-down reads it and must not import this.
619
- export { DISABLED_STASH };
620
-
621
618
  /**
622
619
  * `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
623
620
  *
@@ -733,12 +730,12 @@ export function installTargets() {
733
730
 
734
731
  /**
735
732
  * Each Claude install whose emitted hook FORM no longer matches this machine's hook shell
736
- * (`claudeHookShell`; Task 15) — the "Git Bash removed after the emit" gap, which otherwise
733
+ * (`claudeHookShell`) — the "Git Bash removed after the emit" gap, which otherwise
737
734
  * reports nothing: hooks signal through stdout and Claude treats a parse error as non-blocking.
738
735
  *
739
736
  * Bash form with Git Bash now gone is a PROBLEM: `context`/`learn` fail open under PowerShell
740
- * (no `onFailure`, so a launch failure there is still non-blocking) and, since Task 1, the two
741
- * gates fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
737
+ * (no `onFailure`, so a launch failure there is still non-blocking) while the two gates,
738
+ * being gates, fail CLOSED instead (`onFailure: "block"` blocks every Bash/PowerShell/Write/Edit/
742
739
  * NotebookEdit call until `rebuild-all` re-emits the PowerShell form) — worse for the user
743
740
  * either way, so the message below still reports it as drift needing a rebuild; it does not
744
741
  * distinguish the two outcomes. PowerShell form with Git Bash now present is a `[note]`: the hooks work,
@@ -763,13 +760,52 @@ export function hookShellProblems(targets = installTargets(), platform = process
763
760
  out.push(was === 'bash'
764
761
  ? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
765
762
  + 'Code runs them under PowerShell where context/learn fail open (no effect) and '
766
- + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all'
763
+ + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all, '
764
+ + 'then confirm with: geneseed doctor --probe'
767
765
  : `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
768
766
  + 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
769
767
  }
770
768
  return out;
771
769
  }
772
770
 
771
+ /**
772
+ * Every active Claude-STYLE install (`CLAUDE_STYLE`: claude, bob, openclaude — the hosts that
773
+ * share this one machine-wide shim) whose LIVE, recorded hooks still name `shimPath` by exact
774
+ * string, keyed by its own config dir.
775
+ *
776
+ * Split out of `shimProblems` (`js/inspect/checks-repo.mjs`) for the case `shimDeadPaths`
777
+ * cannot see: that function reads paths back OUT of the shim body, so it has nothing to read once the shim file is gone
778
+ * outright (deleted, not moved-and-regenerated) — a plain `isFile` check upstream then calls
779
+ * that clean, the same as a source checkout that never emitted. `liveRecordedHooks` is already
780
+ * "the manifest claims it AND the settings file still runs it" (`js/hosts/hosts.mjs`), so a
781
+ * disabled or never-wired install cannot appear here — only installs for which the missing
782
+ * shim is a live, standing failure. It also keeps only handlers whose `command` is a string,
783
+ * so the match below needs no type guard of its own.
784
+ *
785
+ * MATCHES THE EXACT PATH, not merely a command that mentions the shim's filename
786
+ * (`geneseed-hook`): the shim path is baked per `GENESEED_HOME`, so a machine with more than
787
+ * one Geneseed home (a relocated one, or a sandboxed test run beside a real install) must not
788
+ * let an install naming a DIFFERENT, perfectly healthy shim answer for this one.
789
+ *
790
+ * LIVES HERE, beside the `claudeCfg` and `installTargets` it is built on, because neither
791
+ * reader owns the install walk: doctor's `shimProblems`, and `status`'s `gateSummary`
792
+ * (`js/inspect/status.mjs`), which folds the same answer into its `dead` field — the one
793
+ * `statusLines` renders as a `DEAD` gates row. Two readers of one fact, not two copies.
794
+ *
795
+ * `targets` is a parameter, the same convention `hookShellProblems` uses, so a test can hand
796
+ * it a sandboxed `[host, scope, root]` triple instead of walking this machine's real config
797
+ * dirs and registry — the live default every production caller takes.
798
+ */
799
+ export function installsReferencingShim(shimPath = hookShimPath(), targets = installTargets()) {
800
+ const out = [];
801
+ for (const [host, scope, root] of targets) {
802
+ if (!CLAUDE_STYLE.includes(host)) continue;
803
+ const cfg = claudeCfg(root, scope, host);
804
+ if (liveRecordedHooks(cfg).some(([, h]) => h.command.includes(shimPath))) out.push(cfg);
805
+ }
806
+ return out;
807
+ }
808
+
773
809
  // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
774
810
 
775
811
  /**
@@ -34,6 +34,11 @@ import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from
34
34
  import { relPosix } from '../lib/text.mjs';
35
35
  import { aliasesOf } from '../build/source.mjs';
36
36
  import { claimLeaf, ownedState } from './owned.mjs';
37
+ // `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
38
+ // (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
39
+ // mutating it away leaves the parity gate green — correctly, there is no reachable behaviour to
40
+ // detect. Kept because a spec author adding a leading blank line is one commit from needing it.
41
+ import { lstripNewlines } from './settings.mjs';
37
42
 
38
43
  /** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
39
44
  const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
@@ -704,16 +709,3 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
704
709
  }
705
710
  return { nAgents, nSkills, written, userOnlySkills };
706
711
  }
707
-
708
- /**
709
- * `str.lstrip("\n")` — only newlines, not all whitespace.
710
- *
711
- * Currently a no-op on every input the generator produces: measured, no file under `src/`
712
- * and no rendered spec in any of the 14 themes begins with a newline. Mutating it away
713
- * leaves the parity gate green, and that is correct rather than a hole — there is no
714
- * reachable behaviour to detect. Kept because it is what the Python does, and a spec
715
- * author adding a leading blank line is one commit away from making it matter.
716
- */
717
- function lstripNewlines(text) {
718
- return text.replace(/^\n+/, '');
719
- }
@@ -28,6 +28,7 @@ import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
28
28
  import { comparePaths } from '../lib/paths.mjs';
29
29
  import { sourceReleaseVersion } from '../build/version.mjs';
30
30
  import { aliasesOf } from '../build/source.mjs';
31
+ import { lstripNewlines } from './settings.mjs';
31
32
  import {
32
33
  firstBlockquote, isUserInvokedOnly, skillDescription, stripSkillBodyLinks, pointSideFiles,
33
34
  pushOverrideLines,
@@ -295,7 +296,7 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
295
296
  || !statSync(cfg.primaryAgentSrc).isFile()) {
296
297
  return null;
297
298
  }
298
- const body = readText(cfg.primaryAgentSrc).replace(/^\n+/, '');
299
+ const body = lstripNewlines(readText(cfg.primaryAgentSrc));
299
300
  const desc = 'Primary orchestrator — works by the harness Rules and delegates to the '
300
301
  + 'capability subagents.';
301
302
  const fm = [`description: ${jsonDumps(desc)}`, 'mode: primary', 'color: primary'];
@@ -309,19 +310,43 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) =>
309
310
  return dest;
310
311
  }
311
312
 
312
- /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
313
- export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
314
- skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
315
- if (!truthyEnv('GENESEED_COMMANDS')) return [];
316
- const byName = new Map();
313
+ /**
314
+ * The top-level skills of `items` (`src/skills/<stem>.md`, no `_` partials) as ordered
315
+ * `[stem, text]` pairs — the one parse both command writers below share.
316
+ */
317
+ function topLevelSkills(cfg, items) {
318
+ /** @type {[string, string][]} */
319
+ const found = [];
317
320
  for (const { text, src } of items) {
318
321
  if (text === null) continue;
319
322
  const sp = path.relative(cfg.src, src).split(path.sep);
320
323
  if (sp.length === 2 && sp[0] === 'skills' && sp[1].endsWith('.md')
321
324
  && !sp[1].startsWith('_')) {
322
- byName.set(sp[1].slice(0, -3), text);
325
+ found.push([sp[1].slice(0, -3), text]);
323
326
  }
324
327
  }
328
+ return found;
329
+ }
330
+
331
+ /** Both command writers' default `skillDirOf`: the skill's own directory beside `commandDir`. */
332
+ const siblingSkillDir = (commandDir) => (n) =>
333
+ path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/');
334
+
335
+ /** A command has no base directory, so a side-file pointer names the skill's own. */
336
+ function commandBody(text, stem, skillDir) {
337
+ return stripSkillBodyLinks(pointSideFiles(lstripNewlines(text), stem, skillDir));
338
+ }
339
+
340
+ function writeCommandFile(dest, desc, body) {
341
+ mkdirSync(path.dirname(dest), { recursive: true });
342
+ writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
343
+ }
344
+
345
+ /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
346
+ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
347
+ skillDirOf = siblingSkillDir(commandDir)) {
348
+ if (!truthyEnv('GENESEED_COMMANDS')) return [];
349
+ const byName = new Map(topLevelSkills(cfg, items));
325
350
  const written = [];
326
351
  for (const [name, skill] of Object.entries(COMMAND_SET)) {
327
352
  // A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
@@ -333,10 +358,7 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
333
358
  const dest = path.join(commandDir, `${name}.md`);
334
359
  if (!claim(dest)) continue;
335
360
  const desc = firstBlockquote(text);
336
- // A command has no base directory, so a side-file pointer names the skill's own.
337
- const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
338
- mkdirSync(path.dirname(dest), { recursive: true });
339
- writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
361
+ writeCommandFile(dest, desc, commandBody(text, skill, skillDirOf(skill)));
340
362
  written.push(dest);
341
363
  }
342
364
  return written;
@@ -358,22 +380,15 @@ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => tru
358
380
  * (Task 4) — it keeps its SKILL.md and does not need a second, command-shaped copy.
359
381
  */
360
382
  export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
361
- skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/'),
362
- alsoUserOnly = false) {
383
+ skillDirOf = siblingSkillDir(commandDir), alsoUserOnly = false) {
363
384
  const written = [];
364
- for (const { text, src } of items) {
365
- if (text === null) continue;
366
- const sp = path.relative(cfg.src, src).split(path.sep);
367
- if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
368
- const stem = sp[1].slice(0, -3);
369
- const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
385
+ for (const [stem, text] of topLevelSkills(cfg, items)) {
386
+ const body = commandBody(text, stem, skillDirOf(stem));
370
387
  const names = alsoUserOnly && isUserInvokedOnly(text) ? [...aliasesOf(text), stem] : aliasesOf(text);
371
388
  for (const name of names) {
372
389
  const dest = path.join(commandDir, `${name}.md`);
373
390
  if (!claim(dest)) continue;
374
- mkdirSync(path.dirname(dest), { recursive: true });
375
- const desc = name === stem ? skillDescription(text) : `Alias of ${stem}.`;
376
- writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
391
+ writeCommandFile(dest, name === stem ? skillDescription(text) : `Alias of ${stem}.`, body);
377
392
  written.push(dest);
378
393
  }
379
394
  }
@@ -144,11 +144,25 @@ export function hashesOf(doc) {
144
144
  }
145
145
 
146
146
  /**
147
- * Where one owned file stands against its recorded hash — the table later tasks (rebuild,
148
- * prune, uninstall) will branch on to stop clobbering a user's edit to a Geneseed-owned file.
149
- * `'unknown'` is deliberately distinct from `'unmodified'`: an older manifest with no `hashes`
150
- * entry for this rel (today's installs, pre-Task-3) must not be read as "proven untouched" —
151
- * see the constraint that a manifest without `hashes` keeps today's behaviour exactly.
147
+ * An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
148
+ * (`opencodeLayer` records them; a deny the user wrote, even on a Geneseed skill, is never in
149
+ * it) — or `[]` when the manifest, or the key, is absent or malformed, which sweeps nothing.
150
+ * Unlike the Law IV `permission.bash` gates (permanent, never taken back — see
151
+ * `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
152
+ * is no skill left to hide, and the name is the user's to reuse. One home because rebuild's
153
+ * deny sweep and uninstall must agree on what Geneseed owns.
154
+ */
155
+ export function recordedDenies(doc) {
156
+ const d = doc && doc.skill_denies;
157
+ return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
158
+ }
159
+
160
+ /**
161
+ * Where one owned file stands against its recorded hash — the table rebuild, prune and
162
+ * uninstall branch on so none of them clobbers a user's edit to a Geneseed-owned file.
163
+ * `'unknown'` is deliberately distinct from `'unmodified'`: a manifest written before hashes
164
+ * were recorded has no entry for this rel, and must not be read as "proven untouched" — a
165
+ * manifest without `hashes` keeps the pre-hash behaviour exactly.
152
166
  */
153
167
  export function ownedState(root, rel, hashes) {
154
168
  if (!Object.hasOwn(hashes, rel)) return 'unknown';