@mmerterden/multi-agent-pipeline 17.1.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +7 -0
  3. package/README.tr.md +7 -0
  4. package/docs/token-budget-history.md +22 -0
  5. package/install/_dev-only-files.mjs +1 -0
  6. package/install/codex.mjs +18 -1
  7. package/install/copilot.mjs +17 -1
  8. package/package.json +1 -1
  9. package/pipeline/agents/code-reviewer.md +35 -1
  10. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +9 -1
  11. package/pipeline/lib/autopilot-state.sh +34 -0
  12. package/pipeline/multi-agent-refs/_dev-context.md +10 -0
  13. package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
  14. package/pipeline/multi-agent-refs/analysis/redesign.md +8 -0
  15. package/pipeline/multi-agent-refs/analysis/render.md +2 -1
  16. package/pipeline/multi-agent-refs/analysis/review.md +9 -0
  17. package/pipeline/multi-agent-refs/android-guide.md +14 -0
  18. package/pipeline/multi-agent-refs/audit-guide.md +12 -0
  19. package/pipeline/multi-agent-refs/backend-guide.md +10 -0
  20. package/pipeline/multi-agent-refs/channels/confluence.md +11 -0
  21. package/pipeline/multi-agent-refs/channels/issue-comment.md +12 -0
  22. package/pipeline/multi-agent-refs/channels/jira.md +10 -0
  23. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +13 -0
  24. package/pipeline/multi-agent-refs/channels/pr.md +37 -0
  25. package/pipeline/multi-agent-refs/component-dispatch.md +11 -0
  26. package/pipeline/multi-agent-refs/component-generation.md +11 -0
  27. package/pipeline/multi-agent-refs/conventions-defaults.md +15 -0
  28. package/pipeline/multi-agent-refs/cross-cli-contract.md +65 -0
  29. package/pipeline/multi-agent-refs/features/analysis-jira.md +11 -0
  30. package/pipeline/multi-agent-refs/features/code-graph.md +40 -0
  31. package/pipeline/multi-agent-refs/features/design-conformance.md +24 -0
  32. package/pipeline/multi-agent-refs/features/doctor.md +10 -0
  33. package/pipeline/multi-agent-refs/features/external-context-injection.md +7 -0
  34. package/pipeline/multi-agent-refs/features/jira-context.md +9 -0
  35. package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
  36. package/pipeline/multi-agent-refs/features/review-file-set.md +132 -0
  37. package/pipeline/multi-agent-refs/features/skill-conformance.md +13 -0
  38. package/pipeline/multi-agent-refs/features/url-enrichment.md +9 -0
  39. package/pipeline/multi-agent-refs/features/visual-evidence.md +42 -0
  40. package/pipeline/multi-agent-refs/generate-issue.md +7 -0
  41. package/pipeline/multi-agent-refs/issue-jira-triad.md +9 -0
  42. package/pipeline/multi-agent-refs/knowledge.md +6 -0
  43. package/pipeline/multi-agent-refs/multi-repo-integration-build.md +13 -0
  44. package/pipeline/multi-agent-refs/phases/modes.md +7 -0
  45. package/pipeline/multi-agent-refs/phases/operations.md +9 -0
  46. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  47. package/pipeline/multi-agent-refs/phases/phase-4-review.md +31 -23
  48. package/pipeline/multi-agent-refs/phases.md +11 -0
  49. package/pipeline/multi-agent-refs/picker-contract.md +12 -0
  50. package/pipeline/multi-agent-refs/platform-parity.md +10 -0
  51. package/pipeline/multi-agent-refs/progress-contract.md +10 -0
  52. package/pipeline/multi-agent-refs/setup/firebase.md +9 -0
  53. package/pipeline/multi-agent-refs/swiftui-guide.md +17 -0
  54. package/pipeline/multi-agent-refs/tracker-contract.md +12 -0
  55. package/pipeline/multi-agent-refs/web-guide.md +10 -0
  56. package/pipeline/multi-agent-refs/wiki-capture.md +11 -0
  57. package/pipeline/schemas/prefs.schema.json +4 -0
  58. package/pipeline/schemas/review-file-exclusions.json +137 -0
  59. package/pipeline/schemas/reviewer-output.schema.json +27 -1
  60. package/pipeline/schemas/token-budget.json +10 -19
  61. package/pipeline/scripts/autopilot-intake.mjs +5 -1
  62. package/pipeline/scripts/autopilot-runner.mjs +6 -1
  63. package/pipeline/scripts/autopilot-status.sh +3 -2
  64. package/pipeline/scripts/capture-evidence.sh +79 -11
  65. package/pipeline/scripts/diff-risk-score.mjs +1 -36
  66. package/pipeline/scripts/gen-ref-toc.mjs +279 -0
  67. package/pipeline/scripts/git-path.mjs +63 -0
  68. package/pipeline/scripts/glob-match.mjs +62 -0
  69. package/pipeline/scripts/graph-mermaid.mjs +251 -0
  70. package/pipeline/scripts/review-file-filter.mjs +180 -0
  71. package/pipeline/scripts/skill-conformance.mjs +1 -31
  72. package/pipeline/scripts/validate-analysis-doc.mjs +53 -0
  73. package/pipeline/scripts/validate-reviewer.mjs +90 -1
  74. package/pipeline/scripts/verify-citations.mjs +346 -0
