prehello 0.0.0-stage → 0.1.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 (51) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +515 -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 +257 -0
  13. package/dist/adapters/skills.js +155 -0
  14. package/dist/adapters/types.js +2 -0
  15. package/dist/analyze/findings.js +82 -0
  16. package/dist/analyze/grade.js +55 -0
  17. package/dist/analyze/savings.js +111 -0
  18. package/dist/analyze/summary.js +57 -0
  19. package/dist/analyze/totals.js +21 -0
  20. package/dist/cli.js +454 -0
  21. package/dist/context.js +331 -0
  22. package/dist/fixtures.js +52 -0
  23. package/dist/fs-safe.js +222 -0
  24. package/dist/mcp/catalog.js +44 -0
  25. package/dist/mcp/configs.js +42 -0
  26. package/dist/model.js +11 -0
  27. package/dist/platform.js +115 -0
  28. package/dist/redact.js +39 -0
  29. package/dist/render/card.js +305 -0
  30. package/dist/render/fix.js +22 -0
  31. package/dist/render/json.js +97 -0
  32. package/dist/render/markdown.js +182 -0
  33. package/dist/render/png.js +162 -0
  34. package/dist/render/sarif.js +472 -0
  35. package/dist/render/terminal.js +329 -0
  36. package/dist/render/treemap.js +58 -0
  37. package/dist/safe-write.js +120 -0
  38. package/dist/scan.js +104 -0
  39. package/dist/spec.js +27 -0
  40. package/dist/text/comments.js +60 -0
  41. package/dist/text/frontmatter.js +90 -0
  42. package/dist/text/imports.js +81 -0
  43. package/dist/tokens/counter.js +41 -0
  44. package/dist/tokens/families.js +47 -0
  45. package/dist/util/fmt.js +102 -0
  46. package/dist/util/secrets.js +35 -0
  47. package/package.json +66 -4
  48. package/spec/claude-code.json +22 -0
  49. package/spec/codex.json +14 -0
  50. package/spec/cursor.json +15 -0
  51. package/spec/mcp-weights.json +62 -0
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.kinds = kinds;
4
+ exports.headlineAgents = headlineAgents;
5
+ exports.renderTerminal = renderTerminal;
6
+ exports.renderRubric = renderRubric;
7
+ const fmt_1 = require("../util/fmt");
8
+ const redact_1 = require("../redact");
9
+ const grade_1 = require("../analyze/grade");
10
+ const MODE_TITLES = {
11
+ always: "ALWAYS ON before hello",
12
+ conditional: "CONDITIONAL (path/dir triggers)",
13
+ deferred: "DEFERRED (stub now, body on demand)",
14
+ "on-demand": "ON-DEMAND (only if invoked)",
15
+ unknown: "UNKNOWN LOAD BEHAVIOUR",
16
+ };
17
+ const LOOKED_FOR = [
18
+ ["Claude Code", "~/.claude, ~/.claude.json, `claude` on PATH; CLAUDE.md, .claude/, .mcp.json in the project"],
19
+ ["Cursor", "~/.cursor, Cursor.app, `cursor` on PATH; .cursor/, .cursorrules, AGENTS.md in the project"],
20
+ ["Codex", "~/.codex, `codex` on PATH; AGENTS.md, .codex/, .agents/skills in the project"],
21
+ ];
22
+ const WHERE_LOOKED = {
23
+ "claude-code": "CLAUDE.md (project, parent folders, ~/.claude), .claude/{rules,skills,commands}, plugins, .mcp.json, ~/.claude.json",
24
+ cursor: ".cursor/rules (incl. nested), AGENTS.md, .cursorrules, skills folders, ~/.cursor/{skills,plugins,mcp.json}, .cursor/mcp.json",
25
+ codex: "AGENTS.md chain, ~/.codex/{AGENTS.md,config.toml}, .agents/skills, ~/.agents/skills, .codex/config.toml",
26
+ };
27
+ const AGENT_HINT = {
28
+ cursor: "Cursor's User Rules live inside the app, not on disk. Paste them into a file and run with --paste-user-rules <file> to count them.",
29
+ };
30
+ const KIND_NOUN = {
31
+ instructions: ["instruction file", "instruction files"],
32
+ rule: ["rule", "rules"],
33
+ memory: ["memory file", "memory files"],
34
+ "skills-listing": ["skills listing", "skills listings"],
35
+ "skill-body": ["skill body", "skill bodies"],
36
+ subagents: ["subagents listing", "subagents listings"],
37
+ "rule-description": ["rule description", "rule descriptions"],
38
+ "rule-body": ["rule body", "rule bodies"],
39
+ "mcp-server": ["MCP server entry", "MCP server entries"],
40
+ "user-rules": ["pasted user rules", "pasted user rules"],
41
+ };
42
+ function kinds(m) {
43
+ return Object.entries(m)
44
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
45
+ .map(([k, n]) => `${n} ${(KIND_NOUN[k] ?? [k, k])[n === 1 ? 0 : 1]}`)
46
+ .join(", ");
47
+ }
48
+ function painter(on) {
49
+ const w = (open, close) => (s) => (on ? `\u001b[${open}m${s}\u001b[${close}m` : s);
50
+ return { bold: w(1, 22), dim: w(2, 22), red: w(31, 39), yellow: w(33, 39), green: w(32, 39), cyan: w(36, 39) };
51
+ }
52
+ function bar(frac, width) {
53
+ const full = Math.max(0, Math.min(width, frac * width));
54
+ const whole = Math.floor(full);
55
+ const half = full - whole >= 0.5 ? "▌" : "";
56
+ return "█".repeat(whole) + half;
57
+ }
58
+ function range(t) {
59
+ return `${(0, fmt_1.fmtTok)(t.low)}–${(0, fmt_1.fmtTok)(t.high)}`;
60
+ }
61
+ /** Summaries by agent for the one-line headline and the card agent choice. */
62
+ function headlineAgents(r) {
63
+ return r.agents.filter((a) => !a.presentInRepoOnly);
64
+ }
65
+ function aggregate(items) {
66
+ const rows = new Map();
67
+ for (const it of items) {
68
+ const key = it.group && it.tokens ? `g:${it.group}` : `i:${it.id}`;
69
+ const cur = rows.get(key);
70
+ const t = it.tokens;
71
+ if (!cur) {
72
+ rows.set(key, { label: it.group && it.tokens ? it.groupLabel ?? it.group : it.label, detail: it.detail, tokens: t ? t.est : null, low: t?.low ?? 0, high: t?.high ?? 0, n: 1, notes: [...it.notes], unit: it.kind === "mcp-server" ? "servers" : "files" });
73
+ }
74
+ else {
75
+ cur.n++;
76
+ if (t) {
77
+ cur.tokens = (cur.tokens ?? 0) + t.est;
78
+ cur.low += t.low;
79
+ cur.high += t.high;
80
+ }
81
+ }
82
+ }
83
+ return [...rows.values()].sort((a, b) => (b.tokens ?? -1) - (a.tokens ?? -1));
84
+ }
85
+ function summaryBlock(r, shown, W, p, verbose) {
86
+ const out = [];
87
+ if (!shown.length)
88
+ return out;
89
+ out.push(p.bold("Measured vs. not measured (always-on items)"));
90
+ const nameW = Math.max(...shown.map((a) => a.name.length));
91
+ for (const a of shown) {
92
+ const c = a.coverage;
93
+ const total = c.alwaysMeasuredItems + c.alwaysUnmeasuredItems;
94
+ let line;
95
+ if (!total) {
96
+ const by = a.detectedBy.length ? ` (found via ${a.detectedBy.join(", ")})` : "";
97
+ line = `nothing that loads up front was found${by}`;
98
+ }
99
+ else {
100
+ line = `measured ${c.alwaysMeasuredItems} of ${total}${c.alwaysFraction !== null ? ` (${Math.round(c.alwaysFraction * 100)}%)` : ""}`;
101
+ if (c.alwaysMeasuredItems)
102
+ line += `: ${kinds(c.measuredKinds)}`;
103
+ }
104
+ out.push(...(0, fmt_1.wrap)(line, W, " " + a.name.padEnd(nameW) + " ", " ".repeat(nameW + 6)));
105
+ if (c.alwaysUnmeasuredItems) {
106
+ const names = a.mcpServers.filter((m) => !m.measured).map((m) => m.name);
107
+ const parts = [kinds(c.unmeasuredKinds)];
108
+ const nm = names.length ? ` (${names.slice(0, 4).join(", ")}${names.length > 4 ? `, +${names.length - 4} more` : ""})` : "";
109
+ out.push(...(0, fmt_1.wrap)(`NOT measured: ${parts.join("")}${c.unmeasuredKinds["mcp-server"] ? nm : ""}; no token count, never shown as 0`, W, " ".repeat(nameW + 4), " ".repeat(nameW + 6)).map((l) => p.yellow(l)));
110
+ }
111
+ }
112
+ const all = shown.flatMap((a) => a.offenders.map((x) => ({ ...x, name: a.name, always: a.totals.always.est })));
113
+ all.sort((a, b) => b.tokens - a.tokens);
114
+ const top = all.slice(0, verbose ? 10 : 5);
115
+ if (top.length >= 3) {
116
+ out.push("");
117
+ out.push(p.bold("Top offenders (always on, estimates)"));
118
+ const multi = shown.length > 1;
119
+ const maxTok = Math.max(1, top[0].tokens);
120
+ top.forEach((x, i) => {
121
+ const lbl = (0, fmt_1.sanitize)(x.label, 200) + (multi ? ` [${x.name}]` : "");
122
+ const share = x.always ? (x.tokens / x.always) * 100 : 0;
123
+ out.push(` ${String(i + 1).padStart(2)}. ${(0, fmt_1.shortenLabel)(lbl, 46).padEnd(46)} ${(0, fmt_1.fmtInt)(x.tokens).padStart(7)} ${(0, fmt_1.fmtPct)(share).padStart(6)} ${p.cyan(bar(x.tokens / maxTok, 12))}`);
124
+ });
125
+ }
126
+ out.push("");
127
+ return out;
128
+ }
129
+ /**
130
+ * "What to cut": the actionable version of the headline. Skills, plugins and MCP servers are the part of the always-on cost
131
+ * you can remove with one click, so say how big that part is and what removing the top few would save. All estimates.
132
+ */
133
+ function cutBlock(shown, window, W, p) {
134
+ const rows = shown.filter((a) => a.savings);
135
+ if (!rows.length)
136
+ return [];
137
+ const out = [p.bold("What to cut (estimates)")];
138
+ const nameW = Math.max(...rows.map((a) => a.name.length));
139
+ for (const a of rows) {
140
+ const sv = a.savings;
141
+ const always = a.totals.always.est;
142
+ const lead = ` ${a.name.padEnd(nameW)} `;
143
+ const pad = " ".repeat(nameW + 4);
144
+ out.push(...(0, fmt_1.wrap)(`skills, plugins and MCP servers add ~${(0, fmt_1.fmtTok)(sv.groupsTokens)} tokens before hello: ${(0, fmt_1.fmtPct)((sv.groupsTokens / always) * 100)} of the measured always-on total, in ${sv.groupsCount} group${sv.groupsCount > 1 ? "s" : ""}.`, W, lead, pad));
145
+ out.push(...(0, fmt_1.wrap)(`Top ${sv.n} group${sv.n > 1 ? "s" : ""} = ${(0, fmt_1.fmtPct)(sv.pctOfGroups)} of that. Removing ${sv.n > 1 ? "them" : "it"} would save about ${(0, fmt_1.fmtTok)(sv.topTokens)} tokens (${(0, fmt_1.fmtPct)(sv.pctOfWindow)} of a ${(0, fmt_1.fmtWindow)(window)} window); estimate, not a promise.`, W, pad, pad));
146
+ sv.top.forEach((c, i) => {
147
+ const cnt = c.kind === "skills" && c.count > 1 && c.label.startsWith("plugin ") ? ` (${c.count} skills)` : "";
148
+ const lbl = (0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(c.label + cnt, 200), Math.max(20, W - nameW - 30)).padEnd(Math.max(20, W - nameW - 30));
149
+ out.push(`${pad}${String(i + 1).padStart(2)}. ${lbl} ${("~" + (0, fmt_1.fmtTok)(c.tokens)).padStart(7)} ${(0, fmt_1.fmtPct)((c.tokens / always) * 100).padStart(5)}`);
150
+ });
151
+ if (a.lowerBound)
152
+ out.push(p.dim(`${pad}(always-on is a lower bound: unmeasured items are not counted here)`));
153
+ }
154
+ out.push("");
155
+ return out;
156
+ }
157
+ function mcpTail(servers) {
158
+ const un = servers.filter((m) => !m.measured).length;
159
+ if (!un)
160
+ return "; sizes come from prehello's measured catalog (version-specific, ballpark)";
161
+ if (un === servers.length)
162
+ return "; tool-schema size unknown (not in the catalog, no server is started)";
163
+ return `; ${servers.length - un} sized from the measured catalog, ${un} unknown (never shown as 0)`;
164
+ }
165
+ function renderTerminal(r, o) {
166
+ const p = painter(o.color);
167
+ const W = Math.max(64, Math.min(o.width, 100));
168
+ const out = [];
169
+ const dp = (x) => (0, redact_1.displayPath)(x, { cwd: o.cwd, home: o.home });
170
+ const win = (0, fmt_1.fmtWindow)(r.scan.window);
171
+ const shown = headlineAgents(r);
172
+ out.push(`${p.bold("prehello " + r.tool.version)} · ${dp(r.scan.cwd) === "." ? "this folder" : (0, fmt_1.shortenLeft)((0, fmt_1.sanitize)(dp(r.scan.cwd), 400), 60)} + user config · window ${win}`);
173
+ out.push(...(0, fmt_1.wrap)(`Token counts are ESTIMATES (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling priors" : "chars/3.5 heuristic"}), shown with ~. Not exact for any model.`, W).map((l) => p.dim(l)));
174
+ out.push("");
175
+ if (r.agents.length === 0) {
176
+ out.push(`No coding agents detected on this machine, and no agent files found in ${dp(r.scan.cwd) === "." ? "this folder" : dp(r.scan.cwd)}.`);
177
+ out.push("");
178
+ out.push(p.bold("prehello looked for"));
179
+ for (const [n, t] of LOOKED_FOR)
180
+ out.push(...(0, fmt_1.wrap)(t, W, ` ${n.padEnd(12)} `, " ".repeat(15)));
181
+ out.push("");
182
+ out.push(p.bold("Try"));
183
+ out.push(" prehello ~/code/my-project scan a project folder (default: the current directory)");
184
+ out.push(" prehello --agent claude-code treat an agent as in use, to see what it WOULD load");
185
+ out.push(" prehello --help all options");
186
+ out.push("");
187
+ out.push(p.dim("Nothing was measured. This is not a score of 0: absence of data is never shown as 0."));
188
+ out.push(...tail(r, o, p, W, false, true));
189
+ return out.join("\n") + "\n";
190
+ }
191
+ // one-line summary
192
+ const sum = shown
193
+ .map((a) => {
194
+ const t = a.totals.always;
195
+ if (!t.measuredItems)
196
+ return t.unmeasuredItems ? `${a.name} tokens unmeasured` : `${a.name} nothing found`;
197
+ return `${a.name} ${a.lowerBound ? "≥ " : ""}~${(0, fmt_1.fmtTok)(t.est)} tokens`;
198
+ })
199
+ .join(" · ");
200
+ if (sum)
201
+ out.push(...(0, fmt_1.wrap)("Before your first message: " + sum, W, "", " ").map((l, i) => (i === 0 ? p.bold(l.slice(0, 27)) + l.slice(27) : l)));
202
+ out.push("");
203
+ out.push(...cutBlock(shown, r.scan.window, W, p));
204
+ out.push(...summaryBlock(r, shown, W, p, o.verbose));
205
+ if (o.quiet) {
206
+ out.push(...tail(r, { ...o, verbose: false }, p, W, true));
207
+ return out.join("\n") + "\n";
208
+ }
209
+ for (const a of r.agents) {
210
+ if (a.presentInRepoOnly) {
211
+ out.push(...(0, fmt_1.wrap)(`── ${a.name}: agent files exist in this repo but ${a.name} was not found on this machine; excluded from headline and card (use --agent ${a.id} to include).`, W, "", " ").map((l) => p.dim(l)));
212
+ out.push("");
213
+ continue;
214
+ }
215
+ const grade = a.grade ? ` Grade ${a.grade.letter}${a.grade.provisional ? (a.grade.cappedAt ? " (provisional, capped)" : " (provisional)") : ""}` : "";
216
+ const head = `── ${a.name} `;
217
+ out.push(p.bold(head + "─".repeat(Math.max(2, W - head.length - grade.length))) + (a.grade ? p.bold(grade) : ""));
218
+ out.push(...(0, fmt_1.wrap)(`estimate model: ${a.profile.label}; band ${a.profile.band}`, W, " ", " ").map((l) => p.dim(l)));
219
+ for (const mode of ["always", "conditional", "deferred", "on-demand", "unknown"]) {
220
+ const items = a.items.filter((i) => i.loadMode === mode);
221
+ if (!items.length)
222
+ continue;
223
+ const t = a.totals[mode];
224
+ const lower = mode === "always" ? (a.lowerBound ? "≥ " : "") : t.unmeasuredItems > 0 ? "≥ " : "";
225
+ let line;
226
+ if (!t.measuredItems)
227
+ line = `${p.bold(MODE_TITLES[mode])} unmeasured only (${t.unmeasuredItems} item${t.unmeasuredItems > 1 ? "s" : ""}) — NOT zero`;
228
+ else {
229
+ line = `${p.bold(MODE_TITLES[mode])} ${lower}~${(0, fmt_1.fmtTok)(t.est)} tokens (${range(t)})`;
230
+ if (mode === "always" && a.percentOfWindowAlways !== null)
231
+ line += ` ≈ ${(0, fmt_1.fmtPct)(a.percentOfWindowAlways)} of ${win}`;
232
+ if (t.unmeasuredItems)
233
+ line += p.yellow(` + ${t.unmeasuredItems} unmeasured`);
234
+ }
235
+ out.push(line);
236
+ const rows = aggregate(items);
237
+ const limit = o.verbose || mode !== "always" ? (mode === "always" ? 50 : 6) : 8;
238
+ const maxTok = Math.max(1, ...rows.map((x) => x.tokens ?? 0));
239
+ const barW = W >= 96 ? 20 : 12;
240
+ const labelW = Math.min(Math.max(30, W - 24 - barW), Math.max(...rows.map((x) => (x.label + (x.detail ? " " + x.detail : "") + (x.n > 1 ? ` (${x.n} ${x.unit})` : "")).length), 10));
241
+ const measuredRows = rows.filter((x) => x.tokens !== null);
242
+ const unmeasuredRows = rows.filter((x) => x.tokens === null).slice(0, o.verbose ? 40 : 5);
243
+ for (const row of [...measuredRows.slice(0, limit), ...unmeasuredRows]) {
244
+ let lbl = row.label + (row.detail ? " " + row.detail : "") + (row.n > 1 ? ` (${row.n} ${row.unit})` : "");
245
+ lbl = (0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(lbl, 300), labelW).padEnd(labelW);
246
+ if (row.tokens === null)
247
+ out.push(` ${lbl} ${p.yellow("unmeasured".padStart(9))}`);
248
+ else {
249
+ const share = t.est ? (row.tokens / t.est) * 100 : 0;
250
+ out.push(` ${lbl} ${(0, fmt_1.fmtInt)(row.tokens).padStart(9)} ${(0, fmt_1.fmtPct)(share).padStart(6)} ${p.cyan(bar(row.tokens / maxTok, barW))}`);
251
+ }
252
+ }
253
+ if (measuredRows.length > limit)
254
+ out.push(p.dim(` … ${measuredRows.length - limit} more (use --verbose)`));
255
+ const unmTotal = rows.filter((x) => x.tokens === null).length;
256
+ if (unmTotal > unmeasuredRows.length)
257
+ out.push(p.dim(` … ${unmTotal - unmeasuredRows.length} more unmeasured (use --verbose)`));
258
+ if (o.verbose) {
259
+ for (const it of items)
260
+ for (const n of it.notes)
261
+ out.push(...(0, fmt_1.wrap)(`· ${(0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(it.label, 300), 50)}: ${(0, fmt_1.sanitize)(n, 300)}`, W, " ", " ").map((l) => p.dim(l)));
262
+ const unm = items.filter((i) => i.tokens === null);
263
+ for (const it of unm)
264
+ out.push(...(0, fmt_1.wrap)(`· ${(0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(it.label, 300), 50)} unmeasured: ${it.unmeasuredReason}`, W, " ", " ").map((l) => p.dim(l)));
265
+ }
266
+ }
267
+ if (a.gradeNote)
268
+ out.push(p.dim(` ${a.gradeNote}`));
269
+ if (!a.items.length) {
270
+ const by = a.detectedBy.length ? ` (via ${a.detectedBy.join(", ")})` : "";
271
+ out.push(p.bold(` ${a.name} is installed${by}, but nothing that loads before hello was found.`));
272
+ out.push(...(0, fmt_1.wrap)(`This is NOT a score of 0 and NOT proof the agent loads nothing: it still loads its own system prompt and tools. Looked in: ${WHERE_LOOKED[a.id]}.`, W, " ", " ").map((l) => p.dim(l)));
273
+ if (AGENT_HINT[a.id])
274
+ out.push(...(0, fmt_1.wrap)(AGENT_HINT[a.id], W, " ", " ").map((l) => p.dim(l)));
275
+ }
276
+ if (a.mcpServers.length) {
277
+ const names = a.mcpServers.map((m) => m.name);
278
+ const shownN = names.slice(0, o.verbose ? 40 : 8).join(", ");
279
+ out.push(...(0, fmt_1.wrap)(`MCP servers configured (${a.mcpServers.length}): ${shownN}${names.length > (o.verbose ? 40 : 8) ? `, +${names.length - (o.verbose ? 40 : 8)} more` : ""}${mcpTail(a.mcpServers)}`, W, " ", " ").map((l) => p.dim(l)));
280
+ }
281
+ out.push(...(0, fmt_1.wrap)("not measurable from disk: " + a.unmeasuredBanner.join(" · "), W, " ", " ").map((l) => p.dim(l)));
282
+ out.push("");
283
+ }
284
+ out.push(...tail(r, o, p, W));
285
+ return out.join("\n") + "\n";
286
+ }
287
+ function tail(r, o, p, W, compact = false, firstRun = false) {
288
+ const out = [];
289
+ if (!compact && r.findings.length) {
290
+ out.push(p.bold(`Findings (${r.findings.length})`));
291
+ for (const f of r.findings.slice(0, o.verbose ? 100 : 10)) {
292
+ const sav = f.savingsTokens ? ` −~${(0, fmt_1.fmtInt)(f.savingsTokens)} tokens` : "";
293
+ out.push(...(0, fmt_1.wrap)((0, fmt_1.sanitize)(f.message, 400) + sav, W - 3, ` ${f.severity === "warn" ? p.yellow("⚠") : "ℹ"} `, " "));
294
+ }
295
+ if (r.findings.length > 10 && !o.verbose)
296
+ out.push(p.dim(` … ${r.findings.length - 10} more (use --verbose)`));
297
+ const total = r.findings.reduce((s, f) => s + (f.savingsTokens ?? 0), 0);
298
+ if (total)
299
+ out.push(p.dim(` Potential always-on savings (estimate): ~${(0, fmt_1.fmtInt)(total)} tokens. Run --fix for the plan; nothing is changed automatically.`));
300
+ out.push("");
301
+ }
302
+ if (r.scan.fixturesSkipped) {
303
+ const n = r.scan.fixturesSkipped;
304
+ out.push(...(0, fmt_1.wrap)(`ℹ Skipped ${n} fixture folder${n > 1 ? "s" : ""} with agent files (use --include-fixtures).`, W, " ", " ").map((l) => p.dim(l)));
305
+ out.push("");
306
+ }
307
+ if (!compact && o.verbose && r.excluded.length) {
308
+ out.push(p.bold(`Not loaded / ignored (${r.excluded.length})`));
309
+ for (const e of r.excluded)
310
+ out.push(...(0, fmt_1.wrap)(`${e.agent}: ${(0, redact_1.displayPath)(e.path, { cwd: o.cwd, home: o.home })} — ${(0, fmt_1.sanitize)(e.reason, 200)}`, W, " ", " "));
311
+ out.push("");
312
+ }
313
+ if (r.warnings.length) {
314
+ out.push(p.yellow(`Partial results (${r.warnings.length} source${r.warnings.length > 1 ? "s" : ""} skipped):`));
315
+ for (const w of r.warnings)
316
+ out.push(...(0, fmt_1.wrap)((0, fmt_1.sanitize)(w, 300), W, " ", " "));
317
+ out.push("");
318
+ }
319
+ if (!compact && !firstRun) {
320
+ out.push(...(0, fmt_1.wrap)("Never measured: " + r.notMeasurable.join("; ") + ".", W).map((l) => p.dim(l)));
321
+ const g = r.agents.filter((a) => a.grade).sort((a, b) => (b.totals.always.est - a.totals.always.est))[0];
322
+ if (g?.grade)
323
+ out.push(...(0, fmt_1.wrap)(`Context Grade (${g.name}): ${g.grade.letter}${g.grade.provisional ? (g.grade.cappedAt ? ` (provisional, capped at ${g.grade.cappedAt}: some always-loaded items are unmeasured)` : " (provisional: some always-loaded items are unmeasured)") : ""} — heuristic, not a standard. See: prehello explain grade`, W).map((l) => p.dim(l)));
324
+ }
325
+ return out;
326
+ }
327
+ function renderRubric() {
328
+ return ["Context Grade rubric", "", ...grade_1.RUBRIC.map((l) => "• " + l)].join("\n") + "\n";
329
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.squarify = squarify;
4
+ /** Squarified treemap (Bruls et al.). Input values must be > 0; output order follows descending value. */
5
+ function squarify(values, x, y, w, h) {
6
+ const total = values.reduce((a, b) => a + b, 0);
7
+ const out = [];
8
+ if (total <= 0 || w <= 0 || h <= 0)
9
+ return out;
10
+ const scale = (w * h) / total;
11
+ let rem = values.map((v, i) => ({ a: v * scale, i })).sort((p, q) => q.a - p.a);
12
+ let cx = x, cy = y, cw = w, ch = h;
13
+ const worst = (row, side) => {
14
+ const s = row.reduce((t, r) => t + r.a, 0);
15
+ const mx = Math.max(...row.map((r) => r.a));
16
+ const mn = Math.min(...row.map((r) => r.a));
17
+ return Math.max((side * side * mx) / (s * s), (s * s) / (side * side * mn));
18
+ };
19
+ while (rem.length) {
20
+ const side = Math.min(cw, ch);
21
+ let row = [rem[0]];
22
+ let k = 1;
23
+ while (k < rem.length) {
24
+ const cand = [...row, rem[k]];
25
+ if (worst(cand, side) <= worst(row, side)) {
26
+ row = cand;
27
+ k++;
28
+ }
29
+ else
30
+ break;
31
+ }
32
+ const s = row.reduce((t, r) => t + r.a, 0);
33
+ if (cw >= ch) {
34
+ const colW = s / ch;
35
+ let yy = cy;
36
+ for (const r of row) {
37
+ const hh = r.a / colW;
38
+ out.push({ x: cx, y: yy, w: colW, h: hh, i: r.i });
39
+ yy += hh;
40
+ }
41
+ cx += colW;
42
+ cw -= colW;
43
+ }
44
+ else {
45
+ const rowH = s / cw;
46
+ let xx = cx;
47
+ for (const r of row) {
48
+ const ww = r.a / rowH;
49
+ out.push({ x: xx, y: cy, w: ww, h: rowH, i: r.i });
50
+ xx += ww;
51
+ }
52
+ cy += rowH;
53
+ ch -= rowH;
54
+ }
55
+ rem = rem.slice(row.length);
56
+ }
57
+ return out;
58
+ }
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MD_MARKER_PREFIX = exports.SafeWriteError = void 0;
7
+ exports.looksLikePrehelloOutput = looksLikePrehelloOutput;
8
+ exports.describeWriteError = describeWriteError;
9
+ exports.safeWrite = safeWrite;
10
+ /**
11
+ * Safe output files for --json=<file>, --card=<file>, --markdown=<file> and --sarif=<file>.
12
+ * Rules: never write through a symlink; only replace a regular file that is a previous prehello output
13
+ * (or anything, with --force); never follow a link swapped in between the check and the open (O_NOFOLLOW + fstat identity check).
14
+ * Together with src/cli.ts, this is the only place that writes to disk, and the static test allowlists exactly these two files.
15
+ */
16
+ const node_fs_1 = __importDefault(require("node:fs"));
17
+ const platform_1 = require("./platform");
18
+ class SafeWriteError extends Error {
19
+ }
20
+ exports.SafeWriteError = SafeWriteError;
21
+ /** First line of a markdown report written to a file. An HTML comment: invisible when the file is rendered or pasted into GitHub. */
22
+ exports.MD_MARKER_PREFIX = "<!-- generated by prehello ";
23
+ /** True when the first bytes look like something prehello itself wrote. */
24
+ function looksLikePrehelloOutput(headRaw, kind) {
25
+ // a Windows editor may have re-saved our file with a UTF-8 BOM (as "\ufeff", or as its three latin1 bytes): still ours
26
+ const head = kind === "png" ? headRaw : headRaw.replace(/^(?:\ufeff|\u00ef\u00bb\u00bf)/, "");
27
+ if (kind === "md")
28
+ return head.startsWith(exports.MD_MARKER_PREFIX);
29
+ // `head` is decoded as latin1, so a PNG's bytes survive: signature, then the tEXt "Software: prehello" chunk tagPng() adds right after IHDR.
30
+ if (kind === "png")
31
+ return head.startsWith("\u0089PNG\r\n\u001a\n") && /tEXtSoftware\u0000prehello /.test(head);
32
+ if (kind === "svg")
33
+ return /^\s*(<\?xml[^>]*>\s*)?<svg[\s>]/.test(head) && /Generated by prehello /.test(head);
34
+ if (kind === "sarif")
35
+ return /^\s*\{/.test(head) && /"version":\s*"2\.1\.0"/.test(head) && /"driver":\s*\{\s*"name":\s*"prehello"/.test(head);
36
+ return /^\s*\{/.test(head) && /"tool":\s*\{\s*"name":\s*"prehello"/.test(head);
37
+ }
38
+ /** Friendly text for the errors a Windows (or any) user actually hits when the target is busy, read-only or in a missing folder. */
39
+ function describeWriteError(code) {
40
+ switch (code) {
41
+ case "ENOENT": return "the folder does not exist (create it first)";
42
+ case "EACCES":
43
+ case "EPERM": return "permission denied (read-only file or folder, or another program such as an editor, sync client or antivirus holds it)";
44
+ case "EBUSY": return "the file is in use by another program; close it and try again";
45
+ case "EISDIR": return "that name is a folder";
46
+ case "ENOSPC": return "the disk is full";
47
+ case "ENAMETOOLONG": return "the path is too long";
48
+ default: return code ?? "error";
49
+ }
50
+ }
51
+ function safeWrite(abs, content, opts) {
52
+ if ((opts.platform ?? process.platform) === "win32") {
53
+ if ((0, platform_1.isWindowsReservedName)(abs))
54
+ throw new SafeWriteError("that is a reserved Windows device name (CON, NUL, COM1, ...); pick another file name");
55
+ if ((0, platform_1.hasWindowsStream)(abs))
56
+ throw new SafeWriteError("a ':' in the file name would create a hidden NTFS data stream; pick another file name");
57
+ }
58
+ let st = null;
59
+ let identity = null;
60
+ try {
61
+ st = node_fs_1.default.lstatSync(abs);
62
+ identity = node_fs_1.default.lstatSync(abs, { bigint: true });
63
+ }
64
+ catch (e) {
65
+ const code = e.code;
66
+ if (code !== "ENOENT")
67
+ throw new SafeWriteError(describeWriteError(code));
68
+ }
69
+ if (st) {
70
+ if (st.isSymbolicLink())
71
+ throw new SafeWriteError("it is a symlink; prehello never writes through symlinks (remove the link or pick another name)");
72
+ if (!st.isFile())
73
+ throw new SafeWriteError("it exists and is not a regular file");
74
+ if (!opts.force) {
75
+ if (st.nlink > 1)
76
+ throw new SafeWriteError("it has other hard links; use --force to write anyway");
77
+ let head = "";
78
+ try {
79
+ const fd = node_fs_1.default.openSync(abs, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW ?? 0));
80
+ try {
81
+ const buf = Buffer.alloc(4096);
82
+ head = buf.toString("latin1", 0, node_fs_1.default.readSync(fd, buf, 0, buf.length, 0));
83
+ }
84
+ finally {
85
+ node_fs_1.default.closeSync(fd);
86
+ }
87
+ }
88
+ catch {
89
+ throw new SafeWriteError("it exists and could not be checked; use --force to replace it");
90
+ }
91
+ if (!looksLikePrehelloOutput(head, opts.kind))
92
+ throw new SafeWriteError("it exists and was not written by prehello; use --force to replace it");
93
+ }
94
+ }
95
+ const C = node_fs_1.default.constants;
96
+ const flags = C.O_WRONLY | C.O_CREAT | (C.O_NOFOLLOW ?? 0) | (st ? 0 : C.O_EXCL);
97
+ let fd;
98
+ try {
99
+ fd = node_fs_1.default.openSync(abs, flags, 0o644);
100
+ }
101
+ catch (e) {
102
+ const code = e.code;
103
+ if (code === "ELOOP" || code === "EEXIST")
104
+ throw new SafeWriteError("the path changed while writing (symlink or new file); nothing was written");
105
+ throw new SafeWriteError(describeWriteError(code));
106
+ }
107
+ try {
108
+ // bigint: NTFS file ids do not fit in a double, two different files could otherwise compare equal
109
+ const now = node_fs_1.default.fstatSync(fd, { bigint: true });
110
+ if (!now.isFile() || (st && (now.ino !== (identity?.ino ?? 0n) || now.dev !== (identity?.dev ?? 0n))))
111
+ throw new SafeWriteError("the file changed while writing; nothing was written");
112
+ node_fs_1.default.ftruncateSync(fd, 0);
113
+ const buf = typeof content === "string" ? Buffer.from(content, "utf8") : content;
114
+ for (let off = 0; off < buf.length;)
115
+ off += node_fs_1.default.writeSync(fd, buf, off, buf.length - off);
116
+ }
117
+ finally {
118
+ node_fs_1.default.closeSync(fd);
119
+ }
120
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NOT_MEASURABLE = exports.VERSION = void 0;
4
+ exports.scan = scan;
5
+ const fs_safe_1 = require("./fs-safe");
6
+ const context_1 = require("./context");
7
+ const fmt_1 = require("./util/fmt");
8
+ const counter_1 = require("./tokens/counter");
9
+ const model_1 = require("./model");
10
+ const claude_code_1 = require("./adapters/claude-code");
11
+ const cursor_1 = require("./adapters/cursor");
12
+ const codex_1 = require("./adapters/codex");
13
+ const totals_1 = require("./analyze/totals");
14
+ const findings_1 = require("./analyze/findings");
15
+ const grade_1 = require("./analyze/grade");
16
+ const savings_1 = require("./analyze/savings");
17
+ const summary_1 = require("./analyze/summary");
18
+ exports.VERSION = "0.1.0";
19
+ const ADAPTERS = {
20
+ "claude-code": { detect: claude_code_1.detectClaude, discover: claude_code_1.discoverClaude },
21
+ cursor: { detect: cursor_1.detectCursor, discover: cursor_1.discoverCursor },
22
+ codex: { detect: codex_1.detectCodex, discover: codex_1.discoverCodex },
23
+ };
24
+ exports.NOT_MEASURABLE = [
25
+ "the agent's built-in system prompt and built-in tool definitions",
26
+ "server-side / org rules, Cursor User & Team Rules, claude.ai connectors",
27
+ "MCP servers whose tools are only known at runtime (no server is started by default)",
28
+ "files outside the scanned locations; anything the agent injects dynamically (hooks, memory/RAG plugins)",
29
+ ];
30
+ function scan(input) {
31
+ // Canonicalise once: on case-insensitive filesystems (macOS APFS default, Windows) `~/Projects/Foo` and
32
+ // `~/projects/foo` are the same directory, and every containment / ceiling / duplicate check below compares strings.
33
+ const opts = { ...input, cwd: (0, fs_safe_1.realpathOrSelf)(input.cwd), home: (0, fs_safe_1.realpathOrSelf)(input.home), ceiling: input.ceiling ? (0, fs_safe_1.realpathOrSelf)(input.ceiling) : undefined };
34
+ const counter = (0, counter_1.makeCounter)(opts.tokenizer);
35
+ const ctx = new context_1.Ctx(opts, counter);
36
+ const forced = opts.agents && opts.agents.length ? opts.agents : undefined;
37
+ const agents = [];
38
+ for (const id of model_1.ALL_AGENTS) {
39
+ if (forced && !forced.includes(id))
40
+ continue;
41
+ const ad = ADAPTERS[id];
42
+ const det = ad.detect(ctx);
43
+ const inUse = !!forced || det.detectedBy.length > 0;
44
+ const repoOnly = !inUse && det.repoFiles.length > 0;
45
+ if (!inUse && !repoOnly)
46
+ continue;
47
+ const res = ad.discover(ctx);
48
+ const rank = { always: 0, conditional: 1, deferred: 2, "on-demand": 3, unknown: 4 };
49
+ const items = [...res.items].sort((a, b) => rank[a.loadMode] - rank[b.loadMode] || (b.tokens?.est ?? -1) - (a.tokens?.est ?? -1) || (0, fmt_1.cmp)(a.label, b.label));
50
+ const totals = (0, totals_1.sumTotals)(items);
51
+ const prof = ctx.profile(id);
52
+ agents.push({
53
+ id,
54
+ name: model_1.AGENT_NAMES[id],
55
+ detected: inUse,
56
+ detectedBy: forced ? ["--agent"] : det.detectedBy,
57
+ presentInRepoOnly: repoOnly,
58
+ profile: { id: prof.id, label: prof.label, band: prof.band },
59
+ items,
60
+ totals,
61
+ percentOfWindowAlways: totals.always.measuredItems ? (totals.always.est / opts.window) * 100 : null,
62
+ unmeasuredBanner: res.unmeasuredBanner,
63
+ grade: null,
64
+ lowerBound: totals.always.unmeasuredItems > 0 || !!ctx.budgetHit,
65
+ savings: totals.always.measuredItems ? (0, savings_1.savingsOf)(items, totals.always.est, opts.window) : null,
66
+ coverage: (0, summary_1.coverageOf)(id, items),
67
+ offenders: (0, summary_1.offendersOf)(id, items),
68
+ mcpServers: (0, summary_1.mcpServersOf)(items),
69
+ });
70
+ }
71
+ const budgetNote = ctx.budgetSummary();
72
+ if (budgetNote) {
73
+ ctx.warn(budgetNote);
74
+ for (const a of agents)
75
+ a.lowerBound = true;
76
+ }
77
+ (0, findings_1.crossFindings)(ctx, agents);
78
+ const visible = new Set(agents.filter((a) => !a.presentInRepoOnly).map((a) => a.id));
79
+ const findings = (0, findings_1.rankFindings)(ctx.findings.filter((f) => f.agent === "all" || visible.has(f.agent)));
80
+ if (!opts.noGrade) {
81
+ for (const a of agents) {
82
+ if (a.presentInRepoOnly)
83
+ continue;
84
+ if (a.totals.always.measuredItems > 0)
85
+ a.grade = (0, grade_1.gradeAgent)(a, findings, opts.window);
86
+ else
87
+ a.gradeNote = a.totals.always.unmeasuredItems > 0 ? "no grade: everything that loads up front is unmeasured" : "no grade: nothing that loads up front was found";
88
+ }
89
+ }
90
+ const epoch = opts.env.SOURCE_DATE_EPOCH;
91
+ const when = epoch && /^\d+$/.test(epoch) ? new Date(parseInt(epoch, 10) * 1000) : opts.now;
92
+ return {
93
+ schemaVersion: model_1.SCHEMA_VERSION,
94
+ tool: { name: "prehello", version: exports.VERSION },
95
+ generatedAt: when.toISOString(),
96
+ scan: { cwd: opts.cwd, window: opts.window, tokenizer: { method: counter.method, note: counter.note }, claudeProfile: opts.claudeProfile, ...(ctx.fixturesSkipped > 0 ? { fixturesSkipped: ctx.fixturesSkipped } : {}) },
97
+ agents,
98
+ excluded: ctx.excluded.filter((e) => e.agent === "all" || visible.has(e.agent)),
99
+ findings,
100
+ warnings: [...ctx.warnings],
101
+ notMeasurable: exports.NOT_MEASURABLE,
102
+ ...(ctx.budgetHit ? { partial: { reason: ctx.budgetHit, filesNotRead: ctx.budgetSkipped } } : {}),
103
+ };
104
+ }
package/dist/spec.js ADDED
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadSpec = loadSpec;
7
+ exports.loadBundledCatalogPath = loadBundledCatalogPath;
8
+ exports.evidenceFor = evidenceFor;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const SPEC_DIR = node_path_1.default.join(__dirname, "..", "spec");
12
+ const cache = new Map();
13
+ function loadSpec(agent) {
14
+ let s = cache.get(agent);
15
+ if (!s) {
16
+ s = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(SPEC_DIR, `${agent}.json`), "utf8"));
17
+ cache.set(agent, s);
18
+ }
19
+ return s;
20
+ }
21
+ function loadBundledCatalogPath() {
22
+ return node_path_1.default.join(SPEC_DIR, "mcp-weights.json");
23
+ }
24
+ function evidenceFor(agent, doc) {
25
+ const s = loadSpec(agent);
26
+ return { docUrl: s.docs[doc] ?? Object.values(s.docs)[0], verifiedAt: s.verifiedAt };
27
+ }