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 +12 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +22 -2
- package/docs/concepts/exclusions.md +21 -9
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +1 -1
- package/docs/token-footprint.md +10 -7
- package/harness.config.json +1 -1
- package/js/build/args.mjs +34 -3
- package/js/build/driver.mjs +8 -7
- package/js/build/generate.mjs +11 -5
- package/js/build/render.mjs +33 -3
- package/js/build/source.mjs +37 -1
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +83 -33
- package/js/hosts/installs.mjs +25 -2
- package/js/hosts/opencode.mjs +2 -0
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/status.mjs +2 -1
- package/js/inspect/validate.mjs +3 -1
- package/js/maintain/setup.mjs +11 -4
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +5 -3
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
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.
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
package/docs/token-footprint.md
CHANGED
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
package/harness.config.json
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
package/js/build/driver.mjs
CHANGED
|
@@ -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
|
}
|
package/js/build/generate.mjs
CHANGED
|
@@ -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
|
|
package/js/build/render.mjs
CHANGED
|
@@ -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
|
|
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
|
package/js/build/source.mjs
CHANGED
|
@@ -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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
335
|
-
*
|
|
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
|
|
338
|
-
if (text
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
388
|
-
spent
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
`[context] MISSING eager file: ${asOsError(e, target)}`, '');
|
|
463
|
+
push(head, `[context] MISSING eager file: ${asOsError(e, target)}`, '');
|
|
406
464
|
continue;
|
|
407
465
|
}
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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.
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -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
|
}
|
package/js/hosts/opencode.mjs
CHANGED
|
@@ -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`);
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -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,
|
|
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
|
package/js/inspect/status.mjs
CHANGED
|
@@ -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
|
|
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)));
|
package/js/inspect/validate.mjs
CHANGED
|
@@ -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]
|
|
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 {
|
package/js/maintain/setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/js/maintain/update.mjs
CHANGED
|
@@ -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
|
/**
|
package/js/web/actions.mjs
CHANGED
|
@@ -46,7 +46,8 @@ import {
|
|
|
46
46
|
opencodeConfigDir, resolvePath,
|
|
47
47
|
} from '../hosts/hosts.mjs';
|
|
48
48
|
import {
|
|
49
|
-
EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir,
|
|
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.
|
|
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
|