@event4u/agent-config 10.2.0 → 10.4.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +168 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
- package/dist/agent-src/commands/analyze.md +2 -1
- package/dist/agent-src/commands/design-system/capture.md +44 -0
- package/dist/agent-src/commands/design-system/generate.md +53 -0
- package/dist/agent-src/commands/design-system/import.md +63 -0
- package/dist/agent-src/commands/design-system.md +66 -0
- package/dist/agent-src/commands/roadmap/create.md +4 -1
- package/dist/agent-src/personas/advisors/first-principles.md +6 -0
- package/dist/agent-src/rules/active-remediation.md +53 -2
- package/dist/agent-src/rules/code-provenance.md +66 -4
- package/dist/agent-src/rules/content-quoting-floor.md +6 -0
- package/dist/agent-src/rules/design-fidelity.md +49 -3
- package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
- package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
- package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
- package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/persona.md +26 -0
- package/dist/agent-src/templates/roadmaps.md +27 -7
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/dist/agent-src/templates/skill.md +35 -1
- package/dist/cli/commands/commands.candidates.test.js +257 -0
- package/dist/cli/commands/commands.candidates.test.js.map +1 -0
- package/dist/cli/commands/commands.js +199 -0
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/main.js +1 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
- package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
- package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
- package/dist/cli-delegate/cmd_conformance.js +672 -214
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +8 -6
- package/dist/cli-delegate/cmd_session_recycle.js +5 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +127 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +9 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +8 -4
- package/dist/hooks/dispatch.js +1056 -411
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1103 -593
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
- package/docs/guidelines/design-antipatterns.md +79 -10
- package/docs/guidelines/design-fidelity-mechanics.md +120 -2
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/src/config/gate-coverage.yml +65 -10
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/review-prompt-binding-baseline.json +19 -0
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_dispatch.bash +1 -0
- package/src/scripts/_lib/design_system_import.ts +987 -0
- package/src/scripts/ai_council/clients.ts +15 -1
- package/src/scripts/check_ci_local_parity.ts +60 -6
- package/src/scripts/check_review_prompt_binding.ts +553 -0
- package/src/scripts/check_suppression_hygiene.ts +19 -3
- package/src/scripts/design_slop_rules.test.ts +37 -6
- package/src/scripts/design_slop_rules.ts +150 -22
- package/src/scripts/design_system_import.ts +264 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -1
- package/src/scripts/hook_manifest.yaml +71 -2
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +10 -0
- package/src/scripts/hooks/host_semantics.ts +2 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
- package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
- package/src/scripts/lint_design_antipattern_parity.ts +201 -0
- package/src/scripts/lint_harvest_provenance.ts +483 -0
- package/src/scripts/lint_hook_manifest.ts +2 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
- package/src/scripts/lint_skill_router_head.ts +254 -0
- package/src/scripts/report_consultation_rate.ts +126 -1
- package/src/scripts/run_skill_evals.ts +10 -5
- package/src/scripts/schemas/persona.schema.json +8 -0
- package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
- package/src/scripts/sweep_dead_scan_roots.ts +51 -0
- package/tests/fixtures/hooks/subagent_start.json +7 -0
- package/tests/fixtures/hooks/subagent_stop.json +8 -0
|
@@ -51,19 +51,40 @@
|
|
|
51
51
|
* findings under `--strict` · 2 usage or IO error. A scan that walked no rules
|
|
52
52
|
* exits 2 rather than green — a gate that scanned nothing must never read as a
|
|
53
53
|
* pass.
|
|
54
|
+
*
|
|
55
|
+
* `--self-test` proves those rejections still fire in the binary a contributor
|
|
56
|
+
* runs, not merely in the imported functions `tests/scripts/pack_reach.test.ts`
|
|
57
|
+
* exercises. `--root <dir>` exists for it: the three inputs are ordinary tree
|
|
58
|
+
* reads, so a temporary fixture repo can reproduce every verdict, which is why
|
|
59
|
+
* this gate adopts the harness rather than claiming the exemption.
|
|
54
60
|
*/
|
|
55
61
|
|
|
56
62
|
import * as fs from 'node:fs';
|
|
63
|
+
import * as os from 'node:os';
|
|
57
64
|
import * as path from 'node:path';
|
|
58
65
|
import { fileURLToPath } from 'node:url';
|
|
59
66
|
|
|
60
67
|
import { parse as parseYaml } from 'yaml';
|
|
61
68
|
|
|
69
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
70
|
+
|
|
62
71
|
const _HERE = fileURLToPath(import.meta.url);
|
|
63
72
|
const REPO = path.resolve(path.dirname(_HERE), '..', '..');
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
|
|
74
|
+
interface Roots {
|
|
75
|
+
rulesDir: string;
|
|
76
|
+
skillsDir: string;
|
|
77
|
+
packsFile: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The three inputs, resolved against `root` so a fixture tree can stand in. */
|
|
81
|
+
function rootsFor(root: string): Roots {
|
|
82
|
+
return {
|
|
83
|
+
rulesDir: path.join(root, 'src', 'rules'),
|
|
84
|
+
skillsDir: path.join(root, 'src', 'skills'),
|
|
85
|
+
packsFile: path.join(root, 'src', 'config', 'discovery', 'packs.yml'),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
67
88
|
|
|
68
89
|
export interface PackDef {
|
|
69
90
|
id: string;
|
|
@@ -212,10 +233,165 @@ export function analyze(
|
|
|
212
233
|
return findings;
|
|
213
234
|
}
|
|
214
235
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
interface Args {
|
|
237
|
+
quiet: boolean;
|
|
238
|
+
strict: boolean;
|
|
239
|
+
selfTest: boolean;
|
|
240
|
+
root: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function _parseArgs(argv: readonly string[]): Args {
|
|
244
|
+
const args: Args = { quiet: false, strict: false, selfTest: false, root: REPO };
|
|
245
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
246
|
+
const a = argv[i];
|
|
247
|
+
if (a === '--quiet') args.quiet = true;
|
|
248
|
+
else if (a === '--strict') args.strict = true;
|
|
249
|
+
else if (a === '--self-test') args.selfTest = true;
|
|
250
|
+
else if (a === '--root') {
|
|
251
|
+
// Never fall back to the real repo. A self-test whose root argument
|
|
252
|
+
// went missing would otherwise scan the live tree instead of its
|
|
253
|
+
// fixture and could still exit 0 — a green run proving nothing,
|
|
254
|
+
// which is the exact failure `_lib/gate_self_test.ts` exists for.
|
|
255
|
+
const value = argv[i + 1];
|
|
256
|
+
if (value === undefined || value.startsWith('--')) {
|
|
257
|
+
throw new Error('--root requires a directory argument');
|
|
258
|
+
}
|
|
259
|
+
args.root = value;
|
|
260
|
+
i += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return args;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build a fixture tree. `packsYml` is written verbatim; each rule and skill is
|
|
268
|
+
* a minimal frontmatter block, which is all `readRules` / `readSkillPacks`
|
|
269
|
+
* ever look at.
|
|
270
|
+
*/
|
|
271
|
+
function _fixture(
|
|
272
|
+
tmp: string,
|
|
273
|
+
name: string,
|
|
274
|
+
packsYml: string,
|
|
275
|
+
rules: ReadonlyArray<{ name: string; packs?: string[]; routes: string[] }>,
|
|
276
|
+
skills: ReadonlyArray<{ name: string; packs?: string[] }>,
|
|
277
|
+
): string {
|
|
278
|
+
const root = path.join(tmp, name);
|
|
279
|
+
const { rulesDir, skillsDir, packsFile } = rootsFor(root);
|
|
280
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
281
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
282
|
+
fs.mkdirSync(path.dirname(packsFile), { recursive: true });
|
|
283
|
+
fs.writeFileSync(packsFile, packsYml, 'utf-8');
|
|
284
|
+
const list = (key: string, values?: string[]): string =>
|
|
285
|
+
values === undefined ? '' : `${key}: [${values.join(', ')}]\n`;
|
|
286
|
+
for (const rule of rules) {
|
|
287
|
+
fs.writeFileSync(
|
|
288
|
+
path.join(rulesDir, `${rule.name}.md`),
|
|
289
|
+
`---\n${list('packs', rule.packs)}${list('routes_to', rule.routes)}---\n\n# ${rule.name}\n`,
|
|
290
|
+
'utf-8',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
for (const skill of skills) {
|
|
294
|
+
fs.mkdirSync(path.join(skillsDir, skill.name), { recursive: true });
|
|
295
|
+
fs.writeFileSync(
|
|
296
|
+
path.join(skillsDir, skill.name, 'SKILL.md'),
|
|
297
|
+
`---\n${list('packs', skill.packs)}---\n\n# ${skill.name}\n`,
|
|
298
|
+
'utf-8',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return root;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _selfTest(): number {
|
|
305
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lrspr-selftest-'));
|
|
306
|
+
const runRaw = (args: readonly string[]): number =>
|
|
307
|
+
runGateCli(REPO, 'src/scripts/lint_rule_skill_pack_reach.ts', args, REPO);
|
|
308
|
+
const run = (root: string, extra: readonly string[] = []): number =>
|
|
309
|
+
runRaw(['--root', root, '--quiet', ...extra]);
|
|
310
|
+
|
|
311
|
+
// `alpha` stands alone; `beta` pulls `alpha` in transitively.
|
|
312
|
+
const PACKS = '- id: alpha\n- id: beta\n requires: [alpha]\n- id: gamma\n';
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const unreachable = _fixture(
|
|
316
|
+
tmp,
|
|
317
|
+
'unreachable',
|
|
318
|
+
PACKS,
|
|
319
|
+
[{ name: 'r', packs: ['gamma'], routes: ['skill:s'] }],
|
|
320
|
+
[{ name: 's', packs: ['alpha'] }],
|
|
321
|
+
);
|
|
322
|
+
const viaRequires = _fixture(
|
|
323
|
+
tmp,
|
|
324
|
+
'via-requires',
|
|
325
|
+
PACKS,
|
|
326
|
+
[{ name: 'r', packs: ['beta'], routes: ['skill:s'] }],
|
|
327
|
+
[{ name: 's', packs: ['alpha'] }],
|
|
328
|
+
);
|
|
329
|
+
const unscopedRule = _fixture(
|
|
330
|
+
tmp,
|
|
331
|
+
'unscoped-rule',
|
|
332
|
+
PACKS,
|
|
333
|
+
[{ name: 'r', routes: ['skill:s'] }],
|
|
334
|
+
[{ name: 's', packs: ['alpha'] }],
|
|
335
|
+
);
|
|
336
|
+
const unscopedSkill = _fixture(
|
|
337
|
+
tmp,
|
|
338
|
+
'unscoped-skill',
|
|
339
|
+
PACKS,
|
|
340
|
+
[{ name: 'r', packs: ['gamma'], routes: ['skill:s'] }],
|
|
341
|
+
[{ name: 's' }],
|
|
342
|
+
);
|
|
343
|
+
const empty = _fixture(tmp, 'empty', PACKS, [], [{ name: 's', packs: ['alpha'] }]);
|
|
344
|
+
|
|
345
|
+
return runSelfTest({
|
|
346
|
+
gate: 'lint_rule_skill_pack_reach',
|
|
347
|
+
minCases: 6,
|
|
348
|
+
minRejectCases: 3,
|
|
349
|
+
cases: [
|
|
350
|
+
{
|
|
351
|
+
name: 'a rule routing into a pack its own closure never reaches is rejected under --strict',
|
|
352
|
+
expect: 'reject',
|
|
353
|
+
run: () => run(unreachable, ['--strict']),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'an empty rule corpus refuses to report a pass',
|
|
357
|
+
expect: 'reject',
|
|
358
|
+
run: () => run(empty, ['--strict']),
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'the same route passes when `requires` pulls the skill pack in transitively',
|
|
362
|
+
expect: 'accept',
|
|
363
|
+
run: () => run(viaRequires, ['--strict']),
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'an unscoped rule ships everywhere, so its route is always reachable',
|
|
367
|
+
expect: 'accept',
|
|
368
|
+
run: () => run(unscopedRule, ['--strict']),
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'an unscoped skill ships everywhere, so no route into it can be unreachable',
|
|
372
|
+
expect: 'accept',
|
|
373
|
+
run: () => run(unscopedSkill, ['--strict']),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: '--root with no value is refused rather than silently scanning the real tree',
|
|
377
|
+
expect: 'reject',
|
|
378
|
+
run: () => runRaw(['--quiet', '--root']),
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
} finally {
|
|
383
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
388
|
+
const args = _parseArgs(argv);
|
|
389
|
+
if (args.selfTest) return _selfTest();
|
|
390
|
+
|
|
391
|
+
const { rulesDir, skillsDir, packsFile } = rootsFor(args.root);
|
|
392
|
+
const packs = readPacks(packsFile);
|
|
393
|
+
const rules = readRules(rulesDir);
|
|
394
|
+
const skillPacks = readSkillPacks(skillsDir);
|
|
219
395
|
|
|
220
396
|
if (rules.length === 0 || skillPacks.size === 0 || packs.size === 0) {
|
|
221
397
|
process.stderr.write(
|
|
@@ -231,8 +407,7 @@ function main(): number {
|
|
|
231
407
|
// --quiet suppresses the per-finding lines but never the count line: this
|
|
232
408
|
// gate is expected to stay non-empty for a while, and a quiet run that
|
|
233
409
|
// printed nothing at all would read as clean.
|
|
234
|
-
|
|
235
|
-
if (!quiet) {
|
|
410
|
+
if (!args.quiet) {
|
|
236
411
|
for (const finding of errors) {
|
|
237
412
|
process.stdout.write(`❌ ${finding.subject} — ${finding.detail}\n`);
|
|
238
413
|
}
|
|
@@ -241,16 +416,22 @@ function main(): number {
|
|
|
241
416
|
}
|
|
242
417
|
}
|
|
243
418
|
|
|
244
|
-
|
|
419
|
+
// Two lines on purpose. `check_gate_coverage`'s SCANNED_RE is
|
|
420
|
+
// `/^\s*scanned:\s*(\d+)\s*$/m` — the count must END the line — so the
|
|
421
|
+
// detailed breakdown below can never satisfy it. This gate was registered
|
|
422
|
+
// `enforced` with `min_scanned: 90` and had therefore reported `null` since
|
|
423
|
+
// 924cad87f: an enforced floor that no output could ever meet. Emit the
|
|
424
|
+
// machine-readable contract line first, then the human breakdown.
|
|
425
|
+
process.stdout.write(`scanned: ${rules.length}\n`);
|
|
245
426
|
process.stdout.write(
|
|
246
|
-
|
|
427
|
+
`${rules.length} rule(s), ${skillPacks.size} skill(s), ${packs.size} pack(s) — ${errors.length} unreachable-route, ${advisories.length} unrouted-skill\n`,
|
|
247
428
|
);
|
|
248
|
-
if (errors.length > 0 && !strict) {
|
|
429
|
+
if (errors.length > 0 && !args.strict) {
|
|
249
430
|
process.stdout.write(
|
|
250
431
|
`advisory run: ${errors.length} unreachable-route finding(s) reported, exit 0. Promote with --strict once the set is empty.\n`,
|
|
251
432
|
);
|
|
252
433
|
}
|
|
253
|
-
return strict && errors.length > 0 ? 1 : 0;
|
|
434
|
+
return args.strict && errors.length > 0 ? 1 : 0;
|
|
254
435
|
}
|
|
255
436
|
|
|
256
437
|
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Router-head gate — makes the published K6 cap true for NEW skills.
|
|
4
|
+
*
|
|
5
|
+
* `src/agent-src/templates/skill.md` has carried "K6: Under 400 lines" as a
|
|
6
|
+
* checklist item since long before this gate. A checklist item is a statement
|
|
7
|
+
* of intent that nothing checks; four skills exceed it today. This gate turns
|
|
8
|
+
* the existing contract into an enforced one, without re-litigating the number.
|
|
9
|
+
*
|
|
10
|
+
* The rule: a SKILL.md over MAX_HEAD_LINES must be a router head — an entry
|
|
11
|
+
* file (when-to-use, mode table, routing) plus per-mode bodies in `tasks/` or
|
|
12
|
+
* `references/` inside the skill folder. Over the cap with neither directory
|
|
13
|
+
* present is a monolith and fails.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS GATE DOES NOT CLAIM. It is not an activation or token argument.
|
|
16
|
+
* The host loads SKILL.md whole on trigger, and whether it then follows a
|
|
17
|
+
* pointer into `tasks/` is host behaviour nothing in this tree observes — so
|
|
18
|
+
* "splitting saves tokens on trigger" is unmeasurable here and is not asserted.
|
|
19
|
+
* ADR-202's lesson binds: an activation claim goes behind a measured instrument
|
|
20
|
+
* or it is not made. The defensible claim is narrower and sufficient: the cap
|
|
21
|
+
* was already the repo's position, and now a new skill cannot quietly ignore it.
|
|
22
|
+
*
|
|
23
|
+
* GRANDFATHERING is a shrink-only ratchet. GRANDFATHERED below is seeded with
|
|
24
|
+
* the measured offender set. Entries may be REMOVED when a skill is
|
|
25
|
+
* restructured or drops under the cap; adding one is the move this gate exists
|
|
26
|
+
* to prevent, and the diff makes any addition visible in review. The list is an
|
|
27
|
+
* inline constant rather than a JSON baseline on purpose — a baseline file
|
|
28
|
+
* grows without anyone reading it, and this one should be read every time.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* ./scripts-run src/scripts/lint_skill_router_head
|
|
32
|
+
* ./scripts-run src/scripts/lint_skill_router_head --quiet
|
|
33
|
+
*
|
|
34
|
+
* Exit codes: 0 = clean · 2 = any finding OR usage error.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
|
+
|
|
41
|
+
import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
|
|
42
|
+
|
|
43
|
+
const _FILE = fileURLToPath(import.meta.url);
|
|
44
|
+
const _HERE = path.dirname(_FILE);
|
|
45
|
+
const REPO = path.resolve(_HERE, '..', '..');
|
|
46
|
+
const SKILLS_REL = 'src/skills';
|
|
47
|
+
|
|
48
|
+
/** The K6 cap, in lines. Mirrors src/agent-src/templates/skill.md § K6. */
|
|
49
|
+
export const MAX_HEAD_LINES = 400;
|
|
50
|
+
|
|
51
|
+
/** Directories a router head may route into. `references/` is the established
|
|
52
|
+
* convention (9 skills use it today); `tasks/` is permitted by the contract
|
|
53
|
+
* for per-mode procedure bodies. */
|
|
54
|
+
export const MODE_BODY_DIRS: readonly string[] = ['tasks', 'references'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shrink-only allowlist — skills already over the cap when the gate landed.
|
|
58
|
+
* Measured, not guessed. REMOVE entries as skills are restructured; never add.
|
|
59
|
+
*/
|
|
60
|
+
export const GRANDFATHERED: readonly string[] = [
|
|
61
|
+
'ai-council', // 1055
|
|
62
|
+
'skill-writing', // 767
|
|
63
|
+
'roadmap-management', // 552
|
|
64
|
+
'quality-tools', // 445
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export interface Finding {
|
|
68
|
+
readonly skill: string;
|
|
69
|
+
readonly lines: number;
|
|
70
|
+
readonly message: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Count lines the way `wc -l` does, so the gate and a shell check agree. */
|
|
74
|
+
export function countLines(text: string): number {
|
|
75
|
+
if (text.length === 0) return 0;
|
|
76
|
+
const n = text.split('\n').length;
|
|
77
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Does this skill folder carry at least one mode-body directory? */
|
|
81
|
+
export function hasModeBodies(skillDir: string, dirs: readonly string[] = MODE_BODY_DIRS): boolean {
|
|
82
|
+
return dirs.some((d) => {
|
|
83
|
+
try {
|
|
84
|
+
return fs.statSync(path.join(skillDir, d)).isDirectory();
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SkillHead {
|
|
92
|
+
readonly name: string;
|
|
93
|
+
readonly lines: number;
|
|
94
|
+
readonly hasBodies: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read every `<skillsRoot>/<name>/SKILL.md`. */
|
|
98
|
+
export function collectSkillHeads(skillsRoot: string): SkillHead[] {
|
|
99
|
+
let entries: fs.Dirent[];
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const heads: SkillHead[] = [];
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
if (!e.isDirectory()) continue;
|
|
108
|
+
const dir = path.join(skillsRoot, e.name);
|
|
109
|
+
const head = path.join(dir, 'SKILL.md');
|
|
110
|
+
let text: string;
|
|
111
|
+
try {
|
|
112
|
+
text = fs.readFileSync(head, 'utf-8');
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
heads.push({ name: e.name, lines: countLines(text), hasBodies: hasModeBodies(dir) });
|
|
117
|
+
}
|
|
118
|
+
return heads.sort((a, b) => a.name.localeCompare(b.name));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A head fails when it is over the cap, carries no mode-body directory, and is
|
|
123
|
+
* not grandfathered. Two separate results are reported so the caller can
|
|
124
|
+
* distinguish a live violation from allowlist drift.
|
|
125
|
+
*/
|
|
126
|
+
export function evaluate(
|
|
127
|
+
heads: readonly SkillHead[],
|
|
128
|
+
grandfathered: readonly string[] = GRANDFATHERED,
|
|
129
|
+
max: number = MAX_HEAD_LINES,
|
|
130
|
+
): { readonly findings: Finding[]; readonly staleAllowlist: string[] } {
|
|
131
|
+
const allow = new Set(grandfathered);
|
|
132
|
+
const findings: Finding[] = [];
|
|
133
|
+
const stillOver = new Set<string>();
|
|
134
|
+
|
|
135
|
+
for (const h of heads) {
|
|
136
|
+
if (h.lines <= max) continue;
|
|
137
|
+
stillOver.add(h.name);
|
|
138
|
+
if (allow.has(h.name)) continue;
|
|
139
|
+
if (h.hasBodies) continue;
|
|
140
|
+
findings.push({
|
|
141
|
+
skill: h.name,
|
|
142
|
+
lines: h.lines,
|
|
143
|
+
message:
|
|
144
|
+
`SKILL.md is ${h.lines} lines (cap ${max}) with no ${MODE_BODY_DIRS.map((d) => `${d}/`).join(' or ')} ` +
|
|
145
|
+
'directory — over the cap a skill restructures as a router head (entry head + one file per mode), ' +
|
|
146
|
+
'per templates/skill.md § The router-head contract. The allowlist is shrink-only and does not accept new entries.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// An allowlist entry for a skill that no longer exceeds the cap (or no
|
|
151
|
+
// longer exists) is the ratchet failing to tighten: the entry silently
|
|
152
|
+
// buys nothing and would re-permit a regression. Reported, not fatal —
|
|
153
|
+
// removing it is a one-line follow-up, not a reason to red the build.
|
|
154
|
+
const staleAllowlist = grandfathered.filter((g) => !stillOver.has(g));
|
|
155
|
+
|
|
156
|
+
return { findings, staleAllowlist };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
class ExitCode extends Error {
|
|
162
|
+
readonly code: number;
|
|
163
|
+
constructor(code: number) {
|
|
164
|
+
super(`exit ${code}`);
|
|
165
|
+
this.code = code;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseArgs(argv: string[]): { readonly quiet: boolean } {
|
|
170
|
+
let quiet = false;
|
|
171
|
+
for (const a of argv) {
|
|
172
|
+
if (a === '--quiet') {
|
|
173
|
+
quiet = true;
|
|
174
|
+
} else if (a === '-h' || a === '--help') {
|
|
175
|
+
process.stdout.write('usage: lint_skill_router_head [--quiet]\n');
|
|
176
|
+
throw new ExitCode(0);
|
|
177
|
+
} else {
|
|
178
|
+
process.stderr.write(`❌ lint_skill_router_head: unrecognized argument: ${a}\n`);
|
|
179
|
+
throw new ExitCode(2);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { quiet };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
186
|
+
const args = parseArgs(argv);
|
|
187
|
+
|
|
188
|
+
// The scanned root is the scope signal. A moved or renamed src/skills would
|
|
189
|
+
// otherwise produce zero heads and a clean green over nothing at all.
|
|
190
|
+
try {
|
|
191
|
+
assertWatchlistResolves({
|
|
192
|
+
gate: 'lint_skill_router_head',
|
|
193
|
+
candidates: [`${SKILLS_REL}/skill-writing/SKILL.md`],
|
|
194
|
+
repoRoot: REPO,
|
|
195
|
+
});
|
|
196
|
+
} catch (exc) {
|
|
197
|
+
if (exc instanceof DeadScopeError) {
|
|
198
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
|
201
|
+
throw exc;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const heads = collectSkillHeads(path.join(REPO, SKILLS_REL));
|
|
205
|
+
const { findings, staleAllowlist } = evaluate(heads);
|
|
206
|
+
|
|
207
|
+
for (const s of staleAllowlist) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`⚠️ lint_skill_router_head: '${s}' is grandfathered but no longer over the cap — remove it from GRANDFATHERED so the ratchet keeps its teeth\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (findings.length > 0) {
|
|
214
|
+
for (const f of findings) {
|
|
215
|
+
process.stderr.write(`❌ ${SKILLS_REL}/${f.skill}/SKILL.md: ${f.message}\n`);
|
|
216
|
+
}
|
|
217
|
+
process.stderr.write(`❌ lint_skill_router_head: ${findings.length} finding(s)\n`);
|
|
218
|
+
return 2;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!args.quiet) {
|
|
222
|
+
const over = heads.filter((h) => h.lines > MAX_HEAD_LINES).length;
|
|
223
|
+
process.stdout.write(
|
|
224
|
+
`✅ lint_skill_router_head: ${heads.length} skill head(s) scanned · ` +
|
|
225
|
+
`${over} over the ${MAX_HEAD_LINES}-line cap, all grandfathered or routed · ` +
|
|
226
|
+
`allowlist holds ${GRANDFATHERED.length} entry(ies), shrink-only\n`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Robust "am I the entry script?" — realpath-compares argv[1] to this file. */
|
|
233
|
+
function _isCliEntry(): boolean {
|
|
234
|
+
const a = process.argv[1];
|
|
235
|
+
if (!a) return false;
|
|
236
|
+
if (a === _FILE || pathToFileURL(path.resolve(a)).href === import.meta.url) return true;
|
|
237
|
+
try {
|
|
238
|
+
return fs.realpathSync(a) === fs.realpathSync(_FILE);
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (_isCliEntry()) {
|
|
244
|
+
try {
|
|
245
|
+
process.exit(main());
|
|
246
|
+
} catch (exc) {
|
|
247
|
+
if (exc instanceof ExitCode) {
|
|
248
|
+
process.exit(exc.code);
|
|
249
|
+
}
|
|
250
|
+
throw exc;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export { REPO, SKILLS_REL, ExitCode };
|