clud-bug 0.6.34 → 0.7.0-rc.1

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 (95) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +8 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +14 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompts.d.ts +9 -0
  55. package/dist/core/prompts.d.ts.map +1 -0
  56. package/dist/core/prompts.js +401 -0
  57. package/dist/core/prompts.js.map +1 -0
  58. package/dist/core/render-review.d.ts +6 -0
  59. package/dist/core/render-review.d.ts.map +1 -0
  60. package/dist/core/render-review.js +219 -0
  61. package/dist/core/render-review.js.map +1 -0
  62. package/dist/core/render.d.ts +13 -0
  63. package/dist/core/render.d.ts.map +1 -0
  64. package/dist/core/render.js +80 -0
  65. package/dist/core/render.js.map +1 -0
  66. package/dist/core/review-schema.d.ts +42 -0
  67. package/dist/core/review-schema.d.ts.map +1 -0
  68. package/dist/core/review-schema.js +156 -0
  69. package/dist/core/review-schema.js.map +1 -0
  70. package/dist/core/skills.d.ts +80 -0
  71. package/dist/core/skills.d.ts.map +1 -0
  72. package/dist/core/skills.js +510 -0
  73. package/dist/core/skills.js.map +1 -0
  74. package/package.json +27 -4
  75. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  76. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  77. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  78. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  79. package/src/cli/index.ts +101 -0
  80. package/src/cli/main.ts +1376 -0
  81. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  82. package/src/cli/skills.ts +386 -0
  83. package/{lib/update.js → src/cli/update.ts} +68 -27
  84. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  85. package/src/core/audit.ts +53 -0
  86. package/{lib/detect.js → src/core/detect.ts} +100 -47
  87. package/src/core/index.ts +70 -0
  88. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  89. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  90. package/{lib/render.js → src/core/render.ts} +36 -10
  91. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  92. package/{lib/skills.js → src/core/skills.ts} +172 -343
  93. package/templates/workflow-py.yml.tmpl +2 -2
  94. package/templates/workflow-ts.yml.tmpl +2 -2
  95. package/templates/workflow.yml.tmpl +17 -8
@@ -1,4 +1,4 @@
1
- // lib/usage.js — Q7-clud-bug $/LOC compute.
1
+ // src/cli/usage.ts — Q7-clud-bug $/LOC compute.
2
2
  //
3
3
  // Pure functions, no I/O. Driven from bin/clud-bug.js which fetches workflow
4
4
  // run JSON + PR metadata via gh CLI. Implementation of the 0.0.M.1 dashboard
@@ -20,9 +20,16 @@
20
20
  // Q7-clud-bug enforcement: dashboard reports the 30-day rolling trend; the
21
21
  // next Phase 0.5 PR ships when the trend stops declining.
22
22
 
23
+ export interface ModelPricing {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ }
29
+
23
30
  // Anthropic pricing as of 2026-05 (per MTok). Cache write is 1.25× input
24
31
  // per Anthropic's published 5-min-TTL ephemeral cache rate.
