geneseed 4.9.0 → 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 (68) hide show
  1. package/CHANGELOG.md +40 -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 +139 -27
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/hooks.md +1 -1
  7. package/docs/web-ui.md +3 -2
  8. package/harness.config.json +1 -1
  9. package/js/README.md +30 -9
  10. package/js/build/args.mjs +9 -8
  11. package/js/build/driver.mjs +21 -21
  12. package/js/build/emit-claude.mjs +3 -3
  13. package/js/build/generate.mjs +4 -5
  14. package/js/build/source.mjs +18 -22
  15. package/js/hosts/gitref.mjs +5 -3
  16. package/js/hosts/hooks-done.mjs +21 -9
  17. package/js/hosts/hooks-learn.mjs +23 -10
  18. package/js/hosts/hooks-prims.mjs +25 -0
  19. package/js/hosts/hooks.mjs +17 -8
  20. package/js/hosts/installs.mjs +5 -4
  21. package/js/hosts/loss.mjs +124 -18
  22. package/js/hosts/native.mjs +3 -1
  23. package/js/hosts/owned.mjs +34 -6
  24. package/js/hosts/settings.mjs +5 -4
  25. package/js/inspect/cost.mjs +37 -5
  26. package/js/inspect/diff.mjs +132 -61
  27. package/js/inspect/doctor.mjs +4 -3
  28. package/js/inspect/validate.mjs +2 -2
  29. package/js/lib/fs.mjs +73 -2
  30. package/js/maintain/setup.mjs +31 -7
  31. package/js/maintain/uninstall.mjs +1 -1
  32. package/js/maintain/update.mjs +1 -1
  33. package/js/web/actions.mjs +38 -4
  34. package/js/web/api.mjs +21 -13
  35. package/js/web/handler.mjs +5 -17
  36. package/js/web/jobs.mjs +5 -3
  37. package/js/web/routes.mjs +3 -1
  38. package/js/web/server.mjs +3 -1
  39. package/js/web/state.mjs +35 -8
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-D7Igwuoo.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-53rp05hx.js} +1 -1
  43. package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
  44. package/web/dist/assets/{Docs-DUxizutI.js → Docs-BM56RCT8.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
  46. package/web/dist/assets/Installs-Dh1tB1NK.js +2 -0
  47. package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
  48. package/web/dist/assets/Laws-iEheocNI.js +3 -0
  49. package/web/dist/assets/Library-d4_uOGmC.js +1 -0
  50. package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BbYeWl70.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-Dki9Q6W3.js} +1 -1
  52. package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
  53. package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-CAl6oFwG.js} +1 -1
  54. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  55. package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
  56. package/web/dist/assets/index-BZgEGRbL.js +2 -0
  57. package/web/dist/index.html +2 -2
  58. package/web/src/pages/Laws.jsx +60 -353
  59. package/web/dist/assets/Dashboard-COLskkOJ.js +0 -2
  60. package/web/dist/assets/GreenhouseView-BSzVX_l7.js +0 -2
  61. package/web/dist/assets/Installs-Ck4YyEzs.js +0 -2
  62. package/web/dist/assets/JournalView-BbbACsYK.js +0 -1
  63. package/web/dist/assets/Laws-BZ_M86r3.js +0 -3
  64. package/web/dist/assets/Library-D_4LbUg2.js +0 -1
  65. package/web/dist/assets/Personal-C66284wd.js +0 -1
  66. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
  67. package/web/dist/assets/drawable-DlrXDOJ7.js +0 -1
  68. package/web/dist/assets/index-DJorAjeH.js +0 -2
