@mmerterden/multi-agent-pipeline 16.11.0 → 16.13.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 (67) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +9 -7
  3. package/README.tr.md +9 -7
  4. package/docs/adr/0001-three-model-triage.md +5 -0
  5. package/docs/adr/0010-own-code-graph.md +129 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/architecture.md +2 -2
  8. package/docs/ecosystem.md +5 -5
  9. package/docs/features.md +26 -2
  10. package/package.json +1 -1
  11. package/pipeline/claude-md-template.md +1 -1
  12. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  13. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/graph/SKILL.md +105 -0
  15. package/pipeline/commands/multi-agent/help/SKILL.md +10 -10
  16. package/pipeline/commands/multi-agent/resume-local/SKILL.md +2 -2
  17. package/pipeline/commands/multi-agent/review/SKILL.md +3 -3
  18. package/pipeline/commands/multi-agent/review-analysis/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -12
  20. package/pipeline/commands/multi-agent/uninstall/SKILL.md +9 -7
  21. package/pipeline/lib/figma-screenshot.sh +107 -7
  22. package/pipeline/lib/md2confluence-v3.py +133 -25
  23. package/pipeline/multi-agent-refs/analysis/locked.md +4 -3
  24. package/pipeline/multi-agent-refs/analysis/render.md +44 -9
  25. package/pipeline/multi-agent-refs/analysis/review.md +17 -1
  26. package/pipeline/multi-agent-refs/analysis-template-corporate.md +14 -3
  27. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -10
  28. package/pipeline/multi-agent-refs/features/code-graph.md +62 -0
  29. package/pipeline/multi-agent-refs/features/model-fallback.md +44 -2
  30. package/pipeline/multi-agent-refs/knowledge.md +7 -1
  31. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  32. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +5 -0
  33. package/pipeline/multi-agent-refs/phases/phase-4-review.md +9 -9
  34. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -0
  35. package/pipeline/preferences-template.json +2 -0
  36. package/pipeline/schemas/analysis-spec.schema.json +2 -0
  37. package/pipeline/schemas/code-graph.schema.json +91 -0
  38. package/pipeline/schemas/prefs.schema.json +45 -0
  39. package/pipeline/schemas/reviewer-output.schema.json +1 -1
  40. package/pipeline/schemas/token-budget.json +2 -2
  41. package/pipeline/schemas/triage-output.schema.json +1 -1
  42. package/pipeline/scripts/_code-graph.mjs +518 -0
  43. package/pipeline/scripts/_path-match.mjs +87 -0
  44. package/pipeline/scripts/anonymize-findings.mjs +1 -1
  45. package/pipeline/scripts/code-graph-rules/android.json +130 -0
  46. package/pipeline/scripts/code-graph-rules/ios.json +95 -0
  47. package/pipeline/scripts/code-graph-rules/node.json +151 -0
  48. package/pipeline/scripts/code-graph-rules/python.json +91 -0
  49. package/pipeline/scripts/graph-affected.mjs +161 -0
  50. package/pipeline/scripts/graph-build.mjs +157 -0
  51. package/pipeline/scripts/graph-query.mjs +191 -0
  52. package/pipeline/scripts/graph-report.mjs +237 -0
  53. package/pipeline/scripts/smoke-cross-cli-behavior.sh +13 -10
  54. package/pipeline/scripts/test-gap-rules/ios.json +38 -10
  55. package/pipeline/scripts/test-gap-scan.mjs +2 -21
  56. package/pipeline/scripts/uninstall.mjs +11 -2
  57. package/pipeline/scripts/validate-analysis-doc.mjs +85 -23
  58. package/pipeline/scripts/validate-code-graph.mjs +174 -0
  59. package/pipeline/skills/.skills-index.json +14 -3
  60. package/pipeline/skills/shared/README.md +6 -5
  61. package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -3
  62. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +106 -0
  63. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -2
  64. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -2
  65. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +11 -11
  66. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +4 -4
  67. package/pipeline/skills/skills-index.md +4 -3
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file graph-build.mjs - build the code graph for one repo.
5
+ *
6
+ * Full rebuild every time, by measurement rather than by principle: on the
7
+ * corporate iOS app (4,311 Swift sources, 14.3 MB) the whole pass takes under
8
+ * three seconds, so an incremental extraction path would add a cache file, a
9
+ * staleness bug surface and a second code path to test in exchange for nothing.
10
+ * The manifest is still written - Phase 1 uses it to report how far behind a
11
+ * graph is, not to rebuild a subset.
12
+ *
13
+ * Inputs:
14
+ * --root <path> Repo root. Default: cwd
15
+ * --stack <id> ios | android | node | python. Required.
16
+ * --out <path> Graph output. Default: ~/.claude/knowledge/<project>/code-graph.json
17
+ * --max-nodes N Refuse to write a graph larger than this. Default: 200000.
18
+ * Callers pass prefs.global.codeGraph.maxNodes. A tree that
19
+ * blows past it is vendored or generated source, and writing
20
+ * a several-hundred-megabyte file for it helps nobody, so
21
+ * the build fails loudly instead of succeeding quietly.
22
+ * --json Emit the summary as JSON instead of a line
23
+ *
24
+ * Output:
25
+ * Writes the graph, prints a one-line summary.
26
+ *
27
+ * Exit codes:
28
+ * 0 - built
29
+ * 1 - rule load, walk, write error, or graph past --max-nodes
30
+ * 64 - usage error
31
+ *
32
+ * @module pipeline/scripts/graph-build
33
+ */
34
+
35
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
36
+ import { execFileSync } from "node:child_process";
37
+ import { join, dirname, resolve, basename } from "node:path";
38
+ import { homedir } from "node:os";
39
+ import { fileURLToPath } from "node:url";
40
+ import {
41
+ loadRules,
42
+ listSourceFiles,
43
+ extractFile,
44
+ buildGraph,
45
+ diffManifest,
46
+ } from "./_code-graph.mjs";
47
+
48
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
49
+ const DEFAULT_MAX_NODES = 200000;
50
+
51
+ export function parseFlags(argv) {
52
+ const flags = {};
53
+ const positional = [];
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a.startsWith("--")) {
57
+ const k = a.slice(2);
58
+ const v = argv[i + 1];
59
+ if (v !== undefined && !v.startsWith("--")) {
60
+ flags[k] = v;
61
+ i++;
62
+ } else {
63
+ flags[k] = true;
64
+ }
65
+ } else {
66
+ positional.push(a);
67
+ }
68
+ }
69
+ return { flags, positional };
70
+ }
71
+
72
+ export function defaultGraphPath(root) {
73
+ return join(homedir(), ".claude", "knowledge", basename(root), "code-graph.json");
74
+ }
75
+
76
+ function headCommit(root) {
77
+ try {
78
+ return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
79
+ encoding: "utf8",
80
+ stdio: ["ignore", "pipe", "ignore"],
81
+ }).trim();
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function main() {
88
+ const { flags } = parseFlags(process.argv.slice(2));
89
+ const stack = flags.stack;
90
+ if (!stack || stack === true) {
91
+ process.stderr.write("graph-build: --stack <ios|android|node|python> required\n");
92
+ process.exit(64);
93
+ }
94
+
95
+ const root = resolve(flags.root || process.cwd());
96
+ if (!existsSync(root)) {
97
+ process.stderr.write(`graph-build: root not found: ${root}\n`);
98
+ process.exit(1);
99
+ }
100
+ const out = flags.out && flags.out !== true ? resolve(flags.out) : defaultGraphPath(root);
101
+
102
+ let rules;
103
+ try {
104
+ rules = loadRules(stack, SCRIPT_DIR);
105
+ } catch (e) {
106
+ process.stderr.write(`graph-build: ${e.message}\n`);
107
+ process.exit(1);
108
+ }
109
+
110
+ const startedAt = Date.now();
111
+ const files = listSourceFiles(root, rules);
112
+ const extracted = new Map();
113
+ for (const path of files) {
114
+ try {
115
+ extracted.set(path, extractFile(readFileSync(join(root, path), "utf8"), rules));
116
+ } catch {
117
+ /* unreadable file: skip, it simply does not reach the graph */
118
+ }
119
+ }
120
+
121
+ const { manifest } = diffManifest(root, [...extracted.keys()], {});
122
+ const graph = buildGraph({
123
+ root,
124
+ stack,
125
+ extracted,
126
+ rules,
127
+ manifest,
128
+ baseCommit: headCommit(root),
129
+ generatedAt: new Date().toISOString(),
130
+ });
131
+
132
+ const maxNodes = Number(flags["max-nodes"]) > 0 ? Number(flags["max-nodes"]) : DEFAULT_MAX_NODES;
133
+ if (graph.stats.nodes > maxNodes) {
134
+ process.stderr.write(
135
+ `graph-build: ${graph.stats.nodes} nodes exceeds --max-nodes ${maxNodes}; ` +
136
+ `nothing written. Narrow the tree with the stack rules' excludePathGlobs, ` +
137
+ `or raise prefs.global.codeGraph.maxNodes if this repo really is that large.\n`,
138
+ );
139
+ process.exit(1);
140
+ }
141
+
142
+ mkdirSync(dirname(out), { recursive: true });
143
+ writeFileSync(out, JSON.stringify(graph), "utf8");
144
+
145
+ const elapsedMs = Date.now() - startedAt;
146
+ const summary = { out, elapsedMs, ...graph.stats };
147
+ if (flags.json === true) {
148
+ process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
149
+ } else {
150
+ process.stdout.write(
151
+ `graph-build: ${graph.stats.files} files, ${graph.stats.nodes} nodes, ` +
152
+ `${graph.stats.edges} edges in ${elapsedMs}ms -> ${out}\n`,
153
+ );
154
+ }
155
+ }
156
+
157
+ if (process.argv[1] && process.argv[1].endsWith("graph-build.mjs")) main();
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file graph-query.mjs - token-budgeted traversal of the code graph.
5
+ *
6
+ * Phase 1's read surface. Ranks graph nodes against the task text with the
7
+ * pipeline's own BM25 (`_retrieval.mjs`, already gated by eval-recall.mjs),
8
+ * takes the best seeds, then walks their edges breadth-first until the token
9
+ * budget is spent. The budget is the point: an unbounded neighbourhood dump is
10
+ * the context-stuffing anti-pattern this is supposed to replace, so the walk
11
+ * stops on budget rather than on depth alone.
12
+ *
13
+ * Inputs:
14
+ * "<question>" Free text. Required.
15
+ * --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
16
+ * --budget N Output token cap. Default: 2000
17
+ * --depth N Max BFS depth from a seed. Default: 2
18
+ * --seeds N How many ranked seeds to expand. Default: 8
19
+ * --json Emit JSON instead of text
20
+ *
21
+ * Exit codes:
22
+ * 0 - answered (possibly empty)
23
+ * 1 - graph missing or unreadable
24
+ * 64 - usage error
25
+ *
26
+ * @module pipeline/scripts/graph-query
27
+ */
28
+
29
+ import { readFileSync, existsSync } from "node:fs";
30
+ import { join, resolve, basename } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { bm25 } from "./_retrieval.mjs";
33
+ import { parseFlags } from "./graph-build.mjs";
34
+
35
+ const CHARS_PER_TOKEN = 4;
36
+
37
+ export function defaultGraphPath(cwd = process.cwd()) {
38
+ return join(homedir(), ".claude", "knowledge", basename(resolve(cwd)), "code-graph.json");
39
+ }
40
+
41
+ export function loadGraph(path) {
42
+ if (!existsSync(path)) throw new Error(`graph not found: ${path}`);
43
+ const graph = JSON.parse(readFileSync(path, "utf8"));
44
+ if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
45
+ throw new Error(`graph is malformed: ${path}`);
46
+ }
47
+ return graph;
48
+ }
49
+
50
+ /**
51
+ * Adjacency in both directions.
52
+ *
53
+ * Traversal has to cross an edge either way: a task naming a base class must
54
+ * reach its subclasses (incoming) as readily as its own file (outgoing).
55
+ *
56
+ * @param {object} graph
57
+ * @returns {Map<string, Array<{id: string, kind: string, dir: string}>>}
58
+ */
59
+ export function adjacency(graph) {
60
+ const adj = new Map();
61
+ const push = (from, to, kind, dir) => {
62
+ if (!adj.has(from)) adj.set(from, []);
63
+ adj.get(from).push({ id: to, kind, dir });
64
+ };
65
+ for (const e of graph.edges) {
66
+ push(e.from, e.to, e.kind, "out");
67
+ push(e.to, e.from, e.kind, "in");
68
+ }
69
+ return adj;
70
+ }
71
+
72
+ /**
73
+ * Rank nodes against the question.
74
+ *
75
+ * `name` outweighs `path` because a task names symbols far more often than
76
+ * directories, and `symbolKind` carries a little signal ("the FlightList view
77
+ * model") without letting the kind word alone win a match.
78
+ *
79
+ * @param {object} graph
80
+ * @param {string} question
81
+ * @returns {Array<{node: object, score: number}>} descending, zero scores dropped
82
+ */
83
+ export function rankNodes(graph, question) {
84
+ const docs = graph.nodes.map((n) => ({
85
+ name: n.name || "",
86
+ path: n.path || "",
87
+ symbolKind: n.symbolKind || "",
88
+ }));
89
+ const scored = bm25(question, docs, { fieldWeights: { name: 3, path: 1, symbolKind: 1 } });
90
+ return scored
91
+ .filter((s) => s.score > 0)
92
+ .sort((a, b) => b.score - a.score)
93
+ .map((s) => ({ node: graph.nodes[s.index], score: s.score }));
94
+ }
95
+
96
+ function renderNode(node) {
97
+ const where = node.path ? `${node.path}${node.line ? `:${node.line}` : ""}` : "-";
98
+ const kind = node.symbolKind || node.kind;
99
+ return `${node.name} (${kind}) ${where}`;
100
+ }
101
+
102
+ /**
103
+ * Seed, then breadth-first expand until the budget is spent.
104
+ *
105
+ * @param {object} params
106
+ * @returns {{seeds: object[], hits: object[], truncated: boolean, tokens: number}}
107
+ */
108
+ export function traverse({ graph, question, budget, depth, seedCount }) {
109
+ const ranked = rankNodes(graph, question);
110
+ const seeds = ranked.slice(0, seedCount).map((r) => r.node);
111
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
112
+ const adj = adjacency(graph);
113
+
114
+ const seen = new Set(seeds.map((n) => n.id));
115
+ const hits = [];
116
+ let tokens = 0;
117
+ let truncated = false;
118
+
119
+ const queue = seeds.map((n) => ({ node: n, dist: 0, via: null }));
120
+ while (queue.length) {
121
+ const { node, dist, via } = queue.shift();
122
+ const cost = Math.ceil(renderNode(node).length / CHARS_PER_TOKEN);
123
+ if (tokens + cost > budget) {
124
+ truncated = true;
125
+ break;
126
+ }
127
+ tokens += cost;
128
+ hits.push({ id: node.id, dist, via, line: renderNode(node), degree: node.degree });
129
+
130
+ if (dist >= depth) continue;
131
+ const neighbours = (adj.get(node.id) || [])
132
+ .slice()
133
+ .sort((a, b) => (byId.get(b.id)?.degree || 0) - (byId.get(a.id)?.degree || 0));
134
+ for (const n of neighbours) {
135
+ if (seen.has(n.id)) continue;
136
+ const target = byId.get(n.id);
137
+ if (!target) continue;
138
+ seen.add(n.id);
139
+ queue.push({ node: target, dist: dist + 1, via: `${n.kind}/${n.dir}` });
140
+ }
141
+ }
142
+
143
+ return { seeds, hits, truncated, tokens };
144
+ }
145
+
146
+ function main() {
147
+ const { flags, positional } = parseFlags(process.argv.slice(2));
148
+ const question = positional.join(" ").trim();
149
+ if (!question) {
150
+ process.stderr.write('graph-query: a question is required, e.g. graph-query "flight search"\n');
151
+ process.exit(64);
152
+ }
153
+
154
+ const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
155
+ let graph;
156
+ try {
157
+ graph = loadGraph(path);
158
+ } catch (e) {
159
+ process.stderr.write(`graph-query: ${e.message}\n`);
160
+ process.exit(1);
161
+ }
162
+
163
+ const budget = Number(flags.budget) > 0 ? Number(flags.budget) : 2000;
164
+ const depth = Number(flags.depth) > 0 ? Number(flags.depth) : 2;
165
+ const seedCount = Number(flags.seeds) > 0 ? Number(flags.seeds) : 8;
166
+
167
+ const result = traverse({ graph, question, budget, depth, seedCount });
168
+
169
+ if (flags.json === true) {
170
+ process.stdout.write(JSON.stringify({ question, budget, ...result }, null, 2) + "\n");
171
+ return;
172
+ }
173
+
174
+ process.stdout.write(`# ${question}\n\n`);
175
+ if (result.hits.length === 0) {
176
+ process.stdout.write("no matching nodes\n");
177
+ return;
178
+ }
179
+ 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`);
183
+ }
184
+ process.stdout.write(
185
+ `\n${result.hits.length} nodes, ~${result.tokens} tokens` +
186
+ (result.truncated ? " (budget reached)" : "") +
187
+ "\n",
188
+ );
189
+ }
190
+
191
+ if (process.argv[1] && process.argv[1].endsWith("graph-query.mjs")) main();
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file graph-report.mjs - GRAPH_REPORT.md, the human-and-model readable summary.
5
+ *
6
+ * Phase 1 reads this before deciding how wide to explore, and Phase 7 ships it
7
+ * next to the knowledge base. Sections are ordered by how much they narrow a
8
+ * search: hubs first (the types everything touches), then the module map, then
9
+ * the long tail that is usually safe to ignore.
10
+ *
11
+ * God-nodes exclude test files. A test base class is a hub of the test target,
12
+ * not of the architecture, and letting it rank pushes the real hubs off a list
13
+ * whose whole value is its first ten rows.
14
+ *
15
+ * Inputs:
16
+ * --graph <path> Graph file. Default: ~/.claude/knowledge/<cwd name>/code-graph.json
17
+ * --out <path> Report path. Default: GRAPH_REPORT.md beside the graph
18
+ * --top N Rows per ranked section. Default: 15
19
+ * --stdout Print instead of writing
20
+ * --status Print one header line and exit, writing nothing. This is
21
+ * what `/multi-agent:graph status` runs: the graph is a
22
+ * 22MB JSON on a large repo, so "read the header yourself"
23
+ * would mean pulling the whole file into a model's context.
24
+ *
25
+ * Exit codes:
26
+ * 0 - written
27
+ * 1 - graph missing or unreadable
28
+ *
29
+ * @module pipeline/scripts/graph-report
30
+ */
31
+
32
+ import { writeFileSync } from "node:fs";
33
+ import { execFileSync } from "node:child_process";
34
+ import { join, dirname, resolve } from "node:path";
35
+ import { parseFlags } from "./graph-build.mjs";
36
+ import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
37
+
38
+ /**
39
+ * Top-level source directory of a path, used as the module bucket.
40
+ *
41
+ * @param {string} path
42
+ * @returns {string}
43
+ */
44
+ export function moduleOf(path) {
45
+ if (!path) return "-";
46
+ const parts = path.split("/");
47
+ return parts.length > 1 ? parts[0] : ".";
48
+ }
49
+
50
+ /**
51
+ * Section inputs, derived once so the renderer stays declarative.
52
+ *
53
+ * @param {object} graph
54
+ * @param {number} top
55
+ * @returns {object}
56
+ */
57
+ export function summarize(graph, top) {
58
+ const symbols = graph.nodes.filter((n) => n.kind === "symbol");
59
+ const files = graph.nodes.filter((n) => n.kind === "file");
60
+
61
+ const hubs = symbols
62
+ .filter((n) => !n.isTest)
63
+ .sort((a, b) => b.degree - a.degree)
64
+ .slice(0, top);
65
+
66
+ const modules = new Map();
67
+ for (const f of files) {
68
+ const key = moduleOf(f.path);
69
+ if (!modules.has(key)) modules.set(key, { files: 0, symbols: 0, tests: 0 });
70
+ const bucket = modules.get(key);
71
+ bucket.files++;
72
+ if (f.isTest) bucket.tests++;
73
+ }
74
+ for (const s of symbols) {
75
+ const key = moduleOf(s.path);
76
+ if (modules.has(key)) modules.get(key).symbols++;
77
+ }
78
+
79
+ const externals = graph.nodes
80
+ .filter((n) => n.kind === "module")
81
+ .sort((a, b) => b.degree - a.degree)
82
+ .slice(0, top);
83
+
84
+ const orphans = files.filter((f) => f.degree === 0);
85
+
86
+ const kinds = {};
87
+ for (const s of symbols) kinds[s.symbolKind] = (kinds[s.symbolKind] || 0) + 1;
88
+
89
+ return { hubs, modules, externals, orphans, kinds, fileCount: files.length };
90
+ }
91
+
92
+ /**
93
+ * @param {object} graph
94
+ * @param {number} top
95
+ * @returns {string} markdown
96
+ */
97
+ export function render(graph, top) {
98
+ const s = summarize(graph, top);
99
+ const L = [];
100
+
101
+ L.push(`# Code graph report`);
102
+ L.push("");
103
+ L.push(`- Root: \`${graph.root}\``);
104
+ L.push(`- Stack: ${graph.stack}`);
105
+ L.push(`- Built: ${graph.generatedAt}`);
106
+ if (graph.baseCommit) L.push(`- Commit: \`${graph.baseCommit.slice(0, 12)}\``);
107
+ L.push(
108
+ `- Scale: ${graph.stats.files} files, ${graph.stats.nodes} nodes, ${graph.stats.edges} edges`,
109
+ );
110
+ L.push("");
111
+
112
+ L.push(`## Architectural hubs`);
113
+ L.push("");
114
+ L.push(`The most connected non-test symbols. A change here reaches the widest surface.`);
115
+ L.push("");
116
+ L.push(`| Degree | Symbol | Kind | Path |`);
117
+ L.push(`|---|---|---|---|`);
118
+ for (const h of s.hubs) {
119
+ L.push(`| ${h.degree} | \`${h.name}\` | ${h.symbolKind} | \`${h.path}:${h.line || 1}\` |`);
120
+ }
121
+ L.push("");
122
+
123
+ L.push(`## Modules`);
124
+ L.push("");
125
+ L.push(`| Module | Files | Symbols | Test files |`);
126
+ L.push(`|---|---|---|---|`);
127
+ const sortedModules = [...s.modules.entries()].sort((a, b) => b[1].files - a[1].files);
128
+ for (const [name, m] of sortedModules) {
129
+ L.push(`| \`${name}\` | ${m.files} | ${m.symbols} | ${m.tests} |`);
130
+ }
131
+ L.push("");
132
+
133
+ L.push(`## External dependencies`);
134
+ L.push("");
135
+ L.push(`Imported names with no declaring file in this repo.`);
136
+ L.push("");
137
+ L.push(`| Imports | Module |`);
138
+ L.push(`|---|---|`);
139
+ for (const e of s.externals) L.push(`| ${e.degree} | \`${e.name}\` |`);
140
+ L.push("");
141
+
142
+ L.push(`## Symbol kinds`);
143
+ L.push("");
144
+ const kindRows = Object.entries(s.kinds).sort((a, b) => b[1] - a[1]);
145
+ L.push(kindRows.map(([k, n]) => `${k}: ${n}`).join(" · "));
146
+ L.push("");
147
+
148
+ L.push(`## Unconnected files`);
149
+ L.push("");
150
+ if (s.orphans.length === 0) {
151
+ L.push(`None: every file has at least one edge.`);
152
+ } else {
153
+ L.push(
154
+ `${s.orphans.length} of ${s.fileCount} files have no edge at all. ` +
155
+ `Either they declare nothing this stack's rules recognise, or nothing references them.`,
156
+ );
157
+ L.push("");
158
+ for (const o of s.orphans.slice(0, top)) L.push(`- \`${o.path}\``);
159
+ if (s.orphans.length > top) L.push(`- ... ${s.orphans.length - top} more`);
160
+ }
161
+ L.push("");
162
+
163
+ return L.join("\n");
164
+ }
165
+
166
+ /**
167
+ * HEAD of a repo, or null when it is not a git checkout.
168
+ *
169
+ * @param {string} root
170
+ * @returns {string|null}
171
+ */
172
+ function headCommit(root) {
173
+ try {
174
+ return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
175
+ encoding: "utf8",
176
+ stdio: ["ignore", "pipe", "ignore"],
177
+ }).trim();
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * One line: what the graph covers and whether it still matches the tree.
185
+ *
186
+ * @param {object} graph
187
+ * @param {string} path
188
+ * @returns {string}
189
+ */
190
+ export function statusLine(graph, path) {
191
+ const head = headCommit(graph.root);
192
+ let freshness = "freshness unknown (no git HEAD)";
193
+ if (graph.baseCommit && head) {
194
+ freshness =
195
+ graph.baseCommit === head
196
+ ? `current (${head.slice(0, 12)})`
197
+ : `STALE: built at ${graph.baseCommit.slice(0, 12)}, HEAD is ${head.slice(0, 12)}`;
198
+ } else if (!graph.baseCommit) {
199
+ freshness = "freshness unknown (graph carries no baseCommit)";
200
+ }
201
+ return (
202
+ `graph-status: ${graph.stack} | ${graph.stats.files} files, ${graph.stats.nodes} nodes, ` +
203
+ `${graph.stats.edges} edges | built ${graph.generatedAt} | ${freshness} | ${path}`
204
+ );
205
+ }
206
+
207
+ function main() {
208
+ const { flags } = parseFlags(process.argv.slice(2));
209
+ const path = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
210
+
211
+ let graph;
212
+ try {
213
+ graph = loadGraph(path);
214
+ } catch (e) {
215
+ process.stderr.write(`graph-report: ${e.message}\n`);
216
+ process.exit(1);
217
+ }
218
+
219
+ if (flags.status === true) {
220
+ process.stdout.write(statusLine(graph, path) + "\n");
221
+ return;
222
+ }
223
+
224
+ const top = Number(flags.top) > 0 ? Number(flags.top) : 15;
225
+ const markdown = render(graph, top);
226
+
227
+ if (flags.stdout === true) {
228
+ process.stdout.write(markdown);
229
+ return;
230
+ }
231
+ const out =
232
+ flags.out && flags.out !== true ? resolve(flags.out) : join(dirname(path), "GRAPH_REPORT.md");
233
+ writeFileSync(out, markdown, "utf8");
234
+ process.stdout.write(`graph-report: ${out}\n`);
235
+ }
236
+
237
+ if (process.argv[1] && process.argv[1].endsWith("graph-report.mjs")) main();
@@ -185,12 +185,12 @@ else
185
185
  fi
