geneseed 3.15.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.16.0] — 2026-10-05
12
+
13
+ **Upgrading:** re-emit (`geneseed rebuild-all`) so the context hook stays under Claude Code's output cap. To drop skills you never use, rebuild an install with `--exclude-skills <name,…>` (see [Exclusions](docs/concepts/exclusions.md)).
14
+
15
+ ### Added
16
+
17
+ - `--exclude-skills <name,…>` leaves skills you never use out of an install, on every host: their files, their catalogue rows and any OpenCode command that runs them. The install records the list on an `Excluded skills:` line, and `upgrade`, `rebuild-all`, `diff`, `setup` and the web console keep it. Unknown skills are refused, and so are the ten the harness links to by path. `status` shows `skills off: …`.
18
+
19
+ ### Fixed
20
+
21
+ - The context hook (Claude Code, Bob, OpenClaude) now keeps its whole output under 9,000 characters. Claude Code shows the model only a preview of a hook output past ~10k, so a doc-heavy repo lost its context silently — this repo's was 25.6 KB, now 2.5 KB. Eager docs that do not fit are listed lazy with the reason; the lazy listing folds three or more docs in one folder into `dir/ — N docs` and ends in a count if it is still too long. The OpenCode plugin folds its project listing the same way.
22
+
11
23
  ## [3.15.0] — 2026-10-05
12
24
 
13
25
  **Upgrading:** re-emit (`geneseed rebuild-all`) so OpenCode installs get the hardened plugins and
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **3.15.0**.
23
+ > string. Current label: **3.16.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -117,6 +117,8 @@ const MAX_DEPTH = 6
117
117
  // the head of each file (enough for an H1) rather than the whole thing — a large
118
118
  // docs/ tree must not cost one full-file read per entry on every session start.
119
119
  const LAZY_HEADING_LIMIT = envNum("GENESEED_LAZY_HEADINGS", 64)
120
+ // Same threshold as the Claude hook's LAZY_FOLD_AT.
121
+ const LAZY_FOLD_AT = 3
120
122
  const HEADING_SLICE_BYTES = 4096
121
123
 
122
124
  // Quiet by default — OpenCode surfaces a plugin's stderr in the UI (red text). Set
