geneseed 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The web console's WRITES — the mutating endpoints, plus the two GETs that belong beside
3
- * them.
2
+ * The web console's WRITES — the mutating endpoints, plus the one GET that belongs beside them
3
+ * (`apiMcp`). The fingerprint-guarded rules and profile editors are `user-files.mjs`.
4
4
  *
5
5
  * `{cmd: [...]}`, returned by `apiInstallCmd` and `apiDeployCmd` below, IS A FUNCTION'S
6
6
  * RETURN TYPE, NOT A RESPONSE BODY: the dispatcher hands it straight to the job runner in
@@ -41,34 +41,54 @@ import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
41
  import { 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
- import { frontmatter, memoryDropIndex } from '../hosts/memory-files.mjs';
45
44
  import {
46
45
  CLAUDE_STYLE, HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
47
46
  opencodeConfigDir, resolvePath,
48
47
  } from '../hosts/hosts.mjs';
49
48
  import {
50
- EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
51
- installTargets, modeOfDir, postureOfDir, readMaybe, trustOfDir,
49
+ EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
50
+ installState,
51
+ installTargets, modeOfDir, postureOfDir, trustOfDir,
52
52
  } from '../hosts/installs.mjs';
53
53
  import {
54
54
  MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
55
55
  mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
56
56
  } from '../hosts/mcp.mjs';
57
- import { readText, writeText, isFile, isDir } from '../lib/fs.mjs';
57
+ import { readText, isFile, isDir } from '../lib/fs.mjs';
58
58
  import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
59
59
  import { NO_WINDOW } from '../lib/proc.mjs';
60
- import { normcase } from '../lib/paths.mjs';
61
- import { WHITESPACE, codePointLength, stripWhitespace } from '../lib/text.mjs';
60
+ import { normcase, which, within } from '../lib/paths.mjs';
61
+ import { stripWhitespace } from '../lib/text.mjs';
62
+ import { memoryDelete } from '../maintain/memory.mjs';
62
63
  import { installDeactivate, installReactivate, installUninstall } from '../maintain/uninstall.mjs';
63
- import { splitLines } from '../lib/udiff.mjs';
64
- import {
65
- NotFound, deployed, emitChoices, fingerprint, themeChoices, viewCfg, within,
66
- } from './api.mjs';
64
+ import { emitChoices, themeChoices, viewCfg } from './api.mjs';
65
+ import { NotFound, deployed, memoryDir } from './catalog.mjs';
67
66
 
68
- const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
67
+ /** One body field, `dflt` when the body is not an object or does not carry the key. */
68
+ export const bget = (body, key, dflt = null) => (isDict(body) && Object.hasOwn(body, key)
69
69
  ? body[key] : dflt);
70
70
 
71
- const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
71
+ export const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
72
+
73
+ /**
74
+ * A request body's LIST field closed against `known()` member by member, re-ordered through
75
+ * `order()`, or `null` for "said nothing usable" — the shared shape of the two doctrine axes
76
+ * below. `norm` rewrites a member before the check (the rule axis accepts `process 7`).
77
+ *
78
+ * `known`/`order` are thunks so a body that names nothing never pays for discovery.
79
+ */
80
+ function bodyList(body, key, known, order = known, norm = (n) => n) {
81
+ const raw = bget(body, key);
82
+ if (Array.isArray(raw) && !raw.length) return [];
83
+ const names = Array.isArray(raw) ? raw
84
+ : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
85
+ if (names === null || !names.length) return null;
86
+ if (names.length === 1 && names[0] === 'none') return [];
87
+ const ids = names.map((n) => (typeof n === 'string' ? norm(n) : n));
88
+ const ok = known();
89
+ if (!ids.every((n) => typeof n === 'string' && ok.includes(n))) return null;
90
+ return order().filter((id) => ids.includes(id));
91
+ }
72
92
 
73
93
  /**
74
94
  * A request body's `doctrines` -> a normalised pack list, or `null` for "said nothing usable".
@@ -89,15 +109,8 @@ const strOr = (v) => (isTruthy(v) ? formatValue(v) : '');
89
109
  * `none`/`[]` both mean the deliberate empty selection — a real configuration, not a rejection.
90
110
  */
91
111
  function bodyDoctrines(body) {
92
- const raw = bget(body, 'doctrines');
93
- if (Array.isArray(raw) && !raw.length) return [];
94
- const names = Array.isArray(raw) ? raw
95
- : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
96
- if (names === null || !names.length) return null;
97
- if (names.length === 1 && names[0] === 'none') return [];
98
- const known = discoverNames('doctrines', PACK_ORDER[0]);
99
- if (!names.every((n) => typeof n === 'string' && known.includes(n))) return null;
100
- return PACK_ORDER.filter((p) => names.includes(p));
112
+ return bodyList(body, 'doctrines', () => discoverNames('doctrines', PACK_ORDER[0]),
113
+ () => PACK_ORDER);
101
114
  }
102
115
 
103
116
  /**
@@ -114,47 +127,44 @@ function bodyDoctrines(body) {
114
127
  * selection: "exclude nothing", which is a real answer and NOT the same as not saying.
115
128
  */
116
129
  function bodyExcludeRules(body) {
117
- const raw = bget(body, 'excludeRules');
118
- if (Array.isArray(raw) && !raw.length) return [];
119
- const names = Array.isArray(raw) ? raw
120
- : (typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null);
121
- if (names === null || !names.length) return null;
122
- if (names.length === 1 && names[0] === 'none') return [];
123
- const known = knownRuleIds();
124
- const ids = names.map((n) => (typeof n === 'string' ? n.trim().replace(/[ \t]+/, '.') : n));
125
- if (!ids.every((n) => typeof n === 'string' && known.includes(n))) return null;
126
- return known.filter((id) => ids.includes(id));
127
- }
128
-
129
- /** Splits on runs of `WHITESPACE` (not `\s`), dropping empty strings. */
130
- const splitWords = (s) => s.split(new RegExp(`[${WHITESPACE}]+`)).filter(Boolean);
131
-
132
- /** Today's date, LOCAL (not UTC) — this is a user-facing date, not a wire timestamp. */
133
- function todayIso(d = new Date()) {
134
- const p2 = (n) => String(n).padStart(2, '0');
135
- return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
130
+ return bodyList(body, 'excludeRules', knownRuleIds, knownRuleIds,
131
+ (n) => n.trim().replace(/[ \t]+/, '.'));
136
132
  }
137
133
 
138
134
  /**
139
- * `today` and `today + n days`, from ONE sample of the clock.
140
- *
141
- * Two separate `new Date()` calls would let the provenance line and the expiry land on
142
- * either side of a midnight that fell between them — a one-run-in-86400 disagreement, which
143
- * is the worst kind to debug.
135
+ * The five scalar axes of a build request — theme, footprint, posture, mode, trust — each
136
+ * taken from the body only when it names a value this checkout ships, else from `dflt`. The
137
+ * SAME allowlist for `apiInstallCmd` and `apiDeployCmd`; they differ only in their defaults (the
138
+ * install's own markers, versus a fresh deploy's). A bogus body value never reaches the argv.
144
139
  */
145
- function promoteDates(days) {
146
- const d = new Date();
147
- const today = todayIso(d);
148
- d.setDate(d.getDate() + days);
149
- return [today, todayIso(d)];
140
+ function bodyAxes(body, dflt) {
141
+ const pick = (key, ok) => { const v = bget(body, key); return ok(v) ? v : dflt[key]; };
142
+ return {
143
+ theme: pick('theme', (v) => themeChoices().some((c) => c.name === v)),
144
+ footprint: pick('footprint', (v) => v === 'lean' || v === 'full'),
145
+ posture: pick('posture', (v) => discoverNames('postures', 'peer').includes(v)),
146
+ mode: pick('mode', (v) => discoverNames('modes', 'direct').includes(v)),
147
+ trust: pick('trust', (v) => Object.keys(PRESETS).includes(v)),
148
+ };
150
149
  }
151
150
 
152
151
  /**
153
- * Strict equality on the RAW body value, not run through `strOr` first. `strOr(0)` folds to
154
- * `''` (0 is falsy), which would wrongly equal an absent file's `''` fingerprint and let a
155
- * client send `"fingerprint": 0` and overwrite regardless of what is actually on disk.
152
+ * The DETECTED install a body's (host, path) pair names — `[host, scope, root]` — or a
153
+ * `NotFound`. THE ALLOWLIST three endpoints share (view select, rebuild, deactivate/remove), and
154
+ * one copy of it: two copies of a security check drift, and the copy that drifts is the one
155
+ * nobody re-read.
156
+ *
157
+ * The pair is SERIALISED into the key rather than concatenated with a separator: the path comes
158
+ * straight out of a request body, and any separator it could contain would let one pair
159
+ * impersonate another. `findLast` keeps the old Map's last-write-wins on a duplicate pair.
156
160
  */
157
- const fpMatches = (got, want) => typeof got === 'string' && got === want;
161
+ function detectedInstall(body) {
162
+ const want = JSON.stringify([strOr(bget(body, 'host')), strOr(bget(body, 'path'))]);
163
+ const hit = installTargets()
164
+ .findLast(([host, , root]) => JSON.stringify([host, String(root)]) === want);
165
+ if (hit === undefined) throw new NotFound('unknown install (host, path)');
166
+ return hit;
167
+ }
158
168
 
159
169
  // ---- OS-native folder picker ---------------------------------------------------------------
160
170
 
@@ -245,7 +255,10 @@ function runPicker(cmd, args, done, resultOf) {
245
255
  */
246
256
  export function apiPickFolder(done) {
247
257
  if (process.platform === 'win32') {
248
- return runPicker('powershell', ['-NoProfile', '-STA', '-Command', PICK_SCRIPT_WIN], done,
258
+ // `which`, never a bare name: Windows resolves a bare name against the cwd first.
259
+ const powershell = which('powershell');
260
+ if (!powershell) return done({ error: 'folder picker unavailable: powershell not on PATH' });
261
+ return runPicker(powershell, ['-NoProfile', '-STA', '-Command', PICK_SCRIPT_WIN], done,
249
262
  (code, out, err) => {
250
263
  if (code !== 0) return { error: (err || out).trim() || 'folder picker failed' };
251
264
  const p = out.trim();
@@ -262,279 +275,8 @@ export function apiPickFolder(done) {
262
275
  return done({ error: 'no native folder dialog on this platform' });
263
276
  }
264
277
 
265
- // ---- user rules (user-rules.md) ----------------------------------------------------------
266
-
267
- export const RULES_FILE = 'user-rules.md';
268
- const rulesPath = (state) => path.join(state.target, RULES_FILE);
269
-
270
- /**
271
- * `## R<n> — Title`, anchored at column 0 so a rule body's fenced code and the stub's
272
- * indented format example never parse as rules.
273
- *
274
- * The whitespace classes are `WHITESPACE`, not `\s`: this pattern runs over a HAND-EDITED
275
- * file, which is where a non-breaking space actually turns up. `\S` is its complement for
276
- * the same reason.
277
- *
278
- * `\d` IS LEFT ASCII, and that is a declared divergence rather than an oversight, unlike
279
- * `ruleFields` below which uses `\p{Nd}`. No theme seeds a non-ASCII rule id, no build
280
- * writes one, and the web editor cannot produce one either — it writes `R<n>` itself from
281
- * `max(ids) + 1`. Widening this to match would only move the gap one line down, to whatever
282
- * parses the captured digits back into a number.
283
- */
284
- const RULE_HEAD_RE = new RegExp(
285
- `^##[${WHITESPACE}]+R(\\d+)[${WHITESPACE}]*[—–-]+[${WHITESPACE}]*`
286
- + `([^${WHITESPACE}].*?)[${WHITESPACE}]*$`,
287
- );
288
-
289
- /** Advisory only; nothing blocks past it. */
290
- const RULES_BUDGET = { max_rules: 15, max_tokens: 1500 };
291
-
292
- const META_RE = new RegExp(`^\\((.+)\\)[${WHITESPACE}]*$`);
293
-
294
- /** `{}` when the line is body rather than metadata. */
295
- function parseRuleMeta(line) {
296
- const m = META_RE.exec(stripWhitespace(line));
297
- if (!m) return {};
298
- const meta = {};
299
- for (const part of m[1].split('|')) {
300
- const at = part.indexOf(':');
301
- if (at < 0) continue; // `partition` with no separator — not a pair
302
- const key = stripWhitespace(part.slice(0, at)).toLowerCase().replaceAll(' ', '_');
303
- if (['scope', 'source', 'trial_until'].includes(key)) {
304
- meta[key] = stripWhitespace(part.slice(at + 1));
305
- }
306
- }
307
- return meta;
308
- }
309
-
310
- /**
311
- * `user-rules.md` parsed to `[rules, warnings]`.
312
- *
313
- * Every rule carries its `start`/`end` LINE INDICES, which is what lets `apiRulesMutate`
314
- * splice exactly one block and leave every other byte of the user's file — prose,
315
- * formatting, hand-written sections — untouched.
316
- */
317
- export function parseRules(text) {
318
- const lines = splitLines(text);
319
- const rules = [];
320
- const warnings = [];
321
- const seen = new Set();
322
- for (let idx = 0; idx < lines.length; idx += 1) {
323
- const m = RULE_HEAD_RE.exec(lines[idx]);
324
- if (!m) continue;
325
- let end = lines.length;
326
- for (let j = idx + 1; j < lines.length; j += 1) {
327
- if (lines[j].startsWith('## ')) { end = j; break; }
328
- }
329
- const rid = Number(m[1]);
330
- if (seen.has(rid)) warnings.push(`duplicate rule id R${rid}`);
331
- seen.add(rid);
332
- let k = idx + 1;
333
- while (k < end && !stripWhitespace(lines[k])) k += 1;
334
- const meta = k < end ? parseRuleMeta(lines[k]) : {};
335
- if (Object.keys(meta).length) k += 1;
336
- rules.push({
337
- id: rid,
338
- title: m[2],
339
- scope: Object.hasOwn(meta, 'scope') ? meta.scope : 'project',
340
- source: Object.hasOwn(meta, 'source') ? meta.source : '',
341
- trial_until: Object.hasOwn(meta, 'trial_until') ? meta.trial_until : '',
342
- body: stripWhitespace(lines.slice(k, end).join('\n')),
343
- start: idx,
344
- end,
345
- });
346
- }
347
- return [rules, warnings];
348
- }
349
-
350
- export function apiRules(state) {
351
- const p = rulesPath(state);
352
- if (!isFile(p)) {
353
- return { exists: false, path: p, rules: [], warnings: [], fingerprint: '',
354
- stats: { rules: 0, lines: 0, tokens: 0, ...RULES_BUDGET } };
355
- }
356
- const text = readMaybe(p) ?? '';
357
- const [rules, warnings] = parseRules(text);
358
- const today = todayIso();
359
- const out = rules.map((r) => ({
360
- id: r.id, title: r.title, scope: r.scope, source: r.source,
361
- trial_until: r.trial_until,
362
- status: r.trial_until ? 'trial' : 'active',
363
- overdue: Boolean(r.trial_until) && r.trial_until < today,
364
- body: r.body,
365
- }));
366
- return { exists: true, path: p, rules: out, warnings, fingerprint: fingerprint(text),
367
- // CODE POINTS, not `String.length` (UTF-16 units) — an emoji in a rule body would
368
- // otherwise put the token estimate off.
369
- stats: { rules: rules.length, lines: splitLines(text).length,
370
- tokens: Math.floor(codePointLength(text) / 4), ...RULES_BUDGET } };
371
- }
372
-
373
- function rulesRead(state) {
374
- const p = rulesPath(state);
375
- return [p, isFile(p) ? (readMaybe(p) ?? '') : ''];
376
- }
377
-
378
- function ruleBlock(rid, title, scope, source, trialUntil, body) {
379
- const meta = [`scope: ${scope}`];
380
- if (source) meta.push(`source: ${source}`);
381
- if (trialUntil) meta.push(`trial until: ${trialUntil}`);
382
- return `## R${rid} — ${title}\n(${meta.join(' | ')})\n${stripWhitespace(body)}\n`;
383
- }
384
-
385
- /**
386
- * Validate and normalise a rule's writable fields. Throws (→ the shell's JSON 500 carrying
387
- * the message) on an unusable rule.
388
- *
389
- * `\p{Nd}` here and not `\d`, unlike `RULE_HEAD_RE` above: this is a pure predicate over a
390
- * string that stays a string, so accepting any Unicode decimal digit costs nothing and
391
- * needs no numeric parse afterward.
392
- */
393
- function ruleFields(body) {
394
- const title = splitWords(strOr(bget(body, 'title'))).join(' ');
395
- const text = stripWhitespace(strOr(bget(body, 'body')));
396
- if (!title) throw new Error('a rule needs a title');
397
- if (!text) throw new Error('a rule needs body text');
398
- let scope = bget(body, 'scope');
399
- if (scope !== 'user' && scope !== 'project') scope = 'project';
400
- const source = splitWords(strOr(bget(body, 'source'))).join(' ');
401
- const trial = stripWhitespace(strOr(bget(body, 'trial_until')));
402
- if (trial && !/^\p{Nd}{4}-\p{Nd}{2}-\p{Nd}{2}$/u.test(trial)) {
403
- throw new Error('trial until must be YYYY-MM-DD');
404
- }
405
- return [title, text, scope, source, trial];
406
- }
407
-
408
- /**
409
- * Coerces whatever `id` came out of the parsed body into an integer, or throws
410
- * `NotFound('rule id')`. Handles a decimal string (surrounding whitespace stripped), a
411
- * boolean, a bare number (truncated), and a `JsonNumber`-shaped value via its `valueOf()` —
412
- * whatever `parseJson` could have produced for this field.
413
- */
414
- function ruleId(v) {
415
- if (typeof v === 'string') {
416
- const s = stripWhitespace(v);
417
- if (!/^[+-]?\d+$/.test(s)) throw new NotFound('rule id');
418
- return Number(s);
419
- }
420
- if (typeof v === 'boolean') return v ? 1 : 0;
421
- if (typeof v === 'number') return Math.trunc(v);
422
- if (v !== null && v !== undefined && typeof v.valueOf === 'function'
423
- && typeof v.valueOf() === 'number') {
424
- return Math.trunc(v.valueOf());
425
- }
426
- throw new NotFound('rule id');
427
- }
428
-
429
- /**
430
- * Add / update / delete on user-rules.md.
431
- *
432
- * EVERY MUTATION REQUIRES THE FINGERPRINT OF THE CONTENT THE CLIENT LAST READ. An agent
433
- * session may be editing the same file mid-flight, so a stale write returns `ok: false`
434
- * (which the shell maps to 409) and the client re-fetches — it never clobbers. The fresh
435
- * fingerprint comes back in the response so a client can chain edits without a re-fetch.
436
- */
437
- export function apiRulesMutate(state, body) {
438
- const op = bget(body, 'op');
439
- if (op !== 'add' && op !== 'update' && op !== 'delete') {
440
- throw new NotFound(`rules op ${formatRepr(op)}`);
441
- }
442
- const [p, original] = rulesRead(state);
443
- let text = original;
444
- if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(text))) {
445
- return { ok: false, error: 'conflict',
446
- detail: 'user-rules.md changed since you loaded it — reloading' };
447
- }
448
- const [rules] = parseRules(text);
449
- let rid;
450
- let next;
451
- if (op === 'add') {
452
- const [title, rtext, scope, source, trial] = ruleFields(body);
453
- rid = rules.reduce((mx, r) => (r.id > mx ? r.id : mx), 0) + 1;
454
- if (!text) text = '# User rules\n';
455
- next = `${text.replace(/\n+$/, '')}\n\n${ruleBlock(rid, title, scope, source, trial, rtext)}`;
456
- } else {
457
- rid = ruleId(bget(body, 'id'));
458
- const target = rules.find((r) => r.id === rid);
459
- if (target === undefined) throw new NotFound(`rule R${rid}`);
460
- const lines = splitLines(text);
461
- if (op === 'update') {
462
- const [title, rtext, scope, source, trial] = ruleFields(body);
463
- const block = ruleBlock(rid, title, scope, source, trial, rtext);
464
- lines.splice(target.start, target.end - target.start,
465
- ...splitLines(block.replace(/\n+$/, '')));
466
- } else {
467
- // delete — also swallow ONE preceding blank separator line, or repeated deletes leave
468
- // a growing run of blank lines in the user's file.
469
- let start = target.start;
470
- if (start > 0 && !stripWhitespace(lines[start - 1])) start -= 1;
471
- lines.splice(start, target.end - start);
472
- }
473
- next = `${lines.join('\n').replace(/\n+$/, '')}\n`;
474
- }
475
- writeText(p, next);
476
- return { ok: true, op, id: rid, fingerprint: fingerprint(next) };
477
- }
478
-
479
- /**
480
- * One memory fact promoted into a trial rule. The provenance line and the month of
481
- * probation are the rule this endpoint owns, and both are DATES read from the real clock —
482
- * not destamped, since destamping would erase exactly the thing being recorded.
483
- */
484
- export function apiRulesPromote(state, body) {
485
- const name = strOr(bget(body, 'name'));
486
- const d = memoryDir(state);
487
- if (!isDir(d)) throw new NotFound('memory store');
488
- if (!name || name.includes('/') || name.includes('\\')
489
- || name === 'MEMORY' || name === 'README') {
490
- throw new NotFound(name);
491
- }
492
- const src = path.join(d, `${name}.md`);
493
- if (!isFile(src)) throw new NotFound(name);
494
- const [fm, memBody] = frontmatter(readMaybe(src) ?? '');
495
- const fmName = fm.has('name') ? fm.get('name') : name;
496
- const fmDesc = fm.has('description') ? fm.get('description') : '';
497
- const [today, trial] = promoteDates(30);
498
- const res = apiRulesMutate(state, {
499
- op: 'add',
500
- fingerprint: bget(body, 'fingerprint', ''),
501
- title: isTruthy(bget(body, 'title')) ? bget(body, 'title') : fmName,
502
- body: isTruthy(bget(body, 'body')) ? bget(body, 'body')
503
- : (stripWhitespace(memBody) || fmDesc),
504
- scope: bget(body, 'scope'),
505
- source: `memory ${name}, promoted ${today}`,
506
- trial_until: trial,
507
- });
508
- if (isTruthy(res.ok) && isTruthy(bget(body, 'delete_memory'))) {
509
- apiMemoryDelete(state, name);
510
- res.deleted_memory = name;
511
- }
512
- return res;
513
- }
514
-
515
- // ---- PROFILE.md --------------------------------------------------------------------------
516
-
517
- /** The whole file, fingerprint-guarded like the rules above. */
518
- export function apiProfileSave(state, body) {
519
- const p = path.join(state.target, 'PROFILE.md');
520
- const cur = isFile(p) ? (readMaybe(p) ?? '') : '';
521
- if (!fpMatches(bget(body, 'fingerprint', ''), fingerprint(cur))) {
522
- return { ok: false, error: 'conflict',
523
- detail: 'PROFILE.md changed since you loaded it — reloading' };
524
- }
525
- let next = strOr(bget(body, 'text'));
526
- if (next && !next.endsWith('\n')) next += '\n';
527
- writeText(p, next);
528
- // The fingerprint of what is ON DISK, which is not the fingerprint of what was SENT once
529
- // the newline has been appended — and the client chains its next save off this value.
530
- return { ok: true, path: p, fingerprint: fingerprint(next) };
531
- }
532
-
533
278
  // ---- memory ------------------------------------------------------------------------------
534
279
 
535
- /** Always `<target>/memory` — never the CWD-scanning resolver. */
536
- const memoryDir = (state) => path.join(state.target, 'memory');
537
-
538
280
  /**
539
281
  * One fact file, and its line in the MEMORY.md index.
540
282
  *
@@ -547,14 +289,10 @@ const memoryDir = (state) => path.join(state.target, 'memory');
547
289
  export function apiMemoryDelete(state, name) {
548
290
  const d = memoryDir(state);
549
291
  if (!isDir(d)) throw new NotFound('memory store');
550
- if (!name || name.includes('/') || name.includes('\\')
551
- || name === 'MEMORY' || name === 'README') {
552
- throw new NotFound(name);
553
- }
554
- const p = path.join(d, `${name}.md`);
555
- if (!isFile(p)) throw new NotFound(name);
556
- unlinkSync(p);
557
- memoryDropIndex(d, name);
292
+ // `memoryDelete` is the `geneseed memory rm` engine, and its reserved-name check is
293
+ // CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
294
+ // resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
295
+ if (!memoryDelete(d, name)) throw new NotFound(name);
558
296
  state.refresh();
559
297
  return { deleted: name };
560
298
  }
@@ -724,15 +462,7 @@ export function apiMcpToggle(state, body) {
724
462
  * pair impersonate another in a lookup whose whole job is to be an allowlist.
725
463
  */
726
464
  export function apiSelectView(state, body) {
727
- const known = new Map();
728
- for (const [host, scope, root] of installTargets()) {
729
- known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
730
- }
731
- const hit = known.get(JSON.stringify(
732
- [strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
733
- ));
734
- if (hit === undefined) throw new NotFound('unknown install (host, path)');
735
- const [host, scope, root] = hit;
465
+ const [host, scope, root] = detectedInstall(body);
736
466
  state.selectView(viewCfg(host, scope, root), root);
737
467
  return { ok: true, target: state.target, theme: state.theme, emit: state.emit };
738
468
  }
@@ -821,6 +551,7 @@ export function apiRestore(state, files) {
821
551
  // The excluded rules for the same boundary reason: without them a restored AGENT.md
822
552
  // re-states a rule whose hook gate the install deliberately left unwired.
823
553
  excludeRules: excludedRulesOfDir(target),
554
+ excludeSkills: excludedSkillsOfDir(target),
824
555
  }));
825
556
  for (const raw of (isTruthy(files) ? files : [])) {
826
557
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -870,33 +601,15 @@ const EMIT_FOR = new Map([
870
601
  * matters and what the argv head is.
871
602
  */
872
603
  export function apiInstallCmd(state, body) {
873
- const known = new Map();
874
- for (const [host, scope, root] of installTargets()) {
875
- known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
876
- }
877
- const hit = known.get(JSON.stringify(
878
- [strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
879
- ));
880
- if (hit === undefined) throw new NotFound('unknown install (host, path)');
881
- const [host, scope, root] = hit;
604
+ const [host, scope, root] = detectedInstall(body);
882
605
  if (installState(root, host, scope) === 'disabled') {
883
606
  return { error: 'install is disabled — reactivate it before (re)building' };
884
607
  }
885
608
  const emit = EMIT_FOR.get(`${host} ${scope}`);
886
609
  if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
887
- const themes = new Set(themeChoices().map((c) => c.name));
888
- const bt = bget(body, 'theme');
889
- const theme = themes.has(bt) ? bt : state.theme;
890
- const bfp = bget(body, 'footprint');
891
- const fp = (bfp === 'lean' || bfp === 'full') ? bfp : footprintOfDir(root);
892
- const bpos = bget(body, 'posture');
893
- const pos = discoverNames('postures', 'peer').includes(bpos)
894
- ? bpos : (postureOfDir(root) || 'peer');
895
- const bmode = bget(body, 'mode');
896
- const mode = discoverNames('modes', 'direct').includes(bmode)
897
- ? bmode : (modeOfDir(root) || 'direct');
898
- const btrust = bget(body, 'trust');
899
- const trust = Object.keys(PRESETS).includes(btrust) ? btrust : (trustOfDir(root) || DEFAULT_PRESET);
610
+ const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
611
+ theme: state.theme, footprint: footprintOfDir(root), posture: postureOfDir(root) || 'peer',
612
+ mode: modeOfDir(root) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
900
613
  // Unspecified means "keep what this install already has", exactly as theme, footprint,
901
614
  // posture and mode above do — a rebuild through the console is not a place to silently
902
615
  // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
@@ -911,7 +624,7 @@ export function apiInstallCmd(state, body) {
911
624
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
912
625
  const out = scope === 'global' ? null : String(root);
913
626
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
914
- PACK_ORDER, excludeRules, trust);
627
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
915
628
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
916
629
  }
917
630
 
@@ -929,15 +642,7 @@ export function apiInstallCmd(state, body) {
929
642
  * in the web tree.
930
643
  */
931
644
  export function apiInstallToggle(state, body) {
932
- const known = new Map();
933
- for (const [host, scope, root] of installTargets()) {
934
- known.set(JSON.stringify([host, String(root)]), [host, scope, root]);
935
- }
936
- const hit = known.get(JSON.stringify(
937
- [strOr(bget(body, 'host')), strOr(bget(body, 'path'))],
938
- ));
939
- if (hit === undefined) throw new NotFound('unknown install (host, path)');
940
- const [host, scope, root] = hit;
645
+ const [host, scope, root] = detectedInstall(body);
941
646
  const action = bget(body, 'action');
942
647
  let res;
943
648
  if (action === 'deactivate') {
@@ -999,17 +704,10 @@ export function apiDeployCmd(state, body) {
999
704
  if (cfgdirs.has(root)) {
1000
705
  return { error: "that's a host global config dir — use its existing row to build a global install" };
1001
706
  }
1002
- const themes = new Set(themeChoices().map((c) => c.name));
1003
- const bt = bget(body, 'theme');
1004
- const theme = themes.has(bt) ? bt : state.theme;
1005
- const bfp = bget(body, 'footprint');
1006
- const fp = (bfp === 'lean' || bfp === 'full') ? bfp : 'full'; // a fresh deploy is full
1007
- const bpos = bget(body, 'posture');
1008
- const pos = discoverNames('postures', 'peer').includes(bpos) ? bpos : 'peer';
1009
- const bmode = bget(body, 'mode');
1010
- const mode = discoverNames('modes', 'direct').includes(bmode) ? bmode : 'direct';
1011
- const btrust = bget(body, 'trust');
1012
- const trust = Object.keys(PRESETS).includes(btrust) ? btrust : DEFAULT_PRESET;
707
+ // A fresh deploy is full, at the default register, mode and trust.
708
+ const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
709
+ theme: state.theme, footprint: 'full', posture: 'peer', mode: 'direct',
710
+ trust: DEFAULT_PRESET });
1013
711
  // Same resolution as `apiInstallCmd` above, and for the same reason: the console's Deploy
1014
712
  // form sends host/path/theme/footprint/posture/mode and NO pack selection, and nothing stops
1015
713
  // it landing on a directory that already holds an install. Taking `bodyDoctrines` alone left
@@ -1020,6 +718,6 @@ export function apiDeployCmd(state, body) {
1020
718
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
1021
719
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
1022
720
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
1023
- PACK_ORDER, excludeRules, trust);
721
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
1024
722
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
1025
723
  }
@@ -40,7 +40,7 @@ import { readText, writeText, isDir, isFile } from '../lib/fs.mjs';
40
40
  import { parseJson, isTruthy } from '../lib/json.mjs';
41
41
  import { normcase } from '../lib/paths.mjs';
42
42
  import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
43
- import { NotFound, stemOf } from './api.mjs';
43
+ import { NotFound, stemOf } from './catalog.mjs';
44
44
 
45
45
  /**
46
46
  * A session whose writer is gone, or that has not been touched in this long, is pruned.