186
186
 
187
187
  # ──────────────────────────────────────────────────────────────────────────
188
- # CLI-aware reviewer-count contract: Claude Code = 2 (Fable + Sonnet), Copilot CLI
188
+ # CLI-aware reviewer-count contract: Claude Code = 3 (Fable + Opus + Sonnet), Copilot CLI
189
189
  # = 3 (Opus + GPT-5.4 + Sonnet), Codex CLI = 3 (gpt-5.6 xhigh + gpt-5.4 + gpt-5.6
190
190
  # medium). Nothing in code enforces the count (the orchestrator dispatches per the
191
191
  # doc), so lock the CONTRACT here against drift across the phase doc, the schema,
192
192
  # and the consensus block.
193
- echo "→ reviewer-count contract (Claude=2, Copilot=3, Codex=3)"
193
+ echo "→ reviewer-count contract (Claude=3, Copilot=3, Codex=3)"
194
194
  # `/multi-agent:update` runs this smoke on the user's machine, where the tree is
195
195
  # ~/.claude/{schemas,multi-agent-refs} and NOT <root>/pipeline/*. Resolving the
196
196
  # root by hand here reported three phantom failures in an install, which reads as
@@ -203,10 +203,13 @@ TRSCHEMA="${MA_SCHEMAS:+$MA_SCHEMAS/triage-output.schema.json}"
203
203
 