package/js/hosts/loss.mjs CHANGED
@@ -18,14 +18,18 @@
18
18
  * `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
19
19
  * holds — the same forms on a feature branch pass;
20
20
  * - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
21
- * - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of anything git
22
- * cannot bring back: untracked, ignored or modified files, a repository's own `.git`, or
23
- * any path outside a git repository.
21
+ * - a file removal (`rm`, `rmdir`, `unlink`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
22
+ * anything git cannot bring back: untracked, ignored or modified files, a repository's own
23
+ * `.git`, or any path outside a git repository.
24
24
  *
25
25
  * FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
26
- * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`) carrying a removal,
27
- * a git that fails, times out or is missing — asks, and the reason says it could not verify.
28
- * "Not proven safe" is an ask; only "proven safe" passes.
26
+ * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`, `find -delete`)
27
+ * carrying a removal, a removal that names no path (`… | Remove-Item` takes its paths from the
28
+ * pipe), a wrapper flag whose arity it does not know, ANY program it does not read (`setsid`,
29
+ * `ForEach-Object`, `case`) with a removal named unquoted after it, a git that fails, times out
30
+ * or is missing — asks, and the reason says it could not verify. A command substitution
31
+ * (`$(…)`, backticks) is judged as a command of its own. "Not proven safe" is an ask; only
32
+ * "proven safe" passes.
29
33
  *
30
34
  * THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
31
35
  * files are untracked, and whether a branch's commits are reachable elsewhere are not readable
@@ -60,16 +64,31 @@ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitr
60
64
  * a superset of what the parse acts on.
61
65
  */
62
66
  export const LOSS_CANDIDATE_RE =
63
- /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
67
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
64
68
 
65
69
  class Unverified extends Error {}
66
70
  const unverified = (why) => { throw new Unverified(why); };
67
71
 
68
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
72
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
69
73
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
70
74
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
71
75
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
72
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
76
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
77
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
78
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
79
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
80
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
81
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
82
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
83
+ '--non-interactive', '--ignore-environment', '--null']);
84
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
85
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
86
+ // The only programs this reader does not parse that may still name a removal word and pass: their
87
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
88
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
89
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
90
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
91
+ 'get-help']);
73
92
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
74
93
  // not on this command line in a form the parse can read, so a removal word inside one asks.
75
94
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -434,6 +453,8 @@ function rmAct(prog, args, dir) {
434
453
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
435
454
  targets.push(a);
436
455
  }
456
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
457
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
437
458
  const groups = new Map();
438
459
  for (const t of targets) {
439
460
  const shown = t.replace(OPAQUE, '');
@@ -475,6 +496,69 @@ function rmAct(prog, args, dir) {
475
496
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
476
497
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
477
498
 
499
+ /**
500
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
501
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
502
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
503
+ * shares it, so `(find / …)` is a scan.
504
+ */
505
+ export function stripStructure(raw) {
506
+ const t = raw.slice();
507
+ while (t.length) {
508
+ if (STRUCTURE.has(t[0])) t.shift();
509
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
510
+ else break;
511
+ }
512
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
513
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
514
+ return t;
515
+ }
516
+
517
+ /**
518
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
519
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
520
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
521
+ */
522
+ function substitutions(text) {
523
+ const out = [];
524
+ let sq = false; let dq = false;
525
+ for (let i = 0; i < text.length; i++) {
526
+ const c = text[i];
527
+ if (sq) { if (c === "'") sq = false; continue; }
528
+ if (c === "'" && !dq) { sq = true; continue; }
529
+ if (c === '"') { dq = !dq; continue; }
530
+ if (c === '`') {
531
+ const end = text.indexOf('`', i + 1);
532
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
533
+ i = end < 0 ? text.length : end;
534
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
535
+ let depth = 1; let j = i + 2;
536
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
537
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
538
+ i = j - 1;
539
+ }
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
545
+ function pastWrapper(name, w) {
546
+ const [takes, plain] = WRAPPERS.get(name).split('|');
547
+ let i = 0;
548
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
549
+ const a = w[i];
550
+ if (a === '--') { i += 1; break; }
551
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
552
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
553
+ for (let j = 1; j < a.length; j++) {
554
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
555
+ if (!plain.includes(a[j])) return null;
556
+ }
557
+ }
558
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
559
+ return w.slice(i);
560
+ }
561
+
478
562
  /**
479
563
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
480
564
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -485,19 +569,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
485
569
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
486
570
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
487
571
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
572
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
573
+ * splitter); without it, a substitution that names a removal asks.
574
+ * @param {string[][]} segments
575
+ * @param {string} cwd
576
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
488
577
  */