@@ -664,7 +666,7 @@ async function wikiSets() {
664
666
  // Render one eager+lazy set into `out`. The budget state (eager bytes, heading
665
667
  // reads) is SHARED across segments — project context and every wiki draw from the
666
668
  // same caps, so the whole block stays bounded no matter how many wikis are declared.
667
- async function renderSet(out, { eager, lazy }, state) {
669
+ async function renderSet(out, { eager, lazy }, state, { fold = false } = {}) {
668
670
  const perFile = EAGER_FILE_KB * 1024
669
671
  const total = EAGER_TOTAL_KB * 1024
670
672
  const demoted = []
@@ -692,8 +694,26 @@ async function renderSet(out, { eager, lazy }, state) {
692
694
  state.injected++
693
695
  }
694
696
 
697
+ // The project listing folds like the Claude hook's (js/hosts/hooks-context.mjs): three or more
698
+ // undescribed docs sharing a parent directory become one `dir/ — N docs` line — a docs/ tree
699
+ // of 186 files was 186 lines on every session. Root files never fold. A wiki listing keeps its
700
+ // per-note headings: a vault is navigated by them, and it carries its own cap.
701
+ const byDir = new Map()
702
+ if (fold) {
703
+ for (const l of lazy) {
704
+ const dir = path.posix.dirname(l.rel.replace(/\\/g, "/"))
705
+ if (!l.desc && dir !== ".") byDir.set(dir, (byDir.get(dir) || 0) + 1)
706
+ }
707
+ }
708
+ const folded = new Set()
695
709
  const lazyLines = []
696
710
  for (const l of lazy) {
711
+ const dir = path.posix.dirname(l.rel.replace(/\\/g, "/"))
712
+ if (!l.desc && byDir.get(dir) >= LAZY_FOLD_AT) {
713
+ if (!folded.has(dir)) lazyLines.push(` - ${dir}/ — ${byDir.get(dir)} docs`)
714
+ folded.add(dir)
715
+ continue
716
+ }
697
717
  let head = l.desc
698
718
  // Only crack open files we have no description for, and cap how many — bounded by
699
719
  // a head-slice read so a big docs/ tree stays cheap on every session start.
@@ -734,7 +754,7 @@ async function buildBlock(sets, wikis = [], commands = [], session = []) {
734
754
  }
735
755
 
736
756
  const proj = []
737
- await renderSet(proj, sets, state)
757
+ await renderSet(proj, sets, state, { fold: true })
738
758
 
739
759
  if (commands.length) {
740
760
  proj.push("--- Project commands (runnable targets discovered in the repo root) ---")
@@ -4,18 +4,18 @@ order: 12
4
4
  title: "Exclusions"
5
5
  kind: "concept"
6
6
  section: "Configuration"
7
- description: "Two ways to switch things off: one rule, or every hook in a folder."
7
+ description: "Three ways to switch things off: one rule, one skill, or every hook in a folder."
8
8
  link: {"hash": "#/harness", "label": "Manage in Harness →"}
9
9
  ---
10
- Geneseed has two different ways to switch something off, and they work at different scales:
10
+ Geneseed has three ways to switch something off, and they work at different scales:
11
11
 
12
- | | Sovereign repo | Per-rule exclusion |
13
- | --- | --- | --- |
14
- | **Switches off** | the whole global install, inside one folder | one doctrine rule, everywhere the install applies |
15
- | **Scope** | a folder and everything under it | the install |
16
- | **Applies to** | **global** installs only | any install |
17
- | **Set with** | `geneseed exclude add <path>` | `--exclude-rules` at build time |
18
- | **Takes effect** | on the next tool call, no rebuild | on rebuild |
12
+ | | Sovereign repo | Per-rule exclusion | Per-skill exclusion |
13
+ | --- | --- | --- | --- |
14
+ | **Switches off** | the whole global install, inside one folder | one doctrine rule, everywhere the install applies | one skill, everywhere the install applies |
15
+ | **Scope** | a folder and everything under it | the install | the install |
16
+ | **Applies to** | **global** installs only | any install | any install |
17
+ | **Set with** | `geneseed exclude add <path>` | `--exclude-rules` at build time | `--exclude-skills` at build time |
18
+ | **Takes effect** | on the next tool call, no rebuild | on rebuild | on rebuild |
19
19
 
20
20
  > **You already know this:** `.gitignore` versus disabling one lint rule. One makes a tool ignore a whole folder; the other keeps the tool running everywhere with one check turned off.
21
21
 
@@ -55,3 +55,15 @@ geneseed build --emit claude-global --exclude-rules "process 7"
55
55
  ```
56
56
 
57
57
  The install records it (`Excluded rules: process 7` beside its pack list) and every rebuild and upgrade keeps it. The always-on Rules and the Ethos cannot be excluded. Picking packs and rules is covered in [Doctrine packs and single rules](../guides/setup-doctrines.md); what the layers are is in [Rules](rules.md).
58
+
59
+ ### Per-skill exclusion — skills you never use
60
+
61
+ Every installed skill costs a line in the host's skill listing, every session. Skills you never use can be left out of an install:
62
+
63
+ ```bash
64
+ geneseed build --emit claude-global --exclude-skills "bruno-test-writer,bruno-collection-generator,react-view-transitions"
65
+ ```
66
+
67
+ The skill's files are not written, its catalogue row goes, and an OpenCode `/command` that runs it goes too. The install records the list (`Excluded skills: …` under its skill section), and every rebuild and upgrade keeps it. `--exclude-skills none` puts them all back.
68
+
69
+ A few skills cannot be excluded, because the harness itself links to them by path — `council`, `rule`, `skill-forge`, `workflow` and a handful more. The flag names them when it refuses one. Skills you installed yourself are not Geneseed's to exclude: remove those from the host's skills folder directly.
@@ -12,7 +12,7 @@ description: "The build command to run in a script, a CI job, or a shell with no
12
12
  geneseed build --emit opencode-global --theme neutral
13
13
  ```
14
14
 
15
- `geneseed build` takes the same flags as the generator `geneseed-build`. `--emit` picks the host and its scope: `files`, `opencode`, `opencode-global`, `claude`, `claude-global`, `bob`, `bob-global`, `openclaude` or `openclaude-global`. A `-global` emit writes into the tool's own config directory. A project emit writes into the repo you name with `--out <repo> --root <repo>`. All the other flags (`--theme`, `--footprint`, `--posture`, `--mode`, `--trust`, `--doctrines`, `--exclude-rules`) are covered in [Choose your setup](choose-your-setup.md) and the two pages after it.
15
+ `geneseed build` takes the same flags as the generator `geneseed-build`. `--emit` picks the host and its scope: `files`, `opencode`, `opencode-global`, `claude`, `claude-global`, `bob`, `bob-global`, `openclaude` or `openclaude-global`. A `-global` emit writes into the tool's own config directory. A project emit writes into the repo you name with `--out <repo> --root <repo>`. All the other flags (`--theme`, `--footprint`, `--posture`, `--mode`, `--trust`, `--doctrines`, `--exclude-rules`) are covered in [Choose your setup](choose-your-setup.md) and the two pages after it. `--exclude-skills` leaves skills you never use out of the install; see [Exclusions](../concepts/exclusions.md).
16
16
 
17
17
  To test a build before writing anything, use `geneseed validate` with the same flags. See [Verify](verify.md).
18
18
 
@@ -25,7 +25,7 @@ With no configuration, the context step finds these files:
25
25
  - **Lazy:** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/` and `ADR/`, monorepo `packages/*/README.md` and `apps/*/README.md`, and any other root `*.md`.
26
26
  - **Never scanned:** `node_modules`, `.git`, `dist`, `build`, `vendor`, `.next`, `target`, `.venv`, `__pycache__`, `.opencode` and `.harness`.
27
27
 
28
- Eager files share a 48 KB budget per session. An eager file that would go over the budget is listed instead, to be read on demand.
28
+ On Claude Code, Bob and OpenClaude the whole injection stays under 9,000 characters, because Claude Code hands the model only a short preview of a hook output past about 10,000 and keeps the rest in a file. Your session files come first, then eager files whole; an eager file that does not fit is listed instead, to be read on demand. On OpenCode eager files share a 48 KB budget. On every host, three or more docs in one folder are listed as that folder with a count (`docs/guides/ — 30 docs`), not file by file.
29
29
 
30
30
  On OpenCode the context plugin does this work. On Claude Code, Bob and OpenClaude, the session-start hook does it (`geneseed-hook context`). Both read the same override file, described next.
31
31
 
@@ -35,11 +35,13 @@ pointer to the host's own inventory instead, ~1.9k tokens lighter. A session fil
35
35
  byte-identical to its seed is skipped — it says nothing — so a fresh install injects none
36
36
  of them; until 2026-10 the hook read them from the repo root, where a Claude-shaped install
37
37
  never puts them, and none reached the session at all. The
38
- eager-injection path is budget-capped identically everywhere — 16 KB per file
39
- (cut at a line break, with a marker saying so) and 48 KB per session (files past
40
- the budget are listed lazy with the reason) — so a 40k-character README no
41
- longer leaves whole every session, and growing Memory degrades every host the
42
- same way instead of one silently falling behind. The hook also **never injects
38
+ eager-injection path is budget-capped on every host. The SessionStart hook (Claude Code,
39
+ Bob, OpenClaude) keeps its whole payload under 9,000 characters: Claude Code persists a
40
+ hook output past ~10k to a file and shows the model a 2 KB preview, so until 2026-10 a
41
+ doc-heavy repo's context was cut off unannounced. Session files come first, eager docs
42
+ are injected whole or listed lazy with the reason. The OpenCode plugin has no such cap
43
+ and keeps 16 KB per file / 48 KB per session. Both fold a lazy listing: three or more
44
+ docs in one folder become one `dir/ — N docs` line. The hook also **never injects
43
45
  the root file the host loads by itself** (`CLAUDE.md` on Claude Code,
44
46
  `AGENTS.md` on Bob, `AGENT.md`/`AGENTS.md`/`CLAUDE.md` on OpenCode):
45
47
  until 2026-09 it did, and the whole harness was paid twice per session.
@@ -106,11 +108,12 @@ token counter also includes, none of which Geneseed controls:
106
108
  2. **Your repo's docs, injected eagerly** — the context delivery
107
109
  (plugin on OpenCode, SessionStart hook on Claude Code / Bob / OpenClaude) discovers and
108
110
  injects `README.md`, `CONTRIBUTING.md`, and files under `docs/`, up to the
109
- 48 KB budget (≈12k tokens). A doc-heavy repo fills it.
111
+ budget: 9,000 characters for the whole hook payload (≈2.3k tokens), 48 KB on
112
+ OpenCode (≈12k tokens).
110
113
  3. **Wiki eager entries** plus the lazy listing of the rest, if a wiki is
111
114
  configured.
112
115
  4. **Grown Memory/Notebook** — the emitted starter set is ~1.2k, but both
113
- accumulate with use (same 48 KB shared budget).
116
+ accumulate with use (same shared budget).
114
117
  5. **Other instruction files the host also loads** — a global install plus a
115
118
  per-repo install, a personal `~/.claude/CLAUDE.md`, or MCP servers each add
116
119
  their own share.
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.15.0"
5
+ "version": "3.16.0"
6
6
  }
package/js/build/args.mjs CHANGED
@@ -10,7 +10,9 @@ import { existsSync, readFileSync } from 'node:fs';
10
10
  import { parseArgs as nodeParseArgs } from 'node:util';
11
11
  import { withPlatformNewlines } from '../lib/fs.mjs';
12
12
  import path from 'node:path';
13
- import { CONFIG, discoverNames, PACK_ORDER, knownRuleIds } from './source.mjs';
13
+ import {
14
+ CONFIG, discoverNames, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds,
15
+ } from './source.mjs';
14
16
  // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
15
17
  // what lets it sit inside this module's `child_process` ban.
16
18
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
@@ -30,7 +32,7 @@ const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global'
30
32
  export function configDefaults() {
31
33
  const d = {
32
34
  theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
33
- doctrines: [...PACK_ORDER], excludeRules: [],
35
+ doctrines: [...PACK_ORDER], excludeRules: [], excludeSkills: [],
34
36
  };
35
37
  if (!existsSync(CONFIG)) return d;
36
38
  try {
@@ -93,7 +95,7 @@ export function configDefaults() {
93
95
  */
94
96
  const VALUED = {
95
97
  '--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
96
- '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
98
+ '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules', '--exclude-skills': 'excludeSkills',
97
99
  '--out': 'out', '--target': 'out', '--emit': 'emit',
98
100
  '--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
99
101
  };
@@ -177,6 +179,33 @@ function parseExcludeRules(value) {
177
179
  return [...new Set(out)].sort();
178
180
  }
179
181
 
182
+ /**
183
+ * `--exclude-skills "bruno-test-writer,openscad"` -> the cfg's skill exclusion array.
184
+ *
185
+ * Refused, by name: a skill this checkout does not ship (a typo would exclude nothing in
186
+ * silence), and a skill the constitution or another skill links to by path — excluding it
187
+ * would ship that link dead (`linkedSkillIds`). Sorted and deduped for marker stability.
188
+ */
189
+ function parseExcludeSkills(value) {
190
+ const s = value.trim();
191
+ if (s === 'none' || s === '') return [];
192
+ const known = knownSkillIds();
193
+ const linked = linkedSkillIds();
194
+ const out = [];
195
+ for (const name of s.split(',').map((x) => x.trim()).filter(Boolean)) {
196
+ if (!known.includes(name)) {
197
+ die(2, `argument --exclude-skills: unknown skill '${name}' (choose from `
198
+ + `${known.filter((k) => !linked.includes(k)).join(', ')}, or 'none')`);
199
+ }
200
+ if (linked.includes(name)) {
201
+ die(2, `argument --exclude-skills: '${name}' cannot be excluded — the harness links to it `
202
+ + `by path (protected: ${linked.join(', ')})`);
203
+ }
204
+ out.push(name);
205
+ }
206
+ return [...new Set(out)].sort();
207
+ }
208
+
180
209
  /**
181
210
  * `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
182
211
  *
@@ -271,6 +300,7 @@ export function parseArgs(argv, defaults) {
271
300
  trust: defaults.trust,
272
301
  doctrines: defaults.doctrines,
273
302
  excludeRules: defaults.excludeRules,
303
+ excludeSkills: defaults.excludeSkills ?? [],
274
304
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
275
305
  syncThemes: false, validateOnly: false, verbose: false,
276
306
  };
@@ -314,6 +344,7 @@ export function parseArgs(argv, defaults) {
314
344
  // already the array `configDefaults` built, and re-parsing an array would stringify it.
315
345
  if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
316
346
  if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
347
+ if (typeof args.excludeSkills === 'string') args.excludeSkills = parseExcludeSkills(args.excludeSkills);
317
348
  return args;
318
349
  }
319
350
 
@@ -596,16 +596,17 @@ const PROJECT_EMITS = {
596
596
  * mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
597
597
  * carry straight past the parameter default.
598
598
  */
599
- function axesCfg({ posture, mode, trust, doctrines, excludeRules }) {
599
+ function axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }) {
600
600
  return makeCfg({
601
601
  ...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
602
602
  ...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
603
+ ...(excludeSkills ? { excludeSkills } : {}),
603
604
  });
604
605
  }
605
606
 
606
607
  export function emitProjectInto(host, {
607
608
  theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
608
- doctrines = null, excludeRules = null,
609
+ doctrines = null, excludeRules = null, excludeSkills = null,
609
610
  }) {
610
611
  const emit = PROJECT_EMITS[host];
611
612
  // The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
@@ -613,7 +614,7 @@ export function emitProjectInto(host, {
613
614
  // edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
614
615
  // checked an all-packs render nobody asked for.
615
616
  return withPlatformNewlines(() => emit(
616
- axesCfg({ posture, mode, trust, doctrines, excludeRules }), { theme, footprint, root }, out,
617
+ axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }), { theme, footprint, root }, out,
617
618
  ));
618
619
  }
619
620
 
@@ -627,15 +628,15 @@ export function emitProjectInto(host, {
627
628
  */
628
629
  export function buildInto({
629
630
  theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
630
- excludeRules = null,
631
+ excludeRules = null, excludeSkills = null,
631
632
  }) {
632
- return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules }),
633
+ return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }),
633
634
  theme, out, { footprint, nativeCatalog: false }));
634
635
  }
635
636
 
636
637
  export function emitGlobalInto(host, {
637
638
  theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
638
- excludeRules = null,
639
+ excludeRules = null, excludeSkills = null,
639
640
  }) {
640
641
  // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
641
642
  // raising, because the host comes from a marker file a user can edit.
@@ -651,7 +652,7 @@ export function emitGlobalInto(host, {
651
652
  // edited on every install that chose a register, narrowed its packs or excluded a rule —
652
653
  // the same scar the footprint left in `diffCollect`, one axis over each time.
653
654
  return withPlatformNewlines(() => emit(
654
- axesCfg({ posture, mode, trust, doctrines, excludeRules }),
655
+ axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }),
655
656
  { theme, footprint, root: null, cfgDir }, out,
656
657
  ));
657
658
  }
@@ -40,7 +40,7 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
40
40
  import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
41
41
  import {
42
42
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
43
- excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
43
+ excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
44
44
  readMaybe, themeOfDir, trustOfDir,
45
45
  } from '../hosts/installs.mjs';
46
46
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
@@ -97,7 +97,7 @@ function sysExit(msg) {
97
97
  const BUILD_FORWARD = [
98
98
  ['theme', '--theme'], ['emit', '--emit'], ['footprint', '--footprint'],
99
99
  ['posture', '--posture'], ['mode', '--mode'], ['trust', '--trust'], ['doctrines', '--doctrines'],
100
- ['excludeRules', '--exclude-rules'], ['out', '--out'], ['root', '--root'],
100
+ ['excludeRules', '--exclude-rules'], ['excludeSkills', '--exclude-skills'], ['out', '--out'], ['root', '--root'],
101
101
  ['configDir', '--config-dir'],
102
102
  ];
103
103
 
@@ -140,7 +140,7 @@ export function cmdBuild(args) {
140
140
  */
141
141
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
142
142
  posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
143
- excludeRules = null, trust = DEFAULT_PRESET) {
143
+ excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null) {
144
144
  const argv = ['--theme', theme, '--emit', emit];
145
145
  if (!emit.endsWith('-global')) {
146
146
  if (out) argv.push('--out', out);
@@ -164,6 +164,10 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
164
164
  if (Array.isArray(excludeRules)) {
165
165
  argv.push('--exclude-rules', excludeRules.length ? excludeRules.join(',') : 'none');
166
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(','));
167
171
  return argv;
168
172
  }
169
173
 
@@ -217,9 +221,11 @@ export function installProfile(host, scope, root) {
217
221
  // dropped it would quietly hand back every rule its owner switched off, and `process 5` is
218
222
  // one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
219
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);
220
226
  const out = scope === 'global' ? null : root;
221
227
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
222
- PACK_ORDER, excludeRules, trust);
228
+ PACK_ORDER, excludeRules, trust, excludeSkills);
223
229
  // A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
224
230
  // honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
225
231
  // same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
@@ -227,7 +233,7 @@ export function installProfile(host, scope, root) {
227
233
  if (scope === 'global') argv.push('--config-dir', root);
228
234
  return {
229
235
  host, scope, root, state: installState(root, host, scope),
230
- emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, argv,
236
+ emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, excludeSkills, argv,
231
237
  };
232
238
  }
233
239
 
@@ -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
@@ -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
  }
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"
@@ -96,11 +96,22 @@ const NATIVE_ROOT = {
96
96
  openclaude: ['AGENTS.md', 'CLAUDE.md'],
97
97
  };
