@mmerterden/multi-agent-pipeline 13.5.0 → 14.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 (119) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +3 -3
  3. package/docs/features.md +1 -1
  4. package/install/_common.mjs +73 -0
  5. package/install/_mcp-register.mjs +70 -31
  6. package/install/_plugin-skills.mjs +73 -14
  7. package/install/claude.mjs +28 -4
  8. package/install/codex.mjs +33 -2
  9. package/install/copilot.mjs +145 -9
  10. package/install/index.mjs +10 -6
  11. package/install/templates/copilot-instructions.md +1 -1
  12. package/package.json +1 -1
  13. package/pipeline/agents/code-reviewer.md +58 -1
  14. package/pipeline/commands/multi-agent/SKILL.md +7 -5
  15. package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -7
  16. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -1
  17. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +7 -7
  18. package/pipeline/commands/multi-agent/channels/SKILL.md +5 -5
  19. package/pipeline/commands/multi-agent/dev/SKILL.md +23 -18
  20. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +19 -13
  21. package/pipeline/commands/multi-agent/dev-local/SKILL.md +14 -12
  22. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +17 -12
  23. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  24. package/pipeline/commands/multi-agent/help/SKILL.md +4 -4
  25. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  26. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +4 -4
  27. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
  28. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  29. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -1
  30. package/pipeline/commands/multi-agent/search/SKILL.md +1 -1
  31. package/pipeline/commands/multi-agent/setup/SKILL.md +6 -6
  32. package/pipeline/commands/multi-agent/{finish → ship}/SKILL.md +12 -12
  33. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
  34. package/pipeline/commands/multi-agent/update/SKILL.md +5 -2
  35. package/pipeline/commands/sim-test.md +2 -2
  36. package/pipeline/lib/credential-store-resolver.sh +16 -0
  37. package/pipeline/lib/credential-store.sh +47 -4
  38. package/pipeline/lib/fetch-figma-annotations.sh +26 -28
  39. package/pipeline/lib/figma-screenshot.sh +28 -39
  40. package/pipeline/lib/figma-token.sh +63 -0
  41. package/pipeline/multi-agent-refs/analysis-template.md +1 -1
  42. package/pipeline/multi-agent-refs/android-guide.md +1 -1
  43. package/pipeline/multi-agent-refs/channels/issue-comment.md +1 -1
  44. package/pipeline/multi-agent-refs/component-dispatch.md +2 -2
  45. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  46. package/pipeline/multi-agent-refs/features/dev-critic.md +2 -2
  47. package/pipeline/multi-agent-refs/features/model-fallback.md +35 -2
  48. package/pipeline/multi-agent-refs/features/plan-todos.md +1 -1
  49. package/pipeline/multi-agent-refs/features/repo-map.md +1 -1
  50. package/pipeline/multi-agent-refs/features/review-multi-repo.md +3 -3
  51. package/pipeline/multi-agent-refs/features/shadow-git.md +1 -1
  52. package/pipeline/multi-agent-refs/features/skill-conformance.md +116 -0
  53. package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
  54. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  55. package/pipeline/multi-agent-refs/multi-repo-integration-build.md +1 -1
  56. package/pipeline/multi-agent-refs/phases/log-format.md +4 -4
  57. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  58. package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -11
  59. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +17 -15
  60. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +7 -7
  61. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +28 -13
  62. package/pipeline/multi-agent-refs/phases/phase-4-review.md +90 -58
  63. package/pipeline/multi-agent-refs/phases/phase-5-test.md +7 -7
  64. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -8
  65. package/pipeline/multi-agent-refs/phases/phase-7-report.md +8 -8
  66. package/pipeline/multi-agent-refs/phases.md +13 -13
  67. package/pipeline/multi-agent-refs/progress-contract.md +2 -2
  68. package/pipeline/multi-agent-refs/rules.md +7 -5
  69. package/pipeline/multi-agent-refs/swiftui-guide.md +1 -1
  70. package/pipeline/multi-agent-refs/tracker-contract.md +16 -15
  71. package/pipeline/preferences-template.json +7 -1
  72. package/pipeline/rules/figma-pipeline.md +2 -2
  73. package/pipeline/schemas/agent-state.schema.json +333 -79
  74. package/pipeline/schemas/criteria-manifest.schema.json +228 -0
  75. package/pipeline/schemas/migrations/prefs-2.4.0-to-2.5.0.mjs +64 -0
  76. package/pipeline/schemas/prefs.schema.json +118 -262
  77. package/pipeline/schemas/reviewer-output.schema.json +48 -3
  78. package/pipeline/schemas/token-budget.json +34 -10
  79. package/pipeline/schemas/triage-output.schema.json +112 -27
  80. package/pipeline/scripts/cost-table.json +7 -4
  81. package/pipeline/scripts/gc-worktrees.sh +1 -1
  82. package/pipeline/scripts/gen-mode-dispatch.mjs +6 -6
  83. package/pipeline/scripts/match-skills.mjs +37 -4
  84. package/pipeline/scripts/migrate-prefs.mjs +88 -17
  85. package/pipeline/scripts/phase-tracker.sh +14 -3
  86. package/pipeline/scripts/pre-commit-check.sh +49 -2
  87. package/pipeline/scripts/skill-conformance.mjs +960 -0
  88. package/pipeline/scripts/smoke-schema-validation.sh +17 -4
  89. package/pipeline/scripts/uninstall.mjs +35 -9
  90. package/pipeline/scripts/validate-reviewer.mjs +108 -1
  91. package/pipeline/skills/.skill-manifest.json +1 -1
  92. package/pipeline/skills/.skills-index.json +36 -9
  93. package/pipeline/skills/shared/README.md +15 -12
  94. package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +1 -0
  95. package/pipeline/skills/shared/core/apple-archive-compliance/references/rules.yml +167 -0
  96. package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +1 -0
  97. package/pipeline/skills/shared/core/google-play-compliance/references/rules.yml +184 -0
  98. package/pipeline/skills/shared/core/multi-agent/SKILL.md +10 -10
  99. package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +4 -4
  100. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +3 -3
  101. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +2 -2
  102. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  103. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +6 -5
  104. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +7 -6
  105. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +4 -3
  106. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +2 -1
  107. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -2
  108. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +1 -1
  109. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -4
  110. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  111. package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +1 -1
  112. package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +1 -1
  113. package/pipeline/skills/shared/core/{multi-agent-finish → multi-agent-ship}/SKILL.md +8 -8
  114. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +44 -5
  115. package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +82 -0
  116. package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +169 -10
  117. package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +13 -1
  118. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +335 -16
  119. package/pipeline/skills/skills-index.md +11 -8
