prehello 0.0.0-stage → 1.0.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 (61) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -2
  4. package/THIRD_PARTY_NOTICES.md +33 -0
  5. package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
  6. package/assets/fonts/Inter-Regular.subset.ttf +0 -0
  7. package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
  8. package/assets/fonts/OFL.txt +94 -0
  9. package/dist/adapters/claude-code.js +480 -0
  10. package/dist/adapters/codex.js +184 -0
  11. package/dist/adapters/common.js +157 -0
  12. package/dist/adapters/cursor.js +275 -0
  13. package/dist/adapters/gemini-cli.js +214 -0
  14. package/dist/adapters/github-copilot.js +249 -0
  15. package/dist/adapters/index.js +16 -0
  16. package/dist/adapters/skills.js +165 -0
  17. package/dist/adapters/types.js +2 -0
  18. package/dist/agents.js +54 -0
  19. package/dist/analyze/findings.js +82 -0
  20. package/dist/analyze/grade.js +55 -0
  21. package/dist/analyze/savings.js +111 -0
  22. package/dist/analyze/summary.js +57 -0
  23. package/dist/analyze/totals.js +21 -0
  24. package/dist/calibrate.js +265 -0
  25. package/dist/cli.js +591 -0
  26. package/dist/context.js +329 -0
  27. package/dist/diff.js +182 -0
  28. package/dist/fixtures.js +52 -0
  29. package/dist/fs-safe.js +222 -0
  30. package/dist/mcp/catalog.js +44 -0
  31. package/dist/mcp/configs.js +42 -0
  32. package/dist/model.js +8 -0
  33. package/dist/platform.js +115 -0
  34. package/dist/redact.js +39 -0
  35. package/dist/render/card.js +306 -0
  36. package/dist/render/fix.js +22 -0
  37. package/dist/render/json.js +97 -0
  38. package/dist/render/markdown.js +182 -0
  39. package/dist/render/png.js +162 -0
  40. package/dist/render/sarif.js +472 -0
  41. package/dist/render/terminal.js +318 -0
  42. package/dist/render/treemap.js +58 -0
  43. package/dist/safe-write.js +122 -0
  44. package/dist/scan.js +98 -0
  45. package/dist/spec.js +27 -0
  46. package/dist/text/comments.js +60 -0
  47. package/dist/text/frontmatter.js +90 -0
  48. package/dist/text/imports.js +86 -0
  49. package/dist/tokens/counter.js +41 -0
  50. package/dist/tokens/families.js +79 -0
  51. package/dist/util/fmt.js +102 -0
  52. package/dist/util/secrets.js +35 -0
  53. package/dist/version.js +5 -0
  54. package/package.json +70 -4
  55. package/spec/calibration.json +33 -0
  56. package/spec/claude-code.json +22 -0
  57. package/spec/codex.json +14 -0
  58. package/spec/cursor.json +15 -0
  59. package/spec/gemini-cli.json +17 -0
  60. package/spec/github-copilot.json +14 -0
  61. package/spec/mcp-weights.json +62 -0
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CARD_FONT = exports.THEMES = exports.AT_LEAST = exports.MIN_CUT_HEADLINE_TOKENS = exports.REPO_URL = void 0;
4
+ exports.buildHeadline = buildHeadline;
5
+ exports.assertHeadline = assertHeadline;
6
+ exports.pickCardAgent = pickCardAgent;
7
+ exports.wrapLabel = wrapLabel;
8
+ exports.renderCard = renderCard;
9
+ const fmt_1 = require("../util/fmt");
10
+ const treemap_1 = require("./treemap");
11
+ const savings_1 = require("../analyze/savings");
12
+ const secrets_1 = require("../util/secrets");
13
+ const agents_1 = require("../agents");
14
+ exports.REPO_URL = "github.com/PIYUSH-KUMAR1809/prehello";
15
+ /** A saving smaller than this (estimated tokens) is noise, not a headline. Same threshold as the cut list itself. */
16
+ exports.MIN_CUT_HEADLINE_TOKENS = 500;
17
+ /** Wording of the lower-bound marker. Words, not a glyph: "≥" is missing from several fallback fonts used by SVG renderers. */
18
+ exports.AT_LEAST = "at least";
19
+ /**
20
+ * Headline templates. Each one is selected by data and re-validated by assertHeadline():
21
+ * no claim is rendered unless it is true for the scanned data (plan §10.1 guard).
22
+ */
23
+ function buildHeadline(a, window, variant = "cut") {
24
+ const t = a.totals.always;
25
+ const lower = a.lowerBound ? `${exports.AT_LEAST} ` : "";
26
+ // The cut headline needs measured always-on data AND a saving that is worth saying out loud; otherwise the total headline is used.
27
+ if (variant === "cut" && a.savings && t.measuredItems > 0 && a.savings.topTokens >= exports.MIN_CUT_HEADLINE_TOKENS) {
28
+ const sv = a.savings;
29
+ const what = sv.top.some((c) => c.kind === "mcp") ? "Skills, plugins & MCP" : "Skills & plugins";
30
+ return {
31
+ id: "cut-list",
32
+ hero: `${what} cost ${lower}~${(0, fmt_1.fmtTok)(sv.groupsTokens)} tokens before hello`,
33
+ sub: `${sv.n === sv.groupsCount ? `all ${sv.n} group${sv.n > 1 ? "s" : ""}` : `top ${sv.n} of ${sv.groupsCount} groups`}: cut ${sv.n > 1 ? "them" : "it"}, save ~${(0, fmt_1.fmtTok)(sv.topTokens)} (estimate)`,
34
+ };
35
+ }
36
+ if (t.measuredItems > 0 && t.est > 0) {
37
+ const pct = (t.est / window) * 100;
38
+ return {
39
+ id: "tokens-before-hello",
40
+ hero: `${lower}~${(0, fmt_1.fmtTok)(t.est)} tokens load before hello`,
41
+ sub: `${pct < 0.1 ? (a.lowerBound ? "under 0.1% so far" : "under 0.1%") : (a.lowerBound ? exports.AT_LEAST + " " : "about ") + (0, fmt_1.fmtPct)(pct)} of a ${(0, fmt_1.fmtWindow)(window)} window · ${a.name} · estimate`,
42
+ };
43
+ }
44
+ if (t.unmeasuredItems > 0) {
45
+ const n = t.unmeasuredItems;
46
+ // only claim "MCP servers" when every unmeasured always-on item really is one (an unreadable CLAUDE.md is not a server)
47
+ const un = a.items.filter((i) => i.loadMode === "always" && i.tokens === null);
48
+ const mcpOnly = un.length > 0 && un.every((i) => i.kind === "mcp-server");
49
+ return { id: "mcp-unmeasured", hero: mcpOnly ? `${n} MCP server${n > 1 ? "s" : ""} load before hello` : `${n} always-loaded item${n > 1 ? "s" : ""} could not be measured`, sub: `${a.name} · token cost unmeasured (not zero)` };
50
+ }
51
+ return null;
52
+ }
53
+ function assertHeadline(h, a, window) {
54
+ const t = a.totals.always;
55
+ if (h.id === "tokens-before-hello") {
56
+ if (!(t.measuredItems > 0 && t.est > 0))
57
+ throw new Error("headline guard: tokens claim without measured always-on data");
58
+ if (a.lowerBound && !h.hero.startsWith(exports.AT_LEAST))
59
+ throw new Error("headline guard: lower-bound marker missing");
60
+ if (!h.hero.includes("~"))
61
+ throw new Error("headline guard: estimate marker missing");
62
+ if (/%/.test(h.hero))
63
+ throw new Error("headline guard: unexpected percentage in hero");
64
+ }
65
+ else if (h.id === "cut-list") {
66
+ if (!a.savings || !(t.measuredItems > 0))
67
+ throw new Error("headline guard: cut-list claim without savings data");
68
+ if (a.savings.topTokens < exports.MIN_CUT_HEADLINE_TOKENS)
69
+ throw new Error("headline guard: cut-list claim below the minimum saving");
70
+ if (!h.hero.includes("~") || !/estimate/.test(h.sub))
71
+ throw new Error("headline guard: estimate marker missing");
72
+ if (a.lowerBound && !/ cost at least /.test(h.hero))
73
+ throw new Error("headline guard: lower-bound marker missing");
74
+ }
75
+ else if (h.id === "mcp-unmeasured") {
76
+ if (t.measuredItems > 0 || t.unmeasuredItems === 0)
77
+ throw new Error("headline guard: mcp claim without unmeasured-only data");
78
+ if (/\d+(\.\d+)?k? tokens/.test(h.hero + h.sub))
79
+ throw new Error("headline guard: token number for unmeasured data");
80
+ }
81
+ void window;
82
+ }
83
+ function pickCardAgent(r, want) {
84
+ const shown = r.agents.filter((a) => !a.presentInRepoOnly);
85
+ if (want)
86
+ return shown.find((a) => a.id === want) ?? null;
87
+ const withData = shown.filter((a) => buildHeadline(a, r.scan.window));
88
+ withData.sort((a, b) => b.totals.always.est - a.totals.always.est || b.totals.always.unmeasuredItems - a.totals.always.unmeasuredItems);
89
+ return withData[0] ?? null;
90
+ }
91
+ const FILLS = ["#1f6feb", "#8250df", "#1a7f37", "#9a6700", "#bf3989", "#0a7a8a", "#bc4c00"];
92
+ const OTHER_FILL = "#57606a";
93
+ exports.THEMES = {
94
+ dark: { bg: "#0d1117", text: "#e6edf3", muted: "#9da7b3", chip: "#21262d", border: "#30363d" },
95
+ light: { bg: "#ffffff", text: "#1f2328", muted: "#4b5563", chip: "#eef1f4", border: "#d0d7de" },
96
+ };
97
+ function fit(text, maxChars) {
98
+ if (text.length <= maxChars)
99
+ return text;
100
+ return maxChars <= 3 ? text.slice(0, Math.max(0, maxChars)) : text.slice(0, maxChars - 3) + "...";
101
+ }
102
+ /**
103
+ * Font stack with sane fallbacks: SVG renderers (browsers, rsvg, Inkscape, link-preview bots) differ in which of these exist,
104
+ * and `system-ui` is unknown to some of them. It always ends in the generic `sans-serif`, and every width estimate below assumes
105
+ * a wide face (DejaVu Sans is ~0.62 em per character) so text still fits when the fallback is the widest one.
106
+ */
107
+ exports.CARD_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Liberation Sans', 'DejaVu Sans', sans-serif";
108
+ const DEFAULT_CHAR_EM = 0.62;
109
+ /** Greedy wrap of a tile label at spaces and after hyphens; the last allowed line ends in "..." when text is left over. Never returns an empty list. */
110
+ function wrapLabel(label, maxChars, maxLines) {
111
+ const toks = label.split(/(?<=-)|\s+/).filter(Boolean);
112
+ const lines = [];
113
+ let cur = "";
114
+ const rest = [];
115
+ for (let i = 0; i < toks.length; i++) {
116
+ const t = toks[i];
117
+ const joined = cur ? (cur.endsWith("-") ? cur + t : `${cur} ${t}`) : t;
118
+ if (!cur || joined.length <= maxChars)
119
+ cur = joined;
120
+ else {
121
+ lines.push(cur);
122
+ cur = t;
123
+ }
124
+ if (lines.length === maxLines) {
125
+ rest.push(...toks.slice(i));
126
+ cur = "";
127
+ break;
128
+ }
129
+ }
130
+ if (cur)
131
+ lines.push(cur);
132
+ if (lines.length > maxLines)
133
+ lines.length = maxLines;
134
+ const out = lines.map((l) => (l.length > maxChars ? fit(l, maxChars) : l));
135
+ if (rest.length > 0)
136
+ out[out.length - 1] = fit(out[out.length - 1] + "...", maxChars);
137
+ return out.length ? out : [fit(label, maxChars)];
138
+ }
139
+ /** Greedy wrap on " · " boundaries so a footer never runs off the card. */
140
+ function wrapSegments(segments, maxChars) {
141
+ const lines = [];
142
+ let cur = "";
143
+ for (const seg of segments) {
144
+ const next = cur ? `${cur} · ${seg}` : seg;
145
+ if (cur && next.length > maxChars) {
146
+ lines.push(cur);
147
+ cur = seg;
148
+ }
149
+ else
150
+ cur = next;
151
+ }
152
+ if (cur)
153
+ lines.push(cur);
154
+ return lines.map((l) => fit(l, maxChars));
155
+ }
156
+ /** No paths or file contents by construction: only cut-list group names (plugin / MCP server names; anonymisable), generic labels (cardLabel), counts, the version and a date reach the SVG. */
157
+ function renderCard(r, a, o) {
158
+ const CHAR_EM = o.charEm ?? DEFAULT_CHAR_EM;
159
+ const maxCharsFor = (px, fontSize) => Math.max(1, Math.floor(px / (fontSize * CHAR_EM)));
160
+ const head = buildHeadline(a, r.scan.window, o.headline ?? "cut") ?? buildHeadline(a, r.scan.window, "total");
161
+ if (!head)
162
+ throw new Error("nothing measurable to put on a card");
163
+ assertHeadline(head, a, r.scan.window);
164
+ const th = exports.THEMES[o.theme];
165
+ const og = o.size === "og";
166
+ const W = og ? 1200 : 1080;
167
+ const H = og ? 630 : 1080;
168
+ const pad = og ? 48 : 56;
169
+ const innerW = W - pad * 2;
170
+ const font = o.fontFamily ?? exports.CARD_FONT;
171
+ const heroMax = og ? 64 : 72;
172
+ let heroLines = [head.hero];
173
+ let heroSize = Math.min(heroMax, Math.floor(innerW / (head.hero.length * CHAR_EM)));
174
+ // Square cards have the room for a two-line hero: split it at the space nearest the middle when one line would be small.
175
+ if (!og && heroSize < 52 && head.hero.includes(" ")) {
176
+ const mid = head.hero.length / 2;
177
+ const cut = [...head.hero.matchAll(/ /g)].map((m) => m.index).sort((x, y) => Math.abs(x - mid) - Math.abs(y - mid))[0];
178
+ heroLines = [head.hero.slice(0, cut), head.hero.slice(cut + 1)];
179
+ heroSize = Math.min(heroMax, Math.floor(innerW / (Math.max(...heroLines.map((l) => l.length)) * CHAR_EM)));
180
+ }
181
+ const heroLineH = Math.round(heroSize * 1.12);
182
+ const heroY = og ? 168 : heroLines.length > 1 ? 196 : 214;
183
+ const subSize = og ? 26 : 30;
184
+ const subY = heroY + (heroLines.length - 1) * heroLineH + (og ? 46 : 52);
185
+ const showStrip = !!a.savings && head.id !== "cut-list";
186
+ const tmY = subY + (og ? 36 : 56);
187
+ // footer: wrap on segment boundaries, anchored to the bottom edge
188
+ const unvalidated = a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic";
189
+ const footSize = og ? 17 : 20;
190
+ const footLines = wrapSegments(["npx prehello", exports.REPO_URL, "local-only scan", `estimate band: ${a.profile.band}${unvalidated ? " (unvalidated)" : ""}`, `window ${(0, fmt_1.fmtWindow)(r.scan.window)}`, ...(r.partial ? ["PARTIAL scan"] : [])], maxCharsFor(innerW, footSize));
191
+ const footLineH = footSize + 6;
192
+ const footTop = H - (og ? 30 : 44) - (footLines.length - 1) * footLineH;
193
+ // Layout flows upward from the footer so a wrapped (2-3 line) footer can never collide with the chips or the treemap.
194
+ const chipH = 38;
195
+ const limit = footTop - footSize - (og ? 14 : 24);
196
+ const stripBlock = showStrip ? (og ? 40 : 52) : 0;
197
+ const tmH = Math.max(og ? 120 : 200, Math.min(og ? 400 : 640, limit - chipH - (og ? 22 : 30) - stripBlock - tmY));
198
+ const stripY = tmY + tmH + (og ? 30 : 42);
199
+ const chipsY = tmY + tmH + (og ? 22 : 30) + stripBlock;
200
+ // Treemap: the same groups as "what to cut" (plugin posthog, 18 Cursor-managed skills, ...) plus the always-on items that are not removable
201
+ // (rules, instruction files). Small tiles are merged into "other" so every visible tile is big enough to read.
202
+ const tiles = (0, savings_1.cardTiles)(a.items, !!o.anonymize);
203
+ const tilesTotal = tiles.reduce((s, t) => s + t.tokens, 0);
204
+ const MAX_TILES = og ? 7 : 8;
205
+ const MIN_SHARE = 0.04;
206
+ const shown = tiles.filter((t, k) => k < MAX_TILES && t.tokens / tilesTotal >= MIN_SHARE);
207
+ const restTiles = tiles.filter((t) => !shown.includes(t));
208
+ if (restTiles.length === 1 && restTiles[0].tokens / tilesTotal >= MIN_SHARE)
209
+ shown.push(restTiles.pop());
210
+ const rest = restTiles.reduce((s, t) => s + t.tokens, 0);
211
+ const cells = shown.map((t, k) => ({ label: t.named ? (0, secrets_1.scrubSecrets)(t.label) : t.label, v: t.tokens, fill: FILLS[k % FILLS.length] }));
212
+ if (rest > 0)
213
+ cells.push({ label: restTiles.length > 1 ? `other (${restTiles.length} smaller)` : "other", v: rest, fill: OTHER_FILL });
214
+ const rects = (0, treemap_1.squarify)(cells.map((c) => c.v), pad, tmY, innerW, tmH);
215
+ const totalCells = cells.reduce((s, c) => s + c.v, 0);
216
+ const parts = [];
217
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="t d">`);
218
+ parts.push(`<title id="t">${(0, fmt_1.xmlEscape)(head.hero)}</title><desc id="d">${(0, fmt_1.xmlEscape)(head.sub)}. Generated by prehello ${(0, fmt_1.xmlEscape)(r.tool.version)}. Token counts are estimates.</desc>`);
219
+ parts.push(`<rect width="${W}" height="${H}" fill="${th.bg}"/>`);
220
+ parts.push(`<text x="${pad}" y="${pad + 20}" font-family="${font}" font-size="28" font-weight="700" fill="${th.text}">prehello <tspan font-weight="400" fill="${th.muted}" font-size="20">v${(0, fmt_1.xmlEscape)(r.tool.version)} · ${(0, fmt_1.xmlEscape)(r.generatedAt.slice(0, 10))}</tspan></text>`);
221
+ if (a.grade) {
222
+ const gx = W - pad - 36;
223
+ const gy = pad + 14;
224
+ parts.push(`<circle cx="${gx}" cy="${gy}" r="34" fill="none" stroke="${th.muted}" stroke-width="3"/>`);
225
+ parts.push(`<text x="${gx}" y="${gy + 16}" text-anchor="middle" font-family="${font}" font-size="46" font-weight="800" fill="${th.text}">${(0, fmt_1.xmlEscape)(a.grade.letter)}</text>`);
226
+ parts.push(`<text x="${W - pad}" y="${gy + 54}" text-anchor="end" font-family="${font}" font-size="14" fill="${th.muted}">Context Grade${a.grade.provisional ? " (provisional)" : ""}</text>`);
227
+ }
228
+ heroLines.forEach((line, k) => parts.push(`<text x="${pad}" y="${heroY + k * heroLineH}" font-family="${font}" font-size="${heroSize}" font-weight="800" fill="${th.text}">${(0, fmt_1.xmlEscape)(line)}</text>`));
229
+ parts.push(`<text x="${pad}" y="${subY}" font-family="${font}" font-size="${subSize}" fill="${th.muted}">${(0, fmt_1.xmlEscape)(fit(head.sub, maxCharsFor(innerW, subSize)))}</text>`);
230
+ rects.forEach((rc) => {
231
+ const c = cells[rc.i];
232
+ const g = 3;
233
+ const rx = rc.x + g / 2, ry = rc.y + g / 2, rw = Math.max(0, rc.w - g), rh = Math.max(0, rc.h - g);
234
+ parts.push(`<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" rx="6" fill="${c.fill}"/>`);
235
+ // label size follows cell size: big cells get a big label, token count and share; small cells get the number only
236
+ const big = rw >= 240 && rh >= 110;
237
+ const mid = rw >= 120 && rh >= 64;
238
+ const lf0 = big ? 26 : mid ? 20 : 15;
239
+ if (rw >= 64 && rh >= 34) {
240
+ const avail = rw - 18;
241
+ // shrink the label to fit before wrapping or truncating it (never below 11 px)
242
+ let lf = Math.max(11, Math.min(lf0, Math.floor(avail / (Math.max(1, c.label.length) * CHAR_EM))));
243
+ let lines = [c.label];
244
+ if (c.label.length * lf * CHAR_EM > avail) {
245
+ // still too wide at the smallest size: wrap onto up to 3 lines at spaces and hyphens, ellipsis only if it still does not fit
246
+ lf = Math.min(lf0, 14);
247
+ const withSub = Math.floor((rh - 14 - (13 + 8)) / (lf + 3));
248
+ const maxLines = Math.min(3, withSub >= 1 ? withSub : Math.max(1, Math.floor((rh - 8) / (lf + 3))));
249
+ lines = wrapLabel(c.label, maxCharsFor(avail, lf), maxLines);
250
+ }
251
+ const lineH = lf + 3;
252
+ const tf = Math.max(13, lf - 4);
253
+ lines.forEach((ln, k) => parts.push(`<text x="${(rx + 10).toFixed(1)}" y="${(ry + lf + 6 + k * lineH).toFixed(1)}" font-family="${font}" font-size="${lf}" font-weight="600" fill="#ffffff">${(0, fmt_1.xmlEscape)(ln)}</text>`));
254
+ const subY = ry + lf + 6 + (lines.length - 1) * lineH + tf + 6;
255
+ if (lines.length === 1 ? rh >= lf + tf + 22 : subY <= ry + rh - 6) {
256
+ const pct = totalCells ? ` · ${(0, fmt_1.fmtPct)((c.v / totalCells) * 100)}` : "";
257
+ const ty = lines.length === 1 ? ry + lf + tf + 12 : subY;
258
+ parts.push(`<text x="${(rx + 10).toFixed(1)}" y="${ty.toFixed(1)}" font-family="${font}" font-size="${tf}" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`~${(0, fmt_1.fmtTok)(c.v)}${big ? pct : ""}`, maxCharsFor(avail, tf)))}</text>`);
259
+ }
260
+ }
261
+ else if (rw >= 24 && rh >= 90) {
262
+ // a tall, narrow tile: the name and size read bottom-to-top instead of being dropped
263
+ const tx = rx + rw / 2 + 4.5;
264
+ const ty = ry + rh - 10;
265
+ parts.push(`<text transform="rotate(-90 ${tx.toFixed(1)} ${ty.toFixed(1)})" x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" font-family="${font}" font-size="13" font-weight="600" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`${c.label} · ~${(0, fmt_1.fmtTok)(c.v)}`, maxCharsFor(rh - 20, 13)))}</text>`);
266
+ }
267
+ else if (rw >= 40 && rh >= 22) {
268
+ parts.push(`<text x="${(rx + 6).toFixed(1)}" y="${(ry + rh / 2 + 5).toFixed(1)}" font-family="${font}" font-size="13" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`~${(0, fmt_1.fmtTok)(c.v)}`, maxCharsFor(rw - 8, 13)))}</text>`);
269
+ }
270
+ });
271
+ if (showStrip && a.savings) {
272
+ const sv = a.savings;
273
+ const kindWord = sv.top.every((c) => c.kind === "mcp") ? "MCP server" : sv.top.some((c) => c.kind === "mcp") ? "skill/plugin/MCP group" : "skill/plugin group";
274
+ const line = `Cut the top ${sv.n} ${kindWord}${sv.n > 1 ? "s" : ""}: save ~${(0, fmt_1.fmtTok)(sv.topTokens)} tokens (estimate)`;
275
+ const ss = og ? 22 : 24;
276
+ parts.push(`<text x="${pad}" y="${stripY}" font-family="${font}" font-size="${ss}" font-weight="700" fill="${th.text}">${(0, fmt_1.xmlEscape)(fit(line, maxCharsFor(innerW, ss)))}</text>`);
277
+ }
278
+ // chips (only for data that exists)
279
+ const chips = [];
280
+ // the cut headline does not show the total itself, so the total always appears as the first chip
281
+ if (head.id === "cut-list")
282
+ chips.push(`always-on ${a.lowerBound ? exports.AT_LEAST + " " : ""}~${(0, fmt_1.fmtTok)(a.totals.always.est)} · ${(0, fmt_1.fmtPct)((a.totals.always.est / r.scan.window) * 100)} of ${(0, fmt_1.fmtWindow)(r.scan.window)}`);
283
+ const mode = (m) => a.totals[m];
284
+ for (const m of ["deferred", "conditional", "on-demand"])
285
+ if (mode(m).measuredItems)
286
+ chips.push(`${m} ~${(0, fmt_1.fmtTok)(mode(m).est)}`);
287
+ const unm = a.totals.always.unmeasuredItems + a.totals.deferred.unmeasuredItems;
288
+ if (unm)
289
+ chips.push(`unmeasured: ${unm} item${unm > 1 ? "s" : ""}`);
290
+ chips.push(...((0, agents_1.agentMeta)(a.id).cardChips ?? []));
291
+ if (r.partial)
292
+ chips.unshift("partial scan");
293
+ let cx = pad;
294
+ const chipSize = 18;
295
+ for (const chip of chips) {
296
+ const cw = chip.length * chipSize * CHAR_EM + 28;
297
+ if (cx + cw > W - pad)
298
+ break;
299
+ parts.push(`<rect x="${cx}" y="${chipsY}" width="${cw.toFixed(0)}" height="${chipH}" rx="19" fill="${th.chip}" stroke="${th.border}"/>`);
300
+ parts.push(`<text x="${(cx + 14).toFixed(0)}" y="${chipsY + 25}" font-family="${font}" font-size="${chipSize}" fill="${th.text}">${(0, fmt_1.xmlEscape)(chip)}</text>`);
301
+ cx += cw + 12;
302
+ }
303
+ footLines.forEach((l, k) => parts.push(`<text x="${pad}" y="${footTop + k * footLineH}" font-family="${font}" font-size="${footSize}" fill="${th.muted}">${(0, fmt_1.xmlEscape)(l)}</text>`));
304
+ parts.push(`</svg>`);
305
+ return parts.join("\n") + "\n";
306
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderFix = renderFix;
4
+ const fmt_1 = require("../util/fmt");
5
+ /** Dry-run plan. This function only formats text; there is no code path in 0.1 that modifies user files. */
6
+ function renderFix(r) {
7
+ const lines = [];
8
+ const items = r.findings.filter((f) => f.fix);
9
+ const total = items.reduce((s, f) => s + (f.savingsTokens ?? 0), 0);
10
+ lines.push(`Fix plan (dry run): ${items.length} suggestion${items.length === 1 ? "" : "s"}${total ? `, ~${(0, fmt_1.fmtInt)(total)} always-on tokens could be saved (estimate)` : ""} · 0 files changed`);
11
+ lines.push("");
12
+ items.forEach((f, i) => {
13
+ lines.push(` ${i + 1}. [${f.id}] ${(0, fmt_1.sanitize)(f.message, 200)}`);
14
+ lines.push(` suggestion: ${(0, fmt_1.sanitize)(f.fix ?? "", 240)}`);
15
+ lines.push(` savings: ${f.savingsTokens ? "~" + (0, fmt_1.fmtInt)(f.savingsTokens) + " tokens (estimate)" : "none measurable (clarity/correctness)"} · confidence: ${f.confidence}`);
16
+ });
17
+ if (!items.length)
18
+ lines.push(" Nothing to suggest from the rules implemented in 0.1 (F1, F2, F4, F5, F7, F8, F9).");
19
+ lines.push("");
20
+ lines.push("Nothing was modified. prehello 0.1 has no --apply or --patch: changes are yours to make. Content-level rewrites are never auto-applied.");
21
+ return lines.join("\n") + "\n";
22
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toJson = toJson;
4
+ exports.numberedLabels = numberedLabels;
5
+ const redact_1 = require("../redact");
6
+ function numberedLabels(agent, only) {
7
+ const list = only ?? agent.items;
8
+ // privacy-safe labels: cardLabel, numbered when repeated ("MCP server 2")
9
+ const totals = new Map();
10
+ for (const it of list)
11
+ totals.set(it.cardLabel, (totals.get(it.cardLabel) ?? 0) + 1);
12
+ const seen = new Map();
13
+ const out = new Map();
14
+ for (const it of list) {
15
+ const n = (seen.get(it.cardLabel) ?? 0) + 1;
16
+ seen.set(it.cardLabel, n);
17
+ out.set(it.id, (totals.get(it.cardLabel) ?? 1) > 1 ? `${it.cardLabel} ${n}` : it.cardLabel);
18
+ }
19
+ return out;
20
+ }
21
+ /** Serialise the Report. NEVER includes file contents. `strict` drops paths/names entirely (use before sharing). */
22
+ /** Defence in depth: user-controlled names (skill `name:`, MCP server keys, …) must never carry a secret-shaped string into JSON. */
23
+ function scrubDeep(v) {
24
+ if (typeof v === "string")
25
+ return (0, redact_1.scrubSecrets)(v);
26
+ if (Array.isArray(v))
27
+ return v.map(scrubDeep);
28
+ if (v && typeof v === "object")
29
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [(0, redact_1.scrubSecrets)(k), scrubDeep(x)]));
30
+ return v;
31
+ }
32
+ function toJson(r, c, strict) {
33
+ return scrubDeep(toJsonRaw(r, c, strict));
34
+ }
35
+ function toJsonRaw(r, c, strict) {
36
+ const dp = (p) => (0, redact_1.displayPath)(p, c);
37
+ return {
38
+ schemaVersion: r.schemaVersion,
39
+ tool: r.tool,
40
+ generatedAt: r.generatedAt,
41
+ redacted: strict ? "strict" : "paths-shortened (~ and ./ prefixes); server and file names kept",
42
+ scan: { ...r.scan, cwd: strict ? "project" : dp(r.scan.cwd) },
43
+ agents: r.agents.map((a) => {
44
+ const labels = numberedLabels(a);
45
+ return {
46
+ id: a.id,
47
+ name: a.name,
48
+ detected: a.detected,
49
+ detectedBy: a.detectedBy,
50
+ presentInRepoOnly: a.presentInRepoOnly,
51
+ profile: a.profile,
52
+ totals: a.totals,
53
+ percentOfWindowAlways: a.percentOfWindowAlways,
54
+ alwaysIsLowerBound: a.lowerBound,
55
+ savings: a.savings
56
+ ? {
57
+ ...a.savings,
58
+ top: a.savings.top.map((c, i) => (strict ? { label: `group ${i + 1}`, kind: c.kind, tokens: c.tokens, count: c.count } : c)),
59
+ note: "estimate: what removing these groups would save; not a promise",
60
+ }
61
+ : null,
62
+ grade: a.grade,
63
+ ...(a.gradeNote ? { gradeNote: a.gradeNote } : {}),
64
+ coverage: a.coverage,
65
+ installedButNothingFound: a.items.length === 0 && !a.presentInRepoOnly,
66
+ offenders: a.offenders.map((x) => (strict ? { kind: x.kind, loadMode: x.loadMode, tokens: x.tokens } : { label: x.label, path: dp(x.path), kind: x.kind, loadMode: x.loadMode, tokens: x.tokens, ...(x.note ? { note: x.note } : {}) })),
67
+ mcpServers: a.mcpServers.map((m, i) => (strict ? { n: i + 1, transport: m.transport, scope: m.scope, measured: m.measured } : { name: m.name, source: dp(m.source), scope: m.scope, transport: m.transport, measured: m.measured })),
68
+ unmeasuredBanner: a.unmeasuredBanner,
69
+ items: a.items.map((it) => ({
70
+ id: it.id,
71
+ kind: it.kind,
72
+ scope: it.scope,
73
+ loadMode: it.loadMode,
74
+ label: strict ? labels.get(it.id) : it.label,
75
+ ...(strict ? {} : { path: dp(it.path) }),
76
+ tokens: it.tokens,
77
+ unmeasured: it.tokens === null,
78
+ ...(it.unmeasuredReason ? { unmeasuredReason: it.unmeasuredReason } : {}),
79
+ chars: it.chars,
80
+ lines: it.lines,
81
+ ...(it.breakdown && !strict ? { breakdown: it.breakdown } : {}),
82
+ ...(it.parts && !strict ? { parts: it.parts } : {}),
83
+ confidence: it.confidence,
84
+ evidence: it.evidence,
85
+ notes: strict ? it.notes.filter((n) => !/["'`\/\\@]/.test(n) && !/\bplugin\b/i.test(n)) : it.notes,
86
+ })),
87
+ };
88
+ }),
89
+ excluded: strict ? r.excluded.map((e) => ({ agent: e.agent, reason: e.reason.replace(/"[^"]*"/g, '"…"') })) : r.excluded.map((e) => ({ agent: e.agent, path: dp(e.path), reason: e.reason })),
90
+ findings: r.findings.map((f) => strict
91
+ ? { id: f.id, severity: f.severity, agent: f.agent, savingsTokens: f.savingsTokens, confidence: f.confidence, fileCount: f.paths.length }
92
+ : { id: f.id, severity: f.severity, agent: f.agent, message: f.message, paths: f.paths.map(dp), savingsTokens: f.savingsTokens, confidence: f.confidence, fix: f.fix }),
93
+ warnings: strict ? r.warnings.length : r.warnings,
94
+ notMeasurable: r.notMeasurable,
95
+ partial: r.partial ?? null,
96
+ };
97
+ }
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mdCode = mdCode;
4
+ exports.fixtureNote = fixtureNote;
5
+ exports.renderMarkdown = renderMarkdown;
6
+ const card_1 = require("./card");
7
+ const savings_1 = require("../analyze/savings");
8
+ const terminal_1 = require("./terminal");
9
+ const fmt_1 = require("../util/fmt");
10
+ const MAX_NAME = 60;
11
+ const ZERO_WIDTH = /[\u200b-\u200f\u2028\u2029\u2060\ufeff]/g;
12
+ /** A user-controlled name as an inline code span. Never throws, never returns an empty span, safe inside a table cell when `table`. */
13
+ function mdCode(name, table = false) {
14
+ let t = (0, fmt_1.sanitize)(name.replace(/[\r\n\t\v\f]+/g, " "), 400).replace(ZERO_WIDTH, "").replace(/\s+/g, " ").trim();
15
+ if (t.length > MAX_NAME)
16
+ t = t.slice(0, MAX_NAME - 3) + "...";
17
+ if (!t)
18
+ t = "(unnamed)";
19
+ if (table)
20
+ t = t.replace(/\|/g, "\\|");
21
+ const longest = Math.max(0, ...(t.match(/`+/g) ?? []).map((r) => r.length));
22
+ const fence = "`".repeat(longest + 1);
23
+ const pad = /^`|`$/.test(t) ? " " : "";
24
+ return `${fence}${pad}${t}${pad}${fence}`;
25
+ }
26
+ /** One-line note (a count, no paths) when fixture folders holding agent files were skipped; null otherwise. */
27
+ function fixtureNote(r) {
28
+ const n = r.scan.fixturesSkipped;
29
+ return n ? `Skipped ${n} fixture folder${n > 1 ? "s" : ""} with agent files (use --include-fixtures to count them).` : null;
30
+ }
31
+ const ascii = (s) => s.replace(/±/g, "+/-").replace(/−/g, "-");
32
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
33
+ function alwaysSentence(a, window) {
34
+ const t = a.totals.always;
35
+ if (!t.measuredItems)
36
+ return t.unmeasuredItems ? `${plural(t.unmeasuredItems, "always-loaded item")} could not be measured: token cost unknown (not zero).` : "nothing that loads up front was found (this is not a score of 0; the agent still has its own system prompt and tools).";
37
+ const lb = a.lowerBound ? `${card_1.AT_LEAST} ` : "";
38
+ const pct = (t.est / window) * 100;
39
+ const share = pct < 0.1 ? (a.lowerBound ? "under 0.1% so far" : "under 0.1%") : `${a.lowerBound ? card_1.AT_LEAST + " " : "about "}${(0, fmt_1.fmtPct)(pct)}`;
40
+ return `${lb}~${(0, fmt_1.fmtTok)(t.est)} tokens always on, ${share} of a ${(0, fmt_1.fmtWindow)(window)} window (range ~${(0, fmt_1.fmtTok)(t.low)}-${(0, fmt_1.fmtTok)(t.high)}).`;
41
+ }
42
+ function gradeLine(a) {
43
+ const g = a.grade;
44
+ if (!g)
45
+ return a.gradeNote ? `Context Grade: none (${ascii((0, fmt_1.sanitize)(a.gradeNote, 200)).replace(/^no grade: /, "")}).` : undefined;
46
+ const why = g.provisional ? ` (provisional${g.cappedAt ? `, capped at ${g.cappedAt}` : ""}: some always-loaded items are unmeasured${a.totals.always.unmeasuredItems ? "" : " or the scan hit its budget"})` : "";
47
+ return `Context Grade: **${g.letter}**${why}. A heuristic, not a standard.`;
48
+ }
49
+ function unmeasuredList(a, o) {
50
+ const names = a.mcpServers.filter((m) => !m.measured).map((m) => m.name);
51
+ if (!names.length)
52
+ return "";
53
+ if (o.anonymize)
54
+ return ` (${plural(names.length, "MCP server")}, names hidden)`;
55
+ return ` (${names.slice(0, 6).map((n) => mdCode(n)).join(", ")}${names.length > 6 ? `, +${names.length - 6} more` : ""})`;
56
+ }
57
+ function agentSection(r, a, o) {
58
+ const out = [`### ${a.name}`, ""];
59
+ const lead = alwaysSentence(a, r.scan.window);
60
+ out.push(lead.charAt(0).toUpperCase() + lead.slice(1));
61
+ const gl = gradeLine(a);
62
+ if (gl)
63
+ out.push("", gl);
64
+ const t = a.totals.always;
65
+ const tiles = (0, savings_1.cardTiles)(a.items, o.anonymize);
66
+ const unmeasuredServers = a.mcpServers.filter((m) => !m.measured);
67
+ if (tiles.length || unmeasuredServers.length) {
68
+ out.push("", "| What loads before hello | ~Tokens | Share of measured |", "|---|--:|--:|");
69
+ const total = tiles.reduce((s, x) => s + x.tokens, 0) || 1;
70
+ const MAX = 10;
71
+ for (const x of tiles.slice(0, MAX))
72
+ out.push(`| ${x.named ? mdCode(x.label, true) : x.label} | ~${(0, fmt_1.fmtTok)(x.tokens)} | ${(0, fmt_1.fmtPct)((x.tokens / total) * 100)} |`);
73
+ if (tiles.length > MAX) {
74
+ const rest = tiles.slice(MAX).reduce((s, x) => s + x.tokens, 0);
75
+ out.push(`| ${tiles.length - MAX} smaller groups | ~${(0, fmt_1.fmtTok)(rest)} | ${(0, fmt_1.fmtPct)((rest / total) * 100)} |`);
76
+ }
77
+ unmeasuredServers.slice(0, 8).forEach((m, i) => out.push(`| ${o.anonymize ? `unmeasured MCP server ${i + 1}` : `MCP server ${mdCode(m.name, true)}`} | not measured | - |`));
78
+ if (unmeasuredServers.length > 8)
79
+ out.push(`| ${unmeasuredServers.length - 8} more unmeasured MCP servers | not measured | - |`);
80
+ if (a.lowerBound)
81
+ out.push("", `The total is a lower bound: items marked "not measured" are not counted, and are never shown as 0.`);
82
+ }
83
+ if (a.savings) {
84
+ const sv = a.savings;
85
+ out.push("", `**What to cut** (estimates, not a promise): removing the top ${sv.n === 1 ? "group" : sv.n + " groups"} would save ~${(0, fmt_1.fmtTok)(sv.topTokens)} tokens (${(0, fmt_1.fmtPct)(sv.pctOfWindow)} of a ${(0, fmt_1.fmtWindow)(r.scan.window)} window).`, "");
86
+ const byCut = new Map(tiles.filter((x) => x.cut !== undefined).map((x) => [x.cut, x]));
87
+ sv.top.forEach((c, i) => {
88
+ const x = byCut.get(i);
89
+ const label = x ? (x.named ? mdCode(x.label) : x.label) : "group";
90
+ const cnt = c.kind === "skills" && c.count > 1 && c.label.startsWith("plugin ") ? `, ${c.count} skills` : "";
91
+ out.push(`${i + 1}. ${label}: ~${(0, fmt_1.fmtTok)(c.tokens)} (${(0, fmt_1.fmtPct)((c.tokens / (t.est || 1)) * 100)} of always-on${cnt})`);
92
+ });
93
+ }
94
+ const c = a.coverage;
95
+ const totalAlways = c.alwaysMeasuredItems + c.alwaysUnmeasuredItems;
96
+ if (!totalAlways)
97
+ return [...out, ""];
98
+ out.push("", "**Measured vs not measured** (always-on items)", "");
99
+ {
100
+ out.push(`- Measured ${c.alwaysMeasuredItems} of ${totalAlways}${c.alwaysFraction !== null ? ` (${Math.round(c.alwaysFraction * 100)}%)` : ""}${c.alwaysMeasuredItems ? `: ${(0, terminal_1.kinds)(c.measuredKinds)}` : ""}`);
101
+ if (c.alwaysUnmeasuredItems)
102
+ out.push(`- NOT measured: ${(0, terminal_1.kinds)(c.unmeasuredKinds)}${c.unmeasuredKinds["mcp-server"] ? unmeasuredList(a, o) : ""}. No token count; never shown as 0.`);
103
+ }
104
+ const other = ["deferred", "conditional", "on-demand"].filter((m) => a.totals[m].measuredItems);
105
+ if (other.length)
106
+ out.push(`- Not always on: ${other.map((m) => `${m} ~${(0, fmt_1.fmtTok)(a.totals[m].est)}`).join(", ")} (loaded only when triggered or invoked)`);
107
+ const nf = r.findings.filter((f) => f.agent === a.id).length;
108
+ if (nf)
109
+ out.push(`- ${plural(nf, "finding")} (duplicates, over-long or ignored files): details name files, so they are left out of this report. Run prehello locally to see them.`);
110
+ out.push("");
111
+ return out;
112
+ }
113
+ /** The headline sentence of the report (top agent), with the same guards as the card. */
114
+ function headlineOf(r, o) {
115
+ const agent = (0, card_1.pickCardAgent)(r);
116
+ if (!agent)
117
+ return null;
118
+ const h = (0, card_1.buildHeadline)(agent, r.scan.window, o.headline) ?? (0, card_1.buildHeadline)(agent, r.scan.window, "total");
119
+ if (!h)
120
+ return null;
121
+ (0, card_1.assertHeadline)(h, agent, r.scan.window);
122
+ return { agent, hero: h.hero, sub: h.sub, id: h.id };
123
+ }
124
+ function footer(r, shown) {
125
+ const unvalidated = shown.some((a) => a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic");
126
+ const bands = [...new Set(shown.filter((a) => a.totals.always.measuredItems).map((a) => `${a.name} ${ascii(a.profile.band)}`))];
127
+ return `_Generated by prehello ${r.tool.version} on ${r.generatedAt.slice(0, 10)}. Local-only scan: no paths or file contents are included. All token counts are estimates (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling factors" : "chars/3.5 heuristic"}${bands.length ? "; band " + bands.join(", ") : ""}${unvalidated ? ", unvalidated" : ""}), not exact for any model. Window ${(0, fmt_1.fmtWindow)(r.scan.window)}.${r.partial ? " PARTIAL scan: every total is a lower bound." : ""} Try it: \`npx prehello\` (https://${card_1.REPO_URL})._`;
128
+ }
129
+ function renderMarkdown(r, o) {
130
+ const shown = r.agents.filter((a) => !a.presentInRepoOnly);
131
+ const out = ["## prehello report: what your coding agent loads before you say hello", ""];
132
+ const repoOnly = r.agents.filter((a) => a.presentInRepoOnly).map((a) => a.name);
133
+ if (!shown.length) {
134
+ out.push("No coding agent was detected on this machine, so nothing was measured. This is not a score of 0.", "");
135
+ if (repoOnly.length)
136
+ out.push(`Agent files for ${repoOnly.join(" and ")} exist in the repo, but the agent is not installed here, so they are not counted.`, "");
137
+ out.push(footer(r, shown));
138
+ return out.join("\n") + "\n";
139
+ }
140
+ const head = headlineOf(r, o);
141
+ if (head) {
142
+ // cut headline: "top 3 of 6 groups: cut them, save ~13.2k (estimate)" -> "Claude Code: top 3 of 6 groups - cut them, save ~13.2k (estimate)."
143
+ const sub = head.sub.replace(/ · /g, ", ");
144
+ const parts = sub.split(", ");
145
+ const line = head.id === "cut-list" ? `${head.agent.name}: ${sub.replace(": ", " - ")}` : (sub.includes(" window") ? `${sub.charAt(0).toUpperCase()}${sub.slice(1, sub.indexOf(" window") + 7)} (${parts.slice(-2).join(", ")})` : `${sub.charAt(0).toUpperCase()}${sub.slice(1)}`);
146
+ out.push(`**${head.hero.charAt(0).toUpperCase()}${head.hero.slice(1)}**`, "", `${line}.`, "");
147
+ }
148
+ else
149
+ out.push("Nothing measurable loads before hello for the detected agents. This is not a score of 0: absence of data is never shown as 0.", "");
150
+ if (o.compact) {
151
+ for (const a of shown) {
152
+ const t = a.totals.always;
153
+ const g = a.grade ? `, grade ${a.grade.letter}${a.grade.provisional ? " (provisional)" : ""}` : "";
154
+ out.push(`- **${a.name}**: ${t.measuredItems ? `${a.lowerBound ? card_1.AT_LEAST + " " : ""}~${(0, fmt_1.fmtTok)(t.est)} tokens always on (${a.lowerBound ? card_1.AT_LEAST + " " : ""}${(0, fmt_1.fmtPct)((t.est / r.scan.window) * 100)} of ${(0, fmt_1.fmtWindow)(r.scan.window)})` : t.unmeasuredItems ? "tokens unmeasured (not zero)" : "nothing found up front"}${g}`);
155
+ }
156
+ const cut = (0, card_1.pickCardAgent)(r)?.savings;
157
+ const top = (0, card_1.pickCardAgent)(r);
158
+ if (cut && top) {
159
+ const tiles = (0, savings_1.cardTiles)(top.items, o.anonymize).filter((x) => x.cut !== undefined && x.cut < cut.n);
160
+ out.push("", `Cut first (${top.name}, estimates): ${tiles.map((x) => `${x.named ? mdCode(x.label) : x.label} ~${(0, fmt_1.fmtTok)(x.tokens)}`).join(", ")}`);
161
+ }
162
+ const fxc = fixtureNote(r);
163
+ if (fxc)
164
+ out.push("", fxc);
165
+ out.push("", footer(r, shown));
166
+ return out.join("\n") + "\n";
167
+ }
168
+ for (const a of shown)
169
+ out.push(...agentSection(r, a, o));
170
+ if (repoOnly.length)
171
+ out.push(`Agent files for ${repoOnly.join(" and ")} exist in the repo, but the agent is not installed here, so they are not counted.`, "");
172
+ const shared = r.findings.filter((f) => f.agent === "all").length;
173
+ if (shared)
174
+ out.push(`${plural(shared, "finding")} apply to more than one agent (duplicate or conflicting files); details name files, so they are left out of this report.`, "");
175
+ if (r.warnings.length || r.partial)
176
+ out.push(`**Partial results:** some sources could not be read or were skipped (or the scan hit its budget), so every total is a lower bound. Run prehello locally for details.`, "");
177
+ const fx = fixtureNote(r);
178
+ if (fx)
179
+ out.push(fx, "");
180
+ out.push(footer(r, shown));
181
+ return out.join("\n") + "\n";
182
+ }