98
98
 
99
- // Eager injection budget: a root README of 40k chars was going out whole, every session and
100
- // every compaction, and cost more than the harness itself. Per-file cut at the last line
101
- // break under the cap; files that would push the total over the budget are listed lazy.
102
- const EAGER_FILE_BYTES = 16 * 1024;
103
- 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;
104
115
 
105
116
  // The files the root instruction file names for session start, relative to the HARNESS dir
106
117
  // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
@@ -331,14 +342,58 @@ export function resolveContextSets(root, hookRoot = null) {
331
342
  }
332
343
 
333
344
  /**
334
- * Cut `text` at the last line break under EAGER_FILE_BYTES and say where the rest is — one rule
335
- * 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.
336
348
  */
337
- function capEager(text, where) {
338
- if (text.length <= EAGER_FILE_BYTES) return text;
339
- const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
340
- return `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
341
- + `[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;
342
397
  }
343
398
 
344
399
  export function cmdContext(args) {
@@ -379,51 +434,46 @@ export function cmdContext(args) {
379
434
  }
380
435
 
381
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.
382
439
  let spent = 0;
440
+ const push = (...ls) => { for (const l of ls) { lines.push(l); spent += cost(l); } };
383
441
  if (session.length) {
384
- lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
442
+ push('=== SESSION FILES \u2014 your harness files, injected at session start '
385
443
  + '(untouched seeds skipped) ===', '');
386
444
  for (const s of session) {
387
- const text = capEager(s.text, `${s.abs} on demand`);
388
- spent += text.length;
389
- 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`), '');
390
448
  }
391
449
  }
392
450
  if (eager.length || lazy.length) {
393
- lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
451
+ push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
394
452
  }
395
453
  const demoted = [];
396
454
  for (const entry of eager) {
397
455
  const p = entry.path === undefined ? '' : entry.path;
398
456
  const desc = entry.description === undefined ? '' : entry.description;
399
457
  const target = path.isAbsolute(p) ? p : path.join(root, p);
458
+ const head = `----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`;
400
459
  let text;
401
460
  try {
402
461
  text = readText(target).replace(/\n+$/, '');
403
462
  } catch (e) {
404
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
405
- `[context] MISSING eager file: ${asOsError(e, target)}`, '');
463
+ push(head, `[context] MISSING eager file: ${asOsError(e, target)}`, '');
406
464
  continue;
407
465
  }
408
- text = capEager(text, `${disp(p, root)} on demand for the rest`);
409
- if (spent + text.length > EAGER_TOTAL_BYTES) {
410
- demoted.push(entry);
466
+ if (spent + cost(head) + cost(text) + cost('') > OUTPUT_BUDGET - LAZY_RESERVE) {
467
+ demoted.push({ ...entry, over: true });
411
468
  continue;
412
469
  }
413
- spent += text.length;
414
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
470
+ push(head, text, '');
415
471
  }
416
472
 
417
473
  if (lazy.length || demoted.length) {
418
- lines.push('--- Lazy entries (load only when the task needs them) ---');
419
- for (const entry of [...lazy, ...demoted]) {
420
- const p = entry.path === undefined ? '' : entry.path;
421
- const desc = entry.description === undefined ? '' : entry.description;
422
- const over = demoted.includes(entry)
423
- ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
424
- lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
425
- }
426
- 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('')), '');
427
477
  }
428
478
 
429
479
  // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
@@ -35,7 +35,7 @@
35
35
  import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
- import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames, knownSkillIds } from '../build/source.mjs';
39
39
  import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
41
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
@@ -408,6 +408,28 @@ export function excludedRulesOfDir(d) {
408
408
  }, []);
409
409
  }
410
410
 
411
+ /** `Excluded skills: bruno-test-writer, daydream` — written only when something is excluded. */
412
+ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
413
+
414
+ /**
415
+ * The skills a DEPLOYED install left out, read back so `upgrade`, `rebuild-all` and `diff`
416
+ * re-render the same set. Same semantics as `excludedRulesOfDir`: a missing line is `[]`
417
+ * (nothing excluded), and a name this checkout does not ship condemns the whole line to `[]`
418
+ * — re-admitting a skill is the safe direction, and the flag would refuse the name anyway.
419
+ */
420
+ export function excludedSkillsOfDir(d) {
421
+ return firstCarrier(d, (carrierPath) => {
422
+ const text = readMaybe(carrierPath);
423
+ if (text === null) return undefined;
424
+ const m = EXCLUDED_SKILLS_RE.exec(text);
425
+ if (!m) return undefined;
426
+ const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
427
+ const known = knownSkillIds();
428
+ if (!names.every((n) => known.includes(n))) return [];
429
+ return [...new Set(names)].sort();
430
+ }, []);
431
+ }
432
+
411
433
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
412
434
 
413
435
  /** `_harness_mcp._claude_read_manifest`. */
@@ -554,7 +576,7 @@ export function installTargets() {
554
576
  export function installedDefaults() {
555
577
  const found = {
556
578
  theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
557
- excludeRules: null,
579
+ excludeRules: null, excludeSkills: null,
558
580
  };
559
581
  const candidates = [];
560
582
  for (const { host, configDir } of HOSTS) {
@@ -596,6 +618,7 @@ export function installedDefaults() {
596
618
  // pre-selects. The cost of that choice is that "excludes nothing" and "no carrier" are
597
619
  // indistinguishable here, which is exactly what the marker itself already says.
598
620
  if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
621
+ if (!found.excludeSkills?.length) found.excludeSkills = excludedSkillsOfDir(base);
599
622
  }
600
623
  return found;
601
624
  }
@@ -309,6 +309,8 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
309
309
  }
310
310
  const written = [];
311
311
  for (const [name, skill] of Object.entries(COMMAND_SET)) {
312
+ // A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
313
+ if ((cfg.excludeSkills ?? []).includes(skill)) continue;
312
314
  const text = byName.get(skill);
313
315
  if (text === undefined) {
314
316
  throw new Error(`COMMAND_SET: /${name} runs skill '${skill}', which src/skills/ does not have`);
@@ -44,7 +44,8 @@ import {
44
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
45
45
  } from '../hosts/hosts.mjs';
46
46
  import {
47
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, modeOfDir, postureOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
48
+ footprintOfDir, modeOfDir, postureOfDir,
48
49
  trustOfDir,
49
50
  readJsonMaybe, readMaybe, themeOfDir,
50
51
  } from '../hosts/installs.mjs';
@@ -180,6 +181,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
180
181
  // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
181
182
  // carrier as drift: `expected` would re-state the rule the deployment took away.
182
183
  excludeRules: excludedRulesOfDir(dir),
184
+ // The excluded skills, or every skill the install left out reads as deleted.
185
+ excludeSkills: excludedSkillsOfDir(dir),
183
186
  }));
184
187
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
185
188
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -370,7 +370,8 @@ export function statusLines(d, color = false) {
370
370
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
371
371
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
372
372
  rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
373
- packs, excl].join(` ${DOT} `) + where]);
373
+ packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : [])]
374
+ .join(` ${DOT} `) + where]);
374
375
  }
375
376
 
376
377
  const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
@@ -32,7 +32,8 @@ import { isDir, isFile, rglob, withTempDir, within } from './scan.mjs';
32
32
  export const VALIDATE_USAGE = [
33
33
  'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
34
  ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
- ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES] [-v]',
35
+ ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
36
+ ' [--exclude-skills SKILLS] [-v]',
36
37
  '',
37
38
  "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
38
39
  "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
@@ -127,6 +128,7 @@ export function cmdValidate(args) {
127
128
  const opts = {
128
129
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
129
130
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
131
+ excludeSkills: args.excludeSkills,
130
132
  };
131
133
  let scanDirs;
132
134
  try {
@@ -277,11 +277,18 @@ export function collectSetupLines() {
277
277
  // `setupBuildArgs` spell `--exclude-rules none`, and that string would land in the
278
278
  // "About to run:" line every recorded wizard probe compares byte for byte.
279
279
  const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
280
+ // Skills left out are preserved the same way, and elide when there are none.
281
+ const excludeSkills = inst.excludeSkills?.length ? inst.excludeSkills : null;
280
282
  printOut(`\nAbout to run: geneseed build ${
281
283
  setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
282
- excludeRules).join(' ')}\n`);
284
+ excludeRules, undefined, excludeSkills).join(' ')}\n`);
283
285
  if (!confirm('Proceed?', true)) return null;
284
- return { theme, posture, mode, doctrines, excludeRules, emit, out, root, footprint };
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.
288
+ return {
289
+ theme, posture, mode, doctrines, excludeRules, ...(excludeSkills ? { excludeSkills } : {}),
290
+ emit, out, root, footprint,
291
+ };
285
292
  }
286
293
 
287
294
  // --------------------------------------------------------------------------------------
@@ -463,7 +470,7 @@ export function setupLines() {
463
470
  }
464
471
  const {
465
472
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
466
- doctrines = null, excludeRules = null,
473
+ doctrines = null, excludeRules = null, excludeSkills = null,
467
474
  } = sel;
468
475
  if (emit === 'opencode-global') {
469
476
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -482,7 +489,7 @@ export function setupLines() {
482
489
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
483
490
  // one command and run another.
484
491
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
485
- doctrineOptions().map(([n]) => n), excludeRules);
492
+ doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills);
486
493
  printOut(`Running: geneseed build ${argv.join(' ')}\n`);
487
494
  const rc = driverMain(argv);
488
495
  if (rc !== 0) {
@@ -617,7 +617,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }) {
617
617
  if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
618
618
  const p = installProfile('opencode', 'global', String(dir));
619
619
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
620
- undefined, p.excludeRules, p.trust).slice(4);
620
+ undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
621
621
  }
