geneseed 3.15.0 → 3.17.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 +35 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +36 -8
- package/adapters/opencode/plugins/geneseed-guard.js +28 -0
- package/bin/geneseed.mjs +17 -0
- package/docs/concepts/exclusions.md +21 -9
- package/docs/concepts/hooks.md +1 -0
- package/docs/concepts/loop-notes.md +3 -0
- package/docs/concepts/loops.md +11 -0
- package/docs/guides/headless.md +1 -1
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +3 -3
- package/docs/guides/read-non-markdown.md +1 -1
- package/docs/reference/plugin-context.md +2 -2
- package/docs/reference/plugin-guard.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 +112 -56
- package/js/hosts/hooks.mjs +34 -1
- package/js/hosts/installs.mjs +25 -2
- package/js/hosts/opencode.mjs +2 -0
- package/js/hosts/settings.mjs +2 -1
- package/js/inspect/checks-constitution.mjs +2 -2
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/status.mjs +2 -1
- package/js/inspect/validate.mjs +3 -1
- package/js/loop/graph.mjs +2 -1
- package/js/loop/state.mjs +30 -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/src/agents/docs.md +5 -0
- package/src/bricks/apply.md +3 -0
- package/src/bricks/docs-update.md +2 -0
- package/src/bricks/test.md +17 -7
- package/src/doctrines/process.md +9 -5
- package/src/loops/api-endpoint.json +1 -1
- package/src/loops/bugfix.json +1 -1
- package/src/loops/ci-repair.json +1 -1
- package/src/loops/deps-upgrade.json +1 -1
- package/src/loops/enforce-architecture-rule.json +1 -1
- package/src/loops/feature.json +1 -1
- package/src/loops/fix-flaky-tests.json +1 -1
- package/src/loops/legacy-refactor.json +1 -1
- package/src/loops/legacy-tests.json +1 -1
- package/src/loops/refactor.json +1 -1
- package/src/loops/remove-dead-code.json +1 -1
- package/src/loops/spec-first-feature.json +1 -1
- package/src/loops/tdd.json +1 -1
- package/src/loops/update-contract.json +1 -1
- package/src/skills/document-project.md +91 -22
- package/web/dist/assets/{Activity-kpWxd70F.js → Activity-DonKN1pi.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Ccr942VL.js → ActivityDetail-e47a3cdE.js} +1 -1
- package/web/dist/assets/{Dashboard-D1lE7jcg.js → Dashboard-Cr7i0PsO.js} +2 -2
- package/web/dist/assets/{Docs-BHyaggFy.js → Docs-Lne3nuzD.js} +1 -1
- package/web/dist/assets/{GreenhouseView-Sc_UDMOE.js → GreenhouseView-4CbIuTpl.js} +1 -1
- package/web/dist/assets/{Installs-Gw-pJ3Zi.js → Installs-CIzbV1Ks.js} +1 -1
- package/web/dist/assets/{JournalView-_gxpsxXi.js → JournalView-BlBfuvzj.js} +1 -1
- package/web/dist/assets/{Laws-S9fq1r9T.js → Laws-B-pjcjHr.js} +1 -1
- package/web/dist/assets/{Library-MBxadRzn.js → Library-x8Nzg6yq.js} +1 -1
- package/web/dist/assets/{Loops-Da_JhlzM.js → Loops-DIOxW1xH.js} +1 -1
- package/web/dist/assets/{OperatorHudView--TkvDybH.js → OperatorHudView-D7S7z7CJ.js} +1 -1
- package/web/dist/assets/{Personal-CTXKHVbV.js → Personal-Ws3v35AB.js} +1 -1
- package/web/dist/assets/{Tabs-B0cdf8k6.js → Tabs-6Z1eziAq.js} +1 -1
- package/web/dist/assets/{drawable-BbWQxe82.js → drawable-ATNeYKbT.js} +1 -1
- package/web/dist/assets/{index-ujKG1ZNf.js → index-CDg5XUhG.js} +2 -2
- package/web/dist/index.html +1 -1
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"
|
|
@@ -32,7 +32,8 @@ const _ERRNO = {
|
|
|
32
32
|
function asOsError(e, filename) {
|
|
33
33
|
const hit = _ERRNO[e && e.code];
|
|
34
34
|
if (!hit) return String((e && e.message) || e);
|
|
35
|
-
//
|
|
35
|
+
// The `[Errno N] text: 'file'` shape this hook has always printed: the filename is quoted,
|
|
36
|
+
// so a Windows path comes out with its backslashes doubled.
|
|
36
37
|
return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
|
|
37
38
|
.replace(/\\"/g, '"')}`;
|
|
38
39
|
}
|
|
@@ -57,7 +58,7 @@ function fnTranslate(pat) {
|
|
|
57
58
|
if (pat[j] === '!') j += 1;
|
|
58
59
|
if (pat[j] === ']') j += 1;
|
|
59
60
|
while (j < pat.length && pat[j] !== ']') j += 1;
|
|
60
|
-
// An unterminated `[` is a LITERAL bracket
|
|
61
|
+
// An unterminated `[` is a LITERAL bracket (fnmatch semantics), not an error.
|
|
61
62
|
if (j >= pat.length) { out += '\\['; continue; }
|
|
62
63
|
let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
|
|
63
64
|
if (body.startsWith('!')) body = `^${body.slice(1)}`;
|
|
@@ -77,9 +78,14 @@ function fnTranslate(pat) {
|
|
|
77
78
|
*/
|
|
78
79
|
let HOST = 'claude';
|
|
79
80
|
|
|
80
|
-
// Kept in step with adapters/opencode/plugins/geneseed-context.js
|
|
81
|
+
// Kept in step with adapters/opencode/plugins/geneseed-context.js.
|
|
81
82
|
const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
|
|
82
|
-
'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
|
|
83
|
+
'README.md', 'README.adoc', 'CONTRIBUTING.md', 'CONTRIBUTING.adoc', 'user-rules.md', 'PROFILE.md'];
|
|
84
|
+
// Project docs are Markdown or AsciiDoc: the document-project skill writes AsciiDoc by default
|
|
85
|
+
// when a project has no docs yet, so a loader blind to `.adoc` would hide every doc it wrote.
|
|
86
|
+
// Agent-runtime files (AGENTS.md, CLAUDE.md, specs) stay Markdown whatever the project uses.
|
|
87
|
+
const DOC_EXT = new Set(['.md', '.adoc']);
|
|
88
|
+
const isDoc = (name) => DOC_EXT.has(path.extname(name).toLowerCase());
|
|
83
89
|
const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
|
|
84
90
|
const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
|
|
85
91
|
'target', '.venv', '__pycache__', '.opencode', '.harness']);
|
|
@@ -96,11 +102,22 @@ const NATIVE_ROOT = {
|
|
|
96
102
|
openclaude: ['AGENTS.md', 'CLAUDE.md'],
|
|
97
103
|
};
|
|
98
104
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
// The whole payload's budget, in characters. Claude Code does not hand a hook's stdout to the
|
|
106
|
+
// model past roughly 10k characters: it saves the output to a file and the model sees a 2 KB
|
|
107
|
+
// preview, so everything after the cut is lost without a word. Observed 2026-10 (a 25.6 KB and
|
|
108
|
+
// a 16.5 KB SessionStart output, both persisted); the exact threshold is not documented, hence
|
|
109
|
+
// the margin. The old 48 KB eager budget never bound — the host's cap always cut first.
|
|
110
|
+
// Priority inside the budget: session files (the user's own), then eager docs (whole, or
|
|
111
|
+
// listed lazy), then the lazy listing, folded per directory and cut with a count.
|
|
112
|
+
const OUTPUT_BUDGET = 9000;
|
|
113
|
+
// Kept back from the eager docs so the lazy listing always has room to say what exists.
|
|
114
|
+
const LAZY_RESERVE = 1500;
|
|
115
|
+
// A directory with this many undescribed lazy files is listed once, with its count.
|
|
116
|
+
const LAZY_FOLD_AT = 3;
|
|
117
|
+
// What a line costs against the budget: its characters plus a terminator counted as two,
|
|
118
|
+
// because stdout goes out as \r\n on Windows — a 144-line listing overran by 137 before this.
|
|
119
|
+
// Multi-line text pays one more per inner line break, for the same reason.
|
|
120
|
+
const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
|
|
104
121
|
|
|
105
122
|
// The files the root instruction file names for session start, relative to the HARNESS dir
|
|
106
123
|
// (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
|
|
@@ -171,9 +188,8 @@ export function globalHookStandingDown(hookRoot, cwd) {
|
|
|
171
188
|
|
|
172
189
|
/** `_disp` — relative to the repo root when it sits under it, else verbatim. */
|
|
173
190
|
function disp(pathStr, root) {
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// print a DIFFERENT string rather than the same one.
|
|
191
|
+
// A path on another drive has no relative form, so it is printed as given. `path.relative`
|
|
192
|
+
// would silently return an absolute path instead — a different string, not the same one.
|
|
177
193
|
if (path.parse(path.resolve(pathStr)).root.toLowerCase()
|
|
178
194
|
!== path.parse(path.resolve(root)).root.toLowerCase()) {
|
|
179
195
|
return pathStr;
|
|
@@ -182,18 +198,18 @@ function disp(pathStr, root) {
|
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
|
|
185
|
-
function
|
|
201
|
+
function rglobDocs(dir, acc = []) {
|
|
186
202
|
for (const name of listDir(dir)) {
|
|
187
203
|
const full = path.join(dir, name);
|
|
188
|
-
if (isDir(full))
|
|
189
|
-
else if (isFile(full) &&
|
|
204
|
+
if (isDir(full)) rglobDocs(full, acc);
|
|
205
|
+
else if (isFile(full) && isDoc(name)) acc.push(full);
|
|
190
206
|
}
|
|
191
207
|
return acc;
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
/**
|
|
195
211
|
* `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
|
|
196
|
-
* Root entry docs are eager; other root
|
|
212
|
+
* Root entry docs are eager; other root docs (.md or .adoc), the doc trees and monorepo package
|
|
197
213
|
* READMEs are lazy.
|
|
198
214
|
*/
|
|
199
215
|
export function discoverContext(root, host = HOST) {
|
|
@@ -206,12 +222,12 @@ export function discoverContext(root, host = HOST) {
|
|
|
206
222
|
const name = path.basename(full);
|
|
207
223
|
if (native.includes(name)) continue;
|
|
208
224
|
if (EAGER_ROOT.includes(name)) eager.set(full, null);
|
|
209
|
-
else if (
|
|
225
|
+
else if (isDoc(name)) lazy.set(full, null);
|
|
210
226
|
}
|
|
211
227
|
for (const d of LAZY_DIRS) {
|
|
212
228
|
const sub = path.join(root, d);
|
|
213
229
|
if (!isDir(sub)) continue;
|
|
214
|
-
for (const md of sortPaths(
|
|
230
|
+
for (const md of sortPaths(rglobDocs(sub))) {
|
|
215
231
|
const parts = path.relative(root, md).split(/[\\/]/);
|
|
216
232
|
if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
|
|
217
233
|
if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
|
|
@@ -222,8 +238,10 @@ export function discoverContext(root, host = HOST) {
|
|
|
222
238
|
if (!isDir(base)) continue;
|
|
223
239
|
for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
|
|
224
240
|
if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
|
|
225
|
-
const
|
|
226
|
-
|
|
241
|
+
for (const name of ['README.md', 'README.adoc']) {
|
|
242
|
+
const readme = path.join(pkg, name);
|
|
243
|
+
if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
|
|
244
|
+
}
|
|
227
245
|
}
|
|
228
246
|
}
|
|
229
247
|
return [
|
|
@@ -270,19 +288,18 @@ export function resolveContextSets(root, hookRoot = null) {
|
|
|
270
288
|
let extend;
|
|
271
289
|
try {
|
|
272
290
|
let data = JSON.parse(raw);
|
|
273
|
-
// Valid JSON of the wrong SHAPE,
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
291
|
+
// Valid JSON of the wrong SHAPE (null, a number, an array) is an empty manifest, not a
|
|
292
|
+
// syntax error. The guard is written out rather than left to fall out of the language:
|
|
293
|
+
// without it, `null.context` throws into the catch below and a file a user really does
|
|
294
|
+
// hand-edit is reported as invalid JSON when it is not.
|
|
277
295
|
data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
278
296
|
entries = data.context || [];
|
|
279
297
|
if (!Array.isArray(entries)) entries = [];
|
|
280
298
|
extend = Boolean(data.extend);
|
|
281
299
|
} catch {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// WHICH decoder found it is what makes two implementations impossible.
|
|
300
|
+
// The decoder's own message is not quoted: its wording and offset are engine details,
|
|
301
|
+
// and V8 supplies no offset at all for the commonest shapes. Reporting the file is what
|
|
302
|
+
// the user needs.
|
|
286
303
|
err(`[context] ${manifest} is not valid JSON — no context was loaded `
|
|
287
304
|
+ '(fix the syntax, then re-run).\n');
|
|
288
305
|
return [[], [], String(manifest)];
|
|
@@ -331,14 +348,58 @@ export function resolveContextSets(root, hookRoot = null) {
|
|
|
331
348
|
}
|
|
332
349
|
|
|
333
350
|
/**
|
|
334
|
-
* Cut
|
|
335
|
-
*
|
|
351
|
+
* Cut a session file at the last line break that fits `room` and say where the rest is. Only
|
|
352
|
+
* session files are cut: they are the user's own rules, and half of them beats none. An eager
|
|
353
|
+
* doc that does not fit is listed lazy instead — a README cut mid-section misleads.
|
|
336
354
|
*/
|
|
337
|
-
function
|
|
338
|
-
if (text
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
355
|
+
function capToRoom(text, room, where) {
|
|
356
|
+
if (cost(text) <= room) return text;
|
|
357
|
+
const marker = `\n[context] truncated (session budget) — read ${where}`;
|
|
358
|
+
let cut = text.length;
|
|
359
|
+
do cut = text.lastIndexOf('\n', cut - 1);
|
|
360
|
+
while (cut > 0 && cost(text.slice(0, cut) + marker) > room);
|
|
361
|
+
return `${cut > 0 ? text.slice(0, cut) : ''}${marker}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The lazy listing: a described entry gets its own line; undescribed entries sharing a parent
|
|
366
|
+
* directory are folded into one `dir/ — N docs` line once there are LAZY_FOLD_AT of them (a
|
|
367
|
+
* `docs/` tree of 186 files was 186 lines). Lines past `room` become one count line.
|
|
368
|
+
*/
|
|
369
|
+
function lazyLines(entries, root, room) {
|
|
370
|
+
const byDir = new Map();
|
|
371
|
+
for (const e of entries) {
|
|
372
|
+
const dir = path.dirname(disp(e.path, root));
|
|
373
|
+
// Root files stay listed one by one: there are few, and each name says what it is.
|
|
374
|
+
if (e.description || e.over || dir === '.') continue;
|
|
375
|
+
byDir.set(dir, (byDir.get(dir) || 0) + 1);
|
|
376
|
+
}
|
|
377
|
+
const lines = [];
|
|
378
|
+
const folded = new Set();
|
|
379
|
+
for (const e of entries) {
|
|
380
|
+
const shown = disp(e.path, root);
|
|
381
|
+
const dir = path.dirname(shown);
|
|
382
|
+
if (!e.description && !e.over && byDir.get(dir) >= LAZY_FOLD_AT) {
|
|
383
|
+
if (folded.has(dir)) continue;
|
|
384
|
+
folded.add(dir);
|
|
385
|
+
lines.push(` - ${dir.replace(/\\/g, '/')}/ — ${byDir.get(dir)} docs`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const why = e.over ? ' (eager, but over the session budget — read on demand)' : '';
|
|
389
|
+
lines.push(` - ${shown}${e.description ? ` — ${e.description}` : ''}${why}`);
|
|
390
|
+
}
|
|
391
|
+
const kept = [];
|
|
392
|
+
let used = 0;
|
|
393
|
+
for (const [i, l] of lines.entries()) {
|
|
394
|
+
const tail = ` - … ${lines.length - i} more not listed (session budget)`;
|
|
395
|
+
if (used + cost(l) + (i < lines.length - 1 ? cost(tail) : 0) > room) {
|
|
396
|
+
kept.push(tail);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
kept.push(l);
|
|
400
|
+
used += cost(l);
|
|
401
|
+
}
|
|
402
|
+
return kept;
|
|
342
403
|
}
|
|
343
404
|
|
|
344
405
|
export function cmdContext(args) {
|
|
@@ -379,51 +440,46 @@ export function cmdContext(args) {
|
|
|
379
440
|
}
|
|
380
441
|
|
|
381
442
|
const lines = [];
|
|
443
|
+
// `spent` counts every character pushed, headers included: the budget is the host's cap on
|
|
444
|
+
// the whole payload, not on the file bodies alone.
|
|
382
445
|
let spent = 0;
|
|
446
|
+
const push = (...ls) => { for (const l of ls) { lines.push(l); spent += cost(l); } };
|
|
383
447
|
if (session.length) {
|
|
384
|
-
|
|
448
|
+
push('=== SESSION FILES \u2014 your harness files, injected at session start '
|
|
385
449
|
+ '(untouched seeds skipped) ===', '');
|
|
386
450
|
for (const s of session) {
|
|
387
|
-
const
|
|
388
|
-
spent
|
|
389
|
-
|
|
451
|
+
const head = `----- ${s.rel} -----`;
|
|
452
|
+
push(head, capToRoom(s.text, OUTPUT_BUDGET - LAZY_RESERVE - spent - cost(head) - cost(''),
|
|
453
|
+
`${s.abs} on demand`), '');
|
|
390
454
|
}
|
|
391
455
|
}
|
|
392
456
|
if (eager.length || lazy.length) {
|
|
393
|
-
|
|
457
|
+
push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
|
|
394
458
|
}
|
|
395
459
|
const demoted = [];
|
|
396
460
|
for (const entry of eager) {
|
|
397
461
|
const p = entry.path === undefined ? '' : entry.path;
|
|
398
462
|
const desc = entry.description === undefined ? '' : entry.description;
|
|
399
463
|
const target = path.isAbsolute(p) ? p : path.join(root, p);
|
|
464
|
+
const head = `----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`;
|
|
400
465
|
let text;
|
|
401
466
|
try {
|
|
402
467
|
text = readText(target).replace(/\n+$/, '');
|
|
403
468
|
} catch (e) {
|
|
404
|
-
|
|
405
|
-
`[context] MISSING eager file: ${asOsError(e, target)}`, '');
|
|
469
|
+
push(head, `[context] MISSING eager file: ${asOsError(e, target)}`, '');
|
|
406
470
|
continue;
|
|
407
471
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
demoted.push(entry);
|
|
472
|
+
if (spent + cost(head) + cost(text) + cost('') > OUTPUT_BUDGET - LAZY_RESERVE) {
|
|
473
|
+
demoted.push({ ...entry, over: true });
|
|
411
474
|
continue;
|
|
412
475
|
}
|
|
413
|
-
|
|
414
|
-
lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
|
|
476
|
+
push(head, text, '');
|
|
415
477
|
}
|
|
416
478
|
|
|
417
479
|
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('');
|
|
480
|
+
const title = '--- Lazy entries (load only when the task needs them) ---';
|
|
481
|
+
push(title);
|
|
482
|
+
push(...lazyLines([...lazy, ...demoted], root, OUTPUT_BUDGET - spent - cost('')), '');
|
|
427
483
|
}
|
|
428
484
|
|
|
429
485
|
// Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -201,7 +201,7 @@ const BOB_DENY_EXIT = 2;
|
|
|
201
201
|
* logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
|
|
202
202
|
* a lockout, not a safeguard, and there is no prompt through which the user could clear it.
|
|
203
203
|
*/
|
|
204
|
-
const BLOCK_RULES = new Set(['law-1', 'law-4']);
|
|
204
|
+
const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5']);
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* THE GATE LEDGER — one JSON line per ask, nothing per defer.
|
|
@@ -358,6 +358,33 @@ const SECRET_RE =
|
|
|
358
358
|
/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
359
359
|
const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* External Gate's boundary half: the checks an agent is judged by must sit outside what it can
|
|
363
|
+
* edit, or a failing check is one edit away from a passing one. A project opts in by listing
|
|
364
|
+
* them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
|
|
365
|
+
* its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
|
|
366
|
+
* writing into another reads that other repo's list. The list protects itself. Returns the entry
|
|
367
|
+
* the path falls under, or null. ponytail: a write tool only — a shell `sed -i` or redirect is
|
|
368
|
+
* not seen here; CI running the checks is the boundary that holds against that.
|
|
369
|
+
*/
|
|
370
|
+
export const SENSOR_LIST = '.geneseed/protected-checks.txt';
|
|
371
|
+
function protectedCheck(p) {
|
|
372
|
+
try {
|
|
373
|
+
const target = resolvePath(p);
|
|
374
|
+
const root = gitRootOf(path.dirname(target));
|
|
375
|
+
if (!root) return null;
|
|
376
|
+
const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
|
|
377
|
+
// `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
|
|
378
|
+
// separator of the prefix included.
|
|
379
|
+
const rel = normcase(path.relative(root, target));
|
|
380
|
+
const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ''))
|
|
381
|
+
.filter((l) => l && !l.startsWith('#'))];
|
|
382
|
+
return entries.find((e) => rel === normcase(e) || rel.startsWith(normcase(`${e}/`))) ?? null;
|
|
383
|
+
} catch {
|
|
384
|
+
return null; // no list (the common case) or an unreadable one: nothing is protected
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
361
388
|
function ruleGate(args) {
|
|
362
389
|
if (sovereignBypass(args.root)) return 0;
|
|
363
390
|
return ruleDecide(args, readPayload());
|
|
@@ -378,6 +405,12 @@ function ruleDecide(args, payload) {
|
|
|
378
405
|
+ 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
|
|
379
406
|
+ 'tracked file.');
|
|
380
407
|
}
|
|
408
|
+
const sensor = protectedCheck(p);
|
|
409
|
+
if (sensor) {
|
|
410
|
+
return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
|
|
411
|
+
+ `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
|
|
412
|
+
+ 'check: fix the code, not the check, unless the user asks for this edit.');
|
|
413
|
+
}
|
|
381
414
|
const target = ruleGateTarget(p, args.root);
|
|
382
415
|
if (!target) return 0;
|
|
383
416
|
return ask(args, 'rule-gate', 'process-1', `Geneseed (Persist Insight) \u2014 writing to `
|
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/hosts/settings.mjs
CHANGED
|
@@ -513,7 +513,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
513
513
|
SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
|
|
514
514
|
// One group, both gates: process 5's consent is a stderr line here (Bob has no ask
|
|
515
515
|
// tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
|
|
516
|
-
// `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries
|
|
516
|
+
// `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
|
|
517
|
+
// write under a project's own `.geneseed/protected-checks.txt` — the project opted in.
|
|
517
518
|
PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
|
|
518
519
|
Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
|
|
519
520
|
};
|
|
@@ -169,14 +169,14 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
|
|
|
169
169
|
/**
|
|
170
170
|
* The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
|
|
171
171
|
* hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
|
|
172
|
-
* (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
|
|
172
|
+
* (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5` in `js/hosts/hooks.mjs` and the OpenCode guard
|
|
173
173
|
* plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
|
|
174
174
|
* number-keyed — and this pin is what turns a renumber that moves one of these rules into a
|
|
175
175
|
* doctor failure instead of a gate silently guarding its neighbour.
|
|
176
176
|
*/
|
|
177
177
|
export const HOOK_PINNED = {
|
|
178
178
|
'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
|
|
179
|
-
'persist-insight': 'process 1', 'consent-before-push': 'process 5',
|
|
179
|
+
'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
/**
|
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 {
|