geneseed 4.5.0 → 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 (70) hide show
  1. package/CHANGELOG.md +19 -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 -27
  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 +19 -9
  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 +17 -11
  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
@@ -445,6 +445,8 @@ export function rootScan(command) {
445
445
  const m = /^-path[=:](.+)$/i.exec(t);
446
446
  return m ? m[1] : t;
447
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.
448
450
  if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
449
451
  || (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
450
452
  || (ps && PS_RECURSE_RE.test(a)))) continue;
@@ -452,8 +454,6 @@ export function rootScan(command) {
452
454
  }
453
455
  return null;
454
456
  }
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
457
  const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
458
458
  // A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
459
459
  // (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
@@ -527,19 +527,19 @@ const SECRET_RE =
527
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-----/;
528
528
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
529
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';
530
534
  /**
531
535
  * External Gate's boundary half: the checks an agent is judged by must sit outside what it can
532
536
  * edit, or a failing check is one edit away from a passing one. A project opts in by listing
533
537
  * them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
534
538
  * its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
535
539
  * 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
540
+ * the path falls under, or null. shortcut: a write tool only — a shell `sed -i` or redirect is
537
541
  * not seen here; CI running the checks is the boundary that holds against that.
538
542
  */
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
543
  function protectedCheck(p) {
544
544
  try {
545
545
  const target = resolvePath(p);
@@ -563,6 +563,14 @@ function ruleGate(args) {
563
563
  return ruleDecide(args, readPayload());
564
564
  }
565
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
+
566
574
  function ruleDecide(args, payload) {
567
575
  const ti = (payload && payload.tool_input) || {};
568
576
  // NotebookEdit names its target `notebook_path`; the settings matcher routes it here too.
@@ -575,9 +583,11 @@ function ruleDecide(args, payload) {
575
583
  // cheap, harmless guard in case that ever changes.
576
584
  // `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
577
585
  // field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
578
- // 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.
579
589
  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]
590
+ const body = [ti.content, ti.new_string, ti.new_source, diffReplaced(ti.diff), ti.replace, ...edits]
581
591
  .filter((v) => typeof v === 'string').join('\n');
582
592
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
583
593
  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
- }
@@ -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
  }