geneseed 4.9.1 → 4.9.2

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 (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +7 -1
  4. package/adapters/opencode/plugins/geneseed-guard.js +18 -10
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/hooks.md +1 -1
  7. package/harness.config.json +1 -1
  8. package/js/README.md +30 -9
  9. package/js/build/args.mjs +9 -8
  10. package/js/build/driver.mjs +21 -21
  11. package/js/build/emit-claude.mjs +3 -3
  12. package/js/build/generate.mjs +4 -5
  13. package/js/build/source.mjs +18 -22
  14. package/js/hosts/gitref.mjs +5 -3
  15. package/js/hosts/hooks-done.mjs +21 -9
  16. package/js/hosts/hooks-learn.mjs +23 -10
  17. package/js/hosts/hooks-prims.mjs +25 -0
  18. package/js/hosts/hooks.mjs +6 -1
  19. package/js/hosts/installs.mjs +5 -4
  20. package/js/hosts/native.mjs +3 -1
  21. package/js/hosts/owned.mjs +34 -6
  22. package/js/hosts/settings.mjs +5 -4
  23. package/js/inspect/cost.mjs +37 -5
  24. package/js/inspect/diff.mjs +132 -61
  25. package/js/inspect/validate.mjs +2 -2
  26. package/js/lib/fs.mjs +73 -2
  27. package/js/maintain/setup.mjs +31 -7
  28. package/js/maintain/uninstall.mjs +1 -1
  29. package/js/maintain/update.mjs +1 -1
  30. package/js/web/actions.mjs +36 -3
  31. package/js/web/api.mjs +12 -5
  32. package/js/web/handler.mjs +4 -16
  33. package/js/web/jobs.mjs +5 -3
  34. package/js/web/state.mjs +3 -0
  35. package/package.json +1 -1
  36. package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-D7Igwuoo.js} +1 -1
  37. package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-53rp05hx.js} +1 -1
  38. package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
  39. package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-BM56RCT8.js} +3 -3
  40. package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
  41. package/web/dist/assets/{Installs-CWuUU4I3.js → Installs-Dh1tB1NK.js} +2 -2
  42. package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
  43. package/web/dist/assets/Laws-iEheocNI.js +3 -0
  44. package/web/dist/assets/Library-d4_uOGmC.js +1 -0
  45. package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-BbYeWl70.js} +2 -2
  46. package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-Dki9Q6W3.js} +1 -1
  47. package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
  48. package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-CAl6oFwG.js} +1 -1
  49. package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
  50. package/web/dist/assets/index-BZgEGRbL.js +2 -0
  51. package/web/dist/index.html +1 -1
  52. package/web/src/pages/Laws.jsx +60 -353
  53. package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
  54. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
  55. package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
  56. package/web/dist/assets/Laws-R5zZCmya.js +0 -3
  57. package/web/dist/assets/Library-u36V7oSD.js +0 -1
  58. package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
  59. package/web/dist/assets/drawable-CE-arUnF.js +0 -1
  60. package/web/dist/assets/index-3lnPT_1J.js +0 -2
@@ -28,6 +28,11 @@
28
28
  * result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
29
29
  * 14 000+ real Claude Code results, 2026-10-17).
30
30
  *
31
+ * READ FROM THE END (constant-cost Stop). Only the current turn matters, so the transcript is read
32
+ * backward in growing windows (`tailReader`) until the turn's opening prompt is inside one; a
33
+ * turn longer than every window falls back to the whole file. Never judged on a truncated turn:
34
+ * a window without a prompt answers "read more", not "no edit".
35
+ *
31
36
  * At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
32
37
  * gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
33
38
  */
@@ -37,7 +42,7 @@ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
37
42
  import { within } from '../lib/paths.mjs';
38
43
  import { gitRootOf } from './gitref.mjs';
39
44
  import { DONE_LIST, recordGate } from './hooks.mjs';
40
- import { splitLines } from './hooks-prims.mjs';
45
+ import { splitLines, tailReader } from './hooks-prims.mjs';
41
46
 
42
47
  const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
43
48
  const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