204
204
  if [ -z "$P4" ] || [ ! -f "$P4" ]; then
205
205
  echo " ↷ SKIP: phase-4-review.md not present in this $MA_LAYOUT layout"
206
- elif grep -qiE "Claude Code (dispatches|=|:) ?2|2-model" "$P4" \
207
- && grep -qiE "Copilot CLI (dispatches|=|:) ?3|3-model" "$P4" \
208
- && grep -qiE "Codex CLI (dispatches|=|:) ?3|Claude Code 2, Copilot CLI 3, Codex CLI 3" "$P4"; then
209
- pass "phase-4-review declares Claude=2 / Copilot=3 / Codex=3 reviewers"
206
+ # Two independent statements, because they can drift apart: the count sentence is
207
+ # the contract, and the matrix is what a reader dispatches from. Three regexes over
208
+ # overlapping prose used to stand in for this and let the count sentence 300 lines
209
+ # further down go stale for a whole release without failing.
210
+ elif grep -qF "Claude Code 3, Copilot CLI 3, Codex CLI 3" "$P4" \
211
+ && grep -qE '^\| Reviewer 3 .*\|.*\|.*\|.*\|' "$P4"; then
212
+ pass "phase-4-review declares Claude=3 / Copilot=3 / Codex=3 reviewers, and the matrix has all three host columns"
210
213
  else
