geneseed 4.5.0 → 4.7.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 (70) hide show
  1. package/CHANGELOG.md +30 -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 +108 -61
  6. package/adapters/opencode/plugins/geneseed-guard.js +33 -28
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/guides/install-clone.md +1 -1
  9. package/docs/guides/install.md +2 -2
  10. package/docs/reference/troubleshoot-install.md +5 -5
  11. package/harness.config.json +1 -1
  12. package/js/README.md +5 -4
  13. package/js/build/bundle.mjs +11 -2
  14. package/js/build/driver.mjs +1 -10
  15. package/js/build/emit-claude.mjs +7 -22
  16. package/js/build/emit-common.mjs +16 -9
  17. package/js/build/emit-opencode.mjs +5 -11
  18. package/js/build/generate.mjs +1 -1
  19. package/js/hosts/hooks-context.mjs +1 -4
  20. package/js/hosts/hooks-done.mjs +23 -6
  21. package/js/hosts/hooks-learn.mjs +5 -5
  22. package/js/hosts/hooks.mjs +28 -15
  23. package/js/hosts/hosts.mjs +2 -0
  24. package/js/hosts/installs.mjs +45 -10
  25. package/js/hosts/native.mjs +5 -13
  26. package/js/hosts/opencode.mjs +37 -22
  27. package/js/hosts/owned.mjs +19 -5
  28. package/js/hosts/settings.mjs +27 -18
  29. package/js/hosts/shim.mjs +4 -3
  30. package/js/hosts/usage.mjs +4 -3
  31. package/js/inspect/checks-authoring.mjs +9 -7
  32. package/js/inspect/checks-probe.mjs +14 -17
  33. package/js/inspect/checks-repo.mjs +13 -54
  34. package/js/inspect/cost.mjs +5 -6
  35. package/js/inspect/doctor.mjs +2 -2
  36. package/js/inspect/scan.mjs +9 -5
  37. package/js/inspect/scorecard-svg.mjs +3 -3
  38. package/js/inspect/scorecard-yaml.mjs +407 -0
  39. package/js/inspect/scorecard.mjs +7 -405
  40. package/js/inspect/status.mjs +14 -13
  41. package/js/lib/node-floor.mjs +4 -3
  42. package/js/maintain/migrate.mjs +2 -3
  43. package/js/maintain/uninstall.mjs +20 -26
  44. package/js/web/actions.mjs +2 -2
  45. package/js/web/api.mjs +36 -24
  46. package/js/web/gates-api.mjs +5 -8
  47. package/js/web/usage-api.mjs +14 -17
  48. package/package.json +2 -2
  49. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  50. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  51. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  52. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  53. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  54. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  55. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  56. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  57. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  58. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  59. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  60. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  61. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  62. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  63. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  64. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  65. package/web/dist/index.html +2 -2
  66. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  67. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  68. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  69. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  70. package/web/dist/assets/index-CdpLpPQN.js +0 -2