489
- export function lossReason(segments, cwd, { always = true } = {}) {
490
- let dir = cwd;
491
- try {
492
- for (const raw of segments) {
578
+ export function lossReason(segments, cwd, { always = true, split } = {}) {
579
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
580
+ const judge = (segs, start, depth = 0) => {
581
+ let dir = start;
582
+ for (const raw of segs.map(stripStructure)) {
583
+ const text = raw.join(' ');
584
+ const named = LOSS_CANDIDATE_RE.test(text);
585
+ for (const body of named ? substitutions(text) : []) {
586
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
587
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
588
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
589
+ const v = judge(split(body), dir, depth + 1);
590
+ if (v) return v;
591
+ }
493
592
  let w = words(raw);
494
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
593
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
495
594
  const wrapper = progOf(w[0]);
496
- w = w.slice(1);
497
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
498
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
595
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
596
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
499
597
  }
500
- if (!w.length) continue;
598
+ if (!w || !w.length) continue;
501
599
  const prog = progOf(w[0]);
502
600
  const args = w.slice(1);
503
601
  if (CD_PROGS.has(prog)) {
@@ -510,10 +608,18 @@ export function lossReason(segments, cwd, { always = true } = {}) {
510
608
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
511
609
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
512
610
  unverified(`\`${prog}\` runs a command the gate cannot read`);
611
+ } else if (named && !INERT.has(prog)
612
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
613
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
614
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
615
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
513
616
  }
514
617
  if (verdict) return verdict;
515
618
  }
516
619
  return null;
620
+ };
621
+ try {
622
+ return judge(segments, cwd);
517
623
  } catch (e) {
518
624
  if (!(e instanceof Unverified)) throw e;
519
625
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -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') {
@@ -35,11 +35,15 @@
35
35
  * directly rather than by comparison, because a check made tolerant of a value owes one
36
36
  * somewhere else.
37
37
  */
38
+ import { createHash } from 'node:crypto';
38
39
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
39
40
  import os from 'node:os';
40
41
  import path from 'node:path';
41
42
 
42
- import { emitGlobalInto } from '../build/driver.mjs';
43
+ import { PRIMARY_AGENT_SRC, emitGlobalInto } from '../build/driver.mjs';
44
+ import { rglobFiles } from '../build/emit-common.mjs';
45
+ import { CONFIG, makeCfg } from '../build/source.mjs';
46
+ import { sourceFingerprint } from '../build/version.mjs';
43
47
  import {
44
48
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
45
49
  } from '../hosts/hosts.mjs';
@@ -53,6 +57,7 @@ import {
53
57
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
54
58
  import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
55
59
  import { toPlatformPath } from '../lib/paths.mjs';
60
+ import { relPosix } from '../lib/text.mjs';
56
61
 
57
62
  /**
58
63
  * `_harness_core._T0` — this process's start, in epoch seconds.
@@ -121,6 +126,81 @@ export function withStdoutSwallowed(fn) {
121
126
  }
122
127
  }
123
128
 
129
+ /** A manifest's `rel`, spelled the way the expected snapshot keys it. */
130
+ const posixRel = (rel) => rel.split('\\').join('/');
131
+
132
+ /**
133
+ * Render the `expected` tree for `host` at `axes` into a temp dir and SNAPSHOT it — every file,
134
+ * not only the owned ones, because a rel the DEPLOYED manifest claims is looked up here too and
135
+ * its presence decides "edited" against "added". The temp dir is gone before this returns.
136
+ *
137
+ * The one place a render depends on WHERE it is installed: a global Bob emit writes each
138
+ * folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
139
+ * reference render names the temp tree. Mapped back to the deployed dir here, or every such
140
+ * skill reads as edited. A deployment that has since MOVED still differs, and that is real
141
+ * drift — its skills point at a directory that is no longer theirs. A side-file pointer (and a
142
+ * global OpenCode command's) spells the same directory `/`-separated, so that spelling is
143
+ * mapped too.
144
+ */
145
+ function renderExpected(host, axes, deployedDir) {
146
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-diff-'));
147
+ try {
148
+ const expected = path.join(tmp, 'expected');
149
+ withStdoutSwallowed(() => emitGlobalInto(host, {
150
+ ...axes, out: path.join(tmp, 'bundle'), cfgDir: expected,
151
+ }));
152
+ const posix = (p) => p.split(path.sep).join('/');
153
+ const texts = new Map();
154
+ for (const p of rglobFiles(expected)) {
155
+ texts.set(relPosix(expected, p), readText(p).split(expected).join(deployedDir)
156
+ .split(posix(expected)).join(posix(deployedDir)));
157
+ }
158
+ return { owned: ownedSet(expected), texts };
159
+ } finally {
160
+ // `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
161
+ rmSync(tmp, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A memo for `diffCollect`'s `expected` render — opt-in, so `geneseed diff` and every other
167
+ * caller still renders afresh. The web console passes one: `/api/overview` and `/api/fleet`
168
+ * both diff the selected install on every refresh, and the render is ~0.6 s of each.
169
+ *
170
+ * ONLY THE RENDER IS MEMOISED, NEVER THE RESULT: the compare against the deployed tree runs on
171
+ * every call, because catching a hand edit there is the whole point of the drift figure.
172
+ * One entry per deployed dir, at most `cap` of them (least recently used goes first); `renders`
173
+ * counts real renders, for the tests.
174
+ */
175
+ export function renderMemo(cap = 4) {
176
+ return { cap, renders: 0, entries: new Map() };
177
+ }
178
+
179
+ /**
180
+ * The memoised `renderExpected`. The key is everything the render reads: the source
181
+ * fingerprint, the two render inputs it does not hash (`harness.config.json` and the
182
+ * orchestrator agent), the host and every axis. A deployed dir whose key changed is
183
+ * re-rendered in place, and a fingerprint change drops every entry from the old source.
184
+ */
185
+ function memoExpected(memo, host, axes, deployedDir, sourceFp) {
186
+ const fp = sourceFp ?? sourceFingerprint(makeCfg());
187
+ const extra = createHash('sha256');
188
+ for (const p of [CONFIG, PRIMARY_AGENT_SRC]) extra.update(`${readMaybe(p) ?? ''}\0`);
189
+ const key = JSON.stringify([fp, extra.digest('hex'), host, axes]);
190
+ const hit = memo.entries.get(deployedDir);
191
+ memo.entries.delete(deployedDir);
192
+ if (hit && hit.key === key) {
193
+ memo.entries.set(deployedDir, hit);
194
+ return hit.exp;
195
+ }
196
+ for (const [d, e] of memo.entries) if (e.fp !== fp) memo.entries.delete(d);
197
+ const exp = renderExpected(host, axes, deployedDir);
198
+ memo.renders += 1;
199
+ memo.entries.set(deployedDir, { key, fp, exp });
200
+ while (memo.entries.size > memo.cap) memo.entries.delete(memo.entries.keys().next().value);
201
+ return exp;
202
+ }
203
+
124
204
  /**
125
205
  * `_harness_diff._diff_collect` — returns `{target, theme, files}`, with `files === null` for
126
206
  * "nothing to compare here".
@@ -140,7 +220,9 @@ export function withStdoutSwallowed(fn) {
140
220
  * choice, which is the same defect with teeth. `rebuild-all` never had it: it reads
141
221
  * `postureOfDir`/`modeOfDir` per install root, and this now asks the same two questions.
142
222
  */
143
- export function diffCollect({ target = null, theme = null, emit = null, footprint = null } = {}) {
223
+ export function diffCollect({
224
+ target = null, theme = null, emit = null, footprint = null, memo = null, sourceFp = null,
225
+ } = {}) {
144
226
  // `Path(target).expanduser()` — expanded and NOT resolved, which is the opposite of
145
227
  // `cmd_version`'s `.expanduser().resolve()` two files over. It is observable: `target` is
146
228
  // printed on the summary line, so `--target .` reports `.` here and an absolute path
@@ -165,69 +247,58 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
165
247
 
166
248
  const fp = footprint || footprintOfDir(dir);
167
249
 
250
+ // Every input the render reads off the deployment, in ONE object: it is what the render is
251
+ // called with AND what the memo keys on, so the two cannot drift apart.
252
+ const axes = {
253
+ theme: themeName, footprint: fp,
254
+ // The third register read off the deployment, for the same reason as the other two: an
255
+ // install built with `--doctrines craft` compared against an `expected` rendered at all
256
+ // four reports its entire doctrines section — and its `Active packs:` line — as drift.
257
+ // `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
258
+ // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
259
+ posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
260
+ // The loop skill's preset, or its `Default trust preset:` line reads as edited.
261
+ trust: trustOfDir(dir, host),
262
+ // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
263
+ // carrier as drift: `expected` would re-state the rule the deployment took away.
264
+ excludeRules: excludedRulesOfDir(dir),
265
+ // The excluded skills, or every skill the install left out reads as deleted.
266
+ excludeSkills: excludedSkillsOfDir(dir),
267
+ // The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
268
+ ...gatesOfDir(dir),
269
+ };
270
+ const deployedDir = resolvePath(dir);
271
+ const exp = memo
272
+ ? memoExpected(memo, host, axes, deployedDir, sourceFp)
273
+ : renderExpected(host, axes, deployedDir);
274
+
168
275
  const files = [];
169
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-diff-'));
170
- try {
171
- const expected = path.join(tmp, 'expected');
172
- withStdoutSwallowed(() => emitGlobalInto(host, {
173
- theme: themeName, out: path.join(tmp, 'bundle'), cfgDir: expected, footprint: fp,
174
- // The third register read off the deployment, for the same reason as the other two: an
175
- // install built with `--doctrines craft` compared against an `expected` rendered at all
176
- // four reports its entire doctrines section — and its `Active packs:` line — as drift.
177
- // `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
178
- // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
179
- posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
180
- // The loop skill's preset, or its `Default trust preset:` line reads as edited.
181
- trust: trustOfDir(dir, host),
182
- // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
183
- // carrier as drift: `expected` would re-state the rule the deployment took away.
184
- excludeRules: excludedRulesOfDir(dir),
185
- // The excluded skills, or every skill the install left out reads as deleted.
186
- excludeSkills: excludedSkillsOfDir(dir),
187
- // The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
188
- ...gatesOfDir(dir),
189
- }));
190
- // The one place a render depends on WHERE it is installed: a global Bob emit writes each
191
- // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
192
- // reference render names the temp tree. Mapped back to the deployed dir before comparing,
193
- // or every such skill reads as edited. A deployment that has since MOVED still differs,
194
- // and that is real drift — its skills point at a directory that is no longer theirs.
195
- // A side-file pointer (and a global OpenCode command's) spells the same directory
196
- // `/`-separated, so that spelling is mapped too.
197
- const deployedDir = resolvePath(dir);
198
- const posix = (p) => p.split(path.sep).join('/');
199
- const rels = [...new Set([...ownedSet(dir), ...ownedSet(expected)])].sort();
200
- for (const rel of rels) {
201
- const a = path.join(dir, rel);
202
- const b = path.join(expected, rel);
203
- if (isFile(a) && isFile(b)) {
204
- // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
205
- // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
206
- const ta = readText(a);
207
- const tb = readText(b).split(expected).join(deployedDir)
208
- .split(posix(expected)).join(posix(deployedDir));
209
- if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
210
- const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
211
- fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
212
- });
213
- files.push({ rel, status: 'edited', diff });
214
- }
215
- } else if (isFile(a)) {
216
- const body = splitLines(readText(a));
217
- files.push({
218
- rel,
219
- status: 'added',
220
- diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
221
- });
222
- } else {
223
- files.push({
224
- rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
276
+ const rels = [...new Set([...ownedSet(dir), ...exp.owned])].sort();
277
+ for (const rel of rels) {
278
+ const a = path.join(dir, rel);
279
+ const tb = exp.texts.get(posixRel(rel));
280
+ if (isFile(a) && tb !== undefined) {
281
+ // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
282
+ // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
283
+ const ta = readText(a);
284
+ if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
285
+ const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
286
+ fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
225
287
  });
288
+ files.push({ rel, status: 'edited', diff });
226
289
  }
290
+ } else if (isFile(a)) {
291
+ const body = splitLines(readText(a));
292
+ files.push({
293
+ rel,
294
+ status: 'added',
295
+ diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
296
+ });
297
+ } else {
298
+ files.push({
299
+ rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
300
+ });
227
301
  }
228
- } finally {
229
- // `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
230
- rmSync(tmp, { recursive: true, force: true });
231
302
  }
232
303
  // Already sorted by `rel` above; the Python sorts again after the loop and so does this,
233
304
  // because the two sorts are over different things — that one is over the RECORDS, and a