@mmerterden/multi-agent-pipeline 20.21.0 → 20.22.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 (41) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/docs/adr/0017-change-impact-from-the-graph.md +69 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/ecosystem.md +6 -5
  5. package/docs/facts.json +4 -3
  6. package/install/catalog-history.json +1 -1
  7. package/install/templates/copilot-instructions.md +1 -0
  8. package/manifest.json +44 -40
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/review/SKILL.md +2 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +6 -15
  12. package/pipeline/multi-agent-refs/_dev-context.md +15 -0
  13. package/pipeline/multi-agent-refs/analysis/evidence.md +5 -3
  14. package/pipeline/multi-agent-refs/analysis/locked.md +1 -1
  15. package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
  16. package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
  17. package/pipeline/multi-agent-refs/features/product-context.md +238 -0
  18. package/pipeline/multi-agent-refs/knowledge.md +2 -0
  19. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +7 -12
  20. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  21. package/pipeline/multi-agent-refs/phases/phase-3-review.md +7 -6
  22. package/pipeline/multi-agent-refs/platform-parity.md +23 -9
  23. package/pipeline/multi-agent-refs/setup/token-save-flow.md +24 -0
  24. package/pipeline/schemas/agent-state.schema.json +31 -0
  25. package/pipeline/schemas/analysis-spec.schema.json +14 -0
  26. package/pipeline/schemas/code-graph.schema.json +12 -1
  27. package/pipeline/schemas/diff-risk.schema.json +2 -1
  28. package/pipeline/schemas/prefs.schema.json +58 -0
  29. package/pipeline/scripts/_code-graph.mjs +76 -3
  30. package/pipeline/scripts/_diff-impact.mjs +312 -0
  31. package/pipeline/scripts/capture-flush.sh +58 -3
  32. package/pipeline/scripts/diff-risk-score.mjs +80 -9
  33. package/pipeline/scripts/graph-affected.mjs +167 -36
  34. package/pipeline/scripts/graph-query.mjs +16 -4
  35. package/pipeline/scripts/product-context.mjs +539 -0
  36. package/pipeline/scripts/smoke-cross-cli-behavior.sh +39 -1
  37. package/pipeline/scripts/validate-code-graph.mjs +2 -1
  38. package/pipeline/scripts/validate-diff-risk.mjs +1 -0
  39. package/pipeline/skills/.skill-manifest.json +3 -3
  40. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -1
  41. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +8 -6
@@ -9,7 +9,14 @@
9
9
  * radius of a change rather than its neighbourhood.
10
10
  *
11
11
  * Inputs:
12
- * "<symbol>" Symbol name, exact or case-insensitive. Required.
12
+ * "<symbol>" Symbol name, exact or case-insensitive. Required unless
13
+ * --diff or --diff-file is given.
14
+ * --diff <spec> Seed from a change instead: `<base>` (working tree against
15
+ * base) or `<base>..<head>`. Hunks map onto the innermost
16
+ * declarations they touch, read on the side of the diff
17
+ * the graph was built from (_diff-impact.mjs graphSide).
18
+ * --diff-file <p> The same, from a saved unified diff.
19
+ * --root <path> Repo for --diff. Default: the root recorded in the graph
13
20
  * --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
14
21
  * --depth N Reverse traversal depth. Default: 2
15
22
  * --kind K Restrict to one edge kind (repeatable via comma)
@@ -24,8 +31,16 @@
24
31
  */
25
32
 
26
33
  import { resolve } from "node:path";
34
+ import { readFileSync } from "node:fs";
27
35
  import { parseFlags } from "./graph-build.mjs";
28
36
  import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
37
+ import {
38
+ parseHunkRanges,
39
+ changedSymbols,
40
+ graphFreshness,
41
+ graphSide,
42
+ diffFor,
43
+ } from "./_diff-impact.mjs";
29
44
 