@@ -24,6 +24,16 @@ import {
24
24
  import { writeVersion } from './version.mjs';
25
25
  import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
26
26
 
27
+ /**
28
+ * Seed the memory index, then move a flat one to topics — the pair every emit runs on its
29
+ * memory store, in that order. Here and not in `js/hosts/memory-files.mjs`: `stubs.mjs`
30
+ * imports that module, so the reverse import would be a cycle.
31
+ */
32
+ export function ensureMemoryStore(memDir) {
33
+ ensureMemoryIndex(memDir);
34
+ migrateMemoryIndex(memDir);
35
+ }
36
+
27
37
  // ---------------------------------------------------------------------------
28
38
  // Source completeness
29
39
  // ---------------------------------------------------------------------------
@@ -223,8 +233,7 @@ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog =
223
233
  ensureRulesStub(out);
224
234
  ensureProfileStub(out);
225
235
  ensureBundleGitignore(out);
226
- ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
227
- migrateMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
236
+ ensureMemoryStore(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
228
237
  ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
229
238
  process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
230
239
  }
@@ -51,7 +51,7 @@ import {
51
51
  openclaudeConfigDir, hostCatalogsNatively,
52
52
  } from '../hosts/hosts.mjs';
53
53
  import {
54
- hashesOf, ownedHashes, ownedState, ownedWithin,
54
+ hashesOf, ownedHashes, ownedState, ownedWithin, recordedDenies,
55
55
  } from '../hosts/owned.mjs';
56
56
 
57
57
  // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
@@ -257,15 +257,6 @@ function readManifest(manifestPath) {
257
257
  return { existed, doc };
258
258
  }
259
259
 
260
- /**
261
- * An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
262
- * (see `opencodeLayer`) — or `[]` when absent or malformed, which sweeps nothing.
263
- */
264
- function recordedDenies(doc) {
265
- const d = doc && doc.skill_denies;
266
- return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
267
- }
268
-
269
260
  /** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
270
261
  function extrasTail(stats) {
271
262
  const extras = [...(stats.primary ? ['primary agent'] : []),
@@ -7,24 +7,21 @@
7
7
  */
8
8
  import path from 'node:path';
9
9
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
- import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
11
10
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
11
  import { ensureAgentOverridesStub, writeAliasCommands } from '../hosts/opencode.mjs';
13
12
  import {
14
13
  managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
15
14
  unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
16
15
  } from '../hosts/settings.mjs';
17
- import { claimLeaf } from '../hosts/owned.mjs';
18
- import { writeText } from '../lib/fs.mjs';
19
16
  import { get, isDict, isTruthy } from '../lib/json.mjs';
20
17
  import { relPosix } from '../lib/text.mjs';
21
- import { assertSourceComplete, phaseLog } from './bundle.mjs';
18
+ import { assertSourceComplete, phaseLog, ensureMemoryStore } from './bundle.mjs';
22
19
  import {
23
- globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
20
+ claimAndWrite, globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
24
21
  } from './emit-common.mjs';
25
22
  import { renderAll, renderFile } from './render.mjs';
26
23
  import {
27
- BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex,
24
+ BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureNotebookIndex,
28
25
  ensureProfileStub,
29
26
  ensureRulesStub, ensureWikiStub, WIKI_FILE,
30
27
  } from './stubs.mjs';
@@ -100,16 +97,8 @@ export function emitClaudeRender(cfg, job) {
100
97
  // slim shadow stub whose only job is to have the same filename.
101
98
  if (isBob && agentText !== null) {
102
99
  const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
103
- // `claimLeaf` BEFORE `mkdirSync`: `safeLeaf` walks up to the nearest EXISTING ancestor
104
- // (`resolvePath`'s own contract), so it needs no directory created first — and creating
105
- // `rules/` first would plant an empty directory at a linked ancestor's target the moment
106
- // before the refusal fires.
107
- if (claimLeaf(cfgDir, rulesMd, 'rules/geneseed.md')) {
108
- mkdirSync(path.dirname(rulesMd), { recursive: true });
109
- writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
110
- : stripCapabilityLinks(prefixedAgentText('../') || agentText));
111
- owned.push('rules/geneseed.md');
112
- }
100
+ claimAndWrite(cfgDir, rulesMd, 'rules/geneseed.md', scope === 'project' ? BOB_RULES_STUB
101
+ : stripCapabilityLinks(prefixedAgentText('../') || agentText), owned);
113
102
  }
114
103
 
115
104
  ensureAgentOverridesStub(cfg, cfgDir);
@@ -146,8 +135,7 @@ export function emitClaudeRender(cfg, job) {
146
135
  }
147
136
 
148
137
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
149
- ensureMemoryIndex(path.join(cfgDir, 'memory'));
150
- migrateMemoryIndex(path.join(cfgDir, 'memory'));
138
+ ensureMemoryStore(path.join(cfgDir, 'memory'));
151
139
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
152
140
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
153
141
  // context.json too: the root names it as the project's own manifest, and until 2026-10 this
@@ -172,10 +160,7 @@ export function emitClaudeRender(cfg, job) {
172
160
  // `!existsSync(gi)` is exactly the case a DANGLING symlink also satisfies — it reads
173
161
  // as absent — so `claimLeaf` (which lstats) runs before the write, not instead of
174
162
  // this check.
175
- if (claimLeaf(cfgDir, gi, '.gitignore')) {
176
- writeText(gi, `${giLines.join('\n')}\n`);
177
- owned.push('.gitignore');
178
- }
163
+ claimAndWrite(cfgDir, gi, '.gitignore', `${giLines.join('\n')}\n`, owned);
179
164
  } else if (oldOwned.includes('.gitignore')) {
180
165
  owned.push('.gitignore');
181
166
  }
@@ -63,7 +63,7 @@ export const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g
63
63
  * `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do.
64
64
  *
65
65
  * `recursive: true, withFileTypes: true` (Node >= 20.1, `parentPath` >= 21.4 — this repo
66
- * requires >= 22.3) replaces a hand-rolled walk; the order it yields is NOT the walk's
66
+ * requires >= 22.7) replaces a hand-rolled walk; the order it yields is NOT the walk's
67
67
  * order, but `sourceFingerprint`, this function's one caller, re-sorts every result by a
68
68
  * plain string key before it is ever observed, so no caller depends on it.
69
69
  */
@@ -184,15 +184,22 @@ export function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
184
184
  // nothing to detect, not because the gate cannot see it.
185
185
  if (text !== null && parts.length && dirs.has(parts[0])) {
186
186
  const dest = path.join(cfgDir, ...parts);
187
- // `claimLeaf` BEFORE `mkdirSync`, for the same reason `emitClaudeRender`'s rules stub
188
- // does: `safeLeaf` needs no directory created first, and creating one ahead of the
189
- // refusal would plant it at a linked ancestor's target.
190
- if (claimLeaf(cfgDir, dest, rel)) {
191
- mkdirSync(path.dirname(dest), { recursive: true });
192
- writeText(dest, text);
193
- owned.push(rel);
194
- }
187
+ claimAndWrite(cfgDir, dest, rel, text, owned);
195
188
  }
196
189
  }
197
190
  }
198
191
 
192
+ /**
193
+ * Claim-on-create one leaf: write `text` to `dest` and own `rel` only when `claimLeaf`
194
+ * allows it. `claimLeaf` runs BEFORE `mkdirSync`: `safeLeaf` walks up to the nearest
195
+ * EXISTING ancestor (`resolvePath`'s own contract), so it needs no directory created first —
196
+ * and creating the parent first would plant an empty directory at a linked ancestor's target
197
+ * the moment before the refusal fires. A leaf directly in an existing `root` makes the
198
+ * `mkdirSync` a no-op.
199
+ */
200
+ export function claimAndWrite(root, dest, rel, text, owned) {
201
+ if (!claimLeaf(root, dest, rel)) return;
202
+ mkdirSync(path.dirname(dest), { recursive: true });
203
+ writeText(dest, text);
204
+ owned.push(rel);
205
+ }
@@ -8,23 +8,21 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
- import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
12
11
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
13
12
  import {
14
13
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
15
14
  writeAliasCommands, writePonytailCommand, writePrimaryAgent, writeTheme,
16
15
  } from '../hosts/opencode.mjs';
17
16
  import { mergeOpencodeJson, opencodeSentinelWrite } from '../hosts/settings.mjs';
18
- import { claimLeaf } from '../hosts/owned.mjs';
19
17
  import { isFile, readText, writeText } from '../lib/fs.mjs';
20
18
  import { relPosix } from '../lib/text.mjs';
21
- import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
19
+ import { assertSourceComplete, build, phaseLog, ensureMemoryStore } from './bundle.mjs';
22
20
  import {
23
- globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
+ claimAndWrite, globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
24
22
  } from './emit-common.mjs';
25
23
  import { renderAll } from './render.mjs';
26
24
  import {
27
- ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
25
+ ensureExcludesStub, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
28
26
  ensureWikiStub,
29
27
  } from './stubs.mjs';
30
28
  import { writeVersion } from './version.mjs';
@@ -216,10 +214,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
216
214
  // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
217
215
  // hermetic — no absolute path a doctor check would flag as an escape.
218
216
  const agentDest = path.join(cfgDir, 'AGENT.md');
219
- if (claimLeaf(cfgDir, agentDest, 'AGENT.md')) {
220
- writeText(agentDest, stripCapabilityLinks(agentText));
221
- owned.push('AGENT.md');
222
- }
217
+ claimAndWrite(cfgDir, agentDest, 'AGENT.md', stripCapabilityLinks(agentText), owned);
223
218
  }
224
219
  // Co-owned, so never in `owned` — see `opencodeSentinelWrite`.
225
220
  opencodeSentinelWrite(cfgDir);
@@ -233,8 +228,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
233
228
  });
234
229
 
235
230
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
236
- ensureMemoryIndex(path.join(cfgDir, 'memory'));
237
- migrateMemoryIndex(path.join(cfgDir, 'memory'));
231
+ ensureMemoryStore(path.join(cfgDir, 'memory'));
238
232
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
239
233
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
240
234
  ensureWikiStub(cfgDir);
@@ -223,7 +223,7 @@ export function installProfile(host, scope, root, notify = null) {
223
223
  // one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
224
224
  const excludeRules = excludedRulesOfDir(root, host);
225
225
  // Skills left out stay left out: an upgrade that forgot them would put them all back.
226
- const excludeSkills = excludedSkillsOfDir(root, notify, host);
226
+ const excludeSkills = excludedSkillsOfDir(root, host, notify);
227
227
  const out = scope === 'global' ? null : root;
228
228
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
229
229
  PACK_ORDER, excludeRules, trust, excludeSkills);
@@ -150,7 +150,7 @@ export { SESSION_FILES, SEED_SHA256 };
150
150
  const MAP_COUNT_RE = /^- \[([^\]]*)\]\(topics\/[^)]+\.md\) — (\d+) facts?$/;
151
151
  // Past this many facts a topic file stops being a quick read, and the curator splits it.
152
152
  const CURATE_TOPIC_MAX = 40;
153
- const CURATE_NUDGE = '[memory] A topic holds over 40 facts, or facts sit in misc: offer the user '
153
+ const CURATE_NUDGE = `[memory] A topic holds over ${CURATE_TOPIC_MAX} facts, or facts sit in misc: offer the user `
154
154
  + 'to dispatch the memory-curator agent to merge, re-topic and archive this store.';
155
155
  export { CURATE_TOPIC_MAX, CURATE_NUDGE };
156
156
 
@@ -193,9 +193,6 @@ export function sessionFiles(hookRoot) {
193
193
  return found;
194
194
  }
195
195
 
196
- // Moved to `hosts.mjs` so the gates and `learn` share it; re-exported for this verb's callers.
197
- export { globalHookStandingDown };
198
-
199
196
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
200
197
  function disp(pathStr, root) {
201
198
  // A path on another drive has no relative form, so it is printed as given. `path.relative`
@@ -51,14 +51,30 @@ export function readDoneChecks(root) {
51
51
  }
52
52
 
53
53
  /**
54
- * True when `command` runs `line` with no pipe after it in the same segment. A segment ends at
55
- * `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. Whitespace is collapsed
56
- * on both sides. shortcut: quotes are not parsed — a `|` inside a quoted argument after the
57
- * check reads as a pipe, which only ever refuses a pass, never grants one.
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.
58
62
  */
59
63
  export function runsUnpiped(command, line) {
60
64
  const cmd = String(command).replace(/[ \t]+/g, ' ');
61
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;
62
78
  const rest = cmd.slice(i + line.length);
63
79
  let piped = false;
64
80
  for (let j = 0; j < rest.length; j += 1) {
@@ -126,8 +142,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
126
142
  /**
127
143
  * The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
128
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.
129
147
  */
130
- export function doneGate(meta, args) {
148
+ export function doneGate(meta, args, install) {
131
149
  try {
132
150
  const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
133
151
  const root = gitRootOf(cwd);
@@ -148,7 +166,6 @@ export function doneGate(meta, args) {
148
166
  }
149
167
  const missing = doneDecide(transcript, checks, root, cwd);
150
168
  if (!missing.length) return false;
151
- const install = args.memory ? path.dirname(args.memory) : null;
152
169
  if (meta.stop_hook_active === true) {
153
170
  recordGate(install, 'done-gate-cap', 'law-3');
154
171
  err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
@@ -293,12 +293,12 @@ export function cmdLearn(args) {
293
293
  }
294
294
 
295
295
  // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
296
- // folder the global install must not learn.
297
- 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;
298
299
  // Same install root, and a project install of this host beside it learns on its own Stop: a
299
300
  // second LLM call, and the global store learning facts the project owns (Claude B4).
300
- if (args.memory && globalHookStandingDown(path.dirname(toPlatformPath(args.memory)),
301
- hookProjectDir(), args.host, 'learn')) return 0;
301
+ if (install && globalHookStandingDown(install, hookProjectDir(), args.host, 'learn')) return 0;
302
302
 
303
303
  const raw = args.file ? readText(args.file) : readStdin();
304
304
  const meta = hookMeta(raw);
@@ -312,7 +312,7 @@ export function cmdLearn(args) {
312
312
 
313
313
  // The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
314
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)) return 0;
315
+ if (hookEvent === 'Stop' && !args.file && doneGate(meta, args, install)) return 0;
316
316
 
317
317
  // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
318
318
  // to distil — and the bare envelope itself is not notes text (that would waste a model call
@@ -90,11 +90,14 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
90
90
  // chase — add them if a probe ever shows one in real use), `checkout -f`/`--force` and
91
91
  // `switch -f`/`--force`/`--discard-changes`
92
92
  // (git's own `-h` lists `-f, --force` AND a separate `--discard-changes` for switch — both
93
- // discard uncommitted work the same way `checkout --` does), `worktree remove --force`/`-f`
94
- // (can discard an uncommitted worktree), `reflog expire` and `gc --prune=now` (delete the
95
- // reflog/dangling-commit safety net recovery depends on), and `push :branch`/`push --delete`
96
- // (deletes a remote branch — the colon form needs a space before the `:` so `push origin
97
- // HEAD:main`, an ordinary refspec, is not caught).
93
+ // discard uncommitted work the same way `checkout --` does), `reflog expire` and `gc
94
+ // --prune=now` (delete the reflog/dangling-commit safety net recovery depends on), and `push
95
+ // :branch`/`push --delete` (deletes a remote branch — the colon form needs a space before the
96
+ // `:` so `push origin HEAD:main`, an ordinary refspec, is not caught).
97
+ //
98
+ // `worktree remove --force`/`-f` is deliberately NOT here (user decision, 2026-10-17): removing
99
+ // a worktree is routine agent housekeeping, so it passes like a plain `worktree remove`. The
100
+ // OpenCode guard's `SHELL_WARN_RE` and `LAW_IV_BASH` (js/hosts/settings.mjs) dropped it too.
98
101
  //
99
102
  // `restore` and `stash drop`/`stash clear` are carved OUT of the shared `\bgit\b[^\n]*\b(…)`
100
103
  // wrapper above and anchored to the verb position instead (`git`, an optional `-C <path>`, then
@@ -106,7 +109,7 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
106
109
  // combination DOES discard working-tree changes, unlike `--staged` alone) while still deferring
107
110
  // on `--staged` alone.
108
111
  const DESTRUCTIVE_GIT_RE =
109
- /\bgit\b[^\n]*\b(?:reset\b[^\n]*\s--hard\b|clean\b[^\n]*\s(?:-[a-zA-Z]*f|--force\b)|branch\b[^\n]*\s(?:-D\b|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)|checkout\s+--\s|checkout\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b)|switch\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b|--discard-changes\b)|worktree\b[^\n]*\bremove\b[^\n]*(?:-[a-zA-Z]*f\b|--force\b)|reflog\b[^\n]*\bexpire\b|gc\b[^\n]*--prune\b|push\b[^\n]*(?:\s--force|\s-[a-zA-Z]*f\b|\s\+\S|\s--delete\b|\s:\S))|\bgit(?:\s+-C\s+\S+)*\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))|\bgit(?:\s+-C\s+\S+)*\s+stash\s+(?:drop|clear)\b/;
112
+ /\bgit\b[^\n]*\b(?:reset\b[^\n]*\s--hard\b|clean\b[^\n]*\s(?:-[a-zA-Z]*f|--force\b)|branch\b[^\n]*\s(?:-D\b|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)|checkout\s+--\s|checkout\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b)|switch\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b|--discard-changes\b)|reflog\b[^\n]*\bexpire\b|gc\b[^\n]*--prune\b|push\b[^\n]*(?:\s--force|\s-[a-zA-Z]*f\b|\s\+\S|\s--delete\b|\s:\S))|\bgit(?:\s+-C\s+\S+)*\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))|\bgit(?:\s+-C\s+\S+)*\s+stash\s+(?:drop|clear)\b/;
110
113
 