@@ -0,0 +1,960 @@
1
+ #!/usr/bin/env node
2
+ // skill-conformance.mjs - resolve WHAT the changed code was supposed to honour,
3
+ // before any reviewer looks at it. Zero LLM calls.
4
+ //
5
+ // Phase 4 used to pick its review criteria from `detectedStack`, a string Phase 1
6
+ // computed before a line of code existed, and the dev phase never recorded which
7
+ // skills it actually applied. So "was this done correctly" could only ever be
8
+ // answered as "it looks fine to me". This script produces the missing denominator:
9
+ // a bounded, enumerable set of rule IDs plus the criteria sources they came from,
10
+ // written to disk BEFORE the reviewers run so it cannot be renegotiated after.
11
+ //
12
+ // Usage:
13
+ // skill-conformance.mjs --diff <patch-file> [--changed-files a.swift,b.kt]
14
+ // [--state <agent-state.json>] # reads telemetry.skillCalls[]
15
+ // [--repo <root>] # default: cwd
16
+ // [--out <criteria-manifest.json>]
17
+ // [--skills-root <dir>] # override discovery root
18
+ // [--json] # manifest to stdout
19
+ //
20
+ // Contract (full spec: multi-agent-refs/features/skill-conformance.md):
21
+ // 1. Registry discovery is DECLARED, never sniffed by name: a skill opts in with
22
+ // `standards-registry: <relative path>` in its SKILL.md frontmatter. The
23
+ // pipeline therefore names no stack-specific skill, and a new language's
24
+ // registry drops in with zero pipeline change.
25
+ // 2. Every registry declares its own `scope` (languages / paths). Rules outside
26
+ // the changed files' languages are DROPPED and counted. Without this an
27
+ // Objective-C or UIKit diff would collect the whole Swift/SwiftUI rule set,
28
+ // which manufactures findings and buries the real ones.
29
+ // 3. The resolver is PRIMARY; `telemetry.skillCalls[]` only corroborates. An
30
+ // unrecorded consultation and no consultation are byte-identical in state, so
31
+ // a coverage number computed from self-report reads green over an empty set.
32
+ // 4. Absent, empty and zero are three different states in the output.
33
+ //
34
+ // Exit codes:
35
+ // 0 manifest written (may still carry findings or declared coverage gaps)
36
+ // 1 setup error: no diff, unreadable state, or an explicit --skills-root that
37
+ // does not exist. A typo there used to resolve to zero roots and produce a
38
+ // clean review over criteria that were never loaded.
39
+ // 2 fail-closed: no skills root resolved at all, a declared registry could not
40
+ // be parsed, or a declared path escaped its skill directory. All named in the
41
+ // manifest (`resolutionFailure`, `unparseableRegistries`).
42
+
43
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
44
+ import { dirname, join, resolve, relative, basename, extname, sep } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Skills-root resolution
51
+ //
52
+ // This script runs from four different trees: `<repo>/pipeline/scripts` in a
53
+ // checkout, and `~/.claude/scripts`, `~/.copilot/scripts`, `~/.codex/scripts`
54
+ // once installed. install/copilot.mjs copies `scripts/` byte-for-byte with no
55
+ // path rewrite, and Codex puts skills under `multi-agent-refs/skills`, so a
56
+ // hardcoded `~/.claude/skills` is inert on two of the three hosts. That exact
57
+ // bug already shipped once here (see match-skills.mjs header): dynamic skill
58
+ // loading exited 1 on every real install while passing a smoke that ran from
59
+ // the repo. Installed layouts are tried first - that is where a run happens.
60
+ // ---------------------------------------------------------------------------
61
+ function skillsRootCandidates() {
62
+ const installRoot = resolve(here, ".."); // ~/.claude | ~/.copilot | ~/.codex
63
+ const repoRoot = resolve(here, "..", ".."); // <repo>
64
+ return [
65
+ join(installRoot, "skills"), // ~/.claude/skills, ~/.copilot/skills
66
+ join(installRoot, "multi-agent-refs", "skills"), // ~/.codex/multi-agent-refs/skills
67
+ join(repoRoot, "pipeline", "skills"), // checkout
68
+ ];
69
+ }
70
+
71
+ /**
72
+ * Resolve the roots to search, and report every candidate that was PROBED - not
73
+ * only the ones that existed.
74
+ *
75
+ * Returning just the survivors made `skillsRootsSearched` empty exactly when it
76
+ * mattered most: a run that found no roots produced `[]`, so "no registries" and
77
+ * "wrong root" were indistinguishable - the failure this field exists to
78
+ * attribute. The schema and the feature doc both promise attribution, so the
79
+ * candidates carry an `existed` flag instead.
80
+ *
81
+ * @returns {{roots: string[], probed: Array<{path: string, existed: boolean}>}}
82
+ */
83
+ function resolveSkillsRoots(override) {
84
+ const candidates = override ? [override] : skillsRootCandidates();
85
+ const probed = candidates.map((p) => ({ path: p, existed: existsSync(p) }));
86
+ return { roots: probed.filter((p) => p.existed).map((p) => p.path), probed };
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Zero-dependency YAML subset reader.
91
+ //
92
+ // The runtime ships no YAML parser by policy. Semantic parsing is not needed and
93
+ // not attempted: the reviewers are handed the registry FILE PATH and read the
94
+ // YAML natively. This extracts only the flat scalars the gate must reason about,
95
+ // anchored so nested maps cannot contribute.
96
+ //
97
+ // Anchoring is load-bearing. A naive `grep -c ' - id:'` over the 99-rule iOS
98
+ // registry returns 112: the extra 13 are `module_overlay_slots.slots[].id` and
99
+ // friends at a deeper indent. Counting those as rules would inflate the
100
+ // denominator with entries that are not rules, and every conformance percentage
101
+ // computed from it would be quietly wrong.
102
+ // ---------------------------------------------------------------------------
103
+ function readRegistry(path) {
104
+ const text = readFileSync(path, "utf8");
105
+ const lines = text.split("\n");
106
+
107
+ const meta = { version: null, exceptionMarker: null, scope: null, notCovered: {} };
108
+ const rules = [];
109
+
110
+ let inRules = false;
111
+ let inTopScope = false;
112
+ let inScopeList = null; // "languages" | "paths" | "excludePaths" | "notCovered"
113
+ let cur = null;
114
+
115
+ const pushCur = () => {
116
+ if (cur) rules.push(cur);
117
+ cur = null;
118
+ };
119
+
120
+ for (const raw of lines) {
121
+ const line = raw.replace(/\s+$/, "");
122
+ if (!line || /^\s*#/.test(line)) continue;
123
+
124
+ // Top-level keys end any nested context.
125
+ const top = /^([a-z_]+):(.*)$/.exec(line);
126
+ if (top) {
127
+ const [, key, rest] = top;
128
+ if (key !== "rules") pushCur();
129
+ inRules = key === "rules";
130
+ inTopScope = key === "scope";
131
+ inScopeList = null;
132
+
133
+ if (key === "version") meta.version = rest.trim() || null;
134
+ if (key === "exception_marker") {
135
+ meta.exceptionMarker = rest.trim().replace(/^["']|["']$/g, "") || null;
136
+ }
137
+ if (key === "severity_levels") meta.severityLevels = parseInlineList(rest);
138
+ if (inTopScope) meta.scope = { languages: [], paths: [], excludePaths: [] };
139
+ continue;
140
+ }
141
+
142
+ if (inTopScope && meta.scope) {
143
+ const kv = /^ {2}([a-zA-Z_-]+):(.*)$/.exec(line);
144
+ if (kv) {
145
+ const [, key, rest] = kv;
146
+ inScopeList = null;
147
+ if (key === "languages") {
148
+ const inline = parseInlineList(rest);
149
+ if (inline.length) meta.scope.languages = inline;
150
+ else inScopeList = "languages";
151
+ } else if (key === "paths" || key === "excludePaths") {
152
+ const inline = parseInlineList(rest);
153
+ if (inline.length) meta.scope[key] = inline;
154
+ else inScopeList = key;
155
+ } else if (key === "notCovered") {
156
+ inScopeList = "notCovered";
157
+ }
158
+ continue;
159
+ }
160
+ if (inScopeList === "notCovered") {
161
+ const nc = /^ {4}([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line);
162
+ if (nc) meta.notCovered[nc[1]] = nc[2].trim();
163
+ continue;
164
+ }
165
+ if (inScopeList) {
166
+ const item = /^ {4}- (.*)$/.exec(line);
167
+ if (item) meta.scope[inScopeList].push(unquote(item[1].trim()));
168
+ continue;
169
+ }
170
+ continue;
171
+ }
172
+
173
+ if (!inRules) continue;
174
+
175
+ // A rule starts at exactly two spaces. Deeper `- id:` lines belong to a
176
+ // nested structure and are deliberately ignored.
177
+ const idLine = /^ {2}- id:\s*(.+)$/.exec(line);
178
+ if (idLine) {
179
+ pushCur();
180
+ cur = { id: idLine[1].trim(), scope: null, statusDeclared: false };
181
+ continue;
182
+ }
183
+ if (!cur) continue;
184
+
185
+ const field = /^ {4}([a-zA-Z_]+):\s*(.*)$/.exec(line);
186
+ if (!field) continue;
187
+ const [, key, value] = field;
188
+ const v = value.trim();
189
+
190
+ if (key === "severity") cur.severity = v;
191
+ else if (key === "enforcement") cur.enforcement = v;
192
+ else if (key === "status") {
193
+ cur.status = v;
194
+ cur.statusDeclared = true;
195
+ } else if (key === "mechanism") cur.mechanism = v || "(block scalar)";
196
+ else if (key === "rationale") cur.rationale = v;
197
+ else if (key === "scope") cur.scope = parseInlineScope(v);
198
+ else if (key === "scope_reason") cur.scopeReason = v;
199
+ else if (key === "title") cur.title = v === ">" ? null : v;
200
+ }
201
+ pushCur();
202
+
203
+ return { meta, rules };
204
+ }
205
+
206
+ function unquote(s) {
207
+ return s.replace(/^["']|["']$/g, "");
208
+ }
209
+
210
+ function parseInlineList(rest) {
211
+ const m = /^\s*\[(.*)\]\s*$/.exec(rest);
212
+ if (!m) return [];
213
+ return m[1]
214
+ .split(",")
215
+ .map((s) => unquote(s.trim()))
216
+ .filter(Boolean);
217
+ }
218
+
219
+ // `scope: { frameworks: [swiftui], paths: ["**/*View.swift"] }`
220
+ function parseInlineScope(v) {
221
+ if (!v.startsWith("{")) return null;
222
+ const out = {};
223
+ for (const key of ["languages", "frameworks", "paths", "excludePaths"]) {
224
+ const m = new RegExp(`${key}:\\s*\\[([^\\]]*)\\]`).exec(v);
225
+ if (m) {
226
+ out[key] = m[1]
227
+ .split(",")
228
+ .map((s) => unquote(s.trim()))
229
+ .filter(Boolean);
230
+ }
231
+ }
232
+ return Object.keys(out).length ? out : null;
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Frontmatter reader: only the keys discovery needs.
237
+ // ---------------------------------------------------------------------------
238
+ function readFrontmatter(path) {
239
+ const text = readFileSync(path, "utf8");
240
+ if (!text.startsWith("---")) return {};
241
+ const end = text.indexOf("\n---", 3);
242
+ if (end < 0) return {};
243
+ const out = {};
244
+ for (const line of text.slice(3, end).split("\n")) {
245
+ const m = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line);
246
+ if (m) out[m[1]] = unquote(m[2].trim());
247
+ }
248
+ return out;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Language classification from file extension.
253
+ //
254
+ // This replaces `detectedStack` for the dev modes, where Phase 1 never ran. It
255
+ // is deliberately extension-based: it describes what the diff CONTAINS, which is
256
+ // what the criteria have to match, rather than what the repo is nominally built
257
+ // in. A Swift repo with one Objective-C bridging file must not get Swift rules
258
+ // applied to that file.
259
+ // ---------------------------------------------------------------------------
260
+ const LANG_BY_EXT = {
261
+ ".swift": "swift",
262
+ ".m": "objective-c",
263
+ ".mm": "objective-c",
264
+ ".h": "objective-c-header",
265
+ ".kt": "kotlin",
266
+ ".kts": "kotlin",
267
+ ".java": "java",
268
+ ".py": "python",
269
+ ".js": "javascript",
270
+ ".mjs": "javascript",
271
+ ".cjs": "javascript",
272
+ ".ts": "typescript",
273
+ ".tsx": "typescript",
274
+ ".jsx": "javascript",
275
+ ".go": "go",
276
+ ".rb": "ruby",
277
+ ".rs": "rust",
278
+ ".c": "c",
279
+ ".cc": "cpp",
280
+ ".cpp": "cpp",
281
+ ".hpp": "cpp",
282
+ ".xml": "xml",
283
+ ".plist": "plist",
284
+ ".gradle": "gradle",
285
+ ".yml": "yaml",
286
+ ".yaml": "yaml",
287
+ ".json": "json",
288
+ ".sh": "shell",
289
+ ".bash": "shell",
290
+ };
291
+
292
+ function classifyLanguage(file) {
293
+ const base = basename(file);
294
+ if (base === "Info.plist" || base.endsWith(".plist")) return "plist";
295
+ if (base === "project.pbxproj") return "pbxproj";
296
+ if (base.endsWith(".entitlements")) return "plist";
297
+ if (base === "AndroidManifest.xml") return "xml";
298
+ if (base.endsWith(".gradle.kts")) return "gradle";
299
+ return LANG_BY_EXT[extname(base).toLowerCase()] ?? "other";
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Minimal glob matcher for the `**/x`, `*.ext`, `dir/**` shapes registries use.
304
+ // ---------------------------------------------------------------------------
305
+ function globToRegExp(glob) {
306
+ let re = "";
307
+ for (let i = 0; i < glob.length; i++) {
308
+ const c = glob[i];
309
+ if (c === "*") {
310
+ if (glob[i + 1] === "*") {
311
+ // `**/` matches zero or more path segments.
312
+ if (glob[i + 2] === "/") {
313
+ re += "(?:.*/)?";
314
+ i += 2;
315
+ } else {
316
+ re += ".*";
317
+ i += 1;
318
+ }
319
+ } else {
320
+ re += "[^/]*";
321
+ }
322
+ } else if (c === "?") re += "[^/]";
323
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
324
+ }
325
+ return new RegExp(`^${re}$`);
326
+ }
327
+
328
+ function matchesAnyGlob(file, globs) {
329
+ if (!globs || globs.length === 0) return true;
330
+ return globs.some((g) => globToRegExp(g).test(file));
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Diff parsing: changed files and added lines (for the exception audit).
335
+ // ---------------------------------------------------------------------------
336
+ /**
337
+ * Decode git's quoted-path form: backslash escapes plus \NNN octal bytes, which
338
+ * git emits per-byte for non-ASCII (a Turkish "ü" arrives as two octal escapes).
339
+ * Decoding octal per byte and then interpreting the result as UTF-8 is what makes
340
+ * a non-ASCII filename come back intact rather than mojibake.
341
+ */
342
+ function unquoteGitPath(p) {
343
+ if (!p.includes("\\")) return p;
344
+ const bytes = [];
345
+ for (let i = 0; i < p.length; i++) {
346
+ if (p[i] !== "\\") {
347
+ bytes.push(...Buffer.from(p[i], "utf8"));
348
+ continue;
349
+ }
350
+ const next = p[i + 1];
351
+ if (next >= "0" && next <= "7") {
352
+ bytes.push(parseInt(p.slice(i + 1, i + 4), 8));
353
+ i += 3;
354
+ } else {
355
+ const simple = { n: 10, t: 9, r: 13, '"': 34, "\\": 92 };
356
+ bytes.push(simple[next] ?? next.charCodeAt(0));
357
+ i += 1;
358
+ }
359
+ }
360
+ return Buffer.from(bytes).toString("utf8");
361
+ }
362
+
363
+ function parseDiff(patch) {
364
+ const files = [];
365
+ const addedByFile = new Map();
366
+ let current = null;
367
+ let newLineNo = 0;
368
+
369
+ for (const line of patch.split("\n")) {
370
+ // git QUOTES a path containing spaces or non-ASCII: `+++ "b/Sources/Ekran
371
+ // Gorunumu.swift"`. Matching only the bare form dropped such a file from
372
+ // changedFiles entirely, so it appeared neither in selectedRules nor in
373
+ // coverage.uncoveredFiles - an unrecorded hole in the very denominator this
374
+ // gate exists to make complete. Turkish, and any non-ASCII, filename hits this.
375
+ const bPath = /^\+\+\+ (?:"b\/(.+)"|b\/(.+))$/.exec(line);
376
+ if (bPath) {
377
+ current = unquoteGitPath(bPath[1] ?? bPath[2]);
378
+ if (current !== "/dev/null" && !files.includes(current)) files.push(current);
379
+ addedByFile.set(current, addedByFile.get(current) ?? []);
380
+ continue;
381
+ }
382
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
383
+ if (hunk) {
384
+ newLineNo = parseInt(hunk[1], 10);
385
+ continue;
386
+ }
387
+ if (!current) continue;
388
+ if (line.startsWith("+") && !line.startsWith("+++")) {
389
+ addedByFile.get(current).push({ line: newLineNo, text: line.slice(1) });
390
+ newLineNo++;
391
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
392
+ // deletion: new-side line number does not advance
393
+ } else if (line.startsWith(" ")) {
394
+ newLineNo++;
395
+ }
396
+ }
397
+ return { files, addedByFile };
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Module guide walk. Same deterministic contract as /multi-agent:review Step 2b:
402
+ // walk each changed file's directory chain to the repo root, collecting guides
403
+ // the host CLI does not auto-load. Root-level files are excluded because the CLI
404
+ // already loads those. Capped, with the drop logged rather than silent.
405
+ // ---------------------------------------------------------------------------
406
+ const GUIDE_CAP = 5;
407
+
408
+ function collectModuleGuides(repoRoot, changedFiles) {
409
+ const found = new Set();
410
+ for (const f of changedFiles) {
411
+ let dir = dirname(resolve(repoRoot, f));
412
+ while (dir.startsWith(repoRoot) && dir !== repoRoot) {
413
+ for (const name of readdirSafe(dir)) {
414
+ if (name === "CLAUDE.md" || name === "AGENTS.md" || name.endsWith("-CLAUDE.md")) {
415
+ found.add(relative(repoRoot, join(dir, name)));
416
+ }
417
+ }
418
+ dir = dirname(dir);
419
+ }
420
+ }
421
+ const all = [...found].sort();
422
+ return { guides: all.slice(0, GUIDE_CAP), dropped: all.slice(GUIDE_CAP) };
423
+ }
424
+
425
+ function readdirSafe(dir) {
426
+ try {
427
+ return readdirSync(dir);
428
+ } catch {
429
+ return [];
430
+ }
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Toolchain wiring check.
435
+ //
436
+ // A registry rule marked `enforcement: lint` names the tool that owns it. This
437
+ // does NOT re-implement that tool: it checks whether the repo has it wired, and
438
+ // reports absence as a finding. That is the honest answer and it is
439
+ // stack-generic - swiftlint, ktlint, detekt, eslint and ruff all take rule ids.
440
+ // Executing a registry's `mechanism` regex directly was considered and rejected:
441
+ // across the 99-rule iOS registry only 43 rules carry `mechanism` at all, the
442
+ // field is prose with an embedded pattern rather than a machine field, and each
443
+ // carries its scope in English. Running them unscoped floods a review.
444
+ // ---------------------------------------------------------------------------
445
+ const TOOLCHAIN_CONFIGS = {
446
+ swiftlint: [".swiftlint.yml", ".swiftlint.yaml"],
447
+ ktlint: [".editorconfig", "ktlint.gradle"],
448
+ detekt: ["detekt.yml", "config/detekt/detekt.yml"],
449
+ eslint: [".eslintrc", ".eslintrc.json", ".eslintrc.cjs", "eslint.config.js", "eslint.config.mjs"],
450
+ ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"],
451
+ swiftformat: [".swiftformat"],
452
+ };
453
+
454
+ function detectToolchains(repoRoot, rules) {
455
+ const named = new Set();
456
+ for (const r of rules) {
457
+ const mech = (r.mechanism ?? "").toLowerCase();
458
+ for (const tool of Object.keys(TOOLCHAIN_CONFIGS)) {
459
+ if (mech.includes(tool)) named.add(tool);
460
+ }
461
+ }
462
+ return [...named].sort().map((tool) => ({
463
+ tool,
464
+ wired: TOOLCHAIN_CONFIGS[tool].some((c) => existsSync(join(repoRoot, c))),
465
+ configCandidates: TOOLCHAIN_CONFIGS[tool],
466
+ }));
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Exception-marker audit - the one bespoke deterministic scan kept.
471
+ //
472
+ // Cheap, language-agnostic, and it answers "completely" directly: an exception
473
+ // is the author's own claim that a rule does not apply here, so an expired or
474
+ // unjustified one is a defect the reviewers would have to take on trust. The
475
+ // marker template comes from the registry's `exception_marker`, never hardcoded,
476
+ // so a registry using a different comment syntax still works.
477
+ // ---------------------------------------------------------------------------
478
+ function auditExceptions(addedByFile, knownIds, today, markerToken) {
479
+ const findings = [];
480
+ // The token comes from the registry's own `exception_marker` (e.g.
481
+ // "// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>" yields
482
+ // "standard:exception"). Hardcoding it, as an earlier version did, meant a
483
+ // registry declaring a different marker got ZERO exception auditing with no gap
484
+ // recorded - a silent pass, and it contradicted the shipped contract. The
485
+ // comment prefix stays open (`//`, `#`, `--`) so the scan is language-agnostic.
486
+ const token = (markerToken ?? "standard:exception").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
487
+ const re = new RegExp(`(?://|#|--)\\s*${token}\\(([^)]*)\\)\\s*(.*)$`);
488
+
489
+ for (const [file, added] of addedByFile) {
490
+ for (const { line, text } of added) {
491
+ const m = re.exec(text);
492
+ if (!m) continue;
493
+ const id = m[1].trim();
494
+ const rest = (m[2] ?? "").trim();
495
+ const dateMatch = /(\d{4}-\d{2}-\d{2})/.exec(rest);
496
+ const reason = rest.replace(/(\d{4}-\d{2}-\d{2})/, "").trim();
497
+
498
+ if (!id) {
499
+ findings.push(f(file, line, "blocking", null, "Exception marker names no rule ID.",
500
+ "Name the rule the exception applies to: standard:exception(<RULE-ID>) <reason> <expiry>."));
501
+ continue;
502
+ }
503
+ if (knownIds.size > 0 && !knownIds.has(id)) {
504
+ findings.push(f(file, line, "blocking", id,
505
+ `Exception cites '${id}', which is not in any registry in scope for this diff.`,
506
+ "Use a real rule ID, or drop the marker. An exception against an unknown rule suppresses nothing and hides intent."));
507
+ }
508
+ if (!dateMatch) {
509
+ findings.push(f(file, line, "important", id,
510
+ `Exception for '${id}' has no expiry date.`,
511
+ "Add an expiry: standard:exception(<RULE-ID>) <reason> YYYY-MM-DD. An exception with no expiry is a permanent silent carve-out."));
512
+ } else if (dateMatch[1] < today) {
513
+ findings.push(f(file, line, "blocking", id,
514
+ `Exception for '${id}' expired on ${dateMatch[1]}.`,
515
+ "Fix the underlying violation, or renew the exception with a new expiry and a current reason."));
516
+ }
517
+ if (!reason) {
518
+ findings.push(f(file, line, "important", id,
519
+ `Exception for '${id}' gives no reason.`,
520
+ "State why the rule does not apply here. An unexplained exception cannot be reviewed, only trusted."));
521
+ }
522
+ }
523
+ }
524
+ return findings;
525
+ }
526
+
527
+ function f(file, line, severity, ruleId, issue, fix) {
528
+ return {
529
+ severity,
530
+ file,
531
+ line,
532
+ issue,
533
+ fix,
534
+ ...(ruleId ? { ruleId } : {}),
535
+ criteriaSource: "exception-marker-audit",
536
+ };
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Registry discovery: declared, not sniffed.
541
+ // ---------------------------------------------------------------------------
542
+ // Depth differs per host, which is why this is a bounded walk rather than a
543
+ // fixed path: an installed tree is flat (`~/.claude/skills/<skill>/SKILL.md`)
544
+ // while a checkout nests by group (`pipeline/skills/shared/core/<skill>/`).
545
+ // Hardcoding either depth finds nothing on the other host.
546
+ const DISCOVERY_MAX_DEPTH = 4;
547
+
548
+ function findSkillDirs(dir, depth, acc) {
549
+ if (depth > DISCOVERY_MAX_DEPTH) return acc;
550
+ if (existsSync(join(dir, "SKILL.md"))) {
551
+ acc.push(dir);
552
+ return acc; // a skill directory does not contain further skills
553
+ }
554
+ for (const entry of readdirSafe(dir)) {
555
+ if (entry.startsWith(".") || entry === "node_modules") continue;
556
+ const child = join(dir, entry);
557
+ let st;
558
+ try {
559
+ st = statSync(child);
560
+ } catch {
561
+ continue;
562
+ }
563
+ if (st.isDirectory()) findSkillDirs(child, depth + 1, acc);
564
+ }
565
+ return acc;
566
+ }
567
+
568
+ function discoverRegistries(roots) {
569
+ const out = [];
570
+ const escaped = [];
571
+ const seen = new Set();
572
+ for (const root of roots) {
573
+ for (const skillDir of findSkillDirs(root, 0, [])) {
574
+ const skillFile = join(skillDir, "SKILL.md");
575
+ const fm = readFrontmatter(skillFile);
576
+ if (!fm["standards-registry"]) continue;
577
+ const name = fm.name ?? basename(skillDir);
578
+ if (seen.has(name)) continue;
579
+ // `standards-registry` is author-controlled frontmatter, and resolve()
580
+ // happily accepts `../../..` chains and absolute paths. Unconstrained, any
581
+ // installed skill could point this gate at an arbitrary readable file
582
+ // (`standards-registry: /Users/x/.aws/credentials`), whose matching lines
583
+ // would be extracted into the manifest and injected into every reviewer
584
+ // prompt via the shared ${CRITERIA} block. Contain it to the skill's own
585
+ // directory; a declaration that escapes is recorded, not followed.
586
+ const rulesPath = resolve(skillDir, fm["standards-registry"]);
587
+ const skillRoot = resolve(skillDir) + sep;
588
+ if (!rulesPath.startsWith(skillRoot)) {
589
+ escaped.push({
590
+ name,
591
+ path: rulesPath,
592
+ reason: `standards-registry escapes the skill directory (declared: ${fm["standards-registry"]})`,
593
+ });
594
+ continue;
595
+ }
596
+ seen.add(name);
597
+ out.push({ name, skillFile, rulesPath, declared: fm["standards-registry"] });
598
+ }
599
+ }
600
+ return { registries: out.sort((a, b) => a.name.localeCompare(b.name)), escaped };
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // main
605
+ // ---------------------------------------------------------------------------
606
+ function parseArgs(argv) {
607
+ const o = { changedFiles: null, json: false };
608
+ for (let i = 0; i < argv.length; i++) {
609
+ const a = argv[i];
610
+ if (a === "--json") o.json = true;
611
+ else if (a === "--diff") o.diff = argv[++i];
612
+ else if (a === "--changed-files") o.changedFiles = argv[++i].split(",").filter(Boolean);
613
+ else if (a === "--state") o.state = argv[++i];
614
+ else if (a === "--repo") o.repo = argv[++i];
615
+ else if (a === "--out") o.out = argv[++i];
616
+ else if (a === "--skills-root") o.skillsRoot = argv[++i];
617
+ else if (a === "--today") o.today = argv[++i]; // tests pin the clock
618
+ else {
619
+ console.error(`unknown argument: ${a}`);
620
+ process.exit(1);
621
+ }
622
+ }
623
+ return o;
624
+ }
625
+
626
+ const opts = parseArgs(process.argv.slice(2));
627
+ const repoRoot = resolve(opts.repo ?? process.cwd());
628
+ const today = opts.today ?? new Date().toISOString().slice(0, 10);
629
+
630
+ let changedFiles = opts.changedFiles ?? [];
631
+ let addedByFile = new Map();
632
+
633
+ if (opts.diff) {
634
+ if (!existsSync(opts.diff)) {
635
+ console.error(`ERR: diff not found: ${opts.diff}`);
636
+ process.exit(1);
637
+ }
638
+ const parsed = parseDiff(readFileSync(opts.diff, "utf8"));
639
+ if (!opts.changedFiles) changedFiles = parsed.files;
640
+ addedByFile = parsed.addedByFile;
641
+ } else if (changedFiles.length === 0) {
642
+ console.error("ERR: pass --diff <patch> or --changed-files a,b");
643
+ process.exit(1);
644
+ }
645
+
646
+ // --- self-report ledger (corroborating only) -------------------------------
647
+ // Three distinct states. `absent` is NOT `empty`: a Phase 3 that consulted five
648
+ // skills and forgot to record them is byte-identical in state to one that
649
+ // consulted nothing, so treating a missing array as "nothing applied" would let
650
+ // every completeness number report green over an empty set.
651
+ let ledger = { status: "absent", entries: [] };
652
+ if (opts.state) {
653
+ if (!existsSync(opts.state)) {
654
+ console.error(`ERR: state not found: ${opts.state}`);
655
+ process.exit(1);
656
+ }
657
+ try {
658
+ const state = JSON.parse(readFileSync(opts.state, "utf8"));
659
+ const calls = state?.telemetry?.skillCalls;
660
+ if (Array.isArray(calls)) {
661
+ ledger = { status: calls.length ? "declared" : "empty", entries: calls };
662
+ }
663
+ } catch (e) {
664
+ console.error(`ERR: state is not valid JSON: ${e.message}`);
665
+ process.exit(1);
666
+ }
667
+ }
668
+
669
+ // --- language census of the diff ------------------------------------------
670
+ const languages = {};
671
+ for (const file of changedFiles) {
672
+ const lang = classifyLanguage(file);
673
+ (languages[lang] ??= []).push(file);
674
+ }
675
+
676
+ // --- discover + load registries -------------------------------------------
677
+ // An explicit --skills-root that does not exist is a SETUP error, not an empty
678
+ // result: silently searching nothing produced a manifest with zero selected
679
+ // rules, which made validate-reviewer.mjs skip the conformance check entirely
680
+ // and a review report clean over criteria it never loaded. That is the single
681
+ // worst outcome this gate exists to prevent, so it fails loudly instead.
682
+ const { roots, probed: probedRoots } = resolveSkillsRoots(opts.skillsRoot);
683
+ if (opts.skillsRoot && roots.length === 0) {
684
+ console.error(`ERR: --skills-root does not exist: ${opts.skillsRoot}`);
685
+ process.exit(1);
686
+ }
687
+ const noRootsResolved = roots.length === 0;
688
+ const { registries: discovered, escaped: escapedDeclarations } = discoverRegistries(roots);
689
+
690
+ const registries = [];
691
+ const unparseable = [...escapedDeclarations];
692
+
693
+ for (const d of discovered) {
694
+ if (!existsSync(d.rulesPath)) {
695
+ unparseable.push({ name: d.name, path: d.rulesPath, reason: "declared standards-registry file does not exist" });
696
+ continue;
697
+ }
698
+ let loaded;
699
+ try {
700
+ loaded = readRegistry(d.rulesPath);
701
+ } catch (e) {
702
+ unparseable.push({ name: d.name, path: d.rulesPath, reason: `unreadable: ${e.message}` });
703
+ continue;
704
+ }
705
+ if (loaded.rules.length === 0) {
706
+ unparseable.push({ name: d.name, path: d.rulesPath, reason: "no rules extracted - rules: block missing or unexpected indentation" });
707
+ continue;
708
+ }
709
+ registries.push({ ...d, ...loaded });
710
+ }
711
+
712
+ // --- scope resolution ------------------------------------------------------
713
+ const selected = [];
714
+ const perRegistry = [];
715
+
716
+ for (const reg of registries) {
717
+ const scope = reg.meta.scope;
718
+ const regLanguages = scope?.languages ?? [];
719
+ const langHit = regLanguages.length === 0
720
+ ? Object.keys(languages)
721
+ : regLanguages.filter((l) => Object.prototype.hasOwnProperty.call(languages, l));
722
+
723
+ // Files this registry may speak about at all.
724
+ const inScopeFiles = changedFiles.filter((file) => {
725
+ const lang = classifyLanguage(file);
726
+ if (regLanguages.length && !regLanguages.includes(lang)) return false;
727
+ if (scope?.excludePaths?.length && matchesAnyGlob(file, scope.excludePaths)) return false;
728
+ if (scope?.paths?.length && !matchesAnyGlob(file, scope.paths)) return false;
729
+ return true;
730
+ });
731
+
732
+ const unscoped = !scope || (regLanguages.length === 0 && !scope.paths?.length);
733
+
734
+ let kept = 0;
735
+ const droppedReasons = {};
736
+ if (inScopeFiles.length > 0 && !unscoped) {
737
+ for (const rule of reg.rules) {
738
+ // A retired rule is retired by status, never deleted. `status` is absent
739
+ // from every shipped registry today, so missing means active - recorded
740
+ // as statusDeclared:false rather than assumed.
741
+ if (rule.status && rule.status !== "active") {
742
+ droppedReasons["retired-by-status"] = (droppedReasons["retired-by-status"] ?? 0) + 1;
743
+ continue;
744
+ }
745
+ // Per-rule scope narrows, never widens.
746
+ const files = rule.scope?.paths?.length
747
+ ? inScopeFiles.filter((f2) => matchesAnyGlob(f2, rule.scope.paths))
748
+ : inScopeFiles;
749
+ if (files.length === 0) {
750
+ const why = rule.scopeReason ? `out-of-scope: ${rule.scopeReason}` : "out-of-scope: no changed file matches the rule's paths";
751
+ droppedReasons[why] = (droppedReasons[why] ?? 0) + 1;
752
+ continue;
753
+ }
754
+ selected.push({
755
+ ruleId: rule.id,
756
+ registry: reg.name,
757
+ severity: rule.severity ?? "important",
758
+ enforcement: rule.enforcement ?? "judgement",
759
+ files,
760
+ statusDeclared: rule.statusDeclared,
761
+ });
762
+ kept++;
763
+ }
764
+ }
765
+
766
+ perRegistry.push({
767
+ name: reg.name,
768
+ rulesPath: reg.rulesPath,
769
+ version: reg.meta.version,
770
+ totalRules: reg.rules.length,
771
+ selectedRules: kept,
772
+ droppedRules: reg.rules.length - kept,
773
+ droppedReasons,
774
+ scopeDeclared: !unscoped,
775
+ scopeLanguages: regLanguages,
776
+ languagesMatched: langHit,
777
+ inScopeFileCount: inScopeFiles.length,
778
+ notCovered: reg.meta.notCovered,
779
+ loadedAs: unscoped ? "reference-only" : inScopeFiles.length ? "rule-checklist" : "not-applicable",
780
+ ...(unscoped
781
+ ? { referenceOnlyReason: "registry declares no scope; applying its rules to unknown languages would manufacture findings" }
782
+ : {}),
783
+ toolchains: detectToolchains(repoRoot, reg.rules),
784
+ exceptionMarker: reg.meta.exceptionMarker,
785
+ });
786
+ }
787
+
788
+ // --- coverage --------------------------------------------------------------
789
+ const coveredFiles = new Set(selected.flatMap((s) => s.files));
790
+ const uncovered = changedFiles.filter((f2) => !coveredFiles.has(f2));
791
+
792
+ const gaps = [];
793
+ for (const [lang, files] of Object.entries(languages)) {
794
+ const covered = files.filter((f2) => coveredFiles.has(f2));
795
+ if (covered.length === 0) {
796
+ const claims = perRegistry
797
+ .flatMap((r) => Object.entries(r.notCovered ?? {}).map(([k, v]) => ({ registry: r.name, key: k, note: v })))
798
+ .filter((c) => c.key === lang || lang.startsWith(c.key));
799
+ gaps.push({
800
+ language: lang,
801
+ fileCount: files.length,
802
+ files,
803
+ reason: claims.length
804
+ ? `no registry in scope covers ${lang}: ${claims.map((c) => `${c.registry} declares '${c.key}' not covered`).join("; ")}`
805
+ : `no registry in scope declares ${lang}`,
806
+ consequence: "these files are reviewed on general criteria only - no rule IDs apply to them",
807
+ });
808
+ }
809
+ }
810
+
811
+ // --- module guides ---------------------------------------------------------
812
+ const { guides, dropped: guidesDropped } = collectModuleGuides(repoRoot, changedFiles);
813
+
814
+ // --- exception audit -------------------------------------------------------
815
+ // knownIds spans EVERY discovered registry, not just the in-scope ones. Narrowing
816
+ // it to the selected set would report a valid rule as unknown whenever the
817
+ // exception sits in a file that rule does not cover, which is a false accusation
818
+ // in the one place the author is trying to be explicit. The narrower question -
819
+ // "is this exception citing a rule that applies here?" - is answered separately
820
+ // below, so a mis-scoped exception is still reported, just not as a typo.
821
+ const knownIds = new Set(registries.flatMap((r) => r.rules.map((x) => x.id)));
822
+ const inScopeIds = new Set(selected.map((s) => s.ruleId));
823
+ const markerToken = registries
824
+ .map((r) => r.meta.exceptionMarker)
825
+ .filter(Boolean)
826
+ .map((m) => (m.match(/([A-Za-z0-9_:.-]+)\s*\(/) ?? [])[1])
827
+ .filter(Boolean)[0];
828
+ const findings = auditExceptions(addedByFile, knownIds, today, markerToken);
829
+
830
+ // A known rule cited where it does not apply: the exception suppresses nothing,
831
+ // so it is dead weight that reads as deliberate coverage.
832
+ if (inScopeIds.size > 0) {
833
+ for (const [file, added] of addedByFile) {
834
+ for (const { line, text } of added) {
835
+ const m = /(?:\/\/|#|--)\s*[A-Za-z0-9_:.-]+\(([^)]*)\)/.exec(text);
836
+ if (!m) continue;
837
+ const id = m[1].trim();
838
+ if (!id || !knownIds.has(id) || inScopeIds.has(id)) continue;
839
+ findings.push({
840
+ severity: "important",
841
+ file,
842
+ line,
843
+ issue: `Exception cites '${id}', a real rule that is not in scope for this file.`,
844
+ fix: "Drop the marker, or move it to a file the rule actually covers. An exception against an out-of-scope rule suppresses nothing while reading as a deliberate carve-out.",
845
+ ruleId: id,
846
+ criteriaSource: "exception-marker-audit",
847
+ });
848
+ }
849
+ }
850
+ }
851
+
852
+ // --- toolchain findings ----------------------------------------------------
853
+ for (const reg of perRegistry) {
854
+ if (reg.loadedAs !== "rule-checklist") continue;
855
+ for (const t of reg.toolchains) {
856
+ if (t.wired) continue;
857
+ findings.push({
858
+ severity: "important",
859
+ file: ".",
860
+ line: 0,
861
+ issue: `${reg.name} delegates its mechanical rules to ${t.tool}, which is not wired in this repo (looked for ${t.configCandidates.join(", ")}).`,
862
+ fix: `Add a ${t.tool} config and run it in CI. Until then every '${t.tool}' rule in this registry is unverified, and a review that reports no violations is reporting that nothing was measured.`,
863
+ criteriaSource: `${reg.name}:toolchain`,
864
+ });
865
+ }
866
+ }
867
+
868
+ // --- ledger corroboration --------------------------------------------------
869
+ // The resolver is primary. A declared entry the resolver did not independently
870
+ // reach is FLAGGED, not trusted: it means the dev phase claims a criterion the
871
+ // review cannot bind to any changed file.
872
+ const resolvedNames = new Set(perRegistry.map((r) => r.name));
873
+ const declaredNames = ledger.entries.map((e) => e?.skill).filter(Boolean);
874
+ const declaredNotResolved = [...new Set(declaredNames)].filter((n) => !resolvedNames.has(n));
875
+ const ledgerMissingTargets = ledger.entries.filter((e) => !Array.isArray(e?.targetFiles) || e.targetFiles.length === 0).length;
876
+
877
+ // Two values, not three. An earlier version branched on
878
+ // `declaredNotResolved.length === 0` and returned "mixed" from BOTH arms, so the
879
+ // condition was dead and the flagged-vs-corroborated distinction it implied was
880
+ // not expressible in the field. The distinction lives in
881
+ // `ledger.declaredButNotResolved` instead, where a consumer can see exactly which
882
+ // claim failed to bind rather than inferring it from a summary word.
883
+ const ledgerSource = ledger.status === "declared" ? "mixed" : "derived";
884
+
885
+ const manifest = {
886
+ schemaVersion: "1.0.0",
887
+ generatedFor: { repoRoot, changedFileCount: changedFiles.length },
888
+ ledger: {
889
+ source: ledgerSource,
890
+ selfReport: ledger.status,
891
+ entryCount: ledger.entries.length,
892
+ entriesMissingTargetFiles: ledgerMissingTargets,
893
+ declaredButNotResolved: declaredNotResolved,
894
+ note: "The resolver below is primary. This block is a self-report from Phase 3 and is corroborating evidence only: an unrecorded consultation and no consultation are indistinguishable in state, so coverage is never computed from it.",
895
+ },
896
+ languages: Object.fromEntries(Object.entries(languages).map(([k, v]) => [k, v.length])),
897
+ registries: perRegistry,
898
+ unparseableRegistries: unparseable,
899
+ selectedRules: selected,
900
+ selectedRuleCount: selected.length,
901
+ coverage: {
902
+ changedFiles: changedFiles.length,
903
+ filesWithRuleCriteria: coveredFiles.size,
904
+ filesWithoutRuleCriteria: uncovered.length,
905
+ uncoveredFiles: uncovered,
906
+ declaredGaps: gaps,
907
+ },
908
+ moduleGuides: { guides, dropped: guidesDropped, cap: GUIDE_CAP },
909
+ findings,
910
+ skillsRootsSearched: probedRoots,
911
+ resolutionFailure: noRootsResolved
912
+ ? "no skills root exists on this host - nothing was searched, so no criteria could be resolved"
913
+ : null,
914
+ };
915
+
916
+ const outPath = opts.out ?? join(repoRoot, ".pipeline", "criteria-manifest.json");
917
+ try {
918
+ const dir = dirname(outPath);
919
+ if (!existsSync(dir)) {
920
+ const { mkdirSync } = await import("node:fs");
921
+ mkdirSync(dir, { recursive: true });
922
+ }
923
+ writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n");
924
+ } catch (e) {
925
+ console.error(`ERR: cannot write manifest to ${outPath}: ${e.message}`);
926
+ process.exit(1);
927
+ }
928
+
929
+ if (opts.json) {
930
+ process.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
931
+ } else {
932
+ console.log(`criteria-manifest: ${outPath}`);
933
+ console.log(` languages: ${Object.entries(manifest.languages).map(([k, v]) => `${k}:${v}`).join(" ") || "(none)"}`);
934
+ console.log(` registries: ${perRegistry.length} discovered, ${perRegistry.filter((r) => r.loadedAs === "rule-checklist").length} as rule-checklist`);
935
+ console.log(` selected rules: ${selected.length}`);
936
+ console.log(` coverage: ${coveredFiles.size}/${changedFiles.length} changed files carry rule criteria`);
937
+ for (const g of gaps) console.log(` GAP ${g.language} (${g.fileCount} file(s)): ${g.reason}`);
938
+ console.log(` ledger: ${manifest.ledger.source} (self-report: ${ledger.status})`);
939
+ console.log(` findings: ${findings.length}`);
940
+ }
941
+
942
+ // Fail closed when NO skills root existed. Continuing would emit a manifest with
943
+ // zero selected rules, which makes validate-reviewer.mjs skip the conformance
944
+ // check and the review read as clean over criteria that were never loaded. An
945
+ // empty registry set inside a root that DOES exist is a different, legitimate
946
+ // case: it yields a declared coverage gap and exit 0.
947
+ if (noRootsResolved) {
948
+ console.error("ERR: no skills root found - criteria could not be resolved. Probed:");
949
+ for (const c of probedRoots) console.error(` ${c.existed ? "found" : "absent"}: ${c.path}`);
950
+ process.exit(2);
951
+ }
952
+
953
+ // Fail closed on a declared-but-unparseable registry: silently continuing would
954
+ // drop that registry's whole rule set from the denominator, and the run would
955
+ // report a clean review over criteria it never loaded.
956
+ if (unparseable.length > 0) {
957
+ console.error("ERR: declared registries could not be parsed:");
958
+ for (const u of unparseable) console.error(` ${u.name}: ${u.reason} (${u.path})`);
959
+ process.exit(2);
960
+ }