@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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +168 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/analyze.md +2 -1
  6. package/dist/agent-src/commands/design-system/capture.md +44 -0
  7. package/dist/agent-src/commands/design-system/generate.md +53 -0
  8. package/dist/agent-src/commands/design-system/import.md +63 -0
  9. package/dist/agent-src/commands/design-system.md +66 -0
  10. package/dist/agent-src/commands/roadmap/create.md +4 -1
  11. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  12. package/dist/agent-src/rules/active-remediation.md +53 -2
  13. package/dist/agent-src/rules/code-provenance.md +66 -4
  14. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  15. package/dist/agent-src/rules/design-fidelity.md +49 -3
  16. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  18. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  19. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  20. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  21. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  22. package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/agent-src/templates/persona.md +26 -0
  26. package/dist/agent-src/templates/roadmaps.md +27 -7
  27. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  28. package/dist/agent-src/templates/skill.md +35 -1
  29. package/dist/cli/commands/commands.candidates.test.js +257 -0
  30. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  31. package/dist/cli/commands/commands.js +199 -0
  32. package/dist/cli/commands/commands.js.map +1 -1
  33. package/dist/cli/main.js +1 -0
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/cli/registry.js +1 -1
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
  38. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  39. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  40. package/dist/cli-delegate/cmd_conformance.js +672 -214
  41. package/dist/cli-delegate/cmd_doctor.js +1 -1
  42. package/dist/cli-delegate/cmd_handoff.js +8 -6
  43. package/dist/cli-delegate/cmd_session_recycle.js +5 -3
  44. package/dist/discovery/deprecation-report.md +1 -1
  45. package/dist/discovery/discovery-manifest.json +127 -22
  46. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  47. package/dist/discovery/discovery-manifest.summary.md +3 -3
  48. package/dist/discovery/dormancy-report.md +1 -1
  49. package/dist/discovery/orphan-report.md +1 -1
  50. package/dist/discovery/packs.json +9 -5
  51. package/dist/discovery/trust-report.md +2 -2
  52. package/dist/discovery/workspaces.json +8 -4
  53. package/dist/hooks/dispatch.js +1056 -411
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1103 -593
  57. package/dist/router.json +1 -1
  58. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  59. package/docs/guidelines/design-antipatterns.md +79 -10
  60. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  61. package/package.json +1 -1
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  63. package/src/config/gate-coverage.yml +65 -10
  64. package/src/config/gate-violation-baselines.json +6 -0
  65. package/src/config/review-prompt-binding-baseline.json +19 -0
  66. package/src/config/rule-enforcement-baseline.json +0 -1
  67. package/src/scripts/_dispatch.bash +1 -0
  68. package/src/scripts/_lib/design_system_import.ts +987 -0
  69. package/src/scripts/ai_council/clients.ts +15 -1
  70. package/src/scripts/check_ci_local_parity.ts +60 -6
  71. package/src/scripts/check_review_prompt_binding.ts +553 -0
  72. package/src/scripts/check_suppression_hygiene.ts +19 -3
  73. package/src/scripts/design_slop_rules.test.ts +37 -6
  74. package/src/scripts/design_slop_rules.ts +150 -22
  75. package/src/scripts/design_system_import.ts +264 -0
  76. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  77. package/src/scripts/hook_manifest.yaml +71 -2
  78. package/src/scripts/hooks/concern_registry.ts +4 -0
  79. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  80. package/src/scripts/hooks/host_semantics.ts +2 -0
  81. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  82. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  83. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  84. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  85. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  86. package/src/scripts/lint_harvest_provenance.ts +483 -0
  87. package/src/scripts/lint_hook_manifest.ts +2 -0
  88. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  89. package/src/scripts/lint_skill_router_head.ts +254 -0
  90. package/src/scripts/report_consultation_rate.ts +126 -1
  91. package/src/scripts/run_skill_evals.ts +10 -5
  92. package/src/scripts/schemas/persona.schema.json +8 -0
  93. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  94. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  95. package/tests/fixtures/hooks/subagent_start.json +7 -0
  96. 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
- const RULES_DIR = path.join(REPO, 'src', 'rules');
65
- const SKILLS_DIR = path.join(REPO, 'src', 'skills');
66
- const PACKS_FILE = path.join(REPO, 'src', 'config', 'discovery', 'packs.yml');
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
- function main(): number {
216
- const packs = readPacks(PACKS_FILE);
217
- const rules = readRules(RULES_DIR);
218
- const skillPacks = readSkillPacks(SKILLS_DIR);
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
- const quiet = process.argv.includes('--quiet');
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
- const strict = process.argv.includes('--strict');
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
- `scanned: ${rules.length} rule(s), ${skillPacks.size} skill(s), ${packs.size} pack(s) — ${errors.length} unreachable-route, ${advisories.length} unrouted-skill\n`,
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 };