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