geneseed 4.9.1 → 4.9.3

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 (66) hide show
  1. package/CHANGELOG.md +30 -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 +123 -12
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/enforcement-table.md +1 -1
  7. package/docs/concepts/exclusions.md +1 -2
  8. package/docs/concepts/hooks.md +3 -2
  9. package/docs/guides/upgrade.md +1 -1
  10. package/harness.config.json +1 -1
  11. package/js/README.md +30 -9
  12. package/js/build/args.mjs +9 -8
  13. package/js/build/driver.mjs +21 -21
  14. package/js/build/emit-claude.mjs +3 -3
  15. package/js/build/generate.mjs +4 -5
  16. package/js/build/source.mjs +18 -22
  17. package/js/hosts/gitref.mjs +5 -3
  18. package/js/hosts/hooks-done.mjs +21 -9
  19. package/js/hosts/hooks-learn.mjs +23 -10
  20. package/js/hosts/hooks-prims.mjs +25 -0
  21. package/js/hosts/hooks.mjs +19 -4
  22. package/js/hosts/installs.mjs +5 -4
  23. package/js/hosts/loss.mjs +98 -0
  24. package/js/hosts/native.mjs +3 -1
  25. package/js/hosts/owned.mjs +34 -6
  26. package/js/hosts/settings.mjs +6 -5
  27. package/js/inspect/checks-probe.mjs +1 -1
  28. package/js/inspect/cost.mjs +37 -5
  29. package/js/inspect/diff.mjs +154 -64
  30. package/js/inspect/validate.mjs +2 -2
  31. package/js/lib/fs.mjs +73 -2
  32. package/js/maintain/setup.mjs +31 -7
  33. package/js/maintain/uninstall.mjs +1 -1
  34. package/js/maintain/update.mjs +1 -1
  35. package/js/web/actions.mjs +36 -3
  36. package/js/web/api.mjs +17 -6
  37. package/js/web/handler.mjs +4 -16
  38. package/js/web/jobs.mjs +5 -3
  39. package/js/web/state.mjs +3 -0
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-C8sZVIUU.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-Cc6vWUS4.js} +1 -1
  43. package/web/dist/assets/Dashboard-C5ATa3ow.js +2 -0
  44. package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-tDW7i7mo.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-zk2Uzc3A.js +2 -0
  46. package/web/dist/assets/Installs-CB_rvbBP.js +3 -0
  47. package/web/dist/assets/JournalView-Bfj27XHC.js +1 -0
  48. package/web/dist/assets/Laws-BA3CzUf5.js +3 -0
  49. package/web/dist/assets/Library-BWwiy9Vb.js +1 -0
  50. package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-DaPk-ckL.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-CyiGhiHk.js} +1 -1
  52. package/web/dist/assets/Personal-Bwd27CmT.js +1 -0
  53. package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-BOD7Kaz1.js} +1 -1
  54. package/web/dist/assets/drawable-CyLe1gtW.js +1 -0
  55. package/web/dist/assets/index-DQXG_mqo.js +2 -0
  56. package/web/dist/index.html +1 -1
  57. package/web/src/pages/Laws.jsx +60 -353
  58. package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
  59. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
  60. package/web/dist/assets/Installs-CWuUU4I3.js +0 -2
  61. package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
  62. package/web/dist/assets/Laws-R5zZCmya.js +0 -3
  63. package/web/dist/assets/Library-u36V7oSD.js +0 -1
  64. package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
  65. package/web/dist/assets/drawable-CE-arUnF.js +0 -1
  66. package/web/dist/assets/index-3lnPT_1J.js +0 -2
@@ -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
  }
@@ -104,7 +104,7 @@ const consentRuleOn = (d, excluded = []) =>
104
104
  *
105
105
  * `rm -rf *` WENT TOO (user ruling, 2026-10-17): a blanket ask on every recursive removal is the
106
106
  * glob-cannot-read-state problem again — the guard's `lossReason` lets an `rm` of tracked,
107
- * unmodified files through and blocks one git cannot restore, and its `SHELL_BLOCK_RE` still
107
+ * unmodified files through and blocks one git cannot restore, and its `catastrophic()` still
108
108
  * blocks `rm -rf /`, `~` and the drive-root forms outright. Same stranded-key handling as above:
109
109
  * an existing `opencode.json` keeps its `rm -rf *` key, asking, until the user deletes it.
110
110
  *
@@ -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 });
@@ -185,7 +185,7 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
185
185
  // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
186
186
 