111
114
  // `loopExempt` — a WHITELIST, not the blacklist this replaced. A false ask costs one prompt; a
112
115
  // false allow publishes. So the exemption holds only when the ENTIRE command is built from
@@ -445,6 +448,8 @@ export function rootScan(command) {
445
448
  const m = /^-path[=:](.+)$/i.exec(t);
446
449
  return m ? m[1] : t;
447
450
  });
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.
448
453
  if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
449
454
  || (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
450
455
  || (ps && PS_RECURSE_RE.test(a)))) continue;
@@ -452,8 +457,6 @@ export function rootScan(command) {
452
457
  }
453
458
  return null;
454
459
  }
455
- // For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
456
- // POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
457
460
  const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
458
461
  // A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
459
462
  // (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
@@ -527,19 +530,19 @@ const SECRET_RE =
527
530
  /\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-----/;
528
531
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
529
532
 
533
+ export const SENSOR_LIST = '.geneseed/protected-checks.txt';
534
+ // The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
535
+ // protected-checks list: emptying it would disarm the gate it feeds.
536
+ export const DONE_LIST = '.geneseed/done-checks.txt';
530
537
  /**
531
538
  * External Gate's boundary half: the checks an agent is judged by must sit outside what it can
532
539
  * edit, or a failing check is one edit away from a passing one. A project opts in by listing
533
540
  * them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
534
541
  * its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
535
542
  * writing into another reads that other repo's list. The list protects itself. Returns the entry
536
- * the path falls under, or null. ponytail: a write tool only — a shell `sed -i` or redirect is
543
+ * the path falls under, or null. shortcut: a write tool only — a shell `sed -i` or redirect is
537
544
  * not seen here; CI running the checks is the boundary that holds against that.
538
545
  */
