@mmerterden/multi-agent-pipeline 17.5.1 → 17.6.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 +149 -0
  2. package/README.md +16 -0
  3. package/README.tr.md +16 -0
  4. package/docs/features.md +24 -0
  5. package/docs/token-budget-history.md +1 -1
  6. package/install/templates/claude-hooks.json +13 -1
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  9. package/pipeline/commands/multi-agent/feedback/SKILL.md +7 -1
  10. package/pipeline/commands/multi-agent/graph/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/issue/SKILL.md +13 -1
  12. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -1
  13. package/pipeline/commands/multi-agent/resume/SKILL.md +16 -1
  14. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -16
  15. package/pipeline/commands/multi-agent/update/SKILL.md +13 -56
  16. package/pipeline/multi-agent-refs/features/code-graph.md +20 -0
  17. package/pipeline/multi-agent-refs/features/doctor.md +23 -0
  18. package/pipeline/multi-agent-refs/features/maturity-followup.md +166 -0
  19. package/pipeline/multi-agent-refs/features/package-manager.md +80 -0
  20. package/pipeline/multi-agent-refs/features/usage-reporting.md +79 -0
  21. package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
  22. package/pipeline/multi-agent-refs/phases/phase-0-init.md +5 -2
  23. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +8 -2
  24. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
  25. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  26. package/pipeline/preferences-template.json +1 -1
  27. package/pipeline/schemas/agent-state.schema.json +122 -11
  28. package/pipeline/schemas/prefs.schema.json +35 -0
  29. package/pipeline/schemas/token-budget.json +2 -2
  30. package/pipeline/scripts/doctor.mjs +65 -0
  31. package/pipeline/scripts/feedback-send.mjs +1 -1
  32. package/pipeline/scripts/graph-report.mjs +155 -1
  33. package/pipeline/scripts/maturity-followup.mjs +294 -0
  34. package/pipeline/scripts/package-manager.mjs +310 -0
  35. package/pipeline/scripts/usage-register.mjs +271 -0
  36. package/pipeline/scripts/usage-report.mjs +2 -2
  37. package/pipeline/skills/.skill-manifest.json +5 -5
  38. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +14 -0
  39. package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +14 -0
  40. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  41. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +6 -0
@@ -97,7 +97,9 @@
97
97
  "enum": ["remote", "local"],
98
98
  "description": "Where the candidate ref list came from. local means the fetch failed and the list is the local cache plus local heads - possibly stale, possibly incomplete."
99
99
  },
100
- "chosen": { "type": "string" },
100
+ "chosen": {
101
+ "type": "string"
102
+ },
101
103
  "ambiguous": {
102
104
  "type": "boolean",
103
105
  "description": "Two or more candidates tied at the top score. Autopilot may not record derived when this is true."
@@ -107,8 +109,13 @@
107
109
  "additionalProperties": false,
108
110
  "description": "The release-branch template inferred from the refs that exist, never from a built-in table.",
109
111
  "properties": {
110
- "template": { "type": "string" },
111
- "members": { "type": "integer", "minimum": 0 }
112
+ "template": {
113
+ "type": "string"
114
+ },
115
+ "members": {
116
+ "type": "integer",
117
+ "minimum": 0
118
+ }
112
119
  }
113
120
  },
114
121
  "candidates": {
@@ -118,9 +125,18 @@
118
125
  "additionalProperties": false,
119
126
  "required": ["branch"],
120
127
  "properties": {
121
- "branch": { "type": "string" },
122
- "score": { "type": "number" },
123
- "refs": { "type": "array", "items": { "type": "string" } },
128
+ "branch": {
129
+ "type": "string"
130
+ },
131
+ "score": {
132
+ "type": "number"
133
+ },
134
+ "refs": {
135
+ "type": "array",
136
+ "items": {
137
+ "type": "string"
138
+ }
139
+ },
124
140
  "evidence": {
125
141
  "type": "array",
126
142
  "items": {
@@ -140,26 +156,121 @@
140
156
  "ref-provenance"
141
157
  ]
142
158
  },
143
- "detail": { "type": "string" }
159
+ "detail": {
160
+ "type": "string"
161
+ }
144
162
  }
145
163
  }
146
164
  }
147
165
  }
