@event4u/agent-config 9.35.0 → 10.0.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +95 -142
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/session-canary.md +28 -0
  10. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/templates/AGENTS.md +1 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-2H5SE7DU.js} +35 -5
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-3JUTT26I.js} +8 -3
  23. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-HJSKTMIF.js} +14 -13
  24. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  25. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  26. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NX5AXSLD.js} +3 -3
  27. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  28. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  29. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  30. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  31. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  32. package/dist/cli-delegate/cmd_conformance.js +1038 -197
  33. package/dist/cli-delegate/cmd_doctor.js +4 -4
  34. package/dist/cli-delegate/cmd_explain.js +2 -2
  35. package/dist/cli-delegate/cmd_export.js +4 -4
  36. package/dist/cli-delegate/cmd_handoff.js +3 -3
  37. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  38. package/dist/cli-delegate/cmd_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  40. package/dist/cli-delegate/cmd_prune.js +4 -4
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  43. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  44. package/dist/cli-delegate/cmd_session_recycle.js +9 -2
  45. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  46. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  47. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  48. package/dist/cli-delegate/cmd_sync.js +4 -4
  49. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  50. package/dist/cli-delegate/cmd_update.js +1 -1
  51. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  52. package/dist/cli-delegate/cmd_validate.js +4 -4
  53. package/dist/cli-delegate/cmd_versions.js +1 -1
  54. package/dist/discovery/deprecation-report.md +1 -1
  55. package/dist/discovery/discovery-manifest.json +14 -14
  56. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  57. package/dist/discovery/discovery-manifest.summary.md +1 -1
  58. package/dist/discovery/dormancy-report.md +1 -1
  59. package/dist/discovery/orphan-report.md +1 -1
  60. package/dist/discovery/packs.json +2 -2
  61. package/dist/discovery/trust-report.md +1 -1
  62. package/dist/discovery/workspaces.json +2 -2
  63. package/dist/hooks/dispatch.js +654 -411
  64. package/dist/install/install.mjs +39 -29
  65. package/dist/mcp/registry-manifest.json +1 -1
  66. package/dist/mcp/server.json +2 -2
  67. package/dist/mcp/server.mjs +1580 -617
  68. package/dist/router.json +1 -1
  69. package/dist/scripts/_lib/agent_settings.js +39 -25
  70. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  71. package/dist/scripts/condense.js +15 -2
  72. package/dist/scripts/condense.js.map +1 -1
  73. package/dist/server/routes/wizard.js +23 -3
  74. package/dist/server/routes/wizard.js.map +1 -1
  75. package/dist/server/schemas/settings.js +11 -6
  76. package/dist/server/schemas/settings.js.map +1 -1
  77. package/docs/contracts/settings-classes.md +16 -9
  78. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  79. package/package.json +2 -2
  80. package/src/agent-src/templates/AGENTS.md +1 -1
  81. package/src/config/agent-settings.template.yml +38 -36
  82. package/src/config/ci-local-parity.yml +44 -0
  83. package/src/config/gate-coverage.yml +12 -0
  84. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  85. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  86. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  87. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  88. package/src/scripts/_lib/agent_settings.ts +39 -25
  89. package/src/scripts/_lib/environment_detector.ts +52 -1
  90. package/src/scripts/_lib/hook_settings.ts +79 -0
  91. package/src/scripts/_lib/ui_surface.ts +97 -0
  92. package/src/scripts/ai_council/config.ts +62 -24
  93. package/src/scripts/before_complete_hook.ts +39 -0
  94. package/src/scripts/capture_skill_catalogue.ts +483 -0
  95. package/src/scripts/check_bridge_derivation.ts +89 -3
  96. package/src/scripts/check_ci_local_parity.ts +74 -0
  97. package/src/scripts/check_references.ts +15 -1
  98. package/src/scripts/condense.ts +16 -2
  99. package/src/scripts/conformance_scan.ts +200 -9
  100. package/src/scripts/council_cli.ts +95 -3
  101. package/src/scripts/hook_manifest.yaml +30 -9
  102. package/src/scripts/hooks/block_no_verify.ts +82 -9
  103. package/src/scripts/hooks/concern_registry.ts +2 -0
  104. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  106. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  107. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  108. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  109. package/src/scripts/probe_promissory_closing.ts +180 -0
  110. package/src/scripts/probe_session_canary.ts +180 -0
  111. package/src/scripts/report_consultation_rate.ts +358 -0
  112. package/src/scripts/report_skill_activation.ts +5 -1
  113. package/src/server/schemas/settings.ts +14 -13
  114. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  115. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  116. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  117. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * PreToolUse UI-route nudge — deterministic, warn-only, default-OFF.