187
187
  const GUARD_PLUGIN = 'geneseed-guard.js';
188
- /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
188
+ /** The guard blocks catastrophic shell (`catastrophic()`); destructive git is the permission
189
189
  * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
190
190
  const GUARD_BAD_COMMAND = 'rm -rf /';
191
191
 
@@ -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';
@@ -50,9 +54,11 @@ import {
50
54
  trustOfDir,
51
55
  readJsonMaybe, readMaybe, themeOfDir,
52
56
  } from '../hosts/installs.mjs';
57
+ import { safeLeaf } from '../hosts/owned.mjs';
53
58
  import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
54
59
  import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
55
60
  import { toPlatformPath } from '../lib/paths.mjs';
61
+ import { relPosix } from '../lib/text.mjs';
56
62
 
57
63
  /**
58
64
  * `_harness_core._T0` — this process's start, in epoch seconds.
@@ -121,6 +127,81 @@ export function withStdoutSwallowed(fn) {
121
127
  }
122
128
  }
123
129
 
130
+ /** A manifest's `rel`, spelled the way the expected snapshot keys it. */
131
+ const posixRel = (rel) => rel.split('\\').join('/');
132
+
133
+ /**
134
+ * Render the `expected` tree for `host` at `axes` into a temp dir and SNAPSHOT it — every file,
135
+ * not only the owned ones, because a rel the DEPLOYED manifest claims is looked up here too and
136
+ * its presence decides "edited" against "added". The temp dir is gone before this returns.
137
+ *
138
+ * The one place a render depends on WHERE it is installed: a global Bob emit writes each
139
+ * folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
140
+ * reference render names the temp tree. Mapped back to the deployed dir here, or every such
141
+ * skill reads as edited. A deployment that has since MOVED still differs, and that is real
142
+ * drift — its skills point at a directory that is no longer theirs. A side-file pointer (and a
143
+ * global OpenCode command's) spells the same directory `/`-separated, so that spelling is
144
+ * mapped too.
145
+ */
146
+ function renderExpected(host, axes, deployedDir) {
147
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-diff-'));
148
+ try {
149
+ const expected = path.join(tmp, 'expected');
150
+ withStdoutSwallowed(() => emitGlobalInto(host, {
151
+ ...axes, out: path.join(tmp, 'bundle'), cfgDir: expected,
152
+ }));
153
+ const posix = (p) => p.split(path.sep).join('/');
154
+ const texts = new Map();
155
+ for (const p of rglobFiles(expected)) {
156
+ texts.set(relPosix(expected, p), readText(p).split(expected).join(deployedDir)
157
+ .split(posix(expected)).join(posix(deployedDir)));
158
+ }
159
+ return { owned: ownedSet(expected), texts };
160
+ } finally {
161
+ // `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
162
+ rmSync(tmp, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ /**
167
+ * A memo for `diffCollect`'s `expected` render — opt-in, so `geneseed diff` and every other
168
+ * caller still renders afresh. The web console passes one: `/api/overview` and `/api/fleet`
169
+ * both diff the selected install on every refresh, and the render is ~0.6 s of each.
170
+ *
171
+ * ONLY THE RENDER IS MEMOISED, NEVER THE RESULT: the compare against the deployed tree runs on
172
+ * every call, because catching a hand edit there is the whole point of the drift figure.
173
+ * One entry per deployed dir, at most `cap` of them (least recently used goes first); `renders`
174
+ * counts real renders, for the tests.
175
+ */
176
+ export function renderMemo(cap = 4) {
177
+ return { cap, renders: 0, entries: new Map() };
178
+ }
179
+
180
+ /**
181
+ * The memoised `renderExpected`. The key is everything the render reads: the source
182
+ * fingerprint, the two render inputs it does not hash (`harness.config.json` and the
183
+ * orchestrator agent), the host and every axis. A deployed dir whose key changed is
184
+ * re-rendered in place, and a fingerprint change drops every entry from the old source.
185
+ */
186
+ function memoExpected(memo, host, axes, deployedDir, sourceFp) {
187
+ const fp = sourceFp ?? sourceFingerprint(makeCfg());
188
+ const extra = createHash('sha256');
189
+ for (const p of [CONFIG, PRIMARY_AGENT_SRC]) extra.update(`${readMaybe(p) ?? ''}\0`);
190
+ const key = JSON.stringify([fp, extra.digest('hex'), host, axes]);
191
+ const hit = memo.entries.get(deployedDir);
192
+ memo.entries.delete(deployedDir);
193
+ if (hit && hit.key === key) {
194
+ memo.entries.set(deployedDir, hit);
195
+ return hit.exp;
196
+ }
197
+ for (const [d, e] of memo.entries) if (e.fp !== fp) memo.entries.delete(d);
198
+ const exp = renderExpected(host, axes, deployedDir);
199
+ memo.renders += 1;
200
+ memo.entries.set(deployedDir, { key, fp, exp });
201
+ while (memo.entries.size > memo.cap) memo.entries.delete(memo.entries.keys().next().value);
202
+ return exp;
203
+ }
204
+
124
205
  /**
125
206
  * `_harness_diff._diff_collect` — returns `{target, theme, files}`, with `files === null` for
126
207
  * "nothing to compare here".
@@ -140,7 +221,9 @@ export function withStdoutSwallowed(fn) {
140
221
  * choice, which is the same defect with teeth. `rebuild-all` never had it: it reads
141
222
  * `postureOfDir`/`modeOfDir` per install root, and this now asks the same two questions.
142
223
  */
143
- export function diffCollect({ target = null, theme = null, emit = null, footprint = null } = {}) {
224
+ export function diffCollect({
225
+ target = null, theme = null, emit = null, footprint = null, memo = null, sourceFp = null,
226
+ } = {}) {
144
227
  // `Path(target).expanduser()` — expanded and NOT resolved, which is the opposite of
145
228
  // `cmd_version`'s `.expanduser().resolve()` two files over. It is observable: `target` is
146
229
  // printed on the summary line, so `--target .` reports `.` here and an absolute path
@@ -165,69 +248,66 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
165
248
 
166
249
  const fp = footprint || footprintOfDir(dir);
167
250
 
251
+ // Every input the render reads off the deployment, in ONE object: it is what the render is
252
+ // called with AND what the memo keys on, so the two cannot drift apart.
253
+ const axes = {
254
+ theme: themeName, footprint: fp,
255
+ // The third register read off the deployment, for the same reason as the other two: an
256
+ // install built with `--doctrines craft` compared against an `expected` rendered at all
257
+ // four reports its entire doctrines section — and its `Active packs:` line — as drift.
258
+ // `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
259
+ // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
260
+ posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
261
+ // The loop skill's preset, or its `Default trust preset:` line reads as edited.
262
+ trust: trustOfDir(dir, host),
263
+ // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
264
+ // carrier as drift: `expected` would re-state the rule the deployment took away.
265
+ excludeRules: excludedRulesOfDir(dir),
266
+ // The excluded skills, or every skill the install left out reads as deleted.
267
+ excludeSkills: excludedSkillsOfDir(dir),
268
+ // The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
269
+ ...gatesOfDir(dir),
270
+ };
271
+ const deployedDir = resolvePath(dir);
272
+ const exp = memo
273
+ ? memoExpected(memo, host, axes, deployedDir, sourceFp)
274
+ : renderExpected(host, axes, deployedDir);
275
+
168
276
  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)'],
277
+ const rels = [...new Set([...ownedSet(dir), ...exp.owned])].sort();
278
+ for (const rel of rels) {
279
+ const a = path.join(dir, rel);
280
+ const tb = exp.texts.get(posixRel(rel));
281
+ let rec = null;
282
+ if (isFile(a) && tb !== undefined) {
283
+ // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
284
+ // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
285
+ const ta = readText(a);
286
+ if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
287
+ const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
288
+ fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
225
289
  });
290
+ rec = { rel, status: 'edited', diff };
226
291
  }
292
+ } else if (isFile(a)) {
293
+ const body = splitLines(readText(a));
294
+ rec = {
295
+ rel,
296
+ status: 'added',
297
+ diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
298
+ };
299
+ } else {
300
+ rec = { rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'] };
227
301
  }
228
- } finally {
229
- // `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
230
- rmSync(tmp, { recursive: true, force: true });
302
+ if (rec === null) continue;
303
+ // A path that is, or sits under, a link the emit refused to write through (a skill dir the
304
+ // user junctioned to their own copy) is theirs: no rebuild can clear it, so it is not drift.
305
+ // `safeLeaf` is the claimer's own test, so "linked" here is exactly "refused" there — and
306
+ // it runs only on a path that differs, because it lstats and resolves.
307
+ if (!safeLeaf(dir, a, deployedDir)) {
308
+ rec = { rel, status: 'linked', diff: ['(a link — yours; Geneseed leaves it alone)'] };
309
+ }
310
+ files.push(rec);
231
311
  }
232
312
  // Already sorted by `rel` above; the Python sorts again after the loop and so does this,
233
313
  // because the two sorts are over different things — that one is over the RECORDS, and a
@@ -236,8 +316,13 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
236
316
  return { target: dir, theme: themeName, files };
237
317
  }
238
318
 
319
+ /** The records that are drift: everything but a `linked` path, which is the user's own. */
320
+ export const drifted = (files) => files.filter((f) => f.status !== 'linked');
321
+
239
322
  /** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
240
- export function improvementsMd(target, theme, files, when) {
323
+ export function improvementsMd(target, theme, allFiles, when) {
324
+ // A linked path is never a back-port item: the file is the user's, not a local edit of ours.
325
+ const files = drifted(allFiles);
241
326
  const edited = files.filter((f) => f.status === 'edited');
242
327
  const added = files.filter((f) => f.status === 'added');
243
328
  const missing = files.filter((f) => f.status === 'missing');
@@ -327,7 +412,8 @@ export function exportImprovements(target = null, theme = null, outPath = null)
327
412
  const collected = diffCollect({ target, theme });
328
413
  // Python's `if not files` — null AND the empty list both return, which is why the caller
329
414
  // gets `files` back rather than a bare path and can tell the two apart itself.
330
- if (!collected.files || collected.files.length === 0) return [null, collected.files];
415
+ // A linked path alone is not drift, or every update would export an empty report.
416
+ if (!collected.files || drifted(collected.files).length === 0) return [null, collected.files];
331
417
  return [writeImprovements(collected.target, collected.theme, collected.files, outPath),
332
418
  collected.files];
333
419
  }
@@ -383,14 +469,18 @@ export function cmdDiff(args) {
383
469
  const edited = files.filter((f) => f.status === 'edited');
384
470
  const added = files.filter((f) => f.status === 'added');
385
471
  const missing = files.filter((f) => f.status === 'missing');
472
+ const linked = files.filter((f) => f.status === 'linked');
386
473
  printOut(`[diff] deployed ${target} vs source (theme: ${theme})\n`);
387
474
  printOut(`[diff] ${edited.length} edited, ${added.length} added-in-deployed, `
388
475
  + `${missing.length} missing-from-deployed\n`);
476
+ // Its own line, printed only when there is one, so the summary above is unchanged otherwise.
477
+ if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
389
478
  for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
390
479
  for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
391
480
  for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
481
+ for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
392
482
  if (args.out) {
393
- if (files.length) {
483
+ if (drifted(files).length) {
394
484
  // Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
395
485
  // path". `parse()` gives a flag `true` and an option a string, so the two arrive
396
486
  // distinguishable here without a third kind of entry in the VERBS table.
@@ -130,8 +130,8 @@ export function cmdValidate(args) {
130
130
  const opts = {
131
131
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
132
132
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
133
- excludeSkills: args.excludeSkills, deletionGate: args.noDeletionGate ? false : null,
134
- secretsGate: args.noSecretsGate ? false : null, doneGate: args.noDoneGate ? false : null,
133
+ excludeSkills: args.excludeSkills,
134
+ ...Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, args[g.arg] ? false : null])),
135
135
  };
136
136
  let scanDirs;
137
137
  try {
package/js/lib/fs.mjs CHANGED
@@ -29,8 +29,32 @@
29
29
  */
30
30
  import {
31
31
  appendFileSync, writeFileSync, readFileSync, copyFileSync, statSync, utimesSync,
32
+ openSync, fstatSync, readSync, closeSync,
32
33
  } from 'node:fs';
33
34
  import { EOL } from 'node:os';
35
+ import { resolve } from 'node:path';
36
+
37
+ /**
38
+ * What `writeText` wrote while an emit has armed this (`withWriteRecord` below, around the
39
+ * three manifest-writing emits in `js/build/driver.mjs`): resolved path ->
40
+ * `{ text, size, mtimeMs }`, the translated text and a stat taken right after the write.
41
+ * `ownedHashes` (`js/hosts/owned.mjs`) hashes that text instead of reading the file back —
42
+ * the read-back was ~25% of `doctor --all` — and only while the file's size and mtime still
43
+ * match, so a later rewrite by any writer that does not record (a raw `writeFileSync`, a
44
+ * rename) falls back to the read. `copyFile` and `appendText` drop the entry outright.
45
+ * `null`, the default, records nothing: no other caller pays the stat or retains the text.
46
+ */
47
+ export const textWrites = { map: null };
48
+
49
+ /**
50
+ * Run one emit with `textWrites` armed, and disarm it on EVERY exit: an emit that throws inside
51
+ * a long-lived process (the web daemon emits in-process) would otherwise leave every later
52
+ * `writeText` paying a stat and keeping its text in memory.
53
+ */
54
+ export function withWriteRecord(fn) {
55
+ textWrites.map = new Map();
56
+ try { return fn(); } finally { textWrites.map = null; }
57
+ }
34
58
  /**
35
59
  * `Path.write_text(text, encoding="utf-8")`.
36
60
  *
@@ -49,7 +73,12 @@ import { EOL } from 'node:os';
49
73
  * The same vendored skill folder can contain files from both categories.
50
74
  */
51
75
  export function writeText(path, text) {
52
- writeFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
76
+ const out = EOL === '\n' ? text : text.replaceAll('\n', EOL);
77
+ writeFileSync(path, out, 'utf8');
78
+ if (textWrites.map) {
79
+ const st = statSync(path);
80
+ textWrites.map.set(resolve(path), { text: out, size: st.size, mtimeMs: st.mtimeMs });
81
+ }
53
82
  }
54
83
 
55
84
  /**
@@ -62,6 +91,7 @@ export function writeText(path, text) {
62
91
  * invisible in an editor and a whole-file difference to the gate.
63
92
  */
64
93
  export function appendText(path, text) {
94
+ textWrites.map?.delete(resolve(path));
65
95
  appendFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
66
96
  }
67
97
 
@@ -79,6 +109,46 @@ export function readText(path) {
79
109
  return readFileSync(path, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
80
110
  }
81
111
 
112
+ /**
113
+ * The last `bytes` of a file, decoded as `readText` decodes it, and the byte offset the window
114
+ * began at (0: the whole file). A window that starts mid-line drops that partial line, cut at a
115
+ * newline BYTE before decoding — so a multibyte character the window split falls in the dropped
116
+ * part, and a `\r\n` split between its two bytes leaves only an empty line. One byte before the
117
+ * window is read too, so a window that starts exactly on a line keeps it. Throws as
118
+ * `readFileSync` does (missing, unreadable, a directory).
119
+ */
120
+ export function readTail(path, bytes) {
121
+ const fd = openSync(path, 'r');
122
+ try {
123
+ const st = fstatSync(fd);
124
+ // Windows opens a directory without complaint and reports size 0: refuse it as readFileSync does.
125
+ if (st.isDirectory()) {
126
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${path}'`),
127
+ { code: 'EISDIR' });
128
+ }
129
+ const size = st.size;
130
+ const start = Math.max(0, size - bytes);
131
+ const from = start > 0 ? start - 1 : 0;
132
+ const buf = Buffer.alloc(size - from);
133
+ let got = 0;
134
+ while (got < buf.length) {
135
+ const n = readSync(fd, buf, got, buf.length - got, from + got);
136
+ if (!n) break;
137
+ got += n;
138
+ }
139
+ let cut = 0;
140
+ if (start > 0) {
141
+ const read = buf.subarray(0, got);
142
+ const ends = [read.indexOf(0x0a), read.indexOf(0x0d)].filter((i) => i !== -1);
143
+ cut = ends.length ? Math.min(...ends) + 1 : got; // no line end: nothing whole to decode
144
+ }
145
+ const text = buf.toString('utf8', cut, got).replaceAll('\r\n', '\n').replaceAll('\r', '\n');
146
+ return { text, start };
147
+ } finally {
148
+ closeSync(fd);
149
+ }
150
+ }
151
+
82
152
  /**
83
153
  * `shutil.copy2(src, dest)`.
84
154
  *
@@ -94,6 +164,7 @@ export function readText(path) {
94
164
  * mode the Python side has and nothing depends on. Mode is not copied here.
95
165
  */
96
166
  export function copyFile(src, dest) {
167
+ textWrites.map?.delete(resolve(dest));
97
168
  copyFileSync(src, dest);
98
169
  const st = statSync(src);
99
170
  utimesSync(dest, st.atime, st.mtime);
@@ -216,7 +287,7 @@ export function withPlatformNewlines(fn) {
216
287
  */
217
288
  const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP', 'EBADF']);
218
289
 
219
- function statOrNull(p) {
290
+ export function statOrNull(p) {
220
291
  try {
221
292
  return statSync(p);
222
293
  } catch (e) {