148
166
  }
149
167
  },
150
- "notes": { "type": "array", "items": { "type": "string" } },
168
+ "notes": {
169
+ "type": "array",
170
+ "items": {
171
+ "type": "string"
172
+ }
173
+ },
151
174
  "askedOnIssue": {
152
175
  "type": ["object", "null"],
153
176
  "additionalProperties": false,
154
177
  "description": "The one comment autopilot is allowed to post when the derivation is ambiguous, gated by prefs.global.baseBranchEvidence.autopilotAsksOnIssue (default false). A question, never a state change: no transition, no close, no assignee. Posting it trips circuit-breaker trigger 6 and the run waits for resume.",
155
178
  "properties": {
156
- "target": { "type": "string" },
157
- "url": { "type": "string" },
158
- "at": { "type": "string", "format": "date-time" }
179
+ "target": {
180
+ "type": "string"
181
+ },
182
+ "url": {
183
+ "type": "string"
184
+ },
185
+ "at": {
186
+ "type": "string",
187
+ "format": "date-time"
188
+ }
159
189
  }
160
190
  }
161
191
  }
162
192
  },
193
+ "maturity": {
194
+ "type": "object",
195
+ "additionalProperties": false,
196
+ "description": "How ready the fetched item was to be developed. Produced by lib/issue-fetcher.sh, which owns the gap codes and their localized wording. Declared as of v17.6.0 - it had been written by every issue-shaped run since long before and read by Phase 0, Phase 4 and the clarifier, while this schema forbade it under additionalProperties: false.",
197
+ "properties": {
198
+ "score": {
199
+ "type": ["integer", "null"],
200
+ "minimum": 0,
201
+ "maximum": 100,
202
+ "description": "100 minus 40 per blocker and 10 per warning. Null for free-text input: there is no item to be immature."
203
+ },
204
+ "blockers": {
205
+ "type": "array",
206
+ "items": {
207
+ "type": "string"
208
+ },
209
+ "description": "Stable gap codes that stop a run: status_closed, already_resolved, description_empty."
210
+ },
211
+ "warnings": {
212
+ "type": "array",
213
+ "items": {
214
+ "type": "string"
215
+ },
216
+ "description": "Stable gap codes that do not stop a run on their own."
217
+ },
218
+ "summary": {
219
+ "type": "string",
220
+ "description": "The blockers and warnings rendered in outputLanguage. The one place this wording exists; maturity-followup.mjs quotes it rather than re-deriving it."
221
+ },
222
+ "accepted": {
223
+ "type": "array",
224
+ "items": {
225
+ "type": "string"
226
+ },
227
+ "description": "Gap codes a human explicitly waved through at the interactive maturity step. Recording WHICH gap was accepted is what separates an informed continue from a skipped check."
228
+ }
229
+ }
230
+ },
231
+ "maturityFollowup": {
232
+ "type": "object",
233
+ "additionalProperties": false,
234
+ "required": ["gaps", "askedAt"],
235
+ "description": "v17.6.0+ - the record of an autopilot run having asked about an immature item (refs/features/maturity-followup.md). Absent means never asked, which is what makes the first pass distinguishable from every pass after it.",
236
+ "properties": {
237
+ "gaps": {
238
+ "type": "array",
239
+ "items": {
240
+ "type": "string"
241
+ },
242
+ "description": "Sorted and deduplicated, so the next pass's comparison is stable rather than order-dependent."
243
+ },
244
+ "askedAt": {
245
+ "type": "string",
246
+ "format": "date-time"
247
+ },
248
+ "target": {
249
+ "type": "object",
250
+ "additionalProperties": false,
251
+ "properties": {
252
+ "kind": {
253
+ "type": "string",
254
+ "enum": ["jira", "github", "confluence", "analysis", "unknown"]
255
+ },
256
+ "key": {
257
+ "type": ["string", "null"]
258
+ },
259
+ "url": {
260
+ "type": ["string", "null"]
261
+ }
262
+ }
263
+ },
264
+ "commentUrl": {
265
+ "type": ["string", "null"]
266
+ }
267
+ }
268
+ },
269
+ "waitingFor": {
270
+ "type": "string",
271
+ "enum": ["maturity", "user-channels-choice"],
272
+ "description": "The step a paused run must RE-ENTER, as opposed to the phase it should continue past. Declared as of v17.6.0: Phase 7's channels pause has written it since long before, and resume/SKILL.md never read it, so that pause was resumable only in prose. resume reads this first and falls back to currentPhase + 1 when it is absent. maturity: Phase 0's maturity step, with the item re-fetched (refs/features/maturity-followup.md). user-channels-choice: Phase 7's channels menu, with the stored channelsInput."
273
+ },
163
274
  "workspaceSource": {
164
275
  "type": "string",
165
276
  "enum": ["asked", "command", "autopilot"],
@@ -969,6 +969,28 @@
969
969
  }
