forge-workflow 0.1.0-beta.2 → 0.1.0-beta.4

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 (155) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -0,0 +1,750 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Static skill-eval scorer (W3) -- a DETERMINISTIC, free, CI-gateable scorecard per skill.
5
+ *
6
+ * WHY only three scored parameters. A skill's real quality includes how reliably it TRIGGERS on
7
+ * paraphrased intent, whether its chains fire correctly, and its live-outcome quality. Those are
8
+ * inherently SEMANTIC: the skills/<name>/evals/evals.json fixtures are natural paraphrases, and
9
+ * the W1 deterministic router (routeSkill) is a curated-KEYWORD matcher -- so measuring recall
10
+ * with it scores keyword overlap, not skill quality (mean ~0.32 on real fixtures). Trigger
11
+ * recall/precision/disambiguation therefore belong to the W5 BEHAVIORAL tier (LLM judge) and
12
+ * appear here ONLY as typed placeholders. The static composite is built from the parameters that
13
+ * ARE deterministic and free to measure:
14
+ *
15
+ * - token_cost description chars + body lines, normalized (lower is better).
16
+ * - caps compliance with the desc<=1024 / body<=500 progressive-disclosure budget.
17
+ * - description_quality has explicit trigger phrases + disambiguation cues (the PR #292 pattern).
18
+ *
19
+ * Alongside the composite we run a ROUTER-REACHABILITY LINT (independent of the quality score): a
20
+ * fixtured skill with NO curated INTENT_RULES rule and not router-exempt can NEVER be reached via
21
+ * `forge skill for` -- a real defect the gate blocks. A skill that HAS a rule but whose paraphrase
22
+ * fixtures don't hit its keywords is only a warning (that gap is exactly what the W5 judge fixes).
23
+ *
24
+ * Everything here is pure fs + pure JS: no LLM, no network, no subprocess -> reproducible in CI on
25
+ * every platform. Follow-up rationale is recorded in kernel issue skill-eval-static.
26
+ *
27
+ * @module skill-eval
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { INTENT_RULES, routeSkill, resolveSkillsRoot, loadSkillCatalog } = require('./using-forge');
33
+
34
+ // Progressive-disclosure budget -- mirrors test/skills/context-cost.test.js (single source of the
35
+ // numbers; kept in sync by the shared gate philosophy, not a runtime import to avoid test-dep).
36
+ const DESC_CAP = 1024; // Anthropic Agent Skills description limit (hard)
37
+ const BODY_CAP = 500; // body-line budget (loads on every trigger)
38
+
39
+ // Skills whose body legitimately exceeds BODY_CAP (documented in context-cost). `plan` keeps its
40
+ // HARD-GATEs in the body by design. Mirror it here so caps scoring agrees with the context gate.
41
+ const BODY_OVER_ALLOWLIST = new Set(['plan']);
42
+
43
+ // Skills intentionally NOT deterministic route targets (harness-internal, never user-invoked).
44
+ // Empty today: hermes-forge is a legitimate Hermes-session trigger target and instead carries a
45
+ // curated INTENT_RULES rule. The mechanism is retained (mirrors the chain-exempt pattern) so a
46
+ // future internal adapter can be classified honestly rather than forced to fake a route.
47
+ const ROUTER_EXEMPT = new Set([]);
48
+
49
+ // Documented composite weighting over the deterministic static params. description_quality leads
50
+ // (it is the actionable, skill-authored signal); token_cost next; caps compliance last (it is also
51
+ // a hard gate, so it contributes little marginal ranking signal). Weights sum to 1.
52
+ const WEIGHTS = Object.freeze({ description_quality: 0.5, token_cost: 0.3, caps: 0.2 });
53
+
54
+ // Gate floor for description_quality. 60 == adequate length + at least one of {trigger,
55
+ // disambiguation} cue; every canonical skill meets it today, and a regression that strips a
56
+ // skill's trigger phrasing AND contrast cues, or shrinks it below the length band, trips it.
57
+ const DESC_QUALITY_FLOOR = 60;
58
+
59
+ // Deterministic description-quality cues (lowercased substring checks -- no regex backtracking).
60
+ const TRIGGER_CUES = ['use when', 'use this', 'use it when', 'use whenever', 'when the user', 'when you', 'triggers:', 'trigger:'];
61
+ const DISAMBIGUATION_CUES = ['not ', "don't", 'do not', 'never', 'unlike', 'instead of', 'rather than', ' vs ', 'vs.', 'as opposed to', 'not for', 'skip'];
62
+
63
+ const RULE_SKILLS = new Set(INTENT_RULES.map(r => r.skill));
64
+
65
+ /** True when `name` has a curated INTENT_RULES rule (i.e. the deterministic router can target it). */
66
+ function hasCuratedRule(name) {
67
+ return RULE_SKILLS.has(name);
68
+ }
69
+
70
+ /** Clamp a ratio into [0, 1]. */
71
+ function clamp01(x) {
72
+ if (x < 0) return 0;
73
+ if (x > 1) return 1;
74
+ return x;
75
+ }
76
+
77
+ /**
78
+ * token_cost: normalized cost of a skill's permanent (description) + on-trigger (body) footprint.
79
+ * Lower cost -> higher score. Each axis is measured against its cap and averaged 50/50.
80
+ * @param {{descLen:number, bodyLines:number}} m
81
+ */
82
+ function scoreTokenCost({ descLen, bodyLines }) {
83
+ const descRatio = clamp01(descLen / DESC_CAP);
84
+ const bodyRatio = clamp01(bodyLines / BODY_CAP);
85
+ const score = Math.round(100 * (1 - (descRatio * 0.5 + bodyRatio * 0.5)));
86
+ return { desc_chars: descLen, body_lines: bodyLines, score };
87
+ }
88
+
89
+ /**
90
+ * caps: hard compliance with the progressive-disclosure budget. Description over the cap always
91
+ * fails; body over the cap fails UNLESS the skill is allowlisted (mirrors the context-cost gate).
92
+ * @param {{descLen:number, bodyLines:number, allowlisted:boolean}} m
93
+ */
94
+ function scoreCaps({ descLen, bodyLines, allowlisted }) {
95
+ const descWithin = descLen <= DESC_CAP;
96
+ const bodyWithin = bodyLines <= BODY_CAP || allowlisted === true;
97
+ return { desc_within: descWithin, body_within: bodyWithin, score: descWithin && bodyWithin ? 100 : 0 };
98
+ }
99
+
100
+ /** Case-insensitive "does haystack contain any of these substrings". */
101
+ function containsAny(haystack, needles) {
102
+ const lower = String(haystack).toLowerCase();
103
+ return needles.some(n => lower.includes(n));
104
+ }
105
+
106
+ /**
107
+ * description_quality: the PR #292 description pattern, scored deterministically.
108
+ * - trigger cues present (explicit "use when / when the user / triggers:") -> 40
109
+ * - disambiguation cues (contrast markers: "NOT the X skill", "unlike", ...) -> 40
110
+ * - adequate length (>=120 chars and within the cap) -> 20
111
+ * @param {string} description
112
+ */
113
+ function scoreDescriptionQuality(description) {
114
+ const desc = String(description || '');
115
+ const hasTrigger = containsAny(desc, TRIGGER_CUES);
116
+ const hasDisambiguation = containsAny(desc, DISAMBIGUATION_CUES);
117
+ const adequate = desc.length >= 120 && desc.length <= DESC_CAP;
118
+ const score = (hasTrigger ? 40 : 0) + (hasDisambiguation ? 40 : 0) + (adequate ? 20 : 0);
119
+ return { has_trigger_cues: hasTrigger, has_disambiguation_cues: hasDisambiguation, adequate_length: adequate, score };
120
+ }
121
+
122
+ /** Weighted composite over the three deterministic static params -> integer 0..100. */
123
+ function composite({ tokenCost, caps, descQuality }) {
124
+ const value =
125
+ WEIGHTS.description_quality * descQuality.score +
126
+ WEIGHTS.token_cost * tokenCost.score +
127
+ WEIGHTS.caps * caps.score;
128
+ return Math.max(0, Math.min(100, Math.round(value)));
129
+ }
130
+
131
+ /** The W5 behavioral-tier params, typed as null placeholders so consumers can rely on the shape. */
132
+ function behavioralPlaceholders() {
133
+ return {
134
+ trigger_recall: null,
135
+ trigger_precision: null,
136
+ disambiguation: null,
137
+ chain_correctness: null,
138
+ outcome_quality: null,
139
+ variance: null,
140
+ note: 'behavioral — W5 (LLM judge): semantic recall/precision/chain/outcome/variance, not deterministic',
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Router-reachability lint (B): run a skill's should_trigger fixtures through the deterministic
146
+ * router and report whether >=1 places this skill as best-match. This measures the W1 router's
147
+ * COVERAGE (a fixable gap), NOT skill quality, so it is separate from the composite.
148
+ *
149
+ * @param {{name:string, fixtures:Array|null, catalog:Array, hasRule:boolean, exempt:boolean}} args
150
+ */
151
+ function routerReachability({ name, fixtures, catalog, hasRule, exempt }) {
152
+ const base = { has_curated_rule: hasRule === true, router_exempt: exempt === true };
153
+ const positives = Array.isArray(fixtures) ? fixtures.filter(f => f && f.should_trigger === true) : null;
154
+ if (!positives || positives.length === 0) {
155
+ return { ...base, fixtures: 'no-fixtures', fixtures_total: 0, fixtures_best_hit: 0, reachable: null, keyword_alignment: null };
156
+ }
157
+ let hit = 0;
158
+ for (const f of positives) {
159
+ const r = routeSkill(f.query, { catalog });
160
+ if (r.best === name) hit += 1;
161
+ }
162
+ return {
163
+ ...base,
164
+ fixtures: 'present',
165
+ fixtures_total: positives.length,
166
+ fixtures_best_hit: hit,
167
+ reachable: hit > 0,
168
+ keyword_alignment: Math.round((hit / positives.length) * 100) / 100,
169
+ };
170
+ }
171
+
172
+ /** Strip a leading UTF-8 BOM without embedding the literal char. */
173
+ function stripBom(value) {
174
+ const s = String(value);
175
+ return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
176
+ }
177
+
178
+ /**
179
+ * Parse a skill's SKILL.md source into { name, description, descLen, bodyLines }. Mirrors the
180
+ * context-cost parser so caps/token measurements agree with that gate. Returns null when absent.
181
+ * @param {string} skillsDir absolute path to the skills/ directory
182
+ * @param {string} name skill folder name
183
+ */
184
+ function parseSkillSource(skillsDir, name) {
185
+ const file = path.join(skillsDir, name, 'SKILL.md');
186
+ if (!fs.existsSync(file)) return null;
187
+ const text = stripBom(fs.readFileSync(file, 'utf8'));
188
+ const lines = text.split(/\r?\n/);
189
+ if (lines[0].trim() !== '---') return { name, description: '', descLen: 0, bodyLines: lines.length };
190
+ let fmEnd = -1;
191
+ for (let i = 1; i < lines.length; i++) {
192
+ if (lines[i].trim() === '---') { fmEnd = i; break; }
193
+ }
194
+ if (fmEnd === -1) return { name, description: '', descLen: 0, bodyLines: 0 };
195
+ const fm = lines.slice(1, fmEnd).join('\n');
196
+ const m = fm.match(/description:\s*([\s\S]*?)(?:\n[A-Za-z_-]+:|$)/);
197
+ const description = m ? m[1].replace(/^>\s*/, '').replace(/\s+/g, ' ').trim() : '';
198
+ const bodyLines = lines.length - (fmEnd + 1);
199
+ return { name, description, descLen: description.length, bodyLines };
200
+ }
201
+
202
+ /**
203
+ * Load a skill's evals.json fixtures as a typed STATE. ONLY a genuinely ABSENT file is
204
+ * "no-fixtures"; every other broken shape is 'invalid' so it can never silently disable the
205
+ * router-reachability lint and pass CI:
206
+ * - 'absent' no evals.json file.
207
+ * - 'valid' file parses to an ARRAY, EVERY entry has a non-empty string `query` and a boolean
208
+ * `should_trigger`, AND there is >=1 positive (should_trigger:true) fixture.
209
+ * - 'invalid' file exists but is unreadable / not JSON / not an array / has a wrong-shape entry
210
+ * (e.g. `shouldTrigger` instead of `should_trigger`, missing `query`) / has zero
211
+ * positive fixtures. The `error` names the first bad entry and why.
212
+ * @returns {{status:'absent'|'valid'|'invalid', fixtures:Array|null, error:string|null}}
213
+ */
214
+ function loadFixtures(skillsDir, name) {
215
+ const file = path.join(skillsDir, name, 'evals', 'evals.json');
216
+ if (!fs.existsSync(file)) return { status: 'absent', fixtures: null, error: null };
217
+ let raw;
218
+ try {
219
+ raw = stripBom(fs.readFileSync(file, 'utf8'));
220
+ } catch (err) {
221
+ return { status: 'invalid', fixtures: null, error: `unreadable evals.json: ${err.message}` };
222
+ }
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(raw);
226
+ } catch (err) {
227
+ return { status: 'invalid', fixtures: null, error: `malformed evals.json: ${err.message}` };
228
+ }
229
+ if (!Array.isArray(parsed)) {
230
+ return { status: 'invalid', fixtures: null, error: 'evals.json is not a JSON array of fixtures' };
231
+ }
232
+ for (let i = 0; i < parsed.length; i += 1) {
233
+ const entry = parsed[i];
234
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
235
+ return { status: 'invalid', fixtures: null, error: `fixture #${i} is not an object` };
236
+ }
237
+ if (typeof entry.query !== 'string' || entry.query.trim() === '') {
238
+ return { status: 'invalid', fixtures: null, error: `fixture #${i} has no non-empty string "query"` };
239
+ }
240
+ if (typeof entry.should_trigger !== 'boolean') {
241
+ return { status: 'invalid', fixtures: null, error: `fixture #${i} has no boolean "should_trigger" (check for a mis-spelled key)` };
242
+ }
243
+ }
244
+ if (!parsed.some(e => e.should_trigger === true)) {
245
+ return { status: 'invalid', fixtures: null, error: 'no positive (should_trigger:true) fixtures — the reachability lint would be a no-op' };
246
+ }
247
+ return { status: 'valid', fixtures: parsed, error: null };
248
+ }
249
+
250
+ /**
251
+ * Build a full deterministic scorecard for one skill. `catalog` is the router catalog (injectable);
252
+ * a skill missing its SKILL.md yields null.
253
+ * @param {{skillsDir:string, name:string, catalog:Array}} args
254
+ */
255
+ function buildScorecard({ skillsDir, name, catalog }) {
256
+ const src = parseSkillSource(skillsDir, name);
257
+ if (!src) return null;
258
+ const allowlisted = BODY_OVER_ALLOWLIST.has(name);
259
+ const tokenCost = scoreTokenCost({ descLen: src.descLen, bodyLines: src.bodyLines });
260
+ const caps = scoreCaps({ descLen: src.descLen, bodyLines: src.bodyLines, allowlisted });
261
+ const descQuality = scoreDescriptionQuality(src.description);
262
+ const fx = loadFixtures(skillsDir, name);
263
+ let reachability;
264
+ if (fx.status === 'invalid') {
265
+ // A broken fixture file is NOT "no-fixtures": mark it explicitly so the gate can fail it.
266
+ reachability = {
267
+ has_curated_rule: hasCuratedRule(name),
268
+ router_exempt: ROUTER_EXEMPT.has(name),
269
+ fixtures: 'invalid',
270
+ error: fx.error,
271
+ fixtures_total: 0,
272
+ fixtures_best_hit: 0,
273
+ reachable: null,
274
+ keyword_alignment: null,
275
+ };
276
+ } else {
277
+ reachability = routerReachability({
278
+ name,
279
+ fixtures: fx.fixtures,
280
+ catalog: catalog || [],
281
+ hasRule: hasCuratedRule(name),
282
+ exempt: ROUTER_EXEMPT.has(name),
283
+ });
284
+ }
285
+ return {
286
+ skill: name,
287
+ fixtures: reachability.fixtures, // 'present' | 'no-fixtures' | 'invalid'
288
+ static: { token_cost: tokenCost, caps, description_quality: descQuality },
289
+ router_reachability: reachability,
290
+ behavioral: behavioralPlaceholders(),
291
+ composite: composite({ tokenCost, caps, descQuality }),
292
+ };
293
+ }
294
+
295
+ /** Build scorecards for every skill under `skillsDir`, keyed by name (sorted, deterministic). */
296
+ function buildAllScorecards(skillsDir, catalog) {
297
+ const cat = catalog || [];
298
+ const names = fs
299
+ .readdirSync(skillsDir, { withFileTypes: true })
300
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
301
+ .map(e => e.name)
302
+ .sort();
303
+ const out = {};
304
+ for (const name of names) {
305
+ const card = buildScorecard({ skillsDir, name, catalog: cat });
306
+ if (card) out[name] = card;
307
+ }
308
+ return out;
309
+ }
310
+
311
+ /**
312
+ * Resolve the canonical skills/ SOURCE dir, preferring the consumer/dev checkout's own skills/
313
+ * and FALLING BACK to the packaged skills root (resolveSkillsRoot() with no override wraps
314
+ * getPackageRoot). This is the SHARED resolver every `forge skill` verb must use so an installed
315
+ * consumer project (which has no root skills/) reads Forge's PACKAGED skills instead of failing
316
+ * with "no skills dir" — mirrors what `forge skill for` already does. Never throws -> null only
317
+ * when no skills/ exists on either root. Keep this the single resolver so no future verb regresses.
318
+ * @param {string} [projectRoot]
319
+ * @returns {string|null}
320
+ */
321
+ function resolveSkillsDir(projectRoot) {
322
+ const roots = [];
323
+ if (projectRoot) roots.push(projectRoot);
324
+ const pkg = resolveSkillsRoot();
325
+ if (pkg && pkg !== projectRoot) roots.push(pkg);
326
+ for (const root of roots) {
327
+ const dir = path.join(root, 'skills');
328
+ // Require at least one <name>/SKILL.md: a consumer project may have an unrelated or empty
329
+ // skills/ dir, which would otherwise be selected and yield ZERO scorecards (a hollow PASS).
330
+ // An empty/skill-less dir falls through to the packaged skills root.
331
+ if (dirHasAnySkill(dir)) return dir;
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /** True when `dir` contains at least one `<name>/SKILL.md` (a real skills source, not an empty dir). */
337
+ function dirHasAnySkill(dir) {
338
+ let entries;
339
+ try {
340
+ entries = fs.readdirSync(dir, { withFileTypes: true });
341
+ } catch {
342
+ return false;
343
+ }
344
+ return entries.some(e => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')));
345
+ }
346
+
347
+ /**
348
+ * Resolve the skills SOURCE dir, its aligned router catalog, AND which root was selected, so eval
349
+ * and scores never read the catalog from a different root than the scorecards. `source` is:
350
+ * - 'project' the consumer/dev SOURCE checkout's own skills/ was selected (projectRoot/skills).
351
+ * A committed .agents/skills MIRROR is expected here, so mirror drift MUST be gated.
352
+ * - 'package' fell back to the PACKAGED skills root (installed consumer with no root skills/).
353
+ * No mirror ships with the package, so a mirror check would be false drift.
354
+ * Returns null when no skills/ is resolvable.
355
+ * @param {string} [projectRoot]
356
+ * @returns {{ skillsDir: string, catalog: {name:string,description:string}[], source: 'project'|'package' } | null}
357
+ */
358
+ function resolveSkillsContext(projectRoot) {
359
+ const skillsDir = resolveSkillsDir(projectRoot);
360
+ if (!skillsDir) return null;
361
+ const source = projectRoot && skillsDir === path.join(projectRoot, 'skills') ? 'project' : 'package';
362
+ // loadSkillCatalog(override) reads override/skills — pass the parent of the resolved skills dir.
363
+ return { skillsDir, catalog: loadSkillCatalog(path.dirname(skillsDir)), source };
364
+ }
365
+
366
+ /** Read a committed scorecard.json under `<baseDir>/<name>/evals/`, or null when absent/unparseable. */
367
+ function loadCommittedScorecard(baseDir, name) {
368
+ const file = path.join(baseDir, name, 'evals', 'scorecard.json');
369
+ if (!fs.existsSync(file)) return null;
370
+ try {
371
+ return JSON.parse(stripBom(fs.readFileSync(file, 'utf8')));
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Detect scorecard DRIFT: for each fresh scorecard, the committed artifact under the canonical
379
+ * skills/ dir (and, when present, the .agents/skills MIRROR) must byte-match the recomputed card.
380
+ * Returns [{ skill, where, reason }] — empty when everything is fresh. This is what lets both the
381
+ * CI gate and `forge skill scores` refuse to report PASS while a committed artifact is stale.
382
+ * @param {{skillsDir:string, freshCards:object, mirrorDir?:string|null}} args
383
+ */
384
+ function detectScorecardDrift({ skillsDir, freshCards, mirrorDir }) {
385
+ const drift = [];
386
+ for (const [name, fresh] of Object.entries(freshCards)) {
387
+ const expected = JSON.stringify(fresh);
388
+ const canonical = loadCommittedScorecard(skillsDir, name);
389
+ if (canonical === null) {
390
+ drift.push({ skill: name, where: 'canonical', reason: 'missing scorecard.json — run `forge skill eval --static`' });
391
+ } else if (JSON.stringify(canonical) !== expected) {
392
+ drift.push({ skill: name, where: 'canonical', reason: 'scorecard.json out of date — run `forge skill eval --static`' });
393
+ }
394
+ // No existsSync(mirrorDir) guard: a TOTALLY missing mirror is the worst case and must report
395
+ // drift per skill (loadCommittedScorecard already returns null safely for missing files). The
396
+ // caller passes mirrorDir only when a mirror is expected in this checkout (null skips it).
397
+ if (mirrorDir) {
398
+ const mirror = loadCommittedScorecard(mirrorDir, name);
399
+ if (mirror === null) {
400
+ drift.push({ skill: name, where: 'mirror', reason: 'missing .agents/skills scorecard.json — regenerate the mirror' });
401
+ } else if (JSON.stringify(mirror) !== expected) {
402
+ drift.push({ skill: name, where: 'mirror', reason: '.agents/skills scorecard.json out of date — regenerate the mirror' });
403
+ }
404
+ }
405
+ }
406
+ return drift;
407
+ }
408
+
409
+ /** List every skill name (a `<name>/SKILL.md` exists) under `skillsDir`, sorted. */
410
+ function listSkillNames(skillsDir) {
411
+ return fs
412
+ .readdirSync(skillsDir, { withFileTypes: true })
413
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
414
+ .map(e => e.name)
415
+ .sort();
416
+ }
417
+
418
+ /**
419
+ * Load skills/coverage.json — the command→owning-skill coverage map. Shape:
420
+ * { "version": 1, "commands": { "<cmd>": "<skill>" | { "exempt": "<reason>" }, ... } }
421
+ * Returns null when the file is absent (a hard gate failure surfaced by evaluateCoverage) or an
422
+ * object with `__error` set when it exists but is unparseable. Never throws.
423
+ * @param {string} skillsDir
424
+ */
425
+ function loadCoverageMap(skillsDir) {
426
+ const file = path.join(skillsDir, 'coverage.json');
427
+ if (!fs.existsSync(file)) return null;
428
+ try {
429
+ return JSON.parse(stripBom(fs.readFileSync(file, 'utf8')));
430
+ } catch (err) {
431
+ return { __error: `unparseable coverage.json: ${err.message}` };
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Enumerate every REGISTERED command name from the Forge command registry (the source of truth for
437
+ * the user-facing command surface). Lazy-requires the registry INSIDE the function — never at module
438
+ * top — because several command modules require this module (skill-eval); a top-level require would
439
+ * be circular. By call time the registry and all command modules are fully loaded and cached, so a
440
+ * lazy require is safe and returns the real dispatchable set (manifest + readdir fallback).
441
+ * @returns {string[]} sorted command names
442
+ */
443
+ function enumerateCommandNames() {
444
+ const { loadCommands } = require('./commands/_registry');
445
+ const commandsDir = path.join(__dirname, 'commands');
446
+ const { commands } = loadCommands(commandsDir);
447
+ const names = new Set(commands.keys());
448
+ // UNION the registry with commands dispatched DIRECTLY in the shipped binaries (docs,
449
+ // reset, rollback, review, verify, …). These never enter the registry, so without this
450
+ // they would escape the coverage gate. Parsed from source so a new direct command is
451
+ // auto-covered. (Read failures are surfaced separately via scanBinDirectCommands so the
452
+ // gate can FAIL CLOSED — see buildCoverageReport — rather than silently drop them.)
453
+ for (const name of scanBinDirectCommands().names) names.add(name);
454
+ return [...names].sort((a, b) => a.localeCompare(b)); // explicit comparator (SonarCloud)
455
+ }
456
+
457
+ /**
458
+ * Command names dispatched directly in bin/forge.js (`command === '<name>'`) rather than
459
+ * through the registry. Parsed from source (best-effort; returns [] if bin/forge.js is
460
+ * unreadable) so the coverage gate covers the non-registry CLI surface too.
461
+ * @returns {string[]}
462
+ */
463
+ function scanBinDirectCommands() {
464
+ const names = new Set();
465
+ const errors = [];
466
+ const { getPackageRoot, isCompiledBinary } = require('./package-root');
467
+ // Resolve bin/ via the package root so it works from an extracted-asset install too, not
468
+ // just __dirname/../bin. In a COMPILED single-file binary the bin/ scripts are not embedded
469
+ // assets, so the tree can be absent — that is EXPECTED (the coverage gate is a dev/CI check),
470
+ // NOT a scan failure. In that case skip the direct scan without failing closed.
471
+ const compiled = (() => { try { return isCompiledBinary(); } catch { return false; } })();
472
+ const binDir = path.join(getPackageRoot(), 'bin');
473
+ if (compiled && !fs.existsSync(binDir)) return { names: [], errors: [] };
474
+ const wordRe = /['"]([a-z][a-z0-9_-]*)['"]/g;
475
+ const allMatches = (text, re) => { const out = []; let m; while ((m = re.exec(text)) !== null) out.push(m[1]); return out; };
476
+ // Each shipped binary (package.json `bin`) dispatches commands its OWN way; use a
477
+ // per-file extractor scoped to that file's real dispatch so unrelated switch/menu
478
+ // cases are never caught. This covers the full bounded bin/ set (forge.js,
479
+ // forge-preflight.js, forge-cmd.js); a new binary needs its extractor added here.
480
+ const sources = [
481
+ // main CLI: `command === '<name>'`
482
+ { file: 'forge.js', extract: (src) => allMatches(src, /command === '([a-z][a-z0-9_-]*)'/g) },
483
+ // preflight binary: `switch(command){ case "<name>": }`
484
+ { file: 'forge-preflight.js', extract: (src) => allMatches(src, /case ['"]([a-z][a-z0-9_-]*)['"]:/g) },
485
+ // legacy forge-cmd dispatcher: a `VALID_COMMANDS = [ '<name>', … ]` array
486
+ { file: 'forge-cmd.js', extract: (src) => {
487
+ const block = /VALID_COMMANDS\s*=\s*\[([\s\S]*?)\]/.exec(src);
488
+ return block ? allMatches(block[1], wordRe) : [];
489
+ } },
490
+ ];
491
+ for (const { file, extract } of sources) {
492
+ try {
493
+ const src = fs.readFileSync(path.join(binDir, file), 'utf8');
494
+ for (const name of extract(src)) names.add(name);
495
+ } catch (err) {
496
+ // FAIL CLOSED in a real source checkout: every source here is an EXPECTED shipped
497
+ // binary, so an unreadable one means the gate is blind to part of the command surface
498
+ // — record a scan error (which fails the gate) rather than silently letting those
499
+ // commands bypass. In a compiled binary a missing bin/ script is expected, not a gap.
500
+ if (!compiled) errors.push({ source: `bin/${file}`, detail: err.message });
501
+ }
502
+ }
503
+ return { names: [...names], errors };
504
+ }
505
+
506
+ /**
507
+ * Coverage GATE (part 1 of §3.3): every registered command must be OWNED by an existing skill or
508
+ * explicitly `{ exempt: "<reason>" }`. A registered command with NO coverage.json entry FAILS — so a
509
+ * new command physically cannot merge without deciding its skill home. A mapping to a skill that
510
+ * does not exist FAILS. A malformed/empty entry FAILS. A coverage.json entry for a command that no
511
+ * longer exists is a non-blocking WARNING (keep the map honest without surprise failures).
512
+ * @param {{commands:string[], coverage:object|null, skillNames:Set<string>}} args
513
+ * @returns {{passed:boolean, failures:Array, warnings:Array, mapped:number, exempt:number, total:number}}
514
+ */
515
+ function evaluateCoverage({ commands, coverage, skillNames, scanErrors = [] }) {
516
+ const failures = [];
517
+ const warnings = [];
518
+ // FAIL CLOSED on unreadable command sources: a binary the scan could not read means the
519
+ // gate is blind to part of the command surface, so it must fail rather than pass.
520
+ for (const e of (Array.isArray(scanErrors) ? scanErrors : [])) {
521
+ failures.push({ command: e.source, kind: 'unreadable_command_source', detail: `could not read ${e.source}; the coverage gate cannot see its commands (${e.detail})` });
522
+ }
523
+ const total = Array.isArray(commands) ? commands.length : 0;
524
+ if (total === 0) {
525
+ // Defensive: an empty registry enumeration would make every coverage.json entry look "stale"
526
+ // and vacuously PASS. Fail instead so a broken enumeration can never hollow-pass the gate.
527
+ failures.push({ command: '*', kind: 'no_commands_enumerated', detail: 'command registry enumerated zero commands — cannot evaluate coverage' });
528
+ return { passed: false, failures, warnings, mapped: 0, exempt: 0, total };
529
+ }
530
+ if (!coverage || coverage.__error) {
531
+ failures.push({
532
+ command: '*',
533
+ kind: 'coverage_map_missing',
534
+ detail: coverage && coverage.__error ? coverage.__error : 'skills/coverage.json not found',
535
+ });
536
+ return { passed: false, failures, warnings, mapped: 0, exempt: 0, total };
537
+ }
538
+ const map = coverage.commands && typeof coverage.commands === 'object' ? coverage.commands : {};
539
+ const known = new Set(commands);
540
+ let mapped = 0;
541
+ let exempt = 0;
542
+ for (const cmd of commands) {
543
+ if (!Object.prototype.hasOwnProperty.call(map, cmd)) {
544
+ failures.push({ command: cmd, kind: 'unmapped', detail: 'no coverage.json entry — map it to an owning skill or add { "exempt": "<reason>" }' });
545
+ continue;
546
+ }
547
+ const entry = map[cmd];
548
+ if (typeof entry === 'string') {
549
+ if (skillNames.has(entry)) mapped += 1;
550
+ else failures.push({ command: cmd, kind: 'unknown_skill', detail: `owning skill '${entry}' does not exist under skills/` });
551
+ } else if (entry && typeof entry === 'object' && typeof entry.exempt === 'string' && entry.exempt.trim() !== '') {
552
+ exempt += 1;
553
+ } else {
554
+ failures.push({ command: cmd, kind: 'malformed_entry', detail: 'entry must be an owning-skill name (string) or { "exempt": "<non-empty reason>" }' });
555
+ }
556
+ }
557
+ for (const key of Object.keys(map)) {
558
+ if (!known.has(key)) warnings.push({ command: key, kind: 'stale_entry', detail: 'coverage.json maps a command that is no longer registered — remove it' });
559
+ }
560
+ return { passed: failures.length === 0, failures, warnings, mapped, exempt, total };
561
+ }
562
+
563
+ /**
564
+ * Resolve the skills dir, enumerate the command registry, load coverage.json, and evaluate the
565
+ * coverage gate — one call for both the CLI (`forge skill coverage` / `forge skill scores`) and the
566
+ * CI test. Returns null when no skills/ is resolvable (mirrors resolveSkillsContext).
567
+ * @param {string} [projectRoot]
568
+ */
569
+ function buildCoverageReport(projectRoot) {
570
+ const skillsDir = resolveSkillsDir(projectRoot);
571
+ if (!skillsDir) return null;
572
+ const commands = enumerateCommandNames();
573
+ const { errors: scanErrors } = scanBinDirectCommands();
574
+ const coverage = loadCoverageMap(skillsDir);
575
+ const skillNames = new Set(listSkillNames(skillsDir));
576
+ const result = evaluateCoverage({ commands, coverage, skillNames, scanErrors });
577
+ return { skillsDir, commands, coverage, ...result };
578
+ }
579
+
580
+ /**
581
+ * The CI GATE. Given scorecards keyed by name, returns { passed, failures, warnings }.
582
+ * HARD failures (block CI):
583
+ * - caps: a description over the char cap or a non-allowlisted body over the line cap.
584
+ * - description_quality: below DESC_QUALITY_FLOOR.
585
+ * - router_unreachable: a FIXTURED skill with NO curated rule and NOT router-exempt (the router
586
+ * can never reach it via `forge skill for`).
587
+ * WARNINGS (surface in `forge skill scores`, never block): a fixtured skill that HAS a rule but
588
+ * whose paraphrase fixtures don't hit its keywords (reachable:false) -- the W5 judge is the real fix.
589
+ */
590
+ function evaluateGate(scorecards, options = {}) {
591
+ const failures = [];
592
+ const warnings = [];
593
+ // Committed-artifact drift is a hard failure: a stale scorecard.json (canonical or mirror) means
594
+ // the published scores disagree with the source, so the gate must not report PASS.
595
+ const drift = Array.isArray(options.drift) ? options.drift : [];
596
+ for (const d of drift) {
597
+ failures.push({ skill: d.skill, kind: 'scorecard_drift', detail: `${d.where}: ${d.reason}` });
598
+ }
599
+ for (const [name, card] of Object.entries(scorecards)) {
600
+ if (card.static.caps.score !== 100) {
601
+ failures.push({ skill: name, kind: 'caps', detail: `desc_within=${card.static.caps.desc_within} body_within=${card.static.caps.body_within}` });
602
+ }
603
+ if (card.static.description_quality.score < DESC_QUALITY_FLOOR) {
604
+ failures.push({ skill: name, kind: 'description_quality', detail: `score ${card.static.description_quality.score} < floor ${DESC_QUALITY_FLOOR}` });
605
+ }
606
+ const rr = card.router_reachability;
607
+ if (rr.fixtures === 'invalid') {
608
+ // A broken evals.json must FAIL, not silently disable the reachability lint.
609
+ failures.push({ skill: name, kind: 'invalid_fixtures', detail: rr.error || 'evals.json is malformed or not a JSON array' });
610
+ } else if (rr.fixtures === 'present' && !rr.has_curated_rule && !rr.router_exempt) {
611
+ failures.push({ skill: name, kind: 'router_unreachable', detail: 'fixtured skill has no curated INTENT_RULES rule and is not router-exempt' });
612
+ } else if (rr.fixtures === 'present' && rr.has_curated_rule && rr.reachable === false) {
613
+ warnings.push({ skill: name, kind: 'router_keyword_gap', detail: 'has a rule but no fixture reaches it (paraphrase gap — W5)' });
614
+ }
615
+ }
616
+ return { passed: failures.length === 0, failures, warnings };
617
+ }
618
+
619
+ /** Escape a string for literal use inside a RegExp. */
620
+ function escapeRegExp(s) {
621
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
622
+ }
623
+
624
+ /**
625
+ * Return a SKILL.md's INSTRUCTION BODY — the text after the YAML frontmatter block.
626
+ * The documentation lint scans the body only: a command named solely in the
627
+ * frontmatter description is NOT taught to an agent that loads the skill.
628
+ * @param {string} text raw SKILL.md contents (BOM already stripped)
629
+ * @returns {string}
630
+ */
631
+ function skillBody(text) {
632
+ if (!text.startsWith('---')) return text;
633
+ const fmEnd = text.indexOf('\n---', 3);
634
+ if (fmEnd === -1) return text;
635
+ const afterFence = text.indexOf('\n', fmEnd + 1);
636
+ return afterFence === -1 ? '' : text.slice(afterFence + 1);
637
+ }
638
+
639
+ /**
640
+ * Skill-accuracy lint, Dimension A — DOCUMENTATION coverage. For every command that
641
+ * coverage.json maps to a skill (string value; `{exempt}` objects are skipped), the
642
+ * command must be documented in that skill's SKILL.md body. "Documented" = the command
643
+ * IS the skill's own name (a self-titled skill documents its command implicitly), OR the
644
+ * command token appears (word-boundary) in the body. A miss is a HOLLOW mapping — the
645
+ * coverage gate passes but an agent routed to the skill is never taught the command
646
+ * (the exact defect that recurred on the batch-1 skills PR, e.g. orphans -> issue-basics).
647
+ *
648
+ * @param {{skillsDir:string, coverage:object}} args
649
+ * @returns {Array<{command:string, skill:string, reason:string}>} empty when clean.
650
+ */
651
+ /**
652
+ * Check one command→skill mapping. Returns a violation object, or null when the command
653
+ * is documented in the owning skill's instruction body. Extracted so the audit loop stays
654
+ * within the cognitive-complexity limit.
655
+ * @param {string} skillsDir
656
+ * @param {string} command
657
+ * @param {string} skill
658
+ * @returns {{command:string, skill:string, reason:string}|null}
659
+ */
660
+ function documentationViolation(skillsDir, command, skill) {
661
+ const file = path.join(skillsDir, skill, 'SKILL.md');
662
+ if (!fs.existsSync(file)) {
663
+ return { command, skill, reason: 'owning skill SKILL.md is missing' };
664
+ }
665
+ const body = skillBody(stripBom(fs.readFileSync(file, 'utf8')));
666
+ const re = new RegExp(`(^|[^a-z0-9-])${escapeRegExp(command)}([^a-z0-9-]|$)`, 'm');
667
+ if (!re.test(body)) {
668
+ return { command, skill, reason: 'command not referenced in the owning skill body' };
669
+ }
670
+ return null;
671
+ }
672
+
673
+ function auditCommandDocumentation({ skillsDir, coverage }) {
674
+ const map = (coverage && (coverage.commands || coverage)) || {};
675
+ const violations = [];
676
+ for (const [command, skill] of Object.entries(map)) {
677
+ // Skip exempt entries (object values) and self-titled skills (command === skill name).
678
+ if (typeof skill !== 'string' || command === skill) continue;
679
+ const violation = documentationViolation(skillsDir, command, skill);
680
+ if (violation) violations.push(violation);
681
+ }
682
+ return violations;
683
+ }
684
+
685
+ /**
686
+ * Skill-accuracy lint, Dimension B — ROUTER PRECISION. Assert that specific queries do
687
+ * NOT route to a named skill through the deterministic router — i.e. a curated keyword
688
+ * does not over-match a phrase it should not own. This targets the DETERMINISTIC
689
+ * keyword-collision class (e.g. a bare `gate status` cue capturing "SonarCloud gate
690
+ * status"), the negative complement to `routerReachability`.
691
+ *
692
+ * It deliberately does NOT blanket-scan every `should_trigger:false` fixture: many of
693
+ * those are SEMANTIC paraphrases where a legitimate keyword fires but the deeper intent
694
+ * differs (e.g. "…plan, build, validate, ship…" hits the `plan` cue but means an
695
+ * end-to-end run). Semantic precision is the W5 LLM tier's job (see the header note),
696
+ * not the deterministic router's — so those cases belong to behavioral eval, not here.
697
+ *
698
+ * Each case asserts the INTENDED destination (`query` must resolve to `expect`), which is
699
+ * stronger than a bare "not the wrong skill": it also catches the query silently routing to
700
+ * a THIRD wrong skill (e.g. "SonarCloud gate status" landing on `status` instead of gates or
701
+ * sonarcloud). Pick queries whose correct destination the deterministic router resolves
702
+ * unambiguously — leave the semantic near-misses to the W5 tier.
703
+ *
704
+ * @param {{cases:Array<{query:string, expect:string}>, catalog:Array, route?:function}} args
705
+ * each case asserts `query` must resolve to `expect`. route defaults to routeSkill.
706
+ * @returns {Array<{query:string, expected:string, routedTo:string}>} empty when clean.
707
+ */
708
+ function auditRouterPrecision({ cases, catalog, route }) {
709
+ const routeFn = typeof route === 'function' ? route : (q, opts) => routeSkill(q, opts);
710
+ const violations = [];
711
+ for (const c of Array.isArray(cases) ? cases : []) {
712
+ if (!c || typeof c.query !== 'string' || typeof c.expect !== 'string') continue;
713
+ const best = routeFn(c.query, { catalog }).best;
714
+ if (best !== c.expect) violations.push({ query: c.query, expected: c.expect, routedTo: best });
715
+ }
716
+ return violations;
717
+ }
718
+
719
+ module.exports = {
720
+ DESC_CAP,
721
+ BODY_CAP,
722
+ BODY_OVER_ALLOWLIST,
723
+ ROUTER_EXEMPT,
724
+ WEIGHTS,
725
+ DESC_QUALITY_FLOOR,
726
+ hasCuratedRule,
727
+ scoreTokenCost,
728
+ scoreCaps,
729
+ scoreDescriptionQuality,
730
+ composite,
731
+ behavioralPlaceholders,
732
+ routerReachability,
733
+ auditCommandDocumentation,
734
+ auditRouterPrecision,
735
+ parseSkillSource,
736
+ loadFixtures,
737
+ buildScorecard,
738
+ buildAllScorecards,
739
+ evaluateGate,
740
+ listSkillNames,
741
+ loadCoverageMap,
742
+ enumerateCommandNames,
743
+ evaluateCoverage,
744
+ buildCoverageReport,
745
+ resolveSkillsRoot,
746
+ resolveSkillsDir,
747
+ resolveSkillsContext,
748
+ loadCommittedScorecard,
749
+ detectScorecardDrift,
750
+ };