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
@@ -9,25 +9,27 @@
9
9
  * WHY `build` IMPORTS THE DRIVER RATHER THAN SPAWNING IT. The Python is
10
10
  * `run([sys.executable, BUILD, *extra]).returncode` — a passthrough to a second process.
11
11
  * Reproducing that shape here would mean spawning `node bin/build-driver.mjs`, and
12
- * `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
13
- * entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
14
- * will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
15
- * a verb that does not. It is also the wrong shape on its own terms. Python needs a second
12
+ * `tests/unit/hook_cli.test.mjs`'s 'the CLI entry reaches child_process only where it is
13
+ * declared' forbids it: the check is transitive over this entry's imports and allow-lists only
14
+ * the modules that genuinely start processes — `web`, `upgrade`, `setup` — and P5c's argument
15
+ * for it is that the ban should outlive them rather than be dismantled by a verb that does not.
16
+ * It is also the wrong shape on its own terms. Python needs a second
16
17
  * process because `build.py` is a different PROGRAM; `bin/build-driver.mjs` is a module in this
17
18
  * one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
18
19
  * the passthrough the whole port has been removing. So the driver's `main` is exported and
19
20
  * called directly, and the observable contract — the tree, the streams, the exit code — is
20
- * what `build/*` in `tests/harness_golden.py` compares.
21
+ * what the `build/*` cells of `tests/golden.mjs --cli` assert.
21
22
  *
22
23
  * WHAT NO CELL CAN REACH HERE, stated rather than left implicit. `fenceFor`'s real arm is
23
24
  * unreachable from the matrix: measured over the whole rendered tree, the longest backtick
24
25
  * run in any source text is 3, so `max(4, longest + 1)` picks 4 for all 96 files, and a port
25
26
  * that hardcoded four backticks is byte-identical in every prompt cell. Reaching the other
26
27
  * arm needs a file in `src/`, and `src/` is the tree no fixture can redirect (P5d). It is
27
- * therefore gated as a pure function over a corpus in `tests/snapshot/pure_snapshot.test.mjs`, with the
28
- * unreachability measured in BOTH directions rather than asserted: one test proves the corpus
29
- * leaves the floor, and one re-renders every theme × posture × mode of the live tree and proves
30
- * `src/` still does not. The 96 above is one render — the sweep is 13 440 files.
28
+ * the former Python suite gated it as a pure function over a corpus, with the unreachability
29
+ * measured in BOTH directions rather than asserted: one test proved the corpus left the floor,
30
+ * and one re-rendered every theme × posture × mode of the live tree and proved `src/` still did
31
+ * not. The 96 above is one render — the sweep was 13 440 files. That suite is gone and no Node
32
+ * test calls `fenceFor`, so the real arm is pinned by nothing today.
31
33
  */
32
34
  import { existsSync, mkdirSync, statSync } from 'node:fs';
33
35
  import path from 'node:path';
@@ -38,7 +40,7 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
38
40
  import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
39
41
  import {
40
42
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
- excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
43
+ excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
42
44
  readMaybe, themeOfDir, trustOfDir,
43
45
  } from '../hosts/installs.mjs';