211
214
  fail "phase-4-review does not declare the CLI-aware reviewer count for all three hosts"
212
215
  fi
@@ -231,13 +234,13 @@ else
231
234
  fail "reviewer-output schema missing the CLI-aware note"
232
235
  fi
233
236
 
234
- # consensus.reviewerCount must accommodate both 2 and 3 (min 1, no max < 3)
237
+ # consensus.reviewerCount must accommodate 3 on every host (min 1, no max < 3)
235
238
  if [ -z "$TRSCHEMA" ] || [ ! -f "$TRSCHEMA" ]; then
236
239
  echo " ↷ SKIP: triage-output.schema.json not present in this $MA_LAYOUT layout"
237
- elif grep -q '"reviewerCount"' "$TRSCHEMA" && grep -qi "Claude Code = 2, Copilot CLI = 3" "$TRSCHEMA"; then
238
- pass "triage consensus.reviewerCount documents 2 (Claude) and 3 (Copilot)"
240
+ elif grep -q '"reviewerCount"' "$TRSCHEMA" && grep -qi "Claude Code = 3, Copilot CLI = 3" "$TRSCHEMA"; then
241
+ pass "triage consensus.reviewerCount documents 3 reviewers on every host"
239
242
  else
240
- fail "triage consensus.reviewerCount does not document the 2/3 split"
243
+ fail "triage consensus.reviewerCount does not document the 3-reviewer set"
241
244
  fi