25
- export const PRICING = {
32
+ export const PRICING: Record<string, ModelPricing> = {
26
33
  'claude-sonnet-4-6': {
27
34
  input: 3.0, output: 15.0, cacheRead: 0.30, cacheWrite: 3.75,
28
35
  },
@@ -39,19 +46,32 @@ export const PRICING = {
39
46
  // update the table. The `unknown` flag in the result lets callers warn.
40
47
  const DEFAULT_MODEL = 'claude-sonnet-4-6';
41
48
 
49
+ export interface TokenCounts {
50
+ input_tokens?: number | undefined;
51
+ output_tokens?: number | undefined;
52
+ cache_read_input_tokens?: number | undefined;
53
+ cache_creation_input_tokens?: number | undefined;
54
+ }
55
+
56
+ export interface CostParts {
57
+ input: number;
58
+ output: number;
59
+ cacheRead: number;
60
+ cacheWrite: number;
61
+ }
62
+
63
+ export interface ReviewCost {
64
+ total: number;
65
+ parts: CostParts;
66
+ model: string;
67
+ unknownModel: boolean;
68
+ }
69
+
42
70
  /**
43
71
  * Compute the USD cost of a single clud-bug review from token counts +
44
72
  * model. All four token classes are billed independently.
45
- *
46
- * Returns:
47
- * {
48
- * total: number USD,
49
- * parts: { input, output, cacheRead, cacheWrite } USD breakdown,
50
- * model: string (normalized),
51
- * unknownModel: boolean (true if we used DEFAULT_MODEL pricing),
52
- * }
53
73
  */
54
- export function computeReviewCost(tokens, model) {
74
+ export function computeReviewCost(tokens: TokenCounts, model: string | null | undefined): ReviewCost {
55
75
  const t = {
56
76
  input: tokens.input_tokens || 0,
57
77
  output: tokens.output_tokens || 0,
@@ -59,8 +79,11 @@ export function computeReviewCost(tokens, model) {
59
79
  cacheWrite: tokens.cache_creation_input_tokens || 0,
60
80
  };
61
81
  const normalized = model && PRICING[model] ? model : DEFAULT_MODEL;
62
- const p = PRICING[normalized];
63
- const parts = {
82
+ // PRICING[normalized] is guaranteed to exist (normalized is either a
83
+ // known key or DEFAULT_MODEL which is defined above). Non-null assert
84
+ // to satisfy noUncheckedIndexedAccess.
85
+ const p = PRICING[normalized]!;
86
+ const parts: CostParts = {
64
87
  input: (t.input / 1e6) * p.input,
65
88
  output: (t.output / 1e6) * p.output,
66
89
  cacheRead: (t.cacheRead / 1e6) * p.cacheRead,
@@ -83,7 +106,7 @@ export function computeReviewCost(tokens, model) {
83
106
  * docs-only / empty PRs); callers can filter zero-LOC reviews out of
84
107
  * trend lines as outliers.
85
108
  */
86
- export function costPerLOC(cost, additions, deletions) {
109
+ export function costPerLOC(cost: number, additions: number | null | undefined, deletions: number | null | undefined): number {
87
110
  const loc = (additions || 0) + (deletions || 0);
88
111
  if (loc === 0) return 0;
89
112
  return cost / loc;
@@ -96,7 +119,7 @@ export function costPerLOC(cost, additions, deletions) {
96
119
  * High hit rate proves the v0.6.3 caching layer is firing on
97
120
  * re-reviews and fix-pushes.
98
121
  */
99
- export function cacheHitRate(tokens) {
122
+ export function cacheHitRate(tokens: TokenCounts): number {
100
123
  const read = tokens.cache_read_input_tokens || 0;
101
124
  const write = tokens.cache_creation_input_tokens || 0;
102
125
  const input = tokens.input_tokens || 0;
@@ -105,6 +128,12 @@ export function cacheHitRate(tokens) {
105
128
  return read / denom;
106
129
  }
107
130
 
131
+ export interface ExtractedTokens {
132
+ model: string | null;
133
+ tokens: Required<TokenCounts> | null;
134
+ ok: boolean;
135
+ }
136
+
108
137
  /**
109
138
  * Parse the model + token counts from a clud-bug-review job log dump.
110
139
  *
@@ -121,15 +150,8 @@ export function cacheHitRate(tokens) {
121
150
  * the same number Anthropic charges. If no result event exists, the
122
151
  * review didn't complete successfully — return ok:false so the caller
123
152
  * skips this run rather than trusting partial token data.
124
- *
125
- * Returns:
126
- * {
127
- * model: string | null,
128
- * tokens: { input, output, cacheRead, cacheWrite } | null,
129
- * ok: boolean (false if no result event — partial / errored job),
130
- * }
131
153
  */
132
- export function extractTokensFromLog(logText) {
154
+ export function extractTokensFromLog(logText: unknown): ExtractedTokens {
133
155
  if (typeof logText !== 'string' || logText.length === 0) {
134
156
  return { model: null, tokens: null, ok: false };
135
157
  }
@@ -138,15 +160,18 @@ export function extractTokensFromLog(logText) {
138
160
  // uses the same model throughout). Captured before the usage parse
139
161
  // so model is reported even when we can't find a result event.
140
162
  const modelMatches = [...logText.matchAll(/"model"\s*:\s*"([^"]+)"/g)];
141
- const model = modelMatches.length > 0
142
- ? modelMatches[modelMatches.length - 1][1]
163
+ // matchAll yields RegExpMatchArray entries; capture group 1 is the
164
+ // model string. Under noUncheckedIndexedAccess every index is typed
165
+ // possibly-undefined — coalesce so the return type stays string|null.
166
+ const model: string | null = modelMatches.length > 0
167
+ ? (modelMatches[modelMatches.length - 1]?.[1] ?? null)
143
168
  : null;
144
169
 
145
170
  // Locate the final result event. There may be multiple over the
146
171
  // life of a long-running session — take the LAST one.
147
172
  const resultMarkerRe = /"type"\s*:\s*"result"/g;
148
173
  let lastResultIdx = -1;
149
- let m;
174
+ let m: RegExpExecArray | null;
150
175
  while ((m = resultMarkerRe.exec(logText)) !== null) {
151
176
  lastResultIdx = m.index;
152
177
  }
@@ -176,9 +201,9 @@ export function extractTokensFromLog(logText) {
176
201
  ? fromUsage.slice(0, iterationsIdx)
177
202
  : fromUsage;
178
203
 
179
- const pluck = (re) => {
204
+ const pluck = (re: RegExp): number => {
180
205
  const match = usageOnly.match(re);
181
- return match ? Number(match[1]) : 0;
206
+ return match && match[1] !== undefined ? Number(match[1]) : 0;
182
207
  };
183
208
 
184
209
  const input = pluck(/"input_tokens"\s*:\s*(\d+)/);
@@ -203,38 +228,88 @@ export function extractTokensFromLog(logText) {
203
228
  };
204
229
  }
205
230
 
231
+ export interface ReviewRecord {
232
+ repo: string;
233
+ pr: number;
234
+ createdAt: string;
235
+ model: string;
236
+ tokens: TokenCounts;
237
+ additions: number;
238
+ deletions: number;
239
+ cost: number;
240
+ costPerLOC: number;
241
+ cacheRate: number;
242
+ unknownModel?: boolean | undefined;
243
+ modelObserved?: string | undefined;
244
+ }
245
+
246
+ export interface RollupGroupStats {
247
+ reviews: number;
248
+ cost: number;
249
+ loc: number;
250
+ costPerLOC: number;
251
+ cacheRate: number;
252
+ }
253
+
254
+ export interface RollupTotal {
255
+ reviews: number;
256
+ cost: number;
257
+ loc: number;
258
+ costPerLOC: number;
259
+ cacheRate: number;
260
+ }
261
+
262
+ export interface RollupTrend {
263
+ current: number;
264
+ previous: number | null;
265
+ slopePct: number | null;
266
+ }
267
+
268
+ export interface RollupOutlier {
269
+ repo: string;
270
+ pr: number;
271
+ costPerLOC: number;
272
+ multiple: number;
273
+ cost: number;
274
+ reason: string;
275
+ }
276
+
277
+ export interface UnknownModelReview {
278
+ repo: string;
279
+ pr: number;
280
+ modelObserved: string | undefined;
281
+ }
282
+
283
+ export interface Rollup {
284
+ total: RollupTotal;
285
+ perRepo: Record<string, RollupGroupStats>;
286
+ perModel: Record<string, RollupGroupStats>;
287
+ trend30d: RollupTrend;
288
+ outliers: RollupOutlier[];
289
+ unknownModelReviews: UnknownModelReview[];
290
+ }
291
+
292
+ // Internal mutable form used during group-by build-up — the *s arrays
293
+ // disappear before return; we type the in-progress shape separately.
294
+ interface GroupAccumulator {
295
+ reviews: number;
296
+ cost: number;
297
+ loc: number;
298
+ costPerLOCs: number[];
299
+ cacheRates: number[];
300
+ costPerLOC?: number;
301
+ cacheRate?: number;
302
+ }
303
+
206
304
  /**
207
305
  * Roll up an array of per-review records into a structured summary.
208
306
  *
209
- * Each review record:
210
- * {
211
- * repo: "owner/name",
212
- * pr: number,
213
- * createdAt: ISO 8601,
214
- * model: string,
215
- * tokens: { ... },
216
- * additions: number,
217
- * deletions: number,
218
- * cost: number (USD, total),
219
- * costPerLOC: number,
220
- * cacheRate: number (0..1),
221
- * }
222
- *
223
- * Returns:
224
- * {
225
- * total: { reviews, cost, loc, costPerLOC (median), cacheRate (median) },
226
- * perRepo: { [repo]: { ... } },
227
- * perModel: { [model]: { ... } },
228
- * trend30d: { dailyMedians: [...], slopePct (MoM) },
229
- * outliers: [{ review, severity }],
230
- * }
231
- *
232
307
  * Pre-conditions: callers should drop zero-LOC reviews before passing in.
233
308
  */
234
- export function rollup(reviews) {
309
+ export function rollup(reviews: ReviewRecord[]): Rollup {
235
310
  const valid = reviews.filter((r) => r.costPerLOC > 0);
236
311
 
237
- const total = {
312
+ const total: RollupTotal = {
238
313
  reviews: valid.length,
239
314
  cost: valid.reduce((a, r) => a + r.cost, 0),
240
315
  loc: valid.reduce((a, r) => a + (r.additions + r.deletions), 0),
@@ -242,34 +317,44 @@ export function rollup(reviews) {
242
317
  cacheRate: median(valid.map((r) => r.cacheRate)),
243
318
  };
244
319
 
245
- const groupBy = (key) => {
246
- const out = {};
320
+ const groupBy = (key: 'repo' | 'model'): Record<string, RollupGroupStats> => {
321
+ const out: Record<string, GroupAccumulator> = {};
247
322
  for (const r of valid) {
248
- const k = r[key];
249
- if (!out[k]) out[k] = { reviews: 0, cost: 0, loc: 0, costPerLOCs: [], cacheRates: [] };
250
- out[k].reviews += 1;
251
- out[k].cost += r.cost;
252
- out[k].loc += r.additions + r.deletions;
253
- out[k].costPerLOCs.push(r.costPerLOC);
254
- out[k].cacheRates.push(r.cacheRate);
323
+ const k = String(r[key]);
324
+ let bucket = out[k];
325
+ if (!bucket) {
326
+ bucket = { reviews: 0, cost: 0, loc: 0, costPerLOCs: [], cacheRates: [] };
327
+ out[k] = bucket;
328
+ }
329
+ bucket.reviews += 1;
330
+ bucket.cost += r.cost;
331
+ bucket.loc += r.additions + r.deletions;
332
+ bucket.costPerLOCs.push(r.costPerLOC);
333
+ bucket.cacheRates.push(r.cacheRate);
255
334
  }
335
+ const finalized: Record<string, RollupGroupStats> = {};
256
336
  for (const k of Object.keys(out)) {
257
- out[k].costPerLOC = median(out[k].costPerLOCs);
258
- out[k].cacheRate = median(out[k].cacheRates);
259
- delete out[k].costPerLOCs;
260
- delete out[k].cacheRates;
337
+ const bucket = out[k]!;
338
+ finalized[k] = {
339
+ reviews: bucket.reviews,
340
+ cost: bucket.cost,
341
+ loc: bucket.loc,
342
+ costPerLOC: median(bucket.costPerLOCs),
343
+ cacheRate: median(bucket.cacheRates),
344
+ };
261
345
  }
262
- return out;
346
+ return finalized;
263
347
  };
264
348
 
265
349
  const perRepo = groupBy('repo');
266
350
  const perModel = groupBy('model');
267
351
 
268
352
  // Outliers: > 2× total.costPerLOC.
269
- const outliers = valid
353
+ const outliers: RollupOutlier[] = valid
270
354
  .filter((r) => r.costPerLOC > total.costPerLOC * 2)
271
355
  .map((r) => ({
272
- repo: r.repo, pr: r.pr,
356
+ repo: r.repo,
357
+ pr: r.pr,
273
358
  costPerLOC: r.costPerLOC,
274
359
  multiple: r.costPerLOC / total.costPerLOC,
275
360
  cost: r.cost,
@@ -287,23 +372,25 @@ export function rollup(reviews) {
287
372
  // per-model table — exactly the false-good signal Q7 must NOT produce.
288
373
  // Caller renders this as a loud warning so the dashboard reader knows
289
374
  // to update the PRICING table.
290
- const unknownModelReviews = valid
375
+ const unknownModelReviews: UnknownModelReview[] = valid
291
376
  .filter((r) => r.unknownModel === true)
292
377
  .map((r) => ({ repo: r.repo, pr: r.pr, modelObserved: r.modelObserved }));
293
378
 
294
379
  return { total, perRepo, perModel, trend30d, outliers, unknownModelReviews };
295
380
  }
296
381
 
297
- function median(nums) {
382
+ function median(nums: number[]): number {
298
383
  if (nums.length === 0) return 0;
299
384
  const sorted = [...nums].sort((a, b) => a - b);
300
385
  const mid = Math.floor(sorted.length / 2);
386
+ // sorted is non-empty here so sorted[mid] is defined; the `!`
387
+ // satisfies noUncheckedIndexedAccess and matches JS semantics.
301
388
  return sorted.length % 2 === 0
302
- ? (sorted[mid - 1] + sorted[mid]) / 2
303
- : sorted[mid];
389
+ ? (sorted[mid - 1]! + sorted[mid]!) / 2
390
+ : sorted[mid]!;
304
391
  }
305
392
 
306
- function computeTrend(reviews) {
393
+ function computeTrend(reviews: ReviewRecord[]): RollupTrend {
307
394
  // PR #104 fix: distinguish "no prior window" (previous bucket empty)
308
395
  // from "exactly flat trend" (current === previous > 0). The original
309
396
  // code returned slopePct=0 for both, which masked the dangerous case
@@ -331,6 +418,10 @@ function computeTrend(reviews) {
331
418
  return { current, previous, slopePct };
332
419
  }
333
420
 
421
+ export interface FormatRollupOptions {
422
+ json?: boolean | undefined;
423
+ }
424
+
334
425
  /**
335
426
  * Render the rollup as a human-readable table. Mirrors the sample output
336
427
  * from the Phase 0.5 plan.
@@ -338,11 +429,11 @@ function computeTrend(reviews) {
338
429
  * Pass `{ json: true }` for the machine-readable form (the same data
339
430
  * the rollup() function returns).
340
431
  */
341
- export function formatRollup(rollup, opts = {}) {
432
+ export function formatRollup(rollup: Rollup, opts: FormatRollupOptions = {}): string {
342
433
  if (opts.json) {
343
434
  return JSON.stringify(rollup, null, 2);
344
435
  }
345
- const lines = [];
436
+ const lines: string[] = [];
346
437
  const t = rollup.total;
347
438
  const trend = rollup.trend30d;
348
439
  // PR #104 fix: null slopePct = "no prior window" (prior 30d bucket
@@ -0,0 +1,53 @@
1
+ // Pure audit helpers — no FS, no git, no child_process.
2
+ //
3
+ // Split from lib/audit.js during the v0.7.0 TS migration: durationToGitSince
4
+ // and renderAuditHeader are pure functions safe to consume from any runtime
5
+ // (e.g. clud-bug-app's serverless review path), so they live in src/core/.
6
+ // The git-spawning siblings (gitLines, computeAuditFileSet) live in
7
+ // src/cli/audit.ts.
8
+
9
+ // Convert a duration like "7d", "2w", "1mo", "3mo", "1y" to a git --since arg.
10
+ // Returns null if the input is empty/undefined; throws on malformed input.
11
+ export function durationToGitSince(input: string | null | undefined): string | null {
12
+ if (!input) return null;
13
+ const m = String(input).trim().match(/^(\d+)\s*(d|w|mo|m|y)$/i);
14
+ if (!m) {
15
+ throw new Error(`Unrecognized duration "${input}". Examples: 7d, 2w, 1mo, 1y.`);
16
+ }
17
+ // Capture group 1 (\d+) is always present when m is truthy; same for group 2.
18
+ const n = Number(m[1]);
19
+ const unit = (m[2] as string).toLowerCase();
20
+ const map: Record<string, string> = { d: 'day', w: 'week', mo: 'month', m: 'month', y: 'year' };
21
+ return `${n} ${map[unit]}${n === 1 ? '' : 's'} ago`;
22
+ }
23
+
24
+ export interface AuditHeaderInput {
25
+ date: string;
26
+ scopeLabel: string;
27
+ files: string[];
28
+ }
29
+
30
+ // Render the audit report's initial markdown body. The Action's Claude run
31
+ // will append findings under a "## Findings" section after this header.
32
+ export function renderAuditHeader({ date, scopeLabel, files }: AuditHeaderInput): string {
33
+ const head = `# 🐛 Clud Bug audit — ${date}
34
+
35
+ A scheduled walk through the habitat. Scope: ${scopeLabel}.
36
+ Files surveyed: **${files.length}**.
37
+
38
+ <details>
39
+ <summary>File manifest (${files.length})</summary>
40
+
41
+ \`\`\`
42
+ ${files.join('\n')}
43
+ \`\`\`
44
+
45
+ </details>
46
+
47
+ ---
48
+
49
+ ## Findings
50
+
51
+ `;
52
+ return head;
53
+ }