@@ -102,18 +107,24 @@ function isPrompt(obj) {
102
107
  /**
103
108
  * The decision, over transcript TEXT: the checks that have not passed since the turn's last
104
109
  * edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
105
- * unit tier — a transcript is text no CLI cell can vary cheaply.
110
+ * unit tier — a transcript is text no CLI cell can vary cheaply. `whole` false means the text
111
+ * is only the file's tail: with no prompt in it the turn may start earlier, so the answer is
112
+ * null ("read more") — a turn judged from its middle could miss its edit and wrongly pass.
106
113
  */
107
- export function doneDecide(transcript, checks, root, cwd = root) {
114
+ export function doneDecide(transcript, checks, root, cwd = root, whole = true) {
108
115
  const entries = [];
109
116
  for (const line of splitLines(transcript)) {
110
117
  if (!line.trim()) continue;
111
118
  try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
112
119
  }
113
- let start = 0;
120
+ let start = -1;
114
121
  for (let i = entries.length - 1; i >= 0; i -= 1) {
115
122
  if (isPrompt(entries[i])) { start = i + 1; break; }
116
123
  }
124
+ if (start === -1) {
125
+ if (!whole) return null;
126
+ start = 0;
127
+ }
117
128
  const calls = []; // tool_use blocks, in order
118
129
  const results = new Map(); // tool_use_id -> is_error
119
130
  for (const obj of entries.slice(start)) {
@@ -147,9 +158,10 @@ export function doneDecide(transcript, checks, root, cwd = root) {
147
158
  * The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
148
159
  * goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
149
160
  * `install` is the root `cmdLearn` already derived from `--memory` (null without one), so the
150
- * ledger lands in the same root its sovereignty check read.
161
+ * ledger lands in the same root its sovereignty check read. `read` is `cmdLearn`'s per-Stop
162
+ * transcript reader, so the distillation after an allow reuses the windows read here.
151
163
  */
152
- export function doneGate(meta, args, install) {
164
+ export function doneGate(meta, args, install, read = tailReader()) {
153
165
  try {
154
166
  const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
155
167
  const root = gitRootOf(cwd);
@@ -160,15 +172,15 @@ export function doneGate(meta, args, install) {
160
172
  err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
161
173
  return false;
162
174
  }
163
- let transcript;
175
+ let missing;
164
176
  try {
165
- transcript = readText(meta.transcript_path);
177
+ missing = read(meta.transcript_path,
178
+ (text, whole) => doneDecide(text, checks, root, cwd, whole));
166
179
  } catch {
167
180
  // No ask tier on Stop: blocking on an unreadable transcript would trap the user.
168
181
  err('[done-gate] transcript unreadable — turn not judged.\n');
169
182
  return false;
170
183
  }
171
- const missing = doneDecide(transcript, checks, root, cwd);
172
184
  if (!missing.length) return false;
173
185
  if (meta.stop_hook_active === true) {
174
186
  recordGate(install, 'done-gate-cap', 'law-3');
@@ -12,7 +12,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
12
12
  import {
13
13
  globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
14
14
  } from './hosts.mjs';
15
- import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
15
+ import { readStdin, splitLines, splitWords, tailReader } from './hooks-prims.mjs';
16
16
  import { doneGate } from './hooks-done.mjs';
17
17
  import {
18
18
  existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
@@ -94,15 +94,26 @@ function contentText(content) {
94
94
  return '';
95
95
  }
96
96
 
97
- /** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
98
- function flattenTranscript(p) {
99
- let raw;
97
+ /**
98
+ * Render a Claude-Code-style JSONL transcript into `role: text` notes, read from the END: the
99
+ * window grows until its notes hold twice MAX_NOTES_CHARS UTF-16 units (so at least
100
+ * MAX_NOTES_CHARS code points) or it is the whole file. The window's notes are a suffix of the
101
+ * whole file's, so the tail `cmdLearn` keeps is the one a full read would give — and a tail of
102
+ * tool output, which flattens to nothing, just reads further back.
103
+ */
104
+ function flattenTranscript(p, read) {
100
105
  try {
101
106
  // `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
102
- raw = readText(p);
107
+ return read(p, (raw, whole) => {
108
+ const flat = flattenText(raw);
109
+ return whole || flat.length >= 2 * MAX_NOTES_CHARS ? flat : null;
110
+ });
103
111
  } catch {
104
112
  return '';
105
113
  }
114
+ }
115
+
116
+ function flattenText(raw) {
106
117
  const acc = [];
107
118
  for (let line of splitLines(raw)) {
108
119
  line = line.trim();
@@ -152,11 +163,11 @@ function flattenTranscript(p) {
152
163
  * store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
153
164
  * reaches none of them.
154
165
  */
155
- export function readNotes(raw, meta = hookMeta(raw)) {
166
+ export function readNotes(raw, meta = hookMeta(raw), read = tailReader()) {
156
167
  if (!raw.trim()) return '';
157
168
  const tp = meta.agent_transcript_path || meta.transcript_path;
158
169
  if (!tp) return raw;
159
- const flat = flattenTranscript(tp);
170
+ const flat = flattenTranscript(tp, read);
160
171
  const lastMsg = typeof meta.last_assistant_message === 'string'
161
172
  ? meta.last_assistant_message.trim() : '';
162
173
  if (!flat) return lastMsg || flat;
@@ -314,7 +325,9 @@ export function cmdLearn(args) {
314
325
  // means the turn goes on, so there is nothing to distil yet: the next Stop will.
315
326
  // `--no-done-gate` (the install switched Law III's gate off) skips the check, never the
316
327
  // distillation below.
317
- if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install)) return 0;
328
+ // One reader for this Stop: an allowed turn's distillation reuses the windows the gate read.
329
+ const read = tailReader();
330
+ if (hookEvent === 'Stop' && !args.file && !args.noDoneGate && doneGate(meta, args, install, read)) return 0;
318
331
 
319
332
  // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
320
333
  // to distil — and the bare envelope itself is not notes text (that would waste a model call
@@ -334,7 +347,7 @@ export function cmdLearn(args) {
334
347
 
335
348
  // B3: a real hook call with `$GENESEED_LLM` unset has nothing to send a prompt to — stdout on
336
349
  // Stop/SubagentStop/PreCompact reaches only the debug log, never the user (docs `hooks.md`) —
337
- // so reading and flattening the transcript (up to 16 KB, every turn) just to print it nowhere
350
+ // so reading and flattening the transcript (a 2 MB tail or more, every turn) just to print it nowhere
338
351
  // is pure waste. Checked BEFORE `readNotes`, which is what does that read. A manual
339
352
  // invocation keeps printing the prompt on an unset `$GENESEED_LLM` — that is the documented,
340
353
  // deliberate behaviour (docs/reference/env-context.md) for someone running `learn` by hand to
@@ -345,7 +358,7 @@ export function cmdLearn(args) {
345
358
  return 0;
346
359
  }
347
360
 
348
- let notes = readNotes(raw, meta);
361
+ let notes = readNotes(raw, meta, read);
349
362
  if (!notes.trim()) {
350
363
  err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
351
364
  return 0;
@@ -10,6 +10,7 @@
10
10
  import { statSync, readFileSync, readdirSync } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { comparePaths } from '../lib/paths.mjs';
13
+ import { readTail } from '../lib/fs.mjs';
13
14
 
14
15
  // ---- Python path and text primitives -------------------------------------------------
15
16
  // Small, and each one is here because a JS-idiomatic equivalent differs observably.
@@ -75,6 +76,30 @@ export function splitLines(text) {
75
76
  return parts;
76
77
  }
77
78
 
79
+ /** The first window a Stop reads from a transcript's end; real transcripts reach 50 MB. */
80
+ export const TAIL_BYTES = 2 * 1024 * 1024;
81
+
82
+ /**
83
+ * A transcript reader for ONE hook invocation: `read(file, judge)` hands `judge(text, whole)`
84
+ * the file's last `bytes`, then a window four times larger, until `judge` answers (anything
85
+ * but null) or the window holds the whole file — where it must answer. Each window is read once
86
+ * per reader, so the done gate and `learn` share one read when both run on the same Stop.
87
+ * A read error (missing, unreadable) throws to the caller, uncached. Never a module-level cache:
88
+ * the host appends between Stops, and a test rewrites the same path.
89
+ */
90
+ export function tailReader(bytes = TAIL_BYTES) {
91
+ const seen = new Map();
92
+ return (file, judge) => {
93
+ for (let n = bytes; ; n *= 4) {
94
+ const key = `${n}:${file}`;
95
+ if (!seen.has(key)) seen.set(key, readTail(file, n));
96
+ const { text, start } = seen.get(key);
97
+ const answer = judge(text, start === 0);
98
+ if (answer !== null || start === 0) return answer;
99
+ }
100
+ };
101
+ }
102
+
78
103
  /** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
79
104
  export function splitWords(s) {
80
105
  return s.split(/\s+/).filter(Boolean);
@@ -525,8 +525,13 @@ export const DONE_LIST = '.geneseed/done-checks.txt';
525
525
  * not seen here; CI running the checks is the boundary that holds against that.
526
526
  */
527
527
  function protectedCheck(p) {
528
+ return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p));
529
+ }
530
+ // One reading of the target. `protectedCheck` takes two — the path as written and the path with
531
+ // links followed — so a link from outside into a protected folder and a protected folder that
532
+ // links out of the repo are both caught: following links never loosens the gate.
533
+ function protectedIn(target) {
528
534
  try {
529
- const target = resolvePath(p);
530
535
  const root = gitRootOf(path.dirname(target));
531
536
  if (!root) return null;
532
537
  if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
@@ -609,9 +609,6 @@ export function gatesOfDir(d, host = null) {
609
609
  return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, gateOfDir(d, g.marker, host)]));
610
610
  }
611
611
 
612
- /** Law IV's switch alone (#319's reader, kept for its callers). */
613
- export const deletionGateOfDir = (d, host = null) => gateOfDir(d, 'Deletion gate', host);
614
-
615
612
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
616
613
 
617
614
  /** `_harness_mcp._claude_read_manifest`. */
@@ -838,7 +835,11 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
838
835
  // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
839
836
 
840
837
  /**
841
- * `_harness_setup._installed_defaults` — all six keys.
838
+ * `_harness_setup._installed_defaults` — eight keys: `theme`, `posture`, `mode`, `emit`,
839
+ * `footprint`, `doctrines`, `excludeRules`, `excludeSkills`. NOT the gate switches: a walk
840
+ * across every install on the machine would carry one host's `off` into another's re-run, and
841
+ * for a gate that disarms a boundary its owner never switched off — `setup` reads them off the
842
+ * one install it is re-running (`targetGates` in js/maintain/setup.mjs).
842
843
  *
843
844
  * It carried only `theme` and `emit` for four phases because those are the two `status`
844
845
  * prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
33
33
  import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
34
34
  import { relPosix } from '../lib/text.mjs';
35
35
  import { aliasesOf } from '../build/source.mjs';
36
+ import { resolvePath } from './hosts.mjs';
36
37
  import { claimLeaf, ownedState } from './owned.mjs';
37
38
  // `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
38
39
  // (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
@@ -497,8 +498,9 @@ export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
497
498
  const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
498
499
  let headerPrinted = false;
499
500
  const editRefused = new Set();
501
+ const rroot = cfg != null ? resolvePath(cfg) : undefined; // once per emit, not per leaf
500
502
  const claim = (dest) => {
501
- if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
503
+ if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest), rroot)) return false;
502
504
  if (oldSet === null || cfg == null || !existsSync(dest)) return true;
503
505
  const rel = relPosix(cfg, dest);
504
506
  if (oldSet.has(rel)) {
@@ -26,7 +26,7 @@ import path from 'node:path';
26
26
 
27
27
  import { resolvePath } from './hosts.mjs';
28
28
  import { within } from '../lib/paths.mjs';
29
- import { isFile } from '../lib/fs.mjs';
29
+ import { isFile, statOrNull, textWrites } from '../lib/fs.mjs';
30
30
 
31
31
  /**
32
32
  * The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
@@ -68,9 +68,13 @@ export function ownedWithin(base, rels) {
68
68
  * `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
69
69
  * ordinary way through a linked root still resolves inside it and stays allowed — only the
70
70
  * LEAF itself being a link is refused.
71
+ *
72
+ * `rroot` is `resolvePath(root)`, passed in by a caller that claims many leaves under one root
73
+ * (`claimer`, once per emit) — re-resolving it per leaf was ~2.6 s of `doctor --all`. Only the
74
+ * ROOT is hoisted: the lstat and the leaf's own `resolvePath` below stay per call, since they
75
+ * are the boundary.
71
76
  */
72
- export function safeLeaf(root, dest) {
73
- const rroot = resolvePath(root);
77
+ export function safeLeaf(root, dest, rroot = resolvePath(root)) {
74
78
  const st = lstatSync(dest, { throwIfNoEntry: false });
75
79
  if (st) {
76
80
  if (st.isSymbolicLink()) return false;
@@ -86,8 +90,8 @@ export function safeLeaf(root, dest) {
86
90
  * callers that already have it (every one of them, since it is what they are about to push
87
91
  * into `owned`) pass it straight through instead of re-deriving it from `dest`.
88
92
  */
89
- export function claimLeaf(root, dest, rel) {
90
- if (safeLeaf(root, dest)) return true;
93
+ export function claimLeaf(root, dest, rel, rroot = undefined) {
94
+ if (safeLeaf(root, dest, rroot)) return true;
91
95
  process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
92
96
  + 'regular files it owns\n');
93
97
  return false;
@@ -107,6 +111,19 @@ export function fileHash(p) {
107
111
  return createHash('sha256').update(folded, 'latin1').digest('hex');
108
112
  }
109
113
 
114
+ /**
115
+ * `fileHash` of a file whose whole content is `written`, computed from the string instead of
116
+ * read back — `written` must be EXACTLY what went to disk as utf8 (`writeText`'s translated
117
+ * text, never the text before its `\n` -> `os.EOL`: an embedded `\r\n` becomes `\r\r\n` on
118
+ * Windows, which folds to `\r\n`, not `\n`). CR and LF are single bytes that never occur
119
+ * inside a multi-byte utf8 sequence, so folding the string then encoding equals encoding then
120
+ * folding.
121
+ */
122
+ export function textHash(written) {
123
+ return createHash('sha256').update(Buffer.from(written.replaceAll('\r\n', '\n'), 'utf8'))
124
+ .digest('hex');
125
+ }
126
+
110
127
  /**
111
128
  * One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
112
129
  * containment boundary every other owned-rel walk goes through) and a file that still exists —
@@ -114,12 +131,23 @@ export function fileHash(p) {
114
131
  * rather than hashed. Keys land in the same sorted order the manifest writers already give
115
132
  * `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
116
133
  * tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
134
+ *
135
+ * A file this emit wrote through `writeText` is hashed from the text it recorded
136
+ * (`textWrites`, `js/lib/fs.mjs`) rather than read back, while its size and mtime still match
137
+ * the stat taken at write time; anything else — a copied binary, an unrecorded rewrite — is read.
138
+ * shortcut: a same-size rewrite by a non-`writeText` writer inside one mtime tick would keep the
139
+ * stale record; no emit does that today (measured across golden's cells, 0 mismatches) — route
140
+ * such a writer through `writeText` or drop its entry if one ever appears.
117
141
  */
118
142
  export function ownedHashes(root, owned) {
119
143
  const hashes = {};
120
144
  for (const rel of ownedWithin(root, owned).sort()) {
121
145
  const p = path.join(root, rel);
122
- if (isFile(p)) hashes[rel] = fileHash(p);
146
+ const st = statOrNull(p);
147
+ if (!st?.isFile()) continue;
148
+ const w = textWrites.map?.get(path.resolve(p));
149
+ hashes[rel] = w && w.size === st.size && w.mtimeMs === st.mtimeMs ? textHash(w.text)
150
+ : fileHash(p);
123
151
  }
124
152
  return hashes;
125
153
  }
@@ -726,7 +726,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
726
726
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
727
727
  */
728
728
  export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
729
- deletionGate = true, { secretsGate = true, doneGate = true } = {}) {
729
+ gates = {}) {
730
+ // One object keyed as `GATE_SWITCHES` keys it; only an explicit `false` disarms a gate.
731
+ const { secretsGate = true, doneGate = true, deletionGate = true } = gates ?? {};
730
732
  const run = hookPrefix(hookOpts);
731
733
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
732
734
  // --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
@@ -932,15 +934,14 @@ function readSettingsForMerge(p, consequence) {
932
934
  * global that never stood down for a project install.
933
935
  */
934
936
  export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
935
- doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true,
936
- gates = {}) {
937
+ doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), gates = {}) {
937
938
  const prior = (priorHooks || []).filter(isDict);
938
939
  const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
939
940
  if (!loaded) return [p, prior];
940
941
  const [config, hadComments] = loaded;
941
942
  let hooks = get(config, 'hooks');
942
943
  if (!isDict(hooks)) hooks = {};
943
- const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate, gates);
944
+ const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, gates);
944
945
  const canonFlat = [];
945
946
  for (const [event, groups] of Object.entries(canonical)) {
946
947
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -9,8 +9,12 @@
9
9
  * `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
10
10
  * `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
11
11
  * which the smoke test does not, so the two agree on any carrier without one.
12
- * - `skills` the name + description every native skill lists (aliases included: they are
13
- * SKILL.md files the host lists like any other).
12
+ * - `skills` the name + description of every native skill the MODEL lists. A user-only skill
13
+ * (and every alias, which native.mjs writes user-only) stays on disk but out of
14
+ * the catalogue, by each host's own mechanism (`userOnly`): the frontmatter's
15
+ * `disable-model-invocation` on Claude Code and OpenClaude; on OpenCode, which
16
+ * ignores that key, the `permission.skill` deny in `opencode.json`; on Bob no
17
+ * SKILL.md exists for one, so there is nothing to skip.
14
18
  * - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
15
19
  * - `context` the session files the context hook injects (untouched seeds already skipped).
16
20
  * The per-repo project context is NOT counted: it changes with the working dir.
@@ -21,6 +25,8 @@ import { sessionFiles } from '../hosts/hooks-context.mjs';
21
25
  import { carriersFor } from '../hosts/installs.mjs';
22
26
  import { frontmatter } from '../hosts/memory-files.mjs';
23
27
  import { listDir } from '../hosts/hooks-prims.mjs';
28
+ import { loadJsonObject, opencodeTarget } from '../hosts/settings.mjs';
29
+ import { isDict } from '../lib/json.mjs';
24
30
  import { isFile, readText } from '../lib/fs.mjs';
25
31
 
26
32
  /**
@@ -44,14 +50,39 @@ function fmValue(raw) {
44
50
  return raw;
45
51
  }
46
52
 
47
- /** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
48
- function listedChars(file, fallbackName) {
53
+ /**
54
+ * name + description of one spec file; `fallbackName` for agents, whose name is the file stem.
55
+ * `hidden(fm)` answers true for a file the host keeps out of the model's catalogue: it counts 0.
56
+ * @param {string} file
57
+ * @param {string} fallbackName
58
+ * @param {(fm: Map<string, string>) => boolean} [hidden]
59
+ */
60
+ function listedChars(file, fallbackName, hidden = () => false) {
49
61
  let fm;
50
62
  try { [fm] = frontmatter(readText(file)); } catch { return 0; }
63
+ if (hidden(fm)) return 0;
51
64
  const name = fmValue(fm.get('name')) || fallbackName;
52
65
  return name.length + fmValue(fm.get('description')).length;
53
66
  }
54
67
 
68
+ /**
69
+ * How `host` hides a skill from the model, mirroring `writeNativeLayer` (js/hosts/native.mjs).
70
+ * OpenCode reads the deny where `mcpConfigFor` finds its config (`opencode.json(c)` at the
71
+ * root); a blanket non-object `permission.skill` is the user's own policy and hides nothing
72
+ * by name, so nothing is skipped. Bob documents no such key: nothing is skipped either.
73
+ */
74
+ function userOnlyTest(host, root) {
75
+ if (host === 'claude' || host === 'openclaude') {
76
+ return (_name, fm) => fm.get('disable-model-invocation') === 'true';
77
+ }
78
+ if (host === 'opencode') {
79
+ const { data } = loadJsonObject(opencodeTarget(path.join(root, 'opencode.json')));
80
+ const skill = isDict(data) && isDict(data.permission) ? data.permission.skill : null;
81
+ return (name) => isDict(skill) && skill[name] === 'deny';
82
+ }
83
+ return () => false;
84
+ }
85
+
55
86
  /**
56
87
  * `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
57
88
  * `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
@@ -64,9 +95,10 @@ export function alwaysOnCost({ host, root, dir, footprint }) {
64
95
 
65
96
  let skills = 0;
66
97
  const skillsDir = path.join(dir, 'skills');
98
+ const userOnly = userOnlyTest(host, root);
67
99
  for (const name of listDir(skillsDir)) {
68
100
  const f = path.join(skillsDir, name, 'SKILL.md');
69
- if (isFile(f)) skills += listedChars(f, name);
101
+ if (isFile(f)) skills += listedChars(f, name, (fm) => userOnly(name, fm));
70
102
  }
71
103
  let agents = 0;
72
104
  if (host !== 'bob') {