242
245
 
243
246
  echo ""
@@ -13,20 +13,48 @@
13
13
  "Generated/",
14
14
  "Sourcery/",
15
15
  "_Generated.swift",
16
- "Tests/Fixtures/"
16
+ "Tests/Fixtures/",
17
+ ".xctemplate/"
17
18
  ],
18
19
  "publicApiPatterns": [
19
- { "id": "public_func", "regex": "\\bpublic\\s+func\\s+([A-Za-z_][A-Za-z0-9_]*)" },
20
- { "id": "public_class", "regex": "\\bpublic\\s+(?:final\\s+)?class\\s+([A-Z][A-Za-z0-9_]*)" },
21
- { "id": "public_struct", "regex": "\\bpublic\\s+struct\\s+([A-Z][A-Za-z0-9_]*)" },
22
- { "id": "public_enum", "regex": "\\bpublic\\s+enum\\s+([A-Z][A-Za-z0-9_]*)" },
23
- { "id": "public_proto", "regex": "\\bpublic\\s+protocol\\s+([A-Z][A-Za-z0-9_]*)" },
24
- { "id": "public_init", "regex": "\\bpublic\\s+init\\b" },
25
- { "id": "objc_export", "regex": "@objc(?:Members)?\\s+(?:public\\s+)?(?:func|class|var)" }
20
+ {
21
+ "id": "public_func",
22
+ "regex": "\\bpublic\\s+func\\s+([A-Za-z_][A-Za-z0-9_]*)"
23
+ },
24
+ {
25
+ "id": "public_class",
26
+ "regex": "\\bpublic\\s+(?:final\\s+)?class\\s+([A-Z][A-Za-z0-9_]*)"
27
+ },
28
+ {
29
+ "id": "public_struct",
30
+ "regex": "\\bpublic\\s+struct\\s+([A-Z][A-Za-z0-9_]*)"
31
+ },
32
+ {
33
+ "id": "public_enum",
34
+ "regex": "\\bpublic\\s+enum\\s+([A-Z][A-Za-z0-9_]*)"
35
+ },
36
+ {
37
+ "id": "public_proto",
38
+ "regex": "\\bpublic\\s+protocol\\s+([A-Z][A-Za-z0-9_]*)"
39
+ },
40
+ {
41
+ "id": "public_init",
42
+ "regex": "\\bpublic\\s+init\\b"
43
+ },
44
+ {
45
+ "id": "objc_export",
46
+ "regex": "@objc(?:Members)?\\s+(?:public\\s+)?(?:func|class|var)"
47
+ }
26
48
  ],
27
49
  "swiftUiViewPatterns": [
28
- { "id": "view_struct", "regex": "struct\\s+([A-Z][A-Za-z0-9_]*)\\s*:\\s*View\\b" },
29
- { "id": "config_struct", "regex": "struct\\s+([A-Z][A-Za-z0-9_]*Configuration)\\b" }
50
+ {
51
+ "id": "view_struct",
52
+ "regex": "struct\\s+([A-Z][A-Za-z0-9_]*)\\s*:\\s*View\\b"
53
+ },
54
+ {
55
+ "id": "config_struct",
56
+ "regex": "struct\\s+([A-Z][A-Za-z0-9_]*Configuration)\\b"
57
+ }
30
58
  ],
31
59
  "expectedTestKindsForView": ["snapshot", "viewinspector", "unit"],
32
60
  "snapshotPathHints": ["SnapshotTests", "__Snapshots__"],