@@ -0,0 +1,346 @@
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
+ * Pull `{file, line}` pairs out of whatever was handed over.
72
+ *
73
+ * @param {string} raw
74
+ * @returns {{file: string, line: number|null, where: string}[]}
75
+ */
76
+ export function extract(raw) {
77
+ const out = [];
78
+ const push = (file, line, where) => {
79
+ if (typeof file === "string" && file.trim()) {
80
+ out.push({ file: file.trim(), line: Number.isFinite(line) ? line : null, where });
81
+ }
82
+ };
83
+
84
+ let doc;
85
+ try {
86
+ doc = JSON.parse(raw);
87
+ } catch {
88
+ doc = null;
89
+ }
90
+
91
+ if (doc && typeof doc === "object") {
92
+ for (const key of ["findings", "accepted", "deferred", "rejected"]) {
93
+ for (const [i, f] of (doc[key] || []).entries()) {
94
+ if (f && typeof f === "object") push(f.file, Number(f.line), `${key}[${i}]`);
95
+ }
96
+ }
97
+ const repoEvidence = doc?.evidence?.repoEvidence || doc?.analysisSpec?.evidence?.repoEvidence;
98
+ if (repoEvidence && typeof repoEvidence === "object") {
99
+ for (const [repo, body] of Object.entries(repoEvidence)) {
100
+ for (const [bucket, rows] of Object.entries(body?.buckets || {})) {
101
+ for (const [i, r] of (rows || []).entries()) {
102
+ if (r && typeof r === "object") {
103
+ push(r.file, Number(r.line), `repoEvidence.${repo}.${bucket}[${i}]`);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Markdown. A citation is a path with an extension followed by a line number.
113
+ //
114
+ // Two things that look exactly like one and are not:
115
+ //
116
+ // - a URL with a port. `https://wiki.example.com:8443/x` hands
117
+ // `wiki.example.com:8443` to the matcher, and an analysis document is
118
+ // full of Confluence, Jira and Figma links. Left in, this gate blocked
119
+ // dispatch on every real document and reported "absolute path" as the
120
+ // reason, which is not even the right complaint. URLs are removed first.
121
+ // - a dotted number. `v1.2:30` has an "extension" of `2`, so the extension
122
+ // must START with a letter. No source file is named `.2`.
123
+ //
124
+ // Fenced blocks are skipped for the same class of reason: a code sample that
125
+ // happens to contain `foo.js:12` is an illustration, not a claim about this
126
+ // repository.
127
+ let fenced = false;
128
+ raw.split("\n").forEach((line, i) => {
129
+ if (/^\s*```/.test(line)) {
130
+ fenced = !fenced;
131
+ return;
132
+ }
133
+ if (fenced) return;
134
+ const cleaned = line.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
135
+ for (const m of cleaned.matchAll(/([A-Za-z0-9_./-]+\.[A-Za-z][A-Za-z0-9]*):(\d+)/g)) {
136
+ push(m[1], Number(m[2]), `line ${i + 1}`);
137
+ }
138
+ });
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Resolve one citation at a commit, or in the checkout when `rev` is null.
144
+ *
145
+ * @param {string} repo
146
+ * @param {string|null} rev
147
+ * @param {{file: string, line: number|null}} c
148
+ * @param {Map<string, number|null>} lineCache
149
+ * @returns {string|null} the reason it failed, or null
150
+ */
151
+ export function resolveOne(repo, rev, c, lineCache) {
152
+ const unsafe = unsafePath(c.file);
153
+ if (unsafe) return unsafe;
154
+
155
+ if (!lineCache.has(c.file)) {
156
+ lineCache.set(c.file, rev === null ? worktreeLines(repo, c.file) : revLines(repo, rev, c.file));
157
+ }
158
+
159
+ const lines = lineCache.get(c.file);
160
+ const at = rev === null ? "in the working tree" : `at ${rev.slice(0, 12)}`;
161
+ if (lines === null) return `no such file ${at}`;
162
+ if (c.line === null) return null;
163
+ if (c.line < 1) return `line ${c.line} is not a line number`;
164
+ if (c.line > lines) return `line ${c.line} is past the end of the file (${lines} lines)`;
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Line count of a blob at a commit, or null when the path is not a file there.
170
+ *
171
+ * @param {string} repo
172
+ * @param {string} rev
173
+ * @param {string} file
174
+ * @returns {number|null}
175
+ */
176
+ function revLines(repo, rev, file) {
177
+ const t = spawnSync("git", ["-C", repo, "cat-file", "-t", `${rev}:${file}`], {
178
+ encoding: "utf-8",
179
+ });
180
+ if (t.status !== 0 || t.stdout.trim() !== "blob") return null;
181
+ const show = spawnSync("git", ["-C", repo, "show", `${rev}:${file}`], {
182
+ encoding: "utf-8",
183
+ maxBuffer: 64 * 1024 * 1024,
184
+ });
185
+ return countLines(show.status === 0 ? show.stdout : "");
186
+ }
187
+
188
+ /**
189
+ * Line count in the checkout, or null when there is no such file.
190
+ *
191
+ * `unsafePath` cannot see a symlink: `docs/out -> /etc` is a relative path with
192
+ * no `..` in it, and git never followed it because git reads blobs rather than
193
+ * the filesystem. In worktree mode the filesystem IS the source, so containment
194
+ * is re-checked after the path is resolved.
195
+ *
196
+ * @param {string} repo
197
+ * @param {string} file
198
+ * @returns {number|null}
199
+ */
200
+ function worktreeLines(repo, file) {
201
+ let real;
202
+ let root;
203
+ try {
204
+ root = realpathSync(repo);
205
+ real = realpathSync(resolvePath(root, file));
206
+ } catch {
207
+ return null;
208
+ }
209
+ if (real !== root && !real.startsWith(root + sep)) return null;
210
+ try {
211
+ if (!statSync(real).isFile()) return null;
212
+ return countLines(readFileSync(real, "utf-8"));
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * A file with no trailing newline still has its last line.
220
+ *
221
+ * @param {string} text
222
+ * @returns {number}
223
+ */
224
+ function countLines(text) {
225
+ if (text.length === 0) return 0;
226
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
227
+ }
228
+
229
+ /**
230
+ * @param {object} params
231
+ * @returns {{checked: number, unresolved: object[], rev: string}}
232
+ */
233
+ export function verify({ raw, repo, rev }) {
234
+ const citations = extract(raw);
235
+ const lineCache = new Map();
236
+ const unresolved = [];
237
+ for (const c of citations) {
238
+ const reason = resolveOne(repo, rev, c, lineCache);
239
+ if (reason) unresolved.push({ ...c, reason });
240
+ }
241
+ return { checked: citations.length, unresolved, rev };
242
+ }
243
+
244
+ const VALUE_FLAGS = new Set(["--repo", "--rev"]);
245
+
246
+ /**
247
+ * One pass, positionally. `argv.indexOf(a)` finds the FIRST occurrence of a
248
+ * string, so `--repo . file .` would have mis-identified which `.` was the
249
+ * target; walking the list is both simpler and correct.
250
+ *
251
+ * @param {string[]} argv
252
+ * @returns {{target: string|null, repo: string|null, rev: string|null}}
253
+ */
254
+ export function parseArgs(argv) {
255
+ let target = null;
256
+ let repo = null;
257
+ let rev = null;
258
+ for (let i = 0; i < argv.length; i++) {
259
+ const a = argv[i];
260
+ if (VALUE_FLAGS.has(a)) {
261
+ const v = argv[i + 1];
262
+ if (v !== undefined && !v.startsWith("--")) {
263
+ if (a === "--repo") repo = v;
264
+ else rev = v;
265
+ i++;
266
+ }
267
+ continue;
268
+ }
269
+ if (a.startsWith("--")) continue;
270
+ if (target === null) target = a;
271
+ }
272
+ return { target, repo, rev };
273
+ }
274
+
275
+ function main() {
276
+ const argv = process.argv.slice(2);
277
+ const parsed = parseArgs(argv);
278
+ const target = parsed.target;
279
+ if (!target) {
280
+ console.error(
281
+ "usage: verify-citations.mjs <file|-> [--repo <path>] [--rev <sha>|--worktree] [--json] [--quiet]",
282
+ );
283
+ process.exit(64);
284
+ }
285
+
286
+ const repo = parsed.repo || process.cwd();
287
+ const worktree = argv.includes("--worktree");
288
+ if (worktree && parsed.rev) {
289
+ console.error("verify-citations: --worktree and --rev name two different trees; pick one");
290
+ process.exit(64);
291
+ }
292
+ const rev = parsed.rev || "HEAD";
293
+ const json = argv.includes("--json");
294
+ const quiet = argv.includes("--quiet");
295
+
296
+ let raw;
297
+ try {
298
+ raw = target === "-" ? readFileSync(0, "utf-8") : readFileSync(target, "utf-8");
299
+ } catch (e) {
300
+ console.error(`verify-citations: cannot read ${target}: ${e.message}`);
301
+ process.exit(2);
302
+ }
303
+
304
+ const top = spawnSync("git", ["-C", repo, "rev-parse", "--show-toplevel"], { encoding: "utf-8" });
305
+ if (top.status !== 0) {
306
+ console.error(`verify-citations: ${repo} is not a git repository`);
307
+ process.exit(2);
308
+ }
309
+ let sha = null;
310
+ if (!worktree) {
311
+ const head = spawnSync("git", ["-C", repo, "rev-parse", rev], { encoding: "utf-8" });
312
+ if (head.status !== 0) {
313
+ console.error(`verify-citations: ${rev} is not a commit in ${repo}`);
314
+ process.exit(2);
315
+ }
316
+ sha = head.stdout.trim();
317
+ }
318
+
319
+ const report = verify({ raw, repo, rev: sha });
320
+ const where = sha === null ? "in the working tree" : `at ${sha.slice(0, 12)}`;
321
+
322
+ if (json) {
323
+ console.log(JSON.stringify(report, null, 2));
324
+ } else if (!quiet) {
325
+ if (report.unresolved.length === 0) {
326
+ console.log(`verify-citations: ${report.checked} citation(s) resolve ${where}`);
327
+ } else {
328
+ console.log(
329
+ `verify-citations: ${report.unresolved.length} of ${report.checked} citation(s) do not resolve ${where}:`,
330
+ );
331
+ for (const u of report.unresolved) {
332
+ console.log(` ${u.where}: ${u.file}${u.line === null ? "" : `:${u.line}`} - ${u.reason}`);
333
+ }
334
+ // The cheap way to make this exit 0 is to delete the line number, which
335
+ // turns a wrong claim into an unfalsifiable one. Said here because this
336
+ // is the moment someone is deciding what to do about it.
337
+ console.log(" Re-search and correct the citation; never drop the line number to pass.");
338
+ }
339
+ }
340
+
341
+ process.exit(report.unresolved.length === 0 ? 0 : 1);
342
+ }
343
+
344
+ if (import.meta.url === `file://${process.argv[1]}`) {
345
+ main();
346
+ }