970
970
  }
971
971
  },
972
+ "maturityFollowup": {
973
+ "type": "object",
974
+ "additionalProperties": false,
975
+ "description": "v17.6.0+ - what to do about an item the maturity check finds is not ready (refs/features/maturity-followup.md). The check has always produced a machine-readable gap list and then thrown it away: a blocker halted the run, the queue moved on, and the item stayed exactly as immature as it was found.",
976
+ "properties": {
977
+ "askInteractively": {
978
+ "type": "boolean",
979
+ "default": true,
980
+ "description": "An interactive blocker asks at the maturity step - open the item and fix it, continue without it (recording which gap was waved through), or abort - instead of ending the run with a summary. False restores the old halt."
981
+ },
982
+ "autopilotCommentsOnIssue": {
983
+ "type": "boolean",
984
+ "default": false,
985
+ "description": "OFF by default, and an outward-facing write. When on, an autopilot run posts ONE comment on the Jira or GitHub item asking for what is missing, then halts on the circuit breaker and waits for resume. A question, never a state change: no transition, no resolution, no assignee, no label, no close. The body uses Ref:, never Closes:/Fixes:/Resolves:, and its copy follows outputLanguage. It never comments twice for the same gap set - a changed item is re-checked, and only a DIFFERENT gap set earns a second comment."
986
+ },
987
+ "commentOnWarnings": {
988
+ "type": "boolean",
989
+ "default": false,
990
+ "description": "Treat warnings like blockers. Off by default because warnings have always auto-continued under autopilot, and converting them to halts in a release would stall queues overnight on items that ran fine yesterday. The gaps are recorded either way."
991
+ }
992
+ }
993
+ },
972
994
  "baseBranchEvidence": {
973
995
  "type": "object",
974
996
  "additionalProperties": false,
@@ -1975,6 +1997,19 @@
1975
1997
  "description": "Prefix of the identity label a created issue carries. The label is how a second run, on a second machine or by a second analyst, finds the tree it already made instead of opening a duplicate."
1976
1998
  }
1977
1999
  }
2000
+ },
2001
+ "mcpSurface": {
2002
+ "type": "object",
2003
+ "additionalProperties": false,
2004
+ "description": "v17.6.0+ - how many MCP servers this host has registered. Every registered server's tool list is charged against the context window on EVERY turn, and the user adds them one at a time without ever seeing the running total; our own toolkit contributes 99 tools by itself. `doctor` reports the count, it never disables anything.",
2005
+ "properties": {
2006
+ "infoAbove": {
2007
+ "type": "integer",
2008
+ "minimum": 0,
2009
+ "default": 8,
2010
+ "description": "Report the registered-server count once it exceeds this. The number is JUDGEMENT, not a measurement - it exists so the threshold is visible and adjustable instead of hidden in the script. 0 reports the count always."
2011
+ }
2012
+ }
1978
2013
  }
1979
2014
  }
1980
2015
  },
@@ -13,7 +13,7 @@
13
13
  "max_tokens": 6500
14
14
  },
15
15
  "phase-3-dev": {
16
- "max_tokens": 9300
16
+ "max_tokens": 9450
17
17
  },
18
18
  "phase-4-review": {
19
19
  "max_tokens": 15150
@@ -28,5 +28,5 @@
28
28
  "max_tokens": 6350
29
29
  }
30
30
  },
31
- "total_max_tokens": 63000
31
+ "total_max_tokens": 63150
32
32
  }
