@mmerterden/multi-agent-pipeline 17.3.0 → 17.5.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 (54) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +23 -5
  3. package/README.tr.md +23 -5
  4. package/docs/adr/0013-lsp-code-intelligence.md +102 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/token-budget-history.md +1 -1
  7. package/install/templates/copilot-instructions.md +9 -3
  8. package/package.json +1 -1
  9. package/pipeline/agents/code-reviewer.md +35 -1
  10. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
  11. package/pipeline/commands/multi-agent/autopilot/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +5 -3
  13. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/local/SKILL.md +17 -6
  15. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +3 -3
  16. package/pipeline/lib/multi-repo-pipeline.sh +26 -0
  17. package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
  18. package/pipeline/multi-agent-refs/analysis/render.md +2 -1
  19. package/pipeline/multi-agent-refs/channels/pr.md +26 -0
  20. package/pipeline/multi-agent-refs/cross-cli-contract.md +22 -0
  21. package/pipeline/multi-agent-refs/features/base-branch-evidence.md +222 -0
  22. package/pipeline/multi-agent-refs/features/code-graph.md +40 -0
  23. package/pipeline/multi-agent-refs/features/code-intelligence.md +80 -0
  24. package/pipeline/multi-agent-refs/features/design-conformance.md +14 -0
  25. package/pipeline/multi-agent-refs/features/review-file-set.md +132 -0
  26. package/pipeline/multi-agent-refs/phases/modes.md +23 -3
  27. package/pipeline/multi-agent-refs/phases/phase-0-init.md +96 -71
  28. package/pipeline/multi-agent-refs/phases/phase-4-review.md +31 -23
  29. package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
  30. package/pipeline/multi-agent-refs/phases.md +7 -2
  31. package/pipeline/multi-agent-refs/picker-contract.md +37 -5
  32. package/pipeline/multi-agent-refs/tracker-contract.md +25 -14
  33. package/pipeline/schemas/agent-state.schema.json +88 -4
  34. package/pipeline/schemas/prefs.schema.json +22 -0
  35. package/pipeline/schemas/review-file-exclusions.json +137 -0
  36. package/pipeline/schemas/reviewer-output.schema.json +27 -1
  37. package/pipeline/schemas/token-budget.json +2 -2
  38. package/pipeline/scripts/autopilot-runner.mjs +292 -45
  39. package/pipeline/scripts/base-branch-candidates.mjs +599 -0
  40. package/pipeline/scripts/diff-risk-score.mjs +1 -36
  41. package/pipeline/scripts/gc-abandoned.sh +5 -3
  42. package/pipeline/scripts/gen-mode-dispatch.mjs +39 -16
  43. package/pipeline/scripts/git-path.mjs +63 -0
  44. package/pipeline/scripts/glob-match.mjs +62 -0
  45. package/pipeline/scripts/graph-mermaid.mjs +251 -0
  46. package/pipeline/scripts/phase-tracker.sh +39 -2
  47. package/pipeline/scripts/phase0-exit-gate.mjs +128 -0
  48. package/pipeline/scripts/review-file-filter.mjs +180 -0
  49. package/pipeline/scripts/skill-conformance.mjs +1 -31
  50. package/pipeline/scripts/validate-analysis-doc.mjs +53 -0
  51. package/pipeline/scripts/validate-reviewer.mjs +90 -1
  52. package/pipeline/scripts/verify-citations.mjs +428 -0
  53. package/pipeline/skills/.skill-manifest.json +2 -2
  54. package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -1
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file verify-citations.mjs - a `file:line` that nothing resolves is a guess.
5
+ *
6
+ * Two surfaces in this pipeline require a citation and neither has ever checked
7
+ * one. `reviewer-output.schema.json` makes `file` and `line` required on every
8
+ * finding; `analysis-spec.schema.json` carries `repoEvidence` entries of
9
+ * `{name, file, line, tag}` and `analysis/locked.md` (Locked 37) asks for
10
+ * `repo/file:line` on every current-behaviour row. The only check that existed
11
+ * was `validate-analysis-doc.mjs`'s `/:\d+\b/` - a test that the row LOOKS like
12
+ * a citation, satisfied by `Foo.swift:9999` in a repo with no Foo.swift.
13
+ *
14
+ * The judgement is moved out of the model and into git. A path is resolved at a
15
+ * commit, not on the working tree, so a run that cites a file deleted three
16
+ * commits ago fails instead of passing on whatever happens to be checked out.
17
+ *
18
+ * Inputs:
19
+ * <file> JSON or markdown to read. `-` reads stdin.
20
+ * --repo <path> Repository root. Default: cwd
21
+ * --rev <sha> Commit to resolve against. Default: HEAD
22
+ * --worktree Resolve against the CHECKOUT instead of a commit. For
23
+ * Phase 4, where a reviewer cites lines the dev has not
24
+ * committed yet and a commit-pinned check would call
25
+ * every one of them invented.
26
+ * --json Machine-readable report
27
+ * --quiet Exit code only
28
+ *
29
+ * Accepted shapes, detected rather than declared, so one script serves both
30
+ * callers and neither has to say which it is:
31
+ * - reviewer output `{findings: [{file, line, ...}]}`
32
+ * - triage output `{accepted|deferred|rejected: [{file, line, ...}]}`
33
+ * - analysis spec `{evidence: {repoEvidence: {<repo>: {buckets: ...}}}}`
34
+ * - markdown every `path/to/file.ext:123` in the prose
35
+ *
36
+ * Exit codes:
37
+ * 0 - every citation resolved, or there were none to resolve
38
+ * 1 - at least one citation did not resolve
39
+ * 2 - the input could not be read or is not a repository
40
+ * 64 - usage error
41
+ *
42
+ * @module pipeline/scripts/verify-citations
43
+ */
44
+
45
+ import { readFileSync, realpathSync, statSync } from "node:fs";
46
+ import { resolve as resolvePath, sep } from "node:path";
47
+ import { spawnSync } from "node:child_process";
48
+
49
+ /**
50
+ * Reject a path before it reaches git.
51
+ *
52
+ * `git show <rev>:<path>` happily resolves `../` out of the tree and an
53
+ * absolute path against the filesystem, so a citation could name something the
54
+ * repository does not contain and still "resolve". The guard is here rather
55
+ * than in the caller because every caller would otherwise need it.
56
+ *
57
+ * @param {string} p
58
+ * @returns {string|null} the reason it is unsafe, or null
59
+ */
60
+ export function unsafePath(p) {
61
+ if (!p) return "empty path";
62
+ if (p.startsWith("/")) return "absolute path";
63
+ if (p.includes("\\")) return "backslash in path";
64
+ const parts = p.split("/");
65
+ if (parts.includes("..")) return "path escapes the repository";
66
+ if (parts.includes(".git")) return "path reaches into .git";
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Extensions a markdown citation may carry.
72
+ *
73
+ * A list, not a pattern, and that direction is deliberate: the cost of a
74
+ * missing extension is one citation going unchecked, while the cost of an open
75
+ * pattern is the gate failing a correct document over a hostname. Add to it
76
+ * when a real repository file is missed - never widen it to a wildcard.
77
+ *
78
+ * `com`, `net`, `io`, `dev`, `tr` are absent on purpose.
79
+ */
80
+ export const SOURCE_EXTENSIONS = new Set([
81
+ "swift",
82
+ "kt",
83
+ "kts",
84
+ "java",
85
+ "m",
86
+ "mm",
87
+ "h",
88
+ "hpp",
89
+ "c",
90
+ "cc",
91
+ "cpp",
92
+ "js",
93
+ "mjs",
94
+ "cjs",
95
+ "jsx",
96
+ "ts",
97
+ "tsx",
98
+ "py",
99
+ "rb",
100
+ "go",
101
+ "rs",
102
+ "php",
103
+ "sh",
104
+ "bash",
105
+ "zsh",
106
+ "pl",
107
+ "sql",
108
+ "json",
109
+ "yml",
110
+ "yaml",
111
+ "toml",
112
+ "xml",
113
+ "plist",
114
+ "xcconfig",
115
+ "pbxproj",
116
+ "gradle",
117
+ "properties",
118
+ "cfg",
119
+ "ini",
120
+ "env",
121
+ "md",
122
+ "mdx",
123
+ "txt",
124
+ "csv",
125
+ "tsv",
126
+ "strings",
127
+ "stringsdict",
128
+ "html",
129
+ "css",
130
+ "scss",
131
+ "vue",
132
+ "svelte",
133
+ "graphql",
134
+ "proto",
135
+ "entitlements",
136
+ "storyboard",
137
+ "xib",
138
+ "lock",
139
+ "gemspec",
140
+ "podspec",
141
+ ]);
142
+
143
+ /**
144
+ * Pull `{file, line}` pairs out of whatever was handed over.
145
+ *
146
+ * @param {string} raw
147
+ * @returns {{file: string, line: number|null, where: string}[]}
148
+ */
149
+ export function extract(raw) {
150
+ const out = [];
151
+ const push = (file, line, where) => {
152
+ if (typeof file === "string" && file.trim()) {
153
+ out.push({ file: file.trim(), line: Number.isFinite(line) ? line : null, where });
154
+ }
155
+ };
156
+
157
+ let doc;
158
+ try {
159
+ doc = JSON.parse(raw);
160
+ } catch {
161
+ doc = null;
162
+ }
163
+
164
+ if (doc && typeof doc === "object") {
165
+ for (const key of ["findings", "accepted", "deferred", "rejected"]) {
166
+ for (const [i, f] of (doc[key] || []).entries()) {
167
+ if (f && typeof f === "object") push(f.file, Number(f.line), `${key}[${i}]`);
168
+ }
169
+ }
170
+ const repoEvidence = doc?.evidence?.repoEvidence || doc?.analysisSpec?.evidence?.repoEvidence;
171
+ if (repoEvidence && typeof repoEvidence === "object") {
172
+ for (const [repo, body] of Object.entries(repoEvidence)) {
173
+ for (const [bucket, rows] of Object.entries(body?.buckets || {})) {
174
+ for (const [i, r] of (rows || []).entries()) {
175
+ if (r && typeof r === "object") {
176
+ push(r.file, Number(r.line), `repoEvidence.${repo}.${bucket}[${i}]`);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ // Markdown. A citation is a path with an extension followed by a line number.
186
+ //
187
+ // Two things that look exactly like one and are not:
188
+ //
189
+ // - a URL with a port. `https://wiki.example.com:8443/x` hands
190
+ // `wiki.example.com:8443` to the matcher, and an analysis document is
191
+ // full of Confluence, Jira and Figma links. Left in, this gate blocked
192
+ // dispatch on every real document and reported "absolute path" as the
193
+ // reason, which is not even the right complaint. URLs are removed first.
194
+ // - a dotted number. `v1.2:30` has an "extension" of `2`, so the extension
195
+ // must START with a letter. No source file is named `.2`.
196
+ //
197
+ // Fenced blocks are skipped for the same class of reason: a code sample that
198
+ // happens to contain `foo.js:12` is an illustration, not a claim about this
199
+ // repository.
200
+ //
201
+ // The URL strip below only removes what carries a scheme. A host written
202
+ // bare - `wiki.example.com:8443/x`, and an analysis document is full of
203
+ // them - still looks exactly like a path with an extension and a line
204
+ // number, and this gate would fail the document over `.com`. So the
205
+ // extension has to be one a repository actually contains. This is the same
206
+ // defect that was already fixed once here for scheme-carrying URLs; the bare
207
+ // form survived it.
208
+ let fenced = false;
209
+ raw.split("\n").forEach((line, i) => {
210
+ if (/^\s*```/.test(line)) {
211
+ fenced = !fenced;
212
+ return;
213
+ }
214
+ if (fenced) return;
215
+ const cleaned = line.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
216
+ for (const m of cleaned.matchAll(/([A-Za-z0-9_./-]+\.([A-Za-z][A-Za-z0-9]*)):(\d+)/g)) {
217
+ if (!SOURCE_EXTENSIONS.has(m[2].toLowerCase())) continue;
218
+ push(m[1], Number(m[3]), `line ${i + 1}`);
219
+ }
220
+ });
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * Resolve one citation at a commit, or in the checkout when `rev` is null.
226
+ *
227
+ * @param {string} repo
228
+ * @param {string|null} rev
229
+ * @param {{file: string, line: number|null}} c
230
+ * @param {Map<string, number|null>} lineCache
231
+ * @returns {string|null} the reason it failed, or null
232
+ */
233
+ export function resolveOne(repo, rev, c, lineCache) {
234
+ const unsafe = unsafePath(c.file);
235
+ if (unsafe) return unsafe;
236
+
237
+ if (!lineCache.has(c.file)) {
238
+ lineCache.set(c.file, rev === null ? worktreeLines(repo, c.file) : revLines(repo, rev, c.file));
239
+ }
240
+
241
+ const lines = lineCache.get(c.file);
242
+ const at = rev === null ? "in the working tree" : `at ${rev.slice(0, 12)}`;
243
+ if (lines === null) return `no such file ${at}`;
244
+ if (c.line === null) return null;
245
+ if (c.line < 1) return `line ${c.line} is not a line number`;
246
+ if (c.line > lines) return `line ${c.line} is past the end of the file (${lines} lines)`;
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Line count of a blob at a commit, or null when the path is not a file there.
252
+ *
253
+ * @param {string} repo
254
+ * @param {string} rev
255
+ * @param {string} file
256
+ * @returns {number|null}
257
+ */
258
+ function revLines(repo, rev, file) {
259
+ const t = spawnSync("git", ["-C", repo, "cat-file", "-t", `${rev}:${file}`], {
260
+ encoding: "utf-8",
261
+ });
262
+ if (t.status !== 0 || t.stdout.trim() !== "blob") return null;
263
+ const show = spawnSync("git", ["-C", repo, "show", `${rev}:${file}`], {
264
+ encoding: "utf-8",
265
+ maxBuffer: 64 * 1024 * 1024,
266
+ });
267
+ return countLines(show.status === 0 ? show.stdout : "");
268
+ }
269
+
270
+ /**
271
+ * Line count in the checkout, or null when there is no such file.
272
+ *
273
+ * `unsafePath` cannot see a symlink: `docs/out -> /etc` is a relative path with
274
+ * no `..` in it, and git never followed it because git reads blobs rather than
275
+ * the filesystem. In worktree mode the filesystem IS the source, so containment
276
+ * is re-checked after the path is resolved.
277
+ *
278
+ * @param {string} repo
279
+ * @param {string} file
280
+ * @returns {number|null}
281
+ */
282
+ function worktreeLines(repo, file) {
283
+ let real;
284
+ let root;
285
+ try {
286
+ root = realpathSync(repo);
287
+ real = realpathSync(resolvePath(root, file));
288
+ } catch {
289
+ return null;
290
+ }
291
+ if (real !== root && !real.startsWith(root + sep)) return null;
292
+ try {
293
+ if (!statSync(real).isFile()) return null;
294
+ return countLines(readFileSync(real, "utf-8"));
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * A file with no trailing newline still has its last line.
302
+ *
303
+ * @param {string} text
304
+ * @returns {number}
305
+ */
306
+ function countLines(text) {
307
+ if (text.length === 0) return 0;
308
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
309
+ }
310
+
311
+ /**
312
+ * @param {object} params
313
+ * @returns {{checked: number, unresolved: object[], rev: string}}
314
+ */
315
+ export function verify({ raw, repo, rev }) {
316
+ const citations = extract(raw);
317
+ const lineCache = new Map();
318
+ const unresolved = [];
319
+ for (const c of citations) {
320
+ const reason = resolveOne(repo, rev, c, lineCache);
321
+ if (reason) unresolved.push({ ...c, reason });
322
+ }
323
+ return { checked: citations.length, unresolved, rev };
324
+ }
325
+
326
+ const VALUE_FLAGS = new Set(["--repo", "--rev"]);
327
+
328
+ /**
329
+ * One pass, positionally. `argv.indexOf(a)` finds the FIRST occurrence of a
330
+ * string, so `--repo . file .` would have mis-identified which `.` was the
331
+ * target; walking the list is both simpler and correct.
332
+ *
333
+ * @param {string[]} argv
334
+ * @returns {{target: string|null, repo: string|null, rev: string|null}}
335
+ */
336
+ export function parseArgs(argv) {
337
+ let target = null;
338
+ let repo = null;
339
+ let rev = null;
340
+ for (let i = 0; i < argv.length; i++) {
341
+ const a = argv[i];
342
+ if (VALUE_FLAGS.has(a)) {
343
+ const v = argv[i + 1];
344
+ if (v !== undefined && !v.startsWith("--")) {
345
+ if (a === "--repo") repo = v;
346
+ else rev = v;
347
+ i++;
348
+ }
349
+ continue;
350
+ }
351
+ if (a.startsWith("--")) continue;
352
+ if (target === null) target = a;
353
+ }
354
+ return { target, repo, rev };
355
+ }
356
+
357
+ function main() {
358
+ const argv = process.argv.slice(2);
359
+ const parsed = parseArgs(argv);
360
+ const target = parsed.target;
361
+ if (!target) {
362
+ console.error(
363
+ "usage: verify-citations.mjs <file|-> [--repo <path>] [--rev <sha>|--worktree] [--json] [--quiet]",
364
+ );
365
+ process.exit(64);
366
+ }
367
+
368
+ const repo = parsed.repo || process.cwd();
369
+ const worktree = argv.includes("--worktree");
370
+ if (worktree && parsed.rev) {
371
+ console.error("verify-citations: --worktree and --rev name two different trees; pick one");
372
+ process.exit(64);
373
+ }
374
+ const rev = parsed.rev || "HEAD";
375
+ const json = argv.includes("--json");
376
+ const quiet = argv.includes("--quiet");
377
+
378
+ let raw;
379
+ try {
380
+ raw = target === "-" ? readFileSync(0, "utf-8") : readFileSync(target, "utf-8");
381
+ } catch (e) {
382
+ console.error(`verify-citations: cannot read ${target}: ${e.message}`);
383
+ process.exit(2);
384
+ }
385
+
386
+ const top = spawnSync("git", ["-C", repo, "rev-parse", "--show-toplevel"], { encoding: "utf-8" });
387
+ if (top.status !== 0) {
388
+ console.error(`verify-citations: ${repo} is not a git repository`);
389
+ process.exit(2);
390
+ }
391
+ let sha = null;
392
+ if (!worktree) {
393
+ const head = spawnSync("git", ["-C", repo, "rev-parse", rev], { encoding: "utf-8" });
394
+ if (head.status !== 0) {
395
+ console.error(`verify-citations: ${rev} is not a commit in ${repo}`);
396
+ process.exit(2);
397
+ }
398
+ sha = head.stdout.trim();
399
+ }
400
+
401
+ const report = verify({ raw, repo, rev: sha });
402
+ const where = sha === null ? "in the working tree" : `at ${sha.slice(0, 12)}`;
403
+
404
+ if (json) {
405
+ console.log(JSON.stringify(report, null, 2));
406
+ } else if (!quiet) {
407
+ if (report.unresolved.length === 0) {
408
+ console.log(`verify-citations: ${report.checked} citation(s) resolve ${where}`);
409
+ } else {
410
+ console.log(
411
+ `verify-citations: ${report.unresolved.length} of ${report.checked} citation(s) do not resolve ${where}:`,
412
+ );
413
+ for (const u of report.unresolved) {
414
+ console.log(` ${u.where}: ${u.file}${u.line === null ? "" : `:${u.line}`} - ${u.reason}`);
415
+ }
416
+ // The cheap way to make this exit 0 is to delete the line number, which
417
+ // turns a wrong claim into an unfalsifiable one. Said here because this
418
+ // is the moment someone is deciding what to do about it.
419
+ console.log(" Re-search and correct the citation; never drop the line number to pass.");
420
+ }
421
+ }
422
+
423
+ process.exit(report.unresolved.length === 0 ? 0 : 1);
424
+ }
425
+
426
+ if (import.meta.url === `file://${process.argv[1]}`) {
427
+ main();
428
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-14T11:56:29Z",
3
+ "generatedAt": "2026-09-15T12:49:28Z",
4
4
  "skillCount": 212,
5
5
  "entries": [
6
6
  {
@@ -237,7 +237,7 @@
237
237
  },
238
238
  {
239
239
  "path": "shared/core/multi-agent/SKILL.md",
240
- "sha256": "de56a86c64f1e6f56d1d4e5f0e129f04e44c36d930caa3857dd5c46689e8e501"
240
+ "sha256": "faac7ceb535d18778efe0ea91c19534c8e076bc709e7b1dbc567f5e71692bbbd"
241
241
  },
242
242
  {
243
243
  "path": "shared/external/accessibility-compliance-accessibility-audit/SKILL.md",
@@ -316,7 +316,7 @@ All TaskCreate calls fire in strict phase-number order BEFORE any TaskUpdate is
316
316
  - `multi-agent-local`, `multi-agent-autopilot`, `multi-agent-local-autopilot`: 0 → 1 → 2 → 3 → 4 → 6 → 7 (the interactive Phase 5 gate needs a worktree checkout and an attended run)
317
317
  - `multi-agent-analysis`: 0 → 1 → 2 → 4 → 6 → 7 (no code, so no Dev and no Test)
318
318
 
319
- Depth does not change the SET. A Short run (the Phase 0 Step 7.5 answer) registers its command's full set and flips Phases 1 and 2 to `skipped` when the answer lands at Step 7.5 - the tracker boots at Step -1, long before the question can be asked.
319
+ Depth decides WHICH of the command's set is registered. The tracker boots at Step -1, long before the depth question can be asked, so it registers Phase 0 alone and the remaining tiles are created at Step 7.5 once the answer is known - a Short run never draws an Analysis or Planning tile.
320
320
 
321
321
  Full contract: `refs/tracker-contract.md` section "TaskCreate ordering (strict)".
322
322