539
- export const SENSOR_LIST = '.geneseed/protected-checks.txt';
540
- // The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
541
- // protected-checks list: emptying it would disarm the gate it feeds.
542
- export const DONE_LIST = '.geneseed/done-checks.txt';
543
546
  function protectedCheck(p) {
544
547
  try {
545
548
  const target = resolvePath(p);
@@ -563,6 +566,14 @@ function ruleGate(args) {
563
566
  return ruleDecide(args, readPayload());
564
567
  }
565
568
 
569
+ // The REPLACE halves of a Roo-style `apply_diff` (`<<<<<<< SEARCH … ======= <new> >>>>>>> REPLACE`).
570
+ // A diff without those markers is a shape we do not know, so it is returned whole: fail closed.
571
+ function diffReplaced(diff) {
572
+ if (typeof diff !== 'string') return diff;
573
+ const halves = [...diff.matchAll(/^=======\r?\n([\s\S]*?)^>>>>>>> REPLACE/gm)].map((m) => m[1]);
574
+ return halves.length ? halves.join('\n') : diff;
575
+ }
576
+
566
577
  function ruleDecide(args, payload) {
567
578
  const ti = (payload && payload.tool_input) || {};
568
579
  // NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
@@ -575,9 +586,11 @@ function ruleDecide(args, payload) {
575
586
  // cheap, harmless guard in case that ever changes.
576
587
  // `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
577
588
  // field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
578
- // required, same as every field above.
589
+ // required, same as every field above. Only WRITTEN text is scanned: `search` and a diff's
590
+ // SEARCH halves are text leaving the file (like Claude's `old_string`), so a secret there is
591
+ // being removed — blocking it would stop the agent cleaning up a leak.
579
592
  const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
580
- const body = [ti.content, ti.new_string, ti.new_source, ti.diff, ti.replace, ti.search, ...edits]
593
+ const body = [ti.content, ti.new_string, ti.new_source, diffReplaced(ti.diff), ti.replace, ...edits]
581
594
  .filter((v) => typeof v === 'string').join('\n');
582
595
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
583
596
  return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
@@ -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,
@@ -771,6 +768,44 @@ export function hookShellProblems(targets = installTargets(), platform = process
771
768
  return out;
772
769
  }
773
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
+
774
809
  // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
775
810
 
776
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
- }