44
46
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
@@ -95,7 +97,7 @@ function sysExit(msg) {
95
97
  const BUILD_FORWARD = [
96
98
  ['theme', '--theme'], ['emit', '--emit'], ['footprint', '--footprint'],
97
99
  ['posture', '--posture'], ['mode', '--mode'], ['trust', '--trust'], ['doctrines', '--doctrines'],
98
- ['excludeRules', '--exclude-rules'], ['out', '--out'], ['root', '--root'],
100
+ ['excludeRules', '--exclude-rules'], ['excludeSkills', '--exclude-skills'], ['out', '--out'], ['root', '--root'],
99
101
  ['configDir', '--config-dir'],
100
102
  ];
101
103
 
@@ -138,7 +140,7 @@ export function cmdBuild(args) {
138
140
  */
139
141
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
140
142
  posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
141
- excludeRules = null, trust = DEFAULT_PRESET) {
143
+ excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null) {
142
144
  const argv = ['--theme', theme, '--emit', emit];
143
145
  if (!emit.endsWith('-global')) {
144
146
  if (out) argv.push('--out', out);
@@ -162,6 +164,10 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
162
164
  if (Array.isArray(excludeRules)) {
163
165
  argv.push('--exclude-rules', excludeRules.length ? excludeRules.join(',') : 'none');
164
166
  }
167
+ // Skills elide when nothing is excluded: the driver's default IS nothing (no config key
168
+ // feeds it), so the flag's absence and `none` are the same answer, and every argv that
169
+ // excludes nothing stays the bytes it was before the flag existed.
170
+ if (excludeSkills?.length) argv.push('--exclude-skills', excludeSkills.join(','));
165
171
  return argv;
166
172
  }
167
173
 
@@ -202,7 +208,7 @@ export function installProfile(host, scope, root) {
202
208
  const posture = postureOfDir(root) || defaultPosture();
203
209
  const mode = modeOfDir(root) || defaultMode();
204
210
  // Read off the deployed loop skill; no config fallback — `DEFAULT_PRESET` is the engine's.
205
- const trust = trustOfDir(root) || DEFAULT_PRESET;
211
+ const trust = trustOfDir(root, host) || DEFAULT_PRESET;
206
212
  // A rebuild that defaulted the pack selection would re-emit an install into a constitution
207
213
  // its owner did not choose — and in one direction that is not merely surprising: dropping
208
214
  // the process pack takes the commit/push consent RULES out of AGENT.md while
@@ -215,9 +221,11 @@ export function installProfile(host, scope, root) {
215
221
  // dropped it would quietly hand back every rule its owner switched off, and `process 5` is
216
222
  // one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
217
223
  const excludeRules = excludedRulesOfDir(root);
224
+ // Skills left out stay left out: an upgrade that forgot them would put them all back.
225
+ const excludeSkills = excludedSkillsOfDir(root);
218
226
  const out = scope === 'global' ? null : root;
219
227
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
220
- PACK_ORDER, excludeRules, trust);
228
+ PACK_ORDER, excludeRules, trust, excludeSkills);
221
229
  // A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
222
230
  // honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
223
231
  // same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
@@ -225,7 +233,7 @@ export function installProfile(host, scope, root) {
225
233
  if (scope === 'global') argv.push('--config-dir', root);
226
234
  return {
227
235
  host, scope, root, state: installState(root, host, scope),
228
- emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, argv,
236
+ emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, excludeSkills, argv,
229
237
  };
230
238
  }
231
239
 
@@ -360,7 +368,9 @@ export function buildPrompt(cfg, themeName) {
360
368
  * Windows. At ~10,000 newlines in a 400 KB document that is the largest byte difference this
361
369
  * port could ship, and `harness_golden` can only see half of it — the FILE half is compared
362
370
  * directly, the stdout half is folded by `subprocess`'s universal-newline decode, which is
363
- * why `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `prompt` row.
371
+ * why the former Python suite's stdout-bytes comparison grew a `prompt` row — kept today as
372
+ * a row of `tests/unit/hook_cli.test.mjs`'s 'every newline the entry points write is the
373
+ * platform's'.
364
374
  */
365
375
  export function cmdPrompt(args) {
366
376
  // `args.theme or "neutral"` — a literal in the Python, NOT the config's theme, so this
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * WHY THIS HALF AND NOT MORE. The emits split into five stages —
10
10
  * RENDER* -> WIRE* -> PRUNE -> MANIFEST -> VERIFY (see `_build_global._emit_claude_core`
11
- * and tests/test_emit_phase_order.py). RENDER produces files Geneseed owns wholesale;
11
+ * and tests/unit/emit_phase_order.test.mjs). RENDER produces files Geneseed owns wholesale;
12
12
  * WIRE reconciles files the user co-owns (settings.json, opencode.json, the CLAUDE.md
13
13
  * managed block) and lives in `js/hosts/settings.mjs`, driven from the two emit modules.
14
14
  * THIS module is only RENDER
@@ -162,10 +162,31 @@ export function substitute(text, theme) {
162
162
  }
163
163
 
164
164
  /** `_build_render._resolve_catalogs`. */
165
- function resolveCatalogs(text, nativeCatalog) {
165
+ function resolveCatalogs(text, nativeCatalog, excludeSkills = []) {
166
166
  // `true`/`false` still mean "both kinds", for the plain-bundle callers that pass a boolean.
167
167
  const nat = nativeCatalog === true ? { skills: true, agents: true } : (nativeCatalog || {});
168
- return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => (nat[kind] ? pointer : table));
168
+ return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => (nat[kind] ? pointer
169
+ : kind === 'skills' ? dropExcludedSkillRows(table, excludeSkills) : table));
170
+ }
171
+
172
+ /**
173
+ * The skills catalogue table, minus the rows of `--exclude-skills` — a row naming a skill the
174
+ * install never wrote is a dead link. A folder skill's bullet runs onto indented continuation
175
+ * lines, which go with it. Only the plain bundle reaches this: every host emit replaces the
176
+ * table with the pointer to its own inventory.
177
+ */
178
+ function dropExcludedSkillRows(table, excludeSkills) {
179
+ if (!excludeSkills.length) return table;
180
+ const names = excludeSkills.join('|');
181
+ const gone = new RegExp(`\\{\\{DIR_SKILLS\\}\\}/(?:${names})(?:\\.md|/SKILL\\.md)\\)`);
182
+ const out = [];
183
+ let dropping = false;
184
+ for (const line of table.split('\n')) {
185
+ if (dropping && /^[ \t]+\S/.test(line)) continue;
186
+ dropping = /^(?:\||- )/.test(line) && gone.test(line);
187
+ if (!dropping) out.push(line);
188
+ }
189
+ return out.join('\n');
169
190
  }
170
191
 
171
192
  /**
@@ -247,7 +268,7 @@ export function renderFile(cfg, filePath, theme, footprint = 'full', lawsPrefix
247
268
  return inner;
248
269
  });
249
270
 
250
- text = resolveCatalogs(text, nativeCatalog);
271
+ text = resolveCatalogs(text, nativeCatalog, cfg.excludeSkills ?? []);
251
272
  text = resolveLean(text, footprint);
252
273
  return substitute(text, theme);
253
274
  }
@@ -414,6 +435,11 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
414
435
  theme.EXCLUDED_RULES_LINE = dropped.length
415
436
  ? `\nExcluded rules: ${dropped.map((id) => id.replace('.', ' ')).join(', ')}`
416
437
  : '';
438
+ // The skills twin, under the same contract: written only when something is excluded, so a
439
+ // default build stays byte-identical, and read back by `excludedSkillsOfDir`.
440
+ const skipped = [...(cfg.excludeSkills ?? [])].sort();
441
+ // It fills a template line of its own, so its value carries the blank line around it.
442
+ theme.EXCLUDED_SKILLS_LINE = skipped.length ? `\nExcluded skills: ${skipped.join(', ')}\n` : '';
417
443
  return theme;
418
444
  }
419
445
 
@@ -460,8 +486,12 @@ export function renderAll(cfg, themeName, {
460
486
  } = {}) {
461
487
  const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
462
488
  const items = [];
489
+ const skipped = new Set(cfg.excludeSkills ?? []);
463
490
  for (const file of sortedSourceFiles(cfg.src)) {
464
491
  const rel = path.relative(cfg.src, file);
492
+ // `--exclude-skills`: the one filter every host's emit and the plain bundle read through.
493
+ const sp = rel.split(path.sep);
494
+ if (sp[0] === 'skills' && sp.length >= 2 && skipped.has(sp[1].replace(/\.md$/, ''))) continue;
465
495
  const outRel = destRel(themedRel(rel, theme)).split(path.sep).join('/'); // as_posix()
466
496
  // The on-disk `laws/`, `ontology/` and `doctrines/` are the "complete text" the two lean
467
497
  // pointers promise — §1's and the Doctrines section's. They render at full whatever the
@@ -20,9 +20,9 @@
20
20
  * `capabilityLinkRe`'s equivalent override in `stripCapabilityLinks` had no producer either
21
21
  * and was deleted outright in the over-engineering cleanup, `cfg` param and all.
22
22
  *
23
- * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
24
- * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
25
- * from these paths. A depth error or a renamed key fails 259 cells, not zero.
23
+ * The move is safe for the reason P5c's was: `tests/golden.mjs` drives `bin/build-driver.mjs`
24
+ * over every emit configuration and requires each to render, and every one of them builds a
25
+ * cfg from these paths. A depth error or a renamed key fails every cell, not zero.
26
26
  */
27
27
  import { readdirSync, readFileSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
@@ -38,7 +38,7 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
38
38
  * on raw or folded text, so one behaviour serves both.
39
39
  */
40
40
  const _packTexts = new Map();
41
- export function readRawText(file) {
41
+ function readRawText(file) {
42
42
  const key = path.resolve(file);
43
43
  const mtime = statSync(file).mtimeMs;
44
44
  const hit = _packTexts.get(key);
@@ -160,8 +160,8 @@ export function resolveRuleIds(text, src = SRC) {
160
160
  * `_OWNED` redirect the suite uses is an in-process write, and no env var moves it
161
161
  * (`$GENESEED_ROOT` is `harness context`'s doc-discovery root — a different name for a
162
162
  * different job). Both implementations therefore answer from their own file's location,
163
- * which is what makes them comparable and what makes them unfenceable. See the
164
- * status/version section of `tests/harness_golden.py`.
163
+ * which is what made them comparable and what makes them unfenceable. See
164
+ * `js/inspect/status.mjs`'s header for what that leaves unreachable from any cell.
165
165
  */
166
166
  export const ROOT = path.resolve(import.meta.dirname, '../..');
167
167
  export const SRC = path.join(ROOT, 'src');
@@ -236,6 +236,40 @@ export function knownRuleIds() {
236
236
  return ruleCanon(SRC).rules.filter((r) => r.kind === 'DOCTRINE').map((r) => `${r.pack}.${r.n}`);
237
237
  }
238
238
 
239
+ /**
240
+ * Every skill this checkout ships: a flat `skills/<name>.md` (authoring `_` files are not
241
+ * skills) or a vendored `skills/<name>/` folder. The one enumerator `--exclude-skills` closes
242
+ * against, for the reason `knownRuleIds` is one.
243
+ */
244
+ export function knownSkillIds() {
245
+ const dir = path.join(SRC, 'skills');
246
+ return readdirSync(dir, { withFileTypes: true })
247
+ .filter((e) => !e.name.startsWith('_') && (e.isDirectory() || e.name.endsWith('.md')))
248
+ .map((e) => e.name.replace(/\.md$/, '')).sort();
249
+ }
250
+
251
+ /**
252
+ * The skills the constitution or another skill LINKS to by path (`{{DIR_SKILLS}}/<name>`),
253
+ * outside the §4 catalogue table. They cannot be excluded: the link would ship dead. Scanned,
254
+ * not listed, so a new link protects its target the day it is written.
255
+ */
256
+ export function linkedSkillIds() {
257
+ const linked = new Set();
258
+ const walk = (d) => {
259
+ for (const e of readdirSync(d, { withFileTypes: true })) {
260
+ const f = path.join(d, e.name);
261
+ if (e.isDirectory()) walk(f);
262
+ else if (/\.(md|tmpl)$/.test(e.name)) {
263
+ const text = readFileSync(f, 'utf8')
264
+ .replace(/<!-- CATALOG:begin skills -->[\s\S]*?<!-- CATALOG:end -->/, '');
265
+ for (const m of text.matchAll(/\{\{DIR_SKILLS\}\}\/([a-z0-9-]+)/g)) linked.add(m[1]);
266
+ }
267
+ }
268
+ };
269
+ walk(SRC);
270
+ return [...linked].sort();
271
+ }
272
+
239
273
  /**
240
274
  * `_build_core.js_cfg()`, originated rather than received.
241
275
  *
@@ -250,7 +284,7 @@ export function knownRuleIds() {
250
284
  */
251
285
  export function makeCfg({
252
286
  posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
253
- excludeRules = [],
287
+ excludeRules = [], excludeSkills = [],
254
288
  } = {}) {
255
289
  return {
256
290
  root: ROOT,
@@ -276,5 +310,7 @@ export function makeCfg({
276
310
  // is already absent, and dropping the exclusion would silently re-admit the rule the day
277
311
  // the pack came back.
278
312
  excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
313
+ // Skill names left out of this install — same "unspecified takes nothing away" default.
314
+ excludeSkills: Array.isArray(excludeSkills) ? [...excludeSkills] : [],
279
315
  };
280
316
  }
@@ -9,8 +9,9 @@
9
9
  */
10
10
  import path from 'node:path';
11
11
  import { writeText } from '../lib/fs.mjs';
12
+ import { isDir } from '../lib/fs.mjs';
12
13
  import { jsonDumpsIndent } from '../lib/json.mjs';
13
- import { isDir } from './emit-common.mjs';
14
+ import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
14
15
  import { existsSync } from 'node:fs';
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -102,8 +103,8 @@ usually one promoted from a recurring memory. Review it by that date, then
102
103
  graduate it (remove the marker) or demote it back to memory.
103
104
  `;
104
105
 
105
- /** `_build_render.EXCLUDES_FILE`. */
106
- export const EXCLUDES_FILE = 'excludes.json';
106
+ // Owned by `js/hosts/hosts.mjs` (the hook reads it there); re-exported for `js/inspect/excludes.mjs`.
107
+ export { EXCLUDES_FILE };
107
108
 
108
109
  /**
109
110
  * `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
@@ -232,8 +233,8 @@ export const ensureProfileStub = stubWriter(PROFILE_FILE, PROFILE_STUB);
232
233
 
233
234
  /**
234
235
  * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
235
- * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
236
- * `emitClaudeRender` rather than with the bundle stubs beside it.
236
+ * overwritten. Called by every GLOBAL emit — the Claude-shaped ones and opencode-global —
237
+ * since that is where the hook looks for it.
237
238
  */
238
239
  export const ensureExcludesStub = stubWriter(EXCLUDES_FILE, EXCLUDES_STUB);
239
240
 
@@ -8,9 +8,11 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
- import { sourceReleaseVersion } from '../hosts/opencode.mjs';
12
11
  import { readText, writeText } from '../lib/fs.mjs';
13
- import { isDir, relPosix, rglobFiles } from './emit-common.mjs';
12
+ import { parseJson } from '../lib/json.mjs';
13
+ import { isDir } from '../lib/fs.mjs';
14
+ import { relPosix } from '../lib/text.mjs';
15
+ import { rglobFiles } from './emit-common.mjs';
14
16
  import { createHash } from 'node:crypto';
15
17
  import { readFileSync } from 'node:fs';
16
18
 
@@ -132,6 +134,23 @@ export function versionIsNewer(a, b) {
132
134
  return false;
133
135
  }
134
136
 
137
+ /**
138
+ * `_build_core.source_release_version` — the release this checkout would install. It reads
139
+ * `cfg.config`, so it takes `cfg`. It lived in `js/hosts/opencode.mjs` for no better reason
140
+ * than that the first caller was there; every reader since asks a version question, so it
141
+ * lives with them. `js/hosts/opencode.mjs` re-exports it for its older importers.
142
+ */
143
+ export function sourceReleaseVersion(cfg) {
144
+ try {
145
+ const data = parseJson(readText(cfg.config));
146
+ const v = data && typeof data === 'object' && !Array.isArray(data) ? data.version : null;
147
+ if (typeof v === 'string' && v) return v;
148
+ } catch {
149
+ // OSError / JSONDecodeError — degrade to "not newer" rather than raising.
150
+ }
151
+ return '0.0.0';
152
+ }
153
+
135
154
  /** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
136
155
  function warnIfDowngrade(cfg, out) {
137
156
  const deployed = readReleaseVersion(out);
package/js/cli-table.json CHANGED
@@ -178,6 +178,21 @@
178
178
  "type": null,
179
179
  "hidden": false
180
180
  },
181
+ {
182
+ "names": [
183
+ "--exclude-skills"
184
+ ],
185
+ "dest": "exclude_skills",
186
+ "metavar": null,
187
+ "help": "",
188
+ "choices": null,
189
+ "default": null,
190
+ "required": false,
191
+ "nargs": null,
192
+ "is_flag": false,
193
+ "type": null,
194
+ "hidden": false
195
+ },
181
196
  {
182
197
  "names": [
183
198
  "--out"
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { readText, printOut as out, printErr as err, withDiscardableStderr } from '../lib/fs.mjs';
10
10
  import { normcase, toPlatformPath } from '../lib/paths.mjs';
11
+ import { relPosix } from '../lib/text.mjs';
11
12
  import { resolvePath, sovereignBypass } from './hosts.mjs';
12
13
  import { isFile, isDir, selfAndParents, sortPaths, listDir } from './hooks-prims.mjs';
13
14
 
@@ -95,11 +96,22 @@ const NATIVE_ROOT = {
95
96
  openclaude: ['AGENTS.md', 'CLAUDE.md'],
96
97
  };
97
98
 
98
- // Eager injection budget: a root README of 40k chars was going out whole, every session and
99
- // every compaction, and cost more than the harness itself. Per-file cut at the last line
100
- // break under the cap; files that would push the total over the budget are listed lazy.
101
- const EAGER_FILE_BYTES = 16 * 1024;
102
- const EAGER_TOTAL_BYTES = 48 * 1024;
99
+ // The whole payload's budget, in characters. Claude Code does not hand a hook's stdout to the
100
+ // model past roughly 10k characters: it saves the output to a file and the model sees a 2 KB
101
+ // preview, so everything after the cut is lost without a word. Observed 2026-10 (a 25.6 KB and
102
+ // a 16.5 KB SessionStart output, both persisted); the exact threshold is not documented, hence
103
+ // the margin. The old 48 KB eager budget never bound — the host's cap always cut first.
104
+ // Priority inside the budget: session files (the user's own), then eager docs (whole, or
105
+ // listed lazy), then the lazy listing, folded per directory and cut with a count.
106
+ const OUTPUT_BUDGET = 9000;
107
+ // Kept back from the eager docs so the lazy listing always has room to say what exists.
108
+ const LAZY_RESERVE = 1500;
109
+ // A directory with this many undescribed lazy files is listed once, with its count.
110
+ const LAZY_FOLD_AT = 3;
111
+ // What a line costs against the budget: its characters plus a terminator counted as two,
112
+ // because stdout goes out as \r\n on Windows — a 144-line listing overran by 137 before this.
113
+ // Multi-line text pays one more per inner line break, for the same reason.
114
+ const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
103
115
 
104
116
  // The files the root instruction file names for session start, relative to the HARNESS dir
105
117
  // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
@@ -177,7 +189,7 @@ function disp(pathStr, root) {
177
189
  !== path.parse(path.resolve(root)).root.toLowerCase()) {
178
190
  return pathStr;
179
191
  }
180
- return path.relative(root, pathStr).split(path.sep).join('/');
192
+ return relPosix(root, pathStr);
181
193
  }
182
194
 
183
195
 
@@ -330,14 +342,58 @@ export function resolveContextSets(root, hookRoot = null) {
330
342
  }
331
343
 
332
344
  /**
333
- * Cut `text` at the last line break under EAGER_FILE_BYTES and say where the rest is — one rule
334
- * for a session file and an eager doc alike. `where` finishes the sentence.
345
+ * Cut a session file at the last line break that fits `room` and say where the rest is. Only
346
+ * session files are cut: they are the user's own rules, and half of them beats none. An eager
347
+ * doc that does not fit is listed lazy instead — a README cut mid-section misleads.
335
348
  */
336
- function capEager(text, where) {
337
- if (text.length <= EAGER_FILE_BYTES) return text;
338
- const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
339
- return `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
340
- + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB — read ${where}`;
349
+ function capToRoom(text, room, where) {
350
+ if (cost(text) <= room) return text;
351
+ const marker = `\n[context] truncated (session budget) — read ${where}`;
352
+ let cut = text.length;
353
+ do cut = text.lastIndexOf('\n', cut - 1);
354
+ while (cut > 0 && cost(text.slice(0, cut) + marker) > room);
355
+ return `${cut > 0 ? text.slice(0, cut) : ''}${marker}`;
356
+ }
357
+
358
+ /**
359
+ * The lazy listing: a described entry gets its own line; undescribed entries sharing a parent
360
+ * directory are folded into one `dir/ — N docs` line once there are LAZY_FOLD_AT of them (a
361
+ * `docs/` tree of 186 files was 186 lines). Lines past `room` become one count line.
362
+ */
363
+ function lazyLines(entries, root, room) {
364
+ const byDir = new Map();
365
+ for (const e of entries) {
366
+ const dir = path.dirname(disp(e.path, root));
367
+ // Root files stay listed one by one: there are few, and each name says what it is.
368
+ if (e.description || e.over || dir === '.') continue;
369
+ byDir.set(dir, (byDir.get(dir) || 0) + 1);
370
+ }
371
+ const lines = [];
372
+ const folded = new Set();
373
+ for (const e of entries) {
374
+ const shown = disp(e.path, root);
375
+ const dir = path.dirname(shown);
376
+ if (!e.description && !e.over && byDir.get(dir) >= LAZY_FOLD_AT) {
377
+ if (folded.has(dir)) continue;
378
+ folded.add(dir);
379
+ lines.push(` - ${dir.replace(/\\/g, '/')}/ — ${byDir.get(dir)} docs`);
380
+ continue;
381
+ }
382
+ const why = e.over ? ' (eager, but over the session budget — read on demand)' : '';
383
+ lines.push(` - ${shown}${e.description ? ` — ${e.description}` : ''}${why}`);
384
+ }
385
+ const kept = [];
386
+ let used = 0;
387
+ for (const [i, l] of lines.entries()) {
388
+ const tail = ` - … ${lines.length - i} more not listed (session budget)`;
389
+ if (used + cost(l) + (i < lines.length - 1 ? cost(tail) : 0) > room) {
390
+ kept.push(tail);
391
+ break;
392
+ }
393
+ kept.push(l);
394
+ used += cost(l);
395
+ }
396
+ return kept;
341
397
  }
342
398
 
343
399
  export function cmdContext(args) {
@@ -378,51 +434,46 @@ export function cmdContext(args) {
378
434
  }
379
435
 
380
436
  const lines = [];
437
+ // `spent` counts every character pushed, headers included: the budget is the host's cap on
438
+ // the whole payload, not on the file bodies alone.
381
439
  let spent = 0;
440
+ const push = (...ls) => { for (const l of ls) { lines.push(l); spent += cost(l); } };
382
441
  if (session.length) {
383
- lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
442
+ push('=== SESSION FILES \u2014 your harness files, injected at session start '
384
443
  + '(untouched seeds skipped) ===', '');
385
444
  for (const s of session) {
386
- const text = capEager(s.text, `${s.abs} on demand`);
387
- spent += text.length;
388
- lines.push(`----- ${s.rel} -----`, text, '');
445
+ const head = `----- ${s.rel} -----`;
446
+ push(head, capToRoom(s.text, OUTPUT_BUDGET - LAZY_RESERVE - spent - cost(head) - cost(''),
447
+ `${s.abs} on demand`), '');
389
448
  }
390
449
  }
391
450
  if (eager.length || lazy.length) {
392
- lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
451
+ push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
393
452
  }
394
453
  const demoted = [];
395
454
  for (const entry of eager) {
396
455
  const p = entry.path === undefined ? '' : entry.path;
397
456
  const desc = entry.description === undefined ? '' : entry.description;
398
457
  const target = path.isAbsolute(p) ? p : path.join(root, p);
458
+ const head = `----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`;
399
459
  let text;
400
460
  try {
401
461
  text = readText(target).replace(/\n+$/, '');
402
462
  } catch (e) {
403
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
404
- `[context] MISSING eager file: ${asOsError(e, target)}`, '');
463
+ push(head, `[context] MISSING eager file: ${asOsError(e, target)}`, '');
405
464
  continue;
406
465
  }
407
- text = capEager(text, `${disp(p, root)} on demand for the rest`);
408
- if (spent + text.length > EAGER_TOTAL_BYTES) {
409
- demoted.push(entry);
466
+ if (spent + cost(head) + cost(text) + cost('') > OUTPUT_BUDGET - LAZY_RESERVE) {
467
+ demoted.push({ ...entry, over: true });
410
468
  continue;
411
469
  }
412
- spent += text.length;
413
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
470
+ push(head, text, '');
414
471
  }
415
472
 
416
473
  if (lazy.length || demoted.length) {
417
- lines.push('--- Lazy entries (load only when the task needs them) ---');
418
- for (const entry of [...lazy, ...demoted]) {
419
- const p = entry.path === undefined ? '' : entry.path;
420
- const desc = entry.description === undefined ? '' : entry.description;
421
- const over = demoted.includes(entry)
422
- ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
423
- lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
424
- }
425
- lines.push('');
474
+ const title = '--- Lazy entries (load only when the task needs them) ---';
475
+ push(title);
476
+ push(...lazyLines([...lazy, ...demoted], root, OUTPUT_BUDGET - spent - cost('')), '');
426
477
  }
427
478
 
428
479
  // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
@@ -23,8 +23,8 @@ import { comparePaths } from '../lib/paths.mjs';
23
23
  // none of that reached the hook, because a corpus finds what it is pointed at and nothing
24
24
  // named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
25
25
  // `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
26
- // down for a repo Python still gates. `context/bypass-does-not-match-a-dotdot-entry` is
27
- // the cell.
26
+ // down for a repo Python still gates. (Since the Python is gone, exclude entries resolve `..`
27
+ // and symlinks on every host — `context/sovereign-bypass-matches-a-dotdot-entry` is the cell.)
28
28
  //
29
29
  // So the line is not "duplicate rather than couple" — it is "one owner for anything that
30
30
  // reproduces a language primitive, wherever it is used". `fs.mjs` carries no