@@ -84,6 +84,7 @@ const CHECK_IDS = [
84
84
  "embedded-credentials",
85
85
  "task-tools",
86
86
  "mcp-registration",
87
+ "mcp-surface",
87
88
  "disk-space",
88
89
  "worktree-residue",
89
90
  ];
@@ -636,6 +637,69 @@ function checkMcpRegistration() {
636
637
  );
637
638
  }
638
639
 
640
+ // How many MCP servers is this host paying for on every turn?
641
+ //
642
+ // checkMcpRegistration answers "is OURS registered". This answers the question the
643
+ // user never gets asked: every registered server's tool list is sent with every
644
+ // turn, they are added one at a time, and nobody sees the running total - our own
645
+ // toolkit is 99 tools by itself. This check only ever REPORTS. It never disables
646
+ // anything, and it never blocks or warns, because how many servers are worth their
647
+ // context is the user's call and not a health failure.
648
+ //
649
+ // The threshold is judgement and is therefore a pref, not a constant hidden here:
650
+ // `prefs.global.mcpSurface.infoAbove` (default 8). 0 reports the count always.
651
+ function checkMcpSurface() {
652
+ // Three places a server can be declared, and all three are charged in the
653
+ // project the caller is standing in: the global config, the per-project block
654
+ // inside ~/.claude.json, and a .mcp.json committed to the repo. Counting only
655
+ // the global block understates exactly where it matters - a project-scoped
656
+ // server is invisible to the user for the same reason a global one is.
657
+ //
658
+ // Not counted, and said so in features/doctor.md: servers a marketplace plugin
659
+ // registers. Nothing in the config names them, and guessing a number is worse
660
+ // than reporting the one that is countable.
661
+ const cwd = process.cwd();
662
+ // The per-project key is matched by resolved path, not by string. On macOS /tmp
663
+ // and /var are symlinks, so the same directory has two spellings: the one the
664
+ // host wrote into its config and the one node reports. A string compare finds
665
+ // nothing on whichever side lost the race, and reports a smaller number with no
666
+ // sign that anything was missed.
667
+ const real = (p) => {
668
+ try {
669
+ return realpathSync(p);
670
+ } catch {
671
+ return p;
672
+ }
673
+ };
674
+ const hereReal = real(cwd);
675
+ const names = new Set();
676
+ const add = (obj, scope) => {
677
+ for (const k of Object.keys(obj || {})) names.add(`${k} (${scope})`);
678
+ };
679
+ const home = readJson(join(HOME, ".claude.json"));
680
+ add(home?.mcpServers, "global");
681
+ add(readJson(join(CLAUDE, "settings.json"))?.mcpServers, "global");
682
+ for (const [dir, entry] of Object.entries(home?.projects || {})) {
683
+ if (real(dir) === hereReal) add(entry?.mcpServers, "this project");
684
+ }
685
+ add(readJson(join(cwd, ".mcp.json"))?.mcpServers, ".mcp.json");
686
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
687
+ const raw = prefs?.global?.mcpSurface?.infoAbove;
688
+ const threshold = Number.isInteger(raw) && raw >= 0 ? raw : 8;
689
+ const sorted = [...names].sort();
690
+ if (sorted.length <= threshold) {
691
+ ok("mcp-surface");
692
+ return;
693
+ }
694
+ report(
695
+ "mcp-surface",
696
+ "INFO",
697
+ `${sorted.length} MCP servers are registered, and every one of them sends its tool list on every turn`,
698
+ "remove the servers this machine does not use (--explain lists them)",
699
+ sorted,
700
+ );
701
+ }
702
+
639
703
  function checkDiskSpace() {
640
704
  let freeGb = null;
641
705
  try {
@@ -770,6 +834,7 @@ function main() {
770
834
  checkEmbeddedCredentials();
771
835
  checkTaskTools();
772
836
  checkMcpRegistration();
837
+ checkMcpSurface();
773
838
  checkDiskSpace();
774
839
  checkWorktreeResidue();
775
840
 
@@ -31,7 +31,7 @@ import { readFileSync, existsSync } from "node:fs";
31
31
  import { homedir } from "node:os";
32
32
  import { join } from "node:path";
33
33
 
34
- const ENDPOINT_DEFAULT = "https://mmerterden.vercel.app/api/feedback/ingest";
34
+ const ENDPOINT_DEFAULT = "https://mmerterden.com/api/feedback/ingest";
35
35
  const TIMEOUT_MS = 8000;
36
36
  const TEXT_MAX = 4000;
37
37
  const KINDS = new Set(["bug", "idea", "question"]);
@@ -34,6 +34,7 @@ import { execFileSync } from "node:child_process";
34
34
  import { join, dirname, resolve } from "node:path";
35
35
  import { parseFlags } from "./graph-build.mjs";
36
36
  import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
37
+ import { loadRules } from "./_code-graph.mjs";
37
38
 
38
39
  /**
39
40
  * Top-level source directory of a path, used as the module bucket.
@@ -86,7 +87,109 @@ export function summarize(graph, top) {
86
87
  const kinds = {};
87
88
  for (const s of symbols) kinds[s.symbolKind] = (kinds[s.symbolKind] || 0) + 1;
88
89
 
89
- return { hubs, modules, externals, orphans, kinds, fileCount: files.length };
90
+ // The stack's rules say which symbol kinds can be reference targets at all.
91
+ // Without them the unreferenced question has no honest answer, so the section
92
+ // says that rather than listing symbols that never could have had an edge.
93
+ let referenceKinds;
94
+ try {
95
+ referenceKinds = loadRules(graph.stack).referenceKinds;
96
+ } catch {
97
+ // A stack with no rule file on disk, or one this build cannot parse. The
98
+ // section says so instead of guessing.
99
+ referenceKinds = null;
100
+ }
101
+ const unreferenced = referenceKinds ? unreferencedSymbols(graph, referenceKinds) : null;
102
+
103
+ return {
104
+ hubs,
105
+ modules,
106
+ externals,
107
+ orphans,
108
+ kinds,
109
+ fileCount: files.length,
110
+ referenceKinds,
111
+ unreferenced,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Symbols no OTHER file in this graph names.
117
+ *
118
+ * The orphan section finds FILES with no edge at all. A file imported for one
119
+ * symbol while three of its other exports are dead has edges, so it is invisible
120
+ * there - which is the gap this closes. The data needed was already in the graph:
121
+ * a symbol's only incoming edge kinds are `defines` (from its own file) and
122
+ * `references` (from another file).
123
+ *
124
+ * CANDIDATES, NOT VERDICTS. ADR-0010 records what the extractor is: regex over
125
+ * comment-stripped source, not a parser. Dynamic dispatch, reflection,
126
+ * string-keyed lookup, a public API consumed outside this repo, and anything
127
+ * reached through a name the rules do not treat as a reference target all look
128
+ * identical to dead code from here. So this reports and never gates, and the
129
+ * exclusions below exist so the list does not fill with symbols that COULD NOT
130
+ * have an edge no matter how heavily used they are:
131
+ *
132
+ * - a nested declaration is never a reference target by construction
133
+ * - a symbolKind outside this stack's referenceKinds is never a target
134
+ * - a name declared in two places gets no reference edge at all (the builder
135
+ * drops ambiguous tokens), so absence proves nothing about it
136
+ * - a symbol declared in a test file is not the subject of this question
137
+ *
138
+ * Same-file use also produces no edge, so "unreferenced" here means precisely
139
+ * "no other file in this repo names it", which is what the section says.
140
+ *
141
+ * @param {object} graph
142
+ * @param {Set<string>|null} referenceKinds - null when the stack's rules did not load
143
+ * @returns {{candidates: object[], testOnly: object[], excluded: object}}
144
+ */
145
+ export function unreferencedSymbols(graph, referenceKinds) {
146
+ const symbols = graph.nodes.filter((n) => n.kind === "symbol");
147
+ const fileById = new Map(graph.nodes.filter((n) => n.kind === "file").map((n) => [n.id, n]));
148
+
149
+ const nameCount = new Map();
150
+ for (const sym of symbols) {
151
+ if (sym.nested === true) continue;
152
+ nameCount.set(sym.name, (nameCount.get(sym.name) || 0) + 1);
153
+ }
154
+
155
+ const incoming = new Map();
156
+ for (const e of graph.edges) {
157
+ if (e.kind !== "references") continue;
158
+ if (!incoming.has(e.to)) incoming.set(e.to, []);
159
+ incoming.get(e.to).push(e.from);
160
+ }
161
+
162
+ const excluded = { nested: 0, kindNotATarget: 0, ambiguousName: 0, test: 0 };
163
+ const candidates = [];
164
+ const testOnly = [];
165
+
166
+ for (const sym of symbols) {
167
+ if (sym.isTest) {
168
+ excluded.test += 1;
169
+ continue;
170
+ }
171
+ if (sym.nested === true) {
172
+ excluded.nested += 1;
173
+ continue;
174
+ }
175
+ if (referenceKinds && !referenceKinds.has(sym.symbolKind)) {
176
+ excluded.kindNotATarget += 1;
177
+ continue;
178
+ }
179
+ if ((nameCount.get(sym.name) || 0) > 1) {
180
+ excluded.ambiguousName += 1;
181
+ continue;
182
+ }
183
+ const from = incoming.get(sym.id) || [];
184
+ if (from.length === 0) {
185
+ candidates.push(sym);
186
+ continue;
187
+ }
188
+ if (from.every((fid) => fileById.get(fid)?.isTest === true)) testOnly.push(sym);
189
+ }
190
+
191
+ const bySymbol = (a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name);
192
+ return { candidates: candidates.sort(bySymbol), testOnly: testOnly.sort(bySymbol), excluded };
90
193
  }
91
194
 
92
195
  /**
@@ -160,6 +263,57 @@ export function render(graph, top) {
160
263
  }
161
264
  L.push("");
162
265
 
266
+ L.push(`## Symbols nothing else references`);
267
+ L.push("");
268
+ if (!s.referenceKinds) {
269
+ L.push(
270
+ `Not computed: this stack's code-graph rules did not load, so which symbol kinds can be ` +
271
+ `reference targets at all is unknown, and every answer would be a guess.`,
272
+ );
273
+ } else {
274
+ const u = s.unreferenced;
275
+ L.push(
276
+ `Candidates, not verdicts. These are symbols no OTHER file in this repo names. ` +
277
+ `The extractor is regex over comment-stripped source, not a parser, so dynamic dispatch, ` +
278
+ `reflection, string-keyed lookup and a public API consumed outside this repo all look ` +
279
+ `exactly like dead code from here. Nothing gates on this list.`,
280
+ );
281
+ L.push("");
282
+ L.push(
283
+ `Excluded because they could not carry a reference edge either way: ` +
284
+ `${u.excluded.kindNotATarget} of a kind this stack never treats as a target, ` +
285
+ `${u.excluded.ambiguousName} declared under a name that exists in more than one place, ` +
286
+ `${u.excluded.nested} nested, ${u.excluded.test} declared in test files.`,
287
+ );
288
+ L.push("");
289
+ if (u.candidates.length === 0) {
290
+ L.push(`None: every eligible symbol is named by at least one other file.`);
291
+ } else {
292
+ L.push(`${u.candidates.length} symbol(s) with no reference from any other file:`);
293
+ L.push("");
294
+ L.push(`| Symbol | Kind | Path |`);
295
+ L.push(`|---|---|---|`);
296
+ for (const c of u.candidates.slice(0, top)) {
297
+ L.push(`| \`${c.name}\` | ${c.symbolKind} | \`${c.path}:${c.line || 1}\` |`);
298
+ }
299
+ if (u.candidates.length > top) L.push("");
300
+ if (u.candidates.length > top) L.push(`... ${u.candidates.length - top} more`);
301
+ }
302
+ L.push("");
303
+ if (u.testOnly.length > 0) {
304
+ L.push(
305
+ `${u.testOnly.length} more are referenced ONLY from test files. That is not dead code - ` +
306
+ `it is code whose only consumer is its own test, which is worth knowing before a plan ` +
307
+ `treats it as load-bearing:`,
308
+ );
309
+ L.push("");
310
+ for (const t of u.testOnly.slice(0, top))
311
+ L.push(`- \`${t.name}\` - \`${t.path}:${t.line || 1}\``);
312
+ if (u.testOnly.length > top) L.push(`- ... ${u.testOnly.length - top} more`);
313
+ L.push("");
314
+ }
315
+ }
316
+
163
317
  return L.join("\n");
164
318
  }
165
319