622
622
 
623
623
  /**
@@ -46,7 +46,8 @@ import {
46
46
  opencodeConfigDir, resolvePath,
47
47
  } from '../hosts/hosts.mjs';
48
48
  import {
49
- EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState,
49
+ EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
50
+ installState,
50
51
  installTargets, modeOfDir, postureOfDir, trustOfDir,
51
52
  } from '../hosts/installs.mjs';
52
53
  import {
@@ -550,6 +551,7 @@ export function apiRestore(state, files) {
550
551
  // The excluded rules for the same boundary reason: without them a restored AGENT.md
551
552
  // re-states a rule whose hook gate the install deliberately left unwired.
552
553
  excludeRules: excludedRulesOfDir(target),
554
+ excludeSkills: excludedSkillsOfDir(target),
553
555
  }));
554
556
  for (const raw of (isTruthy(files) ? files : [])) {
555
557
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -622,7 +624,7 @@ export function apiInstallCmd(state, body) {
622
624
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
623
625
  const out = scope === 'global' ? null : String(root);
624
626
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
625
- PACK_ORDER, excludeRules, trust);
627
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
626
628
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
627
629
  }
628
630
 
@@ -716,6 +718,6 @@ export function apiDeployCmd(state, body) {
716
718
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
717
719
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
718
720
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
719
- PACK_ORDER, excludeRules, trust);
721
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
720
722
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
721
723
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "3.15.0",
4
+ "version": "3.16.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/src/AGENT.md.tmpl CHANGED
@@ -295,7 +295,7 @@ inventory — that list is the catalogue and it is authoritative. Each written
295
295
  procedure is in [`{{DIR_SKILLS}}/`]({{DIR_SKILLS}}/) and the host loads it on
296
296
  demand.
297
297
  <!-- CATALOG:end -->
298
-
298
+ {{EXCLUDED_SKILLS_LINE}}
299
299
  When a task repeats and no {{SKILL}} covers it, forge one through the
300
300
  [skill-forge]({{DIR_SKILLS}}/skill-forge.md) {{SKILL}}: copy
301
301
  [`{{DIR_SKILLS}}/_template.md`]({{DIR_SKILLS}}/_template.md), keep it to one coherent