30
45
  /**
31
46
  * Nodes whose name matches, preferring an exact hit.
@@ -62,37 +77,155 @@ export function incoming(graph, kinds = null) {
62
77
  }
63
78
 
64
79
  /**
65
- * Walk backwards from each seed.
80
+ * Walk backwards from each seed, over files.
81
+ *
82
+ * Dependents are files: a file depends on a target when it references a
83
+ * symbol or imports a file. The walk continues through what a dependent file
84
+ * itself declares, because the next ring depends on those declarations, not on
85
+ * the file node - references point from files to symbols, so a walk that only
86
+ * followed a file's own incoming edges would stop after one ring. `defines`
87
+ * is never a dependency, and the files that define the seeds are never
88
+ * reported as their own dependents.
66
89
  *
67
90
  * @param {object} params
91
+ * @param {object} params.graph
92
+ * @param {object[]} params.seeds - symbol or file nodes
93
+ * @param {number} params.depth
94
+ * @param {Set<string>|null} [params.kinds] - dependency edge kinds to follow
68
95
  * @returns {Array<{id: string, dist: number, kind: string, node: object}>}
69
96
  */
70
- export function affected({ graph, seeds, depth, kinds }) {
97
+ export function affected({ graph, seeds, depth, kinds = null }) {
71
98
  const byId = new Map(graph.nodes.map((n) => [n.id, n]));
72
99
  const adj = incoming(graph, kinds);
73
- const seen = new Set(seeds.map((n) => n.id));
100
+ const declares = new Map();
101
+ const definer = new Map();
102
+ for (const e of graph.edges) {
103
+ if (e.kind !== "defines") continue;
104
+ if (!declares.has(e.from)) declares.set(e.from, []);
105
+ declares.get(e.from).push(e.to);
106
+ definer.set(e.to, e.from);
107
+ }
108
+ const expand = (node) =>
109
+ node.kind === "file"
110
+ ? [node.id, ...(declares.get(node.id) || [])]
111
+ : [node.id, ...(definer.has(node.id) ? [definer.get(node.id)] : [])];
112
+
113
+ const seedFiles = new Set(
114
+ seeds.map((n) => (n.kind === "file" ? n.id : definer.get(n.id))).filter(Boolean),
115
+ );
116
+ const walked = new Set();
117
+ let frontier = [];
118
+ for (const seed of seeds)
119
+ for (const id of expand(seed)) if (!walked.has(id)) walked.add(id) && frontier.push(id);
120
+
121
+ const reported = new Set();
74
122
  const out = [];
75
- const queue = seeds.map((n) => ({ id: n.id, dist: 0, kind: "seed" }));
76
-
77
- while (queue.length) {
78
- const cur = queue.shift();
79
- if (cur.dist > 0) out.push({ ...cur, node: byId.get(cur.id) });
80
- if (cur.dist >= depth) continue;
81
- for (const dep of adj.get(cur.id) || []) {
82
- if (seen.has(dep.id)) continue;
83
- seen.add(dep.id);
84
- queue.push({ id: dep.id, dist: cur.dist + 1, kind: dep.kind });
123
+ for (let dist = 1; dist <= depth && frontier.length; dist++) {
124
+ const next = [];
125
+ for (const target of frontier) {
126
+ for (const dep of adj.get(target) || []) {
127
+ if (dep.kind === "defines" || seedFiles.has(dep.id) || reported.has(dep.id)) continue;
128
+ const node = byId.get(dep.id);
129
+ if (!node) continue;
130
+ reported.add(dep.id);
131
+ out.push({ id: dep.id, dist, kind: dep.kind, node });
132
+ for (const id of expand(node)) if (!walked.has(id)) walked.add(id) && next.push(id);
133
+ }
85
134
  }
135
+ frontier = next;
86
136
  }
87
137
  return out;
88
138
  }
89
139
 
140
+ function printJson(obj) {
141
+ process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
142
+ }
143
+
144
+ function hitRows(hits) {
145
+ return hits.map((h) => ({
146
+ id: h.id,
147
+ dist: h.dist,
148
+ edgeKind: h.kind,
149
+ path: h.node?.path || null,
150
+ }));
151
+ }
152
+
153
+ function freshnessLine(f) {
154
+ if (!f.stale) return "graph: current";
155
+ const parts = [];
156
+ if (f.commitMoved)
157
+ parts.push(
158
+ `built at ${String(f.baseCommit).slice(0, 12)}, HEAD is ${String(f.head).slice(0, 12)}`,
159
+ );
160
+ if (f.changedFiles.length) parts.push(`${f.changedFiles.length} file(s) edited since the build`);
161
+ if (f.removedFiles.length) parts.push(`${f.removedFiles.length} file(s) removed`);
162
+ return `graph: STALE (${parts.join("; ")}) - rebuild with graph-build.mjs for a current answer`;
163
+ }
164
+
165
+ function runDiff(flags, graph, depth, kinds) {
166
+ const root = flags.root && flags.root !== true ? resolve(flags.root) : graph.root;
167
+ let text;
168
+ if (flags["diff-file"] && flags["diff-file"] !== true) {
169
+ text = readFileSync(resolve(flags["diff-file"]), "utf8");
170
+ } else {
171
+ text = diffFor(root, flags.diff);
172
+ }
173
+ const freshness = graphFreshness(graph, { root });
174
+ const side = graphSide(graph, { root });
175
+ const mapped = changedSymbols(graph, parseHunkRanges(text, side));
176
+ const hits = affected({ graph, seeds: mapped.seeds, depth, kinds });
177
+ const result = {
178
+ mode: "diff",
179
+ diff: flags["diff-file"] && flags["diff-file"] !== true ? flags["diff-file"] : flags.diff,
180
+ changedSymbols: mapped.changed.map((c) => ({
181
+ name: c.name,
182
+ path: c.path,
183
+ line: c.line,
184
+ endLine: c.endLine,
185
+ })),
186
+ seeds: mapped.seeds.map((s) => s.id),
187
+ fileLevel: mapped.fileLevel,
188
+ notIndexed: mapped.notIndexed,
189
+ affected: hitRows(hits),
190
+ side,
191
+ freshness,
192
+ };
193
+ if (flags.json === true) return printJson(result);
194
+
195
+ process.stdout.write(`# affected by diff ${result.diff}\n\n`);
196
+ for (const c of result.changedSymbols)
197
+ process.stdout.write(`* ${c.name} ${c.path}:${c.line}-${c.endLine}\n`);
198
+ for (const p of result.fileLevel)
199
+ process.stdout.write(`* ${p} (whole file: graph has no spans, rebuild for symbol level)\n`);
200
+ const files = new Set();
201
+ process.stdout.write("\n");
202
+ for (const hit of hits) {
203
+ if (hit.node?.path) files.add(hit.node.path);
204
+ process.stdout.write(
205
+ `${" ".repeat(hit.dist)}- ${hit.node?.name || hit.id} [${hit.kind}] ${hit.node?.path || "-"}\n`,
206
+ );
207
+ }
208
+ process.stdout.write(`\n${hits.length} dependents across ${files.size} files\n`);
209
+ if (result.notIndexed.length)
210
+ process.stdout.write(`not in the graph: ${result.notIndexed.join(", ")}\n`);
211
+ process.stdout.write(`${freshnessLine(freshness)}\n`);
212
+ }
213
+
90
214
  function main() {
91
215
  const { flags, positional } = parseFlags(process.argv.slice(2));
92
216
  const name = positional.join(" ").trim();
93
- if (!name) {
217
+ const diffMode = flags.diff !== undefined || flags["diff-file"] !== undefined;
218
+ if (diffMode) {
219
+ const spec = flags["diff-file"] !== undefined ? flags["diff-file"] : flags.diff;
220
+ if (spec === true || String(spec).startsWith("-")) {
221
+ process.stderr.write(
222
+ "graph-affected: --diff takes <base> or <base>..<head>; --diff-file takes a path\n",
223
+ );
224
+ process.exit(64);
225
+ }
226
+ } else if (!name) {
94
227
  process.stderr.write(
95
- 'graph-affected: a symbol name is required, e.g. graph-affected "Flight"\n',
228
+ 'graph-affected: a symbol name is required, e.g. graph-affected "Flight", or --diff <base>\n',
96
229
  );
97
230
  process.exit(64);
98
231
  }
@@ -109,12 +242,20 @@ function main() {
109
242
  const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
110
243
  const kinds = flags.kind && flags.kind !== true ? new Set(String(flags.kind).split(",")) : null;
111
244
 
245
+ if (diffMode) {
246
+ try {
247
+ return runDiff(flags, graph, depth, kinds);
248
+ } catch (e) {
249
+ process.stderr.write(`graph-affected: ${e.message}\n`);
250
+ process.exit(1);
251
+ }
252
+ }
253
+
112
254
  const seeds = findByName(graph, name);
255
+ const freshness = graphFreshness(graph);
113
256
  if (seeds.length === 0) {
114
257
  if (flags.json === true) {
115
- process.stdout.write(
116
- JSON.stringify({ symbol: name, seeds: [], affected: [] }, null, 2) + "\n",
117
- );
258
+ printJson({ symbol: name, seeds: [], affected: [], freshness });
118
259
  } else {
119
260
  process.stdout.write(`no node named '${name}'\n`);
120
261
  }
@@ -124,23 +265,12 @@ function main() {
124
265
  const hits = affected({ graph, seeds, depth, kinds });
125
266
 
126
267
  if (flags.json === true) {
127
- process.stdout.write(
128
- JSON.stringify(
129
- {
130
- symbol: name,
131
- seeds: seeds.map((s) => s.id),
132
- affected: hits.map((h) => ({
133
- id: h.id,
134
- dist: h.dist,
135
- edgeKind: h.kind,
136
- path: h.node?.path || null,
137
- })),
138
- },
139
- null,
140
- 2,
141
- ) + "\n",
142
- );
143
- return;
268
+ return printJson({
269
+ symbol: name,
270
+ seeds: seeds.map((s) => s.id),
271
+ affected: hitRows(hits),
272
+ freshness,
273
+ });
144
274
  }
145
275
 
146
276
  process.stdout.write(`# affected by ${name}\n\n`);
@@ -156,6 +286,7 @@ function main() {
156
286
  );
157
287
  }
158
288
  process.stdout.write(`\n${hits.length} dependents across ${files.size} files\n`);
289
+ process.stdout.write(`${freshnessLine(freshness)}\n`);
159
290
  }
160
291
 
161
292
  if (process.argv[1] && process.argv[1].endsWith("graph-affected.mjs")) main();
@@ -99,6 +99,20 @@ function renderNode(node) {
99
99
  return `${node.name} (${kind}) ${where}`;
100
100
  }
101
101
 
102
+ /**
103
+ * The line main() prints for a hit. The budget is charged for this, not for
104
+ * the bare node text, so the token count it reports is the one the caller pays.
105
+ *
106
+ * @param {number} dist
107
+ * @param {string} line
108
+ * @param {string|null} via
109
+ * @returns {string}
110
+ */
111
+ export function printedLine(dist, line, via) {
112
+ const prefix = dist === 0 ? "*" : " ".repeat(dist) + "-";
113
+ return `${prefix} ${line}${via ? ` [${via}]` : ""}`;
114
+ }
115
+
102
116
  /**
103
117
  * Seed, then breadth-first expand until the budget is spent.
104
118
  *
@@ -119,7 +133,7 @@ export function traverse({ graph, question, budget, depth, seedCount }) {
119
133
  const queue = seeds.map((n) => ({ node: n, dist: 0, via: null }));
120
134
  while (queue.length) {
121
135
  const { node, dist, via } = queue.shift();
122
- const cost = Math.ceil(renderNode(node).length / CHARS_PER_TOKEN);
136
+ const cost = Math.ceil(printedLine(dist, renderNode(node), via).length / CHARS_PER_TOKEN);
123
137
  if (tokens + cost > budget) {
124
138
  truncated = true;
125
139
  break;
@@ -177,9 +191,7 @@ function main() {
177
191
  return;
178
192
  }
179
193
  for (const hit of result.hits) {
180
- const prefix = hit.dist === 0 ? "*" : " ".repeat(hit.dist) + "-";
181
- const via = hit.via ? ` [${hit.via}]` : "";
182
- process.stdout.write(`${prefix} ${hit.line}${via}\n`);
194
+ process.stdout.write(`${printedLine(hit.dist, hit.line, hit.via)}\n`);
183
195
  }
184
196
  process.stdout.write(
185
197
  `\n${result.hits.length} nodes, ~${result.tokens} tokens` +