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
@@ -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
@@ -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) {
@@ -46,13 +46,13 @@ import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../build/driver.mjs';
48
48
  import { playLine } from '../ui/anim.mjs';
49
- import { discoverNames, PACK_ORDER } from '../build/source.mjs';
49
+ import { discoverNames, GATE_SWITCHES, PACK_ORDER } from '../build/source.mjs';
50
50
  import { exportImprovements } from '../inspect/diff.mjs';
51
51
  import { cmdDoctor } from '../inspect/doctor.mjs';
52
52
  import { setupBuildArgs } from '../build/generate.mjs';
53
53
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir } from '../hosts/hosts.mjs';
54
54
  import {
55
- claudeCfg, defaultMode, defaultPosture, defaultTheme, EMIT_HOST_SCOPE, installedDefaults,
55
+ claudeCfg, defaultMode, defaultPosture, defaultTheme, EMIT_HOST_SCOPE, gatesOfDir, installedDefaults,
56
56
  readJsonMaybe, themeFiles,
57
57
  } from '../hosts/installs.mjs';
58
58
  import { printOut, printErr } from '../lib/fs.mjs';
@@ -242,6 +242,24 @@ function askDoctrines(deployed = null) {
242
242
  * whole, `null` included, because `askDoctrines` distinguishes "no carrier said" from "a
243
243
  * carrier said none" and the two open the question differently.
244
244
  */
245
+ /**
246
+ * The gate switches of the one install a setup answer re-runs: the chosen emit's global config
247
+ * dir, or the project/bundle root it names. Never another host's install — carrying its `off`
248
+ * across would disarm a gate nobody switched off here. FAILS CLOSED: no install there, or a dir
249
+ * that cannot be resolved, reads as all armed (`gatesOfDir` already fails closed per line).
250
+ */
251
+ function targetGates(emit, out) {
252
+ const [host, scope] = EMIT_HOST_SCOPE.get(emit) ?? [];
253
+ try {
254
+ if (!host) return gatesOfDir(resolveOut(out || 'Harness'));
255
+ const dir = scope === 'global'
256
+ ? HOSTS.find((h) => h.host === host).configDir() : resolveOut(out || '.');
257
+ return gatesOfDir(dir, host);
258
+ } catch {
259
+ return Object.fromEntries(GATE_SWITCHES.map((g) => [g.key, true]));
260
+ }
261
+ }
262
+
245
263
  export function collectSetupLines() {
246
264
  printOut('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
247
265
  const inst = installedDefaults();
@@ -279,14 +297,20 @@ export function collectSetupLines() {
279
297
  const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
280
298
  // Skills left out are preserved the same way, and elide when there are none.
281
299
  const excludeSkills = inst.excludeSkills?.length ? inst.excludeSkills : null;
300
+ // The gate switches too: a re-run that dropped them would re-arm every gate its owner switched
301
+ // off. Read off the install THIS answer targets, never the machine walk above (`targetGates`).
302
+ // `setupBuildArgs` elides an armed gate, so an all-armed install prints what it always did.
303
+ const gates = targetGates(emit, out);
304
+ const gated = Object.values(gates).some((on) => on === false);
282
305
  printOut(`\nAbout to run: geneseed build ${
283
306
  setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
284
- excludeRules, undefined, excludeSkills).join(' ')}\n`);
307
+ excludeRules, undefined, excludeSkills, gates).join(' ')}\n`);
285
308
  if (!confirm('Proceed?', true)) return null;
286
- // The key only when there is something to carry, so a selection that excludes no skill is
287
- // the same object it was before the flag existed.
309
+ // The key only when there is something to carry, so a selection that excludes no skill and
310
+ // switches off no gate is the same object it was before either existed.
288
311
  return {
289
312
  theme, posture, mode, doctrines, excludeRules, ...(excludeSkills ? { excludeSkills } : {}),
313
+ ...(gated ? { gates } : {}),
290
314
  emit, out, root, footprint,
291
315
  };
292
316
  }
@@ -470,7 +494,7 @@ export function setupLines() {
470
494
  }
471
495
  const {
472
496
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
473
- doctrines = null, excludeRules = null, excludeSkills = null,
497
+ doctrines = null, excludeRules = null, excludeSkills = null, gates = {},
474
498
  } = sel;
475
499
  if (emit === 'opencode-global') {
476
500
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -489,7 +513,7 @@ export function setupLines() {
489
513
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
490
514
  // one command and run another.
491
515
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
492
- doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills);
516
+ doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills, gates);
493
517
  printOut(`Running: geneseed build ${argv.join(' ')}\n`);
494
518
  const rc = driverMain(argv);
495
519
  if (rc !== 0) {
@@ -1179,7 +1179,7 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1179
1179
  const gates = Object.fromEntries(Object.keys(atRoot).map((k) => [k, atRoot[k] && atCfg[k]]));
1180
1180
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1181
1181
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg,
1182
- gates.deletionGate, gates);
1182
+ gates);
1183
1183
  // `and data` — a manifest that did not parse is not one to write back.
1184
1184
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1185
1185
  managed.settings_hooks = claims;
@@ -632,7 +632,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }, notif
632
632
  if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
633
633
  const p = installProfile('opencode', 'global', String(dir), notify);
634
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
635
- undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate, p).slice(4);
635
+ undefined, p.excludeRules, p.trust, p.excludeSkills, p).slice(4);
636
636
  }
637
637
 
638
638
  /**
@@ -38,7 +38,7 @@ import {
38
38
  } from '../build/source.mjs';
39
39
  import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
40
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
- import { setupBuildArgs } from '../build/generate.mjs';
41
+ import { installProfile, setupBuildArgs } from '../build/generate.mjs';
42
42
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
43
43
  import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
44
44
  import {
@@ -61,6 +61,7 @@ import { stripWhitespace } from '../lib/text.mjs';
61
61
  import { memoryDelete } from '../maintain/memory.mjs';
62
62
  import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
63
63
  import { emitChoices, themeChoices, viewCfg } from './api.mjs';
64
+ import { actionCommands } from './jobs.mjs';
64
65
  import { NotFound, deployed, memoryDir } from './catalog.mjs';
65
66
 
66
67
  /** One body field, `dflt` when the body is not an object or does not carry the key. */
@@ -439,6 +440,38 @@ export function buildOverride(state, body) {
439
440
  return [themes.has(t) ? t : state.theme, emits.has(e) ? e : state.emit];
440
441
  }
441
442
 
443
+ /**
444
+ * The job steps for a Build POST.
445
+ *
446
+ * A DEPLOYED INSTALL REBUILT IN ITS OWN EMIT TAKES THE ARGV ITS PROFILE OWNS (`installProfile`,
447
+ * the one reader `rebuild-all`, `status` and `upgrade` share), with only the theme the picker
448
+ * chose swapped in. This path used to re-assemble the axes it happened to carry — no gate
449
+ * switches, `--exclude-rules none`, no excluded skills — so a console Build silently re-armed
450
+ * every switched-off gate and handed back every rule and skill its owner had left out.
451
+ *
452
+ * Nothing deployed (the first-deploy Onboarding form) or a re-target to another emit has no
453
+ * install of that emit to honour — a re-target is a FRESH target, by decision (2026-10-18), so it
454
+ * deliberately carries none of this install's switches or exclusions — and keeps the defaults it
455
+ * always had: footprint, posture
456
+ * and mode off the viewed dir, and ALL packs when unknown — never `harness.config.json`, which
457
+ * would drop the consent gate.
458
+ */
459
+ export function buildActionCmds(state, body) {
460
+ const [theme, emit] = buildOverride(state, body);
461
+ const [host, scope] = EMIT_HOST_SCOPE.get(state.emit || '') ?? [];
462
+ if (host && deployed(state) && emit === state.emit) {
463
+ // `setupBuildArgs` always leads with `--theme <theme>`, so the swap is the head alone.
464
+ // `state.root`, not `target`: a project install's markers live at the repo root.
465
+ const [, , ...rest] = installProfile(host, scope, state.root).argv;
466
+ return actionCommands('build', { buildArgv: ['--theme', theme, ...rest] });
467
+ }
468
+ return actionCommands('build', {
469
+ theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
470
+ trust: state.trust,
471
+ doctrines: doctrinesForBuild(state.target),
472
+ });
473
+ }
474
+
442
475
  /**
443
476
  * The global emit matching a deployed install's `.geneseed-emit`, so the EXPECTED render
444
477
  * uses the install's own host dialect.
@@ -595,7 +628,7 @@ export function apiInstallCmd(state, body) {
595
628
  const gates = bodyGates(body, root, host);
596
629
  const out = scope === 'global' ? null : String(root);
597
630
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
598
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
631
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates);
599
632
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
600
633
  }
601
634
 
@@ -697,6 +730,6 @@ export function apiDeployCmd(state, body) {
697
730
  const gates = bodyGates(body, root, host);
698
731
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
699
732
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
700
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates.deletionGate, gates);
733
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), gates);
701
734
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
702
735
  }
package/js/web/api.mjs CHANGED
@@ -15,7 +15,7 @@ import { CONFIG, ROOT, THEMES, discoverNames, makeCfg } from '../build/source.mj
15
15
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
16
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
17
  import { alwaysOnCost } from '../inspect/cost.mjs';
18
- import { diffCollect } from '../inspect/diff.mjs';
18
+ import { diffCollect, renderMemo } from '../inspect/diff.mjs';
19
19
  import { excludesSnapshot } from '../inspect/excludes.mjs';
20
20
  import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
21
21
  import {
@@ -130,6 +130,12 @@ export function* activeInstalls(targets) {
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * The state's memo of `expected` renders, made on first use: overview and fleet diff the same
135
+ * install on every refresh, and only the compare has to run again (see `renderMemo`).
136
+ */
137
+ const memoOf = (state) => (state.renderMemo ??= renderMemo());
138
+
133
139
  /** The edited/added/missing tally of a `diffCollect` file list. */
134
140
  const driftCounts = (files) => ({
135
141
  edited: files.filter((f) => f.status === 'edited').length,
@@ -179,8 +185,9 @@ export function apiInstalls(state) {
179
185
  *
180
186
  * Its own route, not more fields on `/api/installs`: the sidebar refetches that list on every
181
187
  * data revision, and the drift check below re-renders a whole emit per global install.
182
- * ponytail: one `diffCollect` render per global install per request, fine for the handful a
183
- * machine carries; cache per (root, build mtime) if a fleet ever grows past that.
188
+ * Each global install's `expected` render is memoised on the state (`renderMemo`, keyed on the
189
+ * source fingerprint computed once per request and every axis), so a refresh re-runs only the
190
+ * compare; the memo holds a handful of installs, enough for any machine seen so far.
184
191
  *
185
192
  * Each row reads ITS OWN `.geneseed-version`. `statusData().installed_fp` is the first carrier
186
193
  * a fixed walk finds, which describes the wrong install as soon as two hosts are deployed.
@@ -197,7 +204,7 @@ export function apiFleet(state) {
197
204
  let drift = null;
198
205
  if (st === 'active') {
199
206
  try {
200
- const { files } = diffCollect({ target: dir });
207
+ const { files } = diffCollect({ target: dir, memo: memoOf(state), sourceFp });
201
208
  if (files !== null) drift = driftCounts(files);
202
209
  } catch { /* an install that cannot be re-rendered reports no drift figure, not zero */ }
203
210
  }
@@ -226,7 +233,7 @@ export function apiOverview(state) {
226
233
  let diff = null;
227
234
  if (deployed(state)) {
228
235
  const { files } = diffCollect({
229
- target: state.target, theme: state.theme, emit: state.emit });
236
+ target: state.target, theme: state.theme, emit: state.emit, memo: memoOf(state) });
230
237
  if (files !== null) diff = driftCounts(files);
231
238
  }
232
239
  let buildTime = null;
@@ -11,11 +11,10 @@
11
11
  * declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
12
12
  */
13
13
  import { resolvePath } from '../hosts/hosts.mjs';
14
- import { doctrinesForBuild } from '../hosts/installs.mjs';
15
14
  import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
16
15
  import { parseIntStrict, percentDecode } from '../lib/text.mjs';
17
16
  import { preflight } from '../maintain/update.mjs';
18
- import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
17
+ import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildActionCmds, mcpTargetPaths } from './actions.mjs';
19
18
  import { NotFound } from './catalog.mjs';
20
19
  import { openUrl, requestRestart } from './daemon.mjs';
21
20
  import { apiDocs, apiDocsPage } from './docs.mjs';
@@ -301,20 +300,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
301
300
  if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
302
301
  return sendJson(res, { job_id: jid }, 202, ae);
303
302
  }
304
- // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
305
- // the deployed theme downstream. Footprint, posture and mode always follow the current
306
- // install, so a re-theme preserves lean/full, the register and the operating mode.
307
- const [theme, emit] = action === 'build'
308
- ? buildOverride(state, body) : [state.theme, state.emit];
309
- // The pack selection follows the install like footprint/posture/mode, and is read here
310
- // rather than left to the table's default: a console Build must re-emit the constitution
311
- // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
312
- // packs — never to `harness.config.json`, which would drop the consent gate.
313
- const cmds = actionCommands(action, {
314
- theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
315
- trust: state.trust,
316
- doctrines: doctrinesForBuild(state.target),
317
- });
303
+ // Build resolves its argv off the install (`buildActionCmds`); the other actions take no
304
+ // axes and self-resolve the deployed theme downstream.
305
+ const cmds = action === 'build' ? buildActionCmds(state, body) : actionCommands(action);
318
306
  if (!isTruthy(cmds)) {
319
307
  return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
320
308
  }
package/js/web/jobs.mjs CHANGED
@@ -421,14 +421,16 @@ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
421
421
  * `harness.config.json` exactly as the pack list does, so `{"excludeRules":["process.5"]}`
422
422
  * there would take the consent gate out of a console Build that never asked for it. `[]` is
423
423
  * spelled `--exclude-rules none` and says "exclude nothing" out loud.
424
+ *
425
+ * `buildArgv`, when given, is used whole and the axes above are ignored: it is the argv a
426
+ * deployed install's profile owns (`buildActionCmds`), switches and exclusions included.
424
427
  */
425
428
  function actionTable({
426
429
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
427
430
  posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER], excludeRules = [],
428
- trust = DEFAULT_PRESET,
431
+ trust = DEFAULT_PRESET, buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture,
432
+ mode, doctrines, PACK_ORDER, excludeRules, trust),
429
433
  } = {}) {
430
- const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
431
- doctrines, PACK_ORDER, excludeRules, trust);
432
434
  return {
433
435
  doctor: [[NODE(), CLI(), 'doctor']],
434
436
  build: [[NODE(), GEN(), ...buildArgv]],