4
+ *
5
+ * The two UI rules have `enforced_by: none` and say so honestly: nothing
6
+ * observes whether a UI write consulted the design surface first. This concern
7
+ * observes it — and it is a nudge, not a gate. It never blocks (dispatcher
8
+ * contract: 0 allow · 2 warn), because the obligation it points at is a
9
+ * judgement call and a block on a judgement call trades an ignore-problem for
10
+ * a friction-problem.
11
+ *
12
+ * IT DOES NOT READ THE RULES. Stated because the first draft of this header
13
+ * claimed it did. The UI-surface decision comes from `_lib/ui_surface.ts`; no
14
+ * code here parses `src/rules/*.md` frontmatter, so the rules' `keyword:`
15
+ * triggers still have no runtime consumer and this predicate is deliberately
16
+ * wider than their `file_pattern` list. The two are kept from drifting by
17
+ * `ui_rule_triggers.test.ts`, which asserts every declared pattern is accepted
18
+ * here — a test, not a dependency.
19
+ *
20
+ * WHAT IT OBSERVES, WHICH IS ALSO THE MEASUREMENT.
21
+ * Every PreToolUse event passes through here. A read or search touching a
22
+ * design surface (`fe-design`, `existing-ui-audit`, `design-review`,
23
+ * `design-intelligence`) latches "consulted" for the session. A Write or Edit
24
+ * to a UI path with no such latch is the case the nudge exists for. That latch
25
+ * is exactly the consultation signal the pre-registered consultation rate
26
+ * counts, so the nudge and the metric read the same event stream rather than
27
+ * two populations that could drift apart.
28
+ *
29
+ * ANTI-LOOP. At most `MAX_NUDGES` per session, then silent — the same
30
+ * degradation valve `design_slop_hook` carries, for the same reason: an agent
31
+ * that has decided to proceed must not be re-prompted into a loop.
32
+ *
33
+ * HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use` exists on three
34
+ * hosts. Everywhere else this concern has nowhere to bind and the obligation
35
+ * stays model-carried, exactly as the two rules already declare. Run
36
+ * `agent-config hooks:status` for the host you are actually on.
37
+ *
38
+ * Default-OFF: no-ops unless `hooks.ui_route_nudge.enabled: true`.
39
+ * fail_closed: false — any error returns allow.
40
+ */
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ import { hookSectionEnabled } from '../_lib/hook_settings.js';
46
+ import { isUiPath, isUiTreePath } from '../_lib/ui_surface.js';
47
+ import { readHookStdin } from './hook_stdin.js';
48
+
49
+ const EXIT_ALLOW = 0;
50
+ const EXIT_WARN = 2;
51
+ /** Nudges per session before the valve goes silent. */
52
+ export const MAX_NUDGES = 2;
53
+
54
+ /** Reads or searches that count as consulting the design surface. */
55
+ export const DESIGN_SURFACES = [
56
+ 'fe-design',
57
+ 'existing-ui-audit',
58
+ 'design-review',
59
+ 'design-intelligence',
60
+ ] as const;
61
+
62
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
63
+ type JsonObject = { [k: string]: JsonValue };
64
+
65
+ function isObject(v: unknown): v is JsonObject {
66
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
67
+ }
68
+
69
+ function unwrap(envelope: JsonObject): JsonObject {
70
+ const inner = envelope['payload'];
71
+ return isObject(inner) ? inner : envelope;
72
+ }
73
+
74
+ export interface ToolEvent {
75
+ /** Path the tool touches; "" when the tool names none. */
76
+ file: string;
77
+ /** True when the tool proposes new content (Write / Edit). */
78
+ isWrite: boolean;
79
+ }
80
+
81
+ /** Best-effort extraction; mirrors the shape `design_slop_hook` reads. */
82
+ export function extractEvent(outer: JsonObject): ToolEvent | null {
83
+ const envelope = unwrap(outer);
84
+ const ti = envelope['tool_input'] ?? envelope['toolInput'] ?? envelope['input'];
85
+ if (!isObject(ti)) return null;
86
+
87
+ const fileVal = ti['file_path'] ?? ti['path'] ?? ti['filePath'];
88
+ const file = typeof fileVal === 'string' ? fileVal : '';
89
+
90
+ let isWrite = false;
91
+ for (const key of ['content', 'new_string', 'new_str', 'text', 'newText']) {
92
+ if (typeof ti[key] === 'string' && (ti[key] as string).length > 0) isWrite = true;
93
+ }
94
+
95
+ return { file, isWrite };
96
+ }
97
+
98
+ /**
99
+ * True when the event OPENS one of the design surfaces.
100
+ *
101
+ * Deliberately narrow, and the narrowness is the fix for a real defect: an
102
+ * earlier version matched the surface name anywhere in the path OR in any
103
+ * search text the tool carried, including `command`. That silenced the nudge
104
+ * for a whole session on a `git log -- src/skills/fe-design`, on a grep for
105
+ * `design-review` while writing a review artefact, and on a read of
106
+ * `src/rules/design-review-after-ui-write.md` — whose path contains the skill
107
+ * name without being the skill. Since the same latch is the consultation
108
+ * signal the pre-registered rate counts, one false positive corrupted both the
109
+ * gate and the metric.
110
+ *
111
+ * So consultation means: a non-write touching `skills/<surface>/`. A search is
112
+ * not a consultation — reading the skill is.
113
+ */
114
+ export function isConsultation(event: ToolEvent): boolean {
115
+ if (event.isWrite || !event.file) return false;
116
+ const normalized = event.file.replace(/\\/g, '/').toLowerCase();
117
+ return DESIGN_SURFACES.some((surface) => normalized.includes(`skills/${surface}/`));
118
+ }
119
+
120
+ /** True when the event writes a UI surface. */
121
+ export function isUiWrite(event: ToolEvent): boolean {
122
+ if (!event.isWrite || !event.file) return false;
123
+ return isUiPath(event.file) || isUiTreePath(event.file);
124
+ }
125
+
126
+ export interface SessionState {
127
+ consulted: boolean;
128
+ nudges: number;
129
+ }
130
+
131
+ function stateFile(root: string): string {
132
+ return path.join(root, 'agents', 'runtime', 'state', 'ui-route-nudge.json');
133
+ }
134
+
135
+ export function readState(root: string, session: string): SessionState {
136
+ try {
137
+ const all = JSON.parse(fs.readFileSync(stateFile(root), 'utf-8')) as Record<
138
+ string,
139
+ SessionState
140
+ >;
141
+ const entry = all[session];
142
+ if (entry && typeof entry.consulted === 'boolean' && typeof entry.nudges === 'number') {
143
+ return entry;
144
+ }
145
+ } catch {
146
+ /* fresh session */
147
+ }
148
+ return { consulted: false, nudges: 0 };
149
+ }
150
+
151
+ /** Sessions retained in the state file; oldest entries drop past this. */
152
+ export const MAX_SESSIONS = 50;
153
+
154
+ /** Drop the oldest entries so the file cannot grow without bound. */
155
+ export function pruneSessions(
156
+ all: Record<string, SessionState>,
157
+ keep: number = MAX_SESSIONS,
158
+ ): Record<string, SessionState> {
159
+ const keys = Object.keys(all);
160
+ if (keys.length <= keep) return all;
161
+ const survivors = keys.slice(keys.length - keep);
162
+ const pruned: Record<string, SessionState> = {};
163
+ for (const key of survivors) pruned[key] = all[key]!;
164
+ return pruned;
165
+ }
166
+
167
+ export function writeState(root: string, session: string, state: SessionState): void {
168
+ try {
169
+ const file = stateFile(root);
170
+ let all: Record<string, SessionState> = {};
171
+ try {
172
+ all = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, SessionState>;
173
+ } catch {
174
+ // Fresh file, or one nothing can parse. There is no other session
175
+ // state to preserve in either case — a corrupt file has already
176
+ // lost it — so starting from empty is recovery, not discard. The
177
+ // cost is bounded: a lost latch means at most MAX_NUDGES extra
178
+ // reminders in the sessions that were in flight.
179
+ }
180
+ if (typeof all !== 'object' || all === null || Array.isArray(all)) all = {};
181
+ all[session] = state;
182
+ fs.mkdirSync(path.dirname(file), { recursive: true });
183
+ fs.writeFileSync(file, JSON.stringify(pruneSessions(all)));
184
+ } catch {
185
+ /* fail-open: a persistence failure must never break a tool call */
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The decision, pure — so the test exercises the logic rather than the
191
+ * filesystem. Returns the next state and whether to warn.
192
+ */
193
+ export function decide(
194
+ event: ToolEvent,
195
+ state: SessionState,
196
+ ): { state: SessionState; warn: boolean } {
197
+ if (isConsultation(event)) {
198
+ return { state: { ...state, consulted: true }, warn: false };
199
+ }
200
+ if (!isUiWrite(event)) return { state, warn: false };
201
+ if (state.consulted) return { state, warn: false };
202
+ if (state.nudges >= MAX_NUDGES) return { state, warn: false };
203
+ return { state: { ...state, nudges: state.nudges + 1 }, warn: true };
204
+ }
205
+
206
+ export function nudgeReason(file: string): string {
207
+ return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
208
+ }
209
+
210
+ export function main(): number {
211
+ let envelope: JsonValue;
212
+ try {
213
+ const raw = readHookStdin();
214
+ if (!raw.trim()) return EXIT_ALLOW;
215
+ envelope = JSON.parse(raw) as JsonValue;
216
+ } catch {
217
+ return EXIT_ALLOW;
218
+ }
219
+ if (!isObject(envelope)) return EXIT_ALLOW;
220
+
221
+ const rootVal = envelope['workspace_root'];
222
+ const root = typeof rootVal === 'string' && rootVal ? rootVal : process.cwd();
223
+ if (!hookSectionEnabled(root, 'ui_route_nudge')) return EXIT_ALLOW;
224
+
225
+ const sessionVal = envelope['session_id'];
226
+ const session = typeof sessionVal === 'string' && sessionVal ? sessionVal : 'unknown';
227
+
228
+ const event = extractEvent(envelope);
229
+ if (!event) return EXIT_ALLOW;
230
+
231
+ const before = readState(root, session);
232
+ const { state, warn } = decide(event, before);
233
+ if (state.consulted !== before.consulted || state.nudges !== before.nudges) {
234
+ writeState(root, session, state);
235
+ }
236
+ if (!warn) return EXIT_ALLOW;
237
+
238
+ process.stdout.write(`${JSON.stringify({ reason: nudgeReason(event.file) })}\n`);
239
+ return EXIT_WARN;
240
+ }
241
+
242
+ function isCliEntry(): boolean {
243
+ const entry = process.argv[1];
244
+ if (!entry) return false;
245
+ return pathToFileURL(path.resolve(entry)).href === import.meta.url;
246
+ }
247
+
248
+ if (isCliEntry()) process.exit(main());
249
+
250
+ export const _HOOK_FILE = fileURLToPath(import.meta.url);
@@ -216,6 +216,7 @@ function lint_roadmap(
216
216
  _check_human_gate_steps(text, warnings);
217
217
  _check_human_gate_phase_headings(text, warnings);
218
218
  _check_human_gate_exit_criteria(text, warnings);
219
+ _check_external_population_gates(text, warnings);
219
220
  }
220
221
  return problems;
221
222
  }
@@ -383,6 +384,65 @@ function _check_human_gate_exit_criteria(text: string, warnings: string[]): void
383
384
  }
384
385
  }
385
386
 
387
+ // A gate whose opening condition depends on a quantity the project cannot
388
+ // produce is not a gate — it is a permanent no in the shape of an answer. The
389
+ // measured case: a single-user private tool carried a roadmap gate on "external
390
+ // installations with write activity", which held 33 steps closed and would have
391
+ // held them closed forever. Matched only inside gate-shaped context (exit
392
+ // criteria, acceptance criteria, a blocker's `Resolved when:`), because these
393
+ // nouns are ordinary vocabulary in a roadmap for an actual product.
394
+ const EXTERNAL_POPULATION_PATS: Array<[RegExp, string]> = [
395
+ [/\bexternal (users?|installs?|installations?|adopters?|consumers?)\b/i, 'an external user population'],
396
+ // Bare `adopters` is deliberately absent: measured against the real roadmap
397
+ // corpus it matched four internal counts ("4 adopters" of a gate helper),
398
+ // and an external adopter count is already covered by the pattern above.
399
+ [/\b(installations?|sign-?ups?|subscribers?|active users?)\b/i, 'an external adoption count'],
400
+ [/\bmarket (demand|validation|traction|fit|signal)\b/i, 'a market signal'],
401
+ [/\bchurn(ing|ed)?\b/i, 'a metric only a real user population produces'],
402
+ [/\buser (population|base|segment)\b/i, 'a user population'],
403
+ [/\b(?:at least |≥\s?)\d+ (people|users|customers|installs|adopters)\b/i, 'a headcount threshold of external people'],
404
+ ];
405
+
406
+ const RESOLVED_WHEN_PAT = /^\s*(?:[-*]\s+)?\*{0,2}resolved when\*{0,2}\s*:/i;
407
+
408
+ const AUDIENCE_HINT =
409
+ 'is this project meant to have that population at all? A gate whose ' +
410
+ 'condition depends on a quantity the project cannot produce is a permanent ' +
411
+ 'no, not a gate — see project.audience and § 8-pre of ' +
412
+ 'docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md';
413
+
414
+ function _check_external_population_gates(text: string, warnings: string[]): void {
415
+ let inHeadingBlock = false;
416
+ let inInlineBlock = false;
417
+ for (const rawLine of text.split('\n')) {
418
+ const heading = /^#{1,6}\s+(.*)$/.exec(rawLine);
419
+ if (heading) {
420
+ inHeadingBlock = /\b(acceptance|exit criteria)\b/i.test(heading[1]!);
421
+ inInlineBlock = false;
422
+ continue;
423
+ }
424
+ if (rawLine.trim() === '') {
425
+ inInlineBlock = false;
426
+ continue;
427
+ }
428
+ if (EXIT_CRITERIA_OPENER_PAT.test(rawLine)) {
429
+ inInlineBlock = true;
430
+ }
431
+ const isResolvedWhen = RESOLVED_WHEN_PAT.test(rawLine);
432
+ if (!inHeadingBlock && !inInlineBlock && !isResolvedWhen) {
433
+ continue;
434
+ }
435
+ for (const [re, label] of EXTERNAL_POPULATION_PATS) {
436
+ if (re.test(rawLine)) {
437
+ warnings.push(
438
+ `gate rests on ${label}: "${rawLine.trim().slice(0, 80)}" — ` + AUDIENCE_HINT,
439
+ );
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ }
445
+
386
446
  function _check_execution_mode(fm: string, problems: string[]): void {
387
447
  if (!fm || !/^execution:/m.test(fm)) {
388
448
  return; // absent field = interactive default — valid
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * lint_rule_skill_pack_reach — a rule and the skills it routes to must be able
4
+ * to arrive in the same install.
5
+ *
6
+ * The defect that motivated this is real and was undocumented in the direction
7
+ * it actually runs. `ui-audit-gate` and `design-review-after-ui-write` are
8
+ * `packs: [frontend-design]`; `existing-ui-audit`, `design-review` and
9
+ * `fe-design` are `packs: [engineering-base]`. The direction the rule prose
10
+ * described — frontend-design without engineering-base — cannot happen, because
11
+ * `frontend-design` declares `requires: [engineering-base]`. The reverse can: a
12
+ * plain `laravel` or `react` install carries the skills and **no rule that ever
13
+ * routes to them**, since both packs only *suggest* frontend-design.
14
+ *
15
+ * TWO CHECKS, AND THEY ARE NOT THE SAME QUESTION.
16
+ *
17
+ * unreachable-route (error) — a rule routes to a skill that a pack-legal
18
+ * install of that rule cannot receive. The consumer gets the obligation
19
+ * without the artefact that discharges it.
20
+ *
21
+ * unrouted-skill (advisory) — a skill that IS routed somewhere reaches a pack
22
+ * none of its routing rules reach. The consumer has the artefact and no path
23
+ * to it. This is the measured case above.
24
+ *
25
+ * Scope, stated because the obvious reading is wider than the scan: the
26
+ * subject set is skills some rule already routes to. A skill NO rule routes
27
+ * to anywhere is never examined, deliberately — most of the estate is in
28
+ * that set by design (a skill invoked by name needs no rule), so reporting
29
+ * it would bury the finding this check exists for. Advisory for the same
30
+ * reason: reachability-only-by-invocation is a legitimate design, so a
31
+ * finding here is a question, not a verdict.
32
+ *
33
+ * The install closure is `pack + requires*` — the transitive expansion the
34
+ * resolver performs. `suggests` is deliberately NOT followed: it is advisory,
35
+ * the wizard may offer it and the user may decline, so an invariant that
36
+ * counted it would pass on installs that do not exist.
37
+ *
38
+ * ADVISORY UNTIL ITS FINDING SET IS EMPTY, AND THE FIRST RUN SAYS WHY.
39
+ * Measured on introduction: 12 `unreachable-route` and 14 `unrouted-skill`
40
+ * findings across 116 rules. The UI pair is one instance of an estate-wide
41
+ * pattern — safety floors routing into packs their own pack does not pull, and
42
+ * skills delivered by a pack whose routing rule ships in a different one.
43
+ * Shipping this as a blocking gate on day one would have meant either 12
44
+ * unrelated fixes smuggled into a feature change or a suppression file created
45
+ * on day one, which is the ratchet-shaped failure this repo has recorded
46
+ * before. So `--strict` exists and the default does not use it: promotion to
47
+ * error is a one-flag change once the set is empty, and the count is printed
48
+ * on every run so "still 12" cannot quietly become "still 30".
49
+ *
50
+ * Exit codes: 0 advisory run (default) or clean · 1 `unreachable-route`
51
+ * findings under `--strict` · 2 usage or IO error. A scan that walked no rules
52
+ * exits 2 rather than green — a gate that scanned nothing must never read as a
53
+ * pass.
54
+ */
55
+
56
+ import * as fs from 'node:fs';
57
+ import * as path from 'node:path';
58
+ import { fileURLToPath } from 'node:url';
59
+
60
+ import { parse as parseYaml } from 'yaml';
61
+
62
+ const _HERE = fileURLToPath(import.meta.url);
63
+ 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');
67
+
68
+ export interface PackDef {
69
+ id: string;
70
+ requires: string[];
71
+ }
72
+
73
+ export interface Finding {
74
+ kind: 'unreachable-route' | 'unrouted-skill';
75
+ subject: string;
76
+ detail: string;
77
+ }
78
+
79
+ /** Frontmatter block between the leading fences, or "" when absent. */
80
+ function frontmatterOf(content: string): string {
81
+ if (!content.startsWith('---')) return '';
82
+ const end = content.indexOf('\n---', 3);
83
+ return end === -1 ? '' : content.slice(3, end);
84
+ }
85
+
86
+ function frontmatterObject(file: string): Record<string, unknown> {
87
+ const raw = frontmatterOf(fs.readFileSync(file, 'utf-8'));
88
+ if (!raw.trim()) return {};
89
+ try {
90
+ const parsed = parseYaml(raw) as unknown;
91
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
92
+ ? (parsed as Record<string, unknown>)
93
+ : {};
94
+ } catch {
95
+ return {};
96
+ }
97
+ }
98
+
99
+ function stringList(value: unknown): string[] {
100
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string');
101
+ if (typeof value === 'string') return [value];
102
+ return [];
103
+ }
104
+
105
+ /** Parse the pack registry into id → hard requirements. */
106
+ export function readPacks(file: string): Map<string, PackDef> {
107
+ const parsed = parseYaml(fs.readFileSync(file, 'utf-8')) as unknown;
108
+ const out = new Map<string, PackDef>();
109
+ if (!Array.isArray(parsed)) return out;
110
+ for (const entry of parsed) {
111
+ if (!entry || typeof entry !== 'object') continue;
112
+ const record = entry as Record<string, unknown>;
113
+ const id = record['id'];
114
+ if (typeof id !== 'string') continue;
115
+ out.set(id, { id, requires: stringList(record['requires']) });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** `pack + requires*` — what an install selecting `pack` actually receives. */
121
+ export function installClosure(pack: string, packs: Map<string, PackDef>): Set<string> {
122
+ const seen = new Set<string>();
123
+ const queue = [pack];
124
+ while (queue.length > 0) {
125
+ const current = queue.pop()!;
126
+ if (seen.has(current)) continue;
127
+ seen.add(current);
128
+ for (const required of packs.get(current)?.requires ?? []) queue.push(required);
129
+ }
130
+ return seen;
131
+ }
132
+
133
+ interface RuleInfo {
134
+ name: string;
135
+ packs: string[];
136
+ routesToSkills: string[];
137
+ }
138
+
139
+ export function readRules(dir: string): RuleInfo[] {
140
+ return fs
141
+ .readdirSync(dir)
142
+ .filter((f) => f.endsWith('.md'))
143
+ .map((f) => {
144
+ const fm = frontmatterObject(path.join(dir, f));
145
+ return {
146
+ name: f.replace(/\.md$/, ''),
147
+ packs: stringList(fm['packs']),
148
+ routesToSkills: stringList(fm['routes_to'])
149
+ .filter((r) => r.startsWith('skill:'))
150
+ .map((r) => r.slice('skill:'.length)),
151
+ };
152
+ });
153
+ }
154
+
155
+ export function readSkillPacks(dir: string): Map<string, string[]> {
156
+ const out = new Map<string, string[]>();
157
+ for (const name of fs.readdirSync(dir)) {
158
+ const file = path.join(dir, name, 'SKILL.md');
159
+ if (!fs.existsSync(file)) continue;
160
+ out.set(name, stringList(frontmatterObject(file)['packs']));
161
+ }
162
+ return out;
163
+ }
164
+
165
+ export function analyze(
166
+ rules: RuleInfo[],
167
+ skillPacks: Map<string, string[]>,
168
+ packs: Map<string, PackDef>,
169
+ ): Finding[] {
170
+ const findings: Finding[] = [];
171
+
172
+ // 1. unreachable-route — the obligation without the artefact.
173
+ for (const rule of rules) {
174
+ if (rule.packs.length === 0) continue; // unscoped rule ships everywhere
175
+ for (const skill of rule.routesToSkills) {
176
+ const target = skillPacks.get(skill);
177
+ if (target === undefined) continue; // a missing skill is check_references' job
178
+ if (target.length === 0) continue; // unscoped skill ships everywhere
179
+ const unreachableFrom = rule.packs.filter((p) => {
180
+ const closure = installClosure(p, packs);
181
+ return !target.some((tp) => closure.has(tp));
182
+ });
183
+ if (unreachableFrom.length === 0) continue;
184
+ findings.push({
185
+ kind: 'unreachable-route',
186
+ subject: `${rule.name} → skill:${skill}`,
187
+ detail: `rule pack(s) [${unreachableFrom.join(', ')}] do not reach skill pack(s) [${target.join(', ')}]`,
188
+ });
189
+ }
190
+ }
191
+
192
+ // 2. unrouted-skill — the artefact without a path to it.
193
+ const routedSkills = new Set(rules.flatMap((r) => r.routesToSkills));
194
+ for (const skill of routedSkills) {
195
+ const target = skillPacks.get(skill);
196
+ if (target === undefined || target.length === 0) continue;
197
+ const routingRules = rules.filter((r) => r.routesToSkills.includes(skill));
198
+ for (const skillPack of target) {
199
+ const closure = installClosure(skillPack, packs);
200
+ const reached = routingRules.some(
201
+ (r) => r.packs.length === 0 || r.packs.some((p) => closure.has(p)),
202
+ );
203
+ if (reached) continue;
204
+ findings.push({
205
+ kind: 'unrouted-skill',
206
+ subject: `skill:${skill}`,
207
+ detail: `an install of pack [${skillPack}] receives this skill but none of its routing rule(s) [${routingRules.map((r) => r.name).join(', ')}]`,
208
+ });
209
+ }
210
+ }
211
+
212
+ return findings;
213
+ }
214
+
215
+ function main(): number {
216
+ const packs = readPacks(PACKS_FILE);
217
+ const rules = readRules(RULES_DIR);
218
+ const skillPacks = readSkillPacks(SKILLS_DIR);
219
+
220
+ if (rules.length === 0 || skillPacks.size === 0 || packs.size === 0) {
221
+ process.stderr.write(
222
+ `❌ lint_rule_skill_pack_reach scanned nothing (rules=${rules.length}, skills=${skillPacks.size}, packs=${packs.size}) — refusing to report a pass\n`,
223
+ );
224
+ return 2;
225
+ }
226
+
227
+ const findings = analyze(rules, skillPacks, packs);
228
+ const errors = findings.filter((f) => f.kind === 'unreachable-route');
229
+ const advisories = findings.filter((f) => f.kind === 'unrouted-skill');
230
+
231
+ // --quiet suppresses the per-finding lines but never the count line: this
232
+ // gate is expected to stay non-empty for a while, and a quiet run that
233
+ // printed nothing at all would read as clean.
234
+ const quiet = process.argv.includes('--quiet');
235
+ if (!quiet) {
236
+ for (const finding of errors) {
237
+ process.stdout.write(`❌ ${finding.subject} — ${finding.detail}\n`);
238
+ }
239
+ for (const finding of advisories) {
240
+ process.stdout.write(`⚠️ ${finding.subject} — ${finding.detail}\n`);
241
+ }
242
+ }
243
+
244
+ const strict = process.argv.includes('--strict');
245
+ 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`,
247
+ );
248
+ if (errors.length > 0 && !strict) {
249
+ process.stdout.write(
250
+ `advisory run: ${errors.length} unreachable-route finding(s) reported, exit 0. Promote with --strict once the set is empty.\n`,
251
+ );
252
+ }
253
+ return strict && errors.length > 0 ? 1 : 0;
254
+ }
255
+
256
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
257
+ try {
258
+ process.exit(main());
259
+ } catch (error) {
260
+ process.stderr.write(`❌ ${(error as Error).message}\n`);
261
+ process.exit(2);
262
+ }
263
+ }