argus-reviewer-e2e 0.3.0 → 0.4.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 (89) hide show
  1. package/README.md +84 -71
  2. package/action/action.yml +134 -10
  3. package/action/approval-review.mjs +13 -3
  4. package/action/bootstrap.mjs +4 -0
  5. package/action/emit-review.mjs +16 -0
  6. package/action/runtime.mjs +32 -0
  7. package/action/sticky-comment.cjs +1260 -479
  8. package/dist/cli.d.ts +103 -7
  9. package/dist/cli.js +1208 -186
  10. package/dist/config.d.ts +96 -11
  11. package/dist/config.js +102 -4
  12. package/dist/detect.d.ts +29 -2
  13. package/dist/detect.js +98 -7
  14. package/dist/driver/browser.d.ts +32 -0
  15. package/dist/driver/browser.js +56 -1
  16. package/dist/driver/target.d.ts +4 -1
  17. package/dist/driver/target.js +27 -6
  18. package/dist/engine/actions.d.ts +5 -0
  19. package/dist/engine/actions.js +8 -0
  20. package/dist/engine/explore.d.ts +78 -0
  21. package/dist/engine/explore.js +373 -0
  22. package/dist/engine/loop.d.ts +2 -2
  23. package/dist/engine/loop.js +8 -8
  24. package/dist/engine/prompts.d.ts +28 -1
  25. package/dist/engine/prompts.js +88 -0
  26. package/dist/evidence/ci.d.ts +13 -1
  27. package/dist/evidence/ci.js +38 -3
  28. package/dist/evidence/gate.d.ts +8 -0
  29. package/dist/evidence/gate.js +1 -1
  30. package/dist/evidence/link.js +1 -1
  31. package/dist/executor/a0.d.ts +114 -1
  32. package/dist/executor/a0.js +216 -4
  33. package/dist/fsutil.d.ts +3 -2
  34. package/dist/fsutil.js +7 -4
  35. package/dist/journal/schema.d.ts +1 -1
  36. package/dist/log.d.ts +2 -1
  37. package/dist/log.js +10 -2
  38. package/dist/mention.d.ts +45 -0
  39. package/dist/mention.js +107 -0
  40. package/dist/pipeline/app.d.ts +126 -0
  41. package/dist/pipeline/app.js +250 -0
  42. package/dist/pipeline/budget.d.ts +1 -0
  43. package/dist/pipeline/budget.js +1 -1
  44. package/dist/pipeline/verify.d.ts +20 -3
  45. package/dist/pipeline/verify.js +189 -35
  46. package/dist/probe/persist.d.ts +68 -0
  47. package/dist/probe/persist.js +184 -0
  48. package/dist/probe/queue.d.ts +12 -0
  49. package/dist/probe/queue.js +10 -2
  50. package/dist/report/brand-assets.generated.d.ts +9 -0
  51. package/dist/report/brand-assets.generated.js +8 -0
  52. package/dist/report/comment.d.ts +99 -6
  53. package/dist/report/comment.js +292 -103
  54. package/dist/report/html.d.ts +50 -0
  55. package/dist/report/html.js +879 -0
  56. package/dist/report/manifest.d.ts +29 -0
  57. package/dist/report/manifest.js +37 -0
  58. package/dist/report/run.d.ts +54 -1
  59. package/dist/report/run.js +34 -9
  60. package/dist/report/viewmodel.d.ts +91 -0
  61. package/dist/report/viewmodel.js +241 -0
  62. package/dist/review/adjudicate.d.ts +6 -6
  63. package/dist/review/adjudicate.js +2 -2
  64. package/dist/review/inline.d.ts +44 -0
  65. package/dist/review/inline.js +95 -0
  66. package/dist/review/packs.d.ts +21 -0
  67. package/dist/review/packs.js +47 -0
  68. package/dist/review/scope.d.ts +16 -0
  69. package/dist/review/scope.js +74 -0
  70. package/dist/review/secrets.d.ts +10 -10
  71. package/dist/review/secrets.js +7 -7
  72. package/dist/review/testfiles.d.ts +18 -0
  73. package/dist/review/testfiles.js +26 -0
  74. package/dist/review/triage.d.ts +1 -1
  75. package/dist/review/triage.js +10 -10
  76. package/dist/review/validate.d.ts +41 -0
  77. package/dist/review/validate.js +76 -0
  78. package/dist/ui/errors.d.ts +54 -0
  79. package/dist/ui/errors.js +236 -0
  80. package/dist/ui/style.d.ts +34 -0
  81. package/dist/ui/style.js +48 -0
  82. package/dist/ui/summary.d.ts +38 -0
  83. package/dist/ui/summary.js +101 -0
  84. package/dist/vision/cost.d.ts +1 -1
  85. package/dist/vision/decisions.d.ts +9 -3
  86. package/dist/vision/decisions.js +31 -21
  87. package/dist/vision/openrouter.d.ts +4 -0
  88. package/dist/vision/openrouter.js +30 -4
  89. package/package.json +11 -2
@@ -1,128 +1,317 @@
1
+ import { formatUsd, manifestToRunView, maskSecrets, PROOF_LEVELS, proofMeter, SEVERITY_GLYPH, shortSha, STATUS_GLYPH, VERDICT_LABEL, VERDICT_STATUS, } from './viewmodel.js';
1
2
  export const SENTINEL = '<!-- argus-reviewer -->';
2
- function formatUsd(n) {
3
- return `$${(n ?? 0).toFixed(6)}`;
3
+ /** Cell semantics mirror the action: flatten newlines, escape pipes, mask secrets, cap length. */
4
+ function cell(s, max = 200) {
5
+ return maskSecrets(String(s ?? '')
6
+ .replace(/\|/g, '\\|')
7
+ .replace(/[\r\n]+/g, ' ')).slice(0, max);
4
8
  }
5
- function statusLine(report, missingKey) {
6
- if (missingKey)
7
- return '## argus-reviewer ⚪ skipped — no OpenRouter key';
8
- if (!report)
9
- return '## argus-reviewer ⚪ no report';
10
- const emoji = report.ok ? '✅' : '❌';
11
- const status = report.ok ? 'PASS' : 'FAIL';
12
- const budget = report.totals.budgetExceeded ? ' (budget cap exceeded)' : '';
13
- return `## argus-reviewer ${emoji} ${status}${budget}`;
14
- }
15
- function testRows(tests) {
16
- const lines = ['### Tests', ''];
17
- lines.push('| Test | Result | Calls | Cost | Failure |');
18
- lines.push('| --- | --- | --- | --- | --- |');
19
- for (const t of tests) {
20
- const result = t.ok ? '✅ pass' : '❌ fail';
21
- const failure = t.failureMessage ? t.failureMessage.replace(/\|/g, '\\|') : '';
22
- lines.push(`| ${t.name} | ${result} | ${t.visionCalls} | ${formatUsd(t.visionCostUsd)} | ${failure} |`);
9
+ function code(s) {
10
+ const t = cell(s);
11
+ const longest = Math.max(0, ...(t.match(/`+/g) ?? []).map((r) => r.length));
12
+ const fence = '`'.repeat(longest + 1);
13
+ const pad = t.startsWith('`') || t.endsWith('`') ? ' ' : '';
14
+ return `${fence}${pad}${t}${pad}${fence}`;
15
+ }
16
+ export function plural(n, one, many = `${one}s`) {
17
+ return `${n} ${n === 1 ? one : many}`;
18
+ }
19
+ function formatDuration(ms) {
20
+ if (ms === undefined || !Number.isFinite(ms) || ms < 0)
21
+ return undefined;
22
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
23
+ }
24
+ function statusText(status) {
25
+ return `${STATUS_GLYPH[status]} ${status}`;
26
+ }
27
+ function proofText(level) {
28
+ if (level === null)
29
+ return '';
30
+ if (level === 'none')
31
+ return `${proofMeter(undefined)} none`;
32
+ return `${proofMeter(level)} ${level}`;
33
+ }
34
+ export function findingsOf(cr) {
35
+ return cr !== undefined && Array.isArray(cr.findings) ? cr.findings : [];
36
+ }
37
+ function bestFindingProof(cr) {
38
+ let best = 0;
39
+ for (const f of findingsOf(cr)) {
40
+ best = Math.max(best, PROOF_LEVELS.indexOf(f.evidence?.status ?? ''));
23
41
  }
24
- return lines;
42
+ return PROOF_LEVELS[best] ?? 'suspected';
43
+ }
44
+ export function laneProof(lane, status, cr) {
45
+ if (status === 'skipped')
46
+ return null;
47
+ if (status === 'blocked' || status === 'unavailable')
48
+ return 'none';
49
+ if (status === 'inconclusive' || lane === 'a0')
50
+ return 'suspected';
51
+ if (lane === 'review')
52
+ return bestFindingProof(cr);
53
+ return 'exercised';
25
54
  }
26
- function costRows(totals) {
27
- const lines = ['### Cost ledger', ''];
28
- lines.push('| Line item | Value |');
29
- lines.push('| --- | --- |');
30
- lines.push(`| Vision calls | ${totals.visionCalls} |`);
31
- const perCall = totals.visionCalls > 0 ? formatUsd(totals.visionCostUsd / totals.visionCalls) : '$0.00';
32
- lines.push(`| Per-call cost (avg) | ${perCall} |`);
33
- for (const model of Object.keys(totals.callsByModel).sort()) {
34
- lines.push(`| Calls (${model}) | ${totals.callsByModel[model]} |`);
35
- lines.push(`| Spend (${model}) | ${formatUsd(totals.costByModel[model] ?? 0)} |`);
55
+ export function manifestRow(lane, cr) {
56
+ if (!lane.selected) {
57
+ return { lane: lane.lane, status: 'skipped', result: 'not selected', proof: null, spend: '' };
36
58
  }
37
- lines.push(`| Total vision spend | ${formatUsd(totals.visionCostUsd)} |`);
38
- lines.push(`| Sandbox seconds | ${totals.sandboxSeconds.toFixed(1)}s |`);
39
- return lines;
59
+ return {
60
+ lane: lane.lane,
61
+ status: lane.status,
62
+ result: lane.reason ?? lane.summary ?? '',
63
+ proof: laneProof(lane.lane, lane.status, cr),
64
+ spend: lane.usage.metered ? formatUsd(lane.usage.costUsd) : 'unmetered',
65
+ };
66
+ }
67
+ export function reproducedCount(cr) {
68
+ return typeof cr.provenBlockers === 'number'
69
+ ? cr.provenBlockers
70
+ : findingsOf(cr).filter((f) => f.evidence?.status === 'reproduced').length;
40
71
  }
41
- function healRows(tests) {
42
- const lines = ['### Heal events', ''];
43
- const heals = tests.flatMap((t) => t.healEvents);
44
- if (heals.length === 0) {
45
- lines.push('No heals this run.');
72
+ function reviewRow(cr) {
73
+ if (cr === undefined) {
74
+ return { lane: 'review', status: 'failed', result: 'no code-review.json', proof: 'none', spend: '' };
46
75
  }
47
- else {
48
- for (const h of heals) {
49
- lines.push(`- \`${h.instruction}\` healed with ${h.model ?? 'unknown model'}`);
50
- }
76
+ if (cr.skipped === true) {
77
+ return { lane: 'review', status: 'skipped', result: cr.summary ?? 'skipped', proof: null, spend: '' };
51
78
  }
52
- return lines;
79
+ const status = cr.ok === true ? 'passed' : 'failed';
80
+ return {
81
+ lane: 'review',
82
+ status,
83
+ result: `${plural(findingsOf(cr).length, 'finding')}, ${reproducedCount(cr)} reproduced`,
84
+ proof: laneProof('review', status, cr),
85
+ spend: formatUsd(cr.visionCostUsd),
86
+ };
53
87
  }
54
- function assertRows(tests) {
55
- const lines = ['### Assertions', ''];
56
- let any = false;
57
- for (const t of tests) {
58
- if (t.asserts.length === 0)
59
- continue;
60
- any = true;
61
- lines.push(`**${t.name}**`);
62
- for (const a of t.asserts) {
63
- const icon = a.verdict === 'pass' ? '✅' : '❌';
64
- lines.push(`- ${icon} *${a.question}* — ${a.reasoning}`);
65
- }
66
- lines.push('');
88
+ function flowRow(report) {
89
+ if (report === undefined) {
90
+ return { lane: 'flow', status: 'skipped', result: 'run lane disabled', proof: null, spend: '' };
67
91
  }
68
- if (!any) {
69
- lines.push('No assertions recorded.');
92
+ const heals = report.tests.flatMap((t) => t.healEvents ?? []).length;
93
+ const status = report.ok ? 'passed' : 'failed';
94
+ return {
95
+ lane: 'flow',
96
+ status,
97
+ result: `${report.totals.passed} of ${report.totals.tests} tests passed${heals > 0 ? `, ${heals} healed` : ''}`,
98
+ proof: laneProof('flow', status, undefined),
99
+ spend: formatUsd(report.totals.visionCostUsd),
100
+ };
101
+ }
102
+ function laneTable(rows) {
103
+ const lines = ['| Status | Lane | Result | Proof | Spend |', '|---|---|---|---|--:|'];
104
+ for (const r of rows) {
105
+ lines.push(`| ${statusText(r.status)} | ${cell(r.lane)} | ${cell(r.result)} | ${proofText(r.proof)} | ${r.spend} |`);
70
106
  }
71
107
  return lines;
72
108
  }
73
- function evidenceRows(videos, runUrl) {
74
- const lines = ['### Evidence', ''];
75
- for (const v of videos) {
76
- lines.push(`- video: ${v}`);
77
- }
78
- if (runUrl) {
79
- lines.push(`- [workflow run / artifacts](${runUrl})`);
109
+ function isVerdict(v) {
110
+ return typeof v === 'string' && Object.hasOwn(VERDICT_LABEL, v);
111
+ }
112
+ /** Headline status and word, shared by the comment and the HTML report. */
113
+ export function verdictOf(ok, aggregate, cr) {
114
+ const verdict = cr !== undefined && cr.skipped !== true && isVerdict(cr.verdict) ? cr.verdict : undefined;
115
+ if (ok !== true) {
116
+ if (verdict === 'needs_changes')
117
+ return { status: 'failed', label: VERDICT_LABEL.needs_changes };
118
+ const s = aggregate !== undefined && aggregate !== 'passed' && aggregate !== 'skipped' ? aggregate : 'failed';
119
+ return { status: s, label: s };
80
120
  }
81
- if (videos.length === 0 && !runUrl) {
82
- lines.push('No artifact links configured.');
121
+ if (verdict !== undefined)
122
+ return { status: VERDICT_STATUS[verdict], label: VERDICT_LABEL[verdict] };
123
+ const s = aggregate ?? 'passed';
124
+ return { status: s, label: s };
125
+ }
126
+ function headline(ok, aggregate, cr) {
127
+ const { status, label } = verdictOf(ok, aggregate, cr);
128
+ return `${STATUS_GLYPH[status]} ${label}`;
129
+ }
130
+ const settled = (s) => s === 'passed' || s === 'skipped';
131
+ const MARKDOWN_LEAD = { strong: (s) => `**${s}**`, code, text: cell };
132
+ /** The verdict line's lead: what the run proved, in one phrase. */
133
+ export function verdictLead(rows, cr, f = MARKDOWN_LEAD) {
134
+ const findings = findingsOf(cr);
135
+ const reviewed = cr !== undefined && cr.skipped !== true;
136
+ if (reviewed) {
137
+ const reproduced = reproducedCount(cr);
138
+ if (reproduced > 0) {
139
+ const files = new Set(findings.filter((x) => x.evidence?.status === 'reproduced').map((x) => x.file));
140
+ const where = files.size === 1 ? ` in ${f.code([...files][0])}` : '';
141
+ return `${f.strong(`${plural(reproduced, 'finding')} reproduced`)}${where}`;
142
+ }
83
143
  }
84
- return lines;
144
+ const failing = rows.filter((r) => r.lane !== 'review' && !settled(r.status));
145
+ if (failing.length > 0)
146
+ return f.strong(failing.map((r) => `${f.text(r.lane)} ${r.status}`).join(', '));
147
+ if (reviewed && findings.length > 0)
148
+ return f.strong(`${plural(findings.length, 'finding')}, none reproduced`);
149
+ const review = rows.find((r) => r.lane === 'review');
150
+ if (review !== undefined && !settled(review.status))
151
+ return f.strong(`review ${review.status}`);
152
+ if (reviewed)
153
+ return f.strong('No findings');
154
+ return f.strong(rows.some((r) => r.status !== 'skipped') ? 'All selected lanes passed' : 'No lane ran');
85
155
  }
86
- function missingKeyBody() {
87
- return [
88
- '',
89
- '`OPENROUTER_API_KEY` is not configured. Add it as a repository or workflow secret to run argus-reviewer.',
90
- '',
91
- 'This status is intentionally neutral, not a failure.',
92
- '',
93
- ];
156
+ function verdictLine(p) {
157
+ const bits = [verdictLead(p.rows, p.cr)];
158
+ const sha = shortSha(p.headSha);
159
+ if (sha !== undefined)
160
+ bits.push(`head ${code(sha)}`);
161
+ if (p.binding?.status === 'mismatch')
162
+ bits.push('head binding mismatch');
163
+ bits.push(formatUsd(p.costUsd));
164
+ const duration = formatDuration(p.durationMs);
165
+ if (duration !== undefined)
166
+ bits.push(duration);
167
+ return bits.join(' · ');
94
168
  }
95
- function noReportBody() {
96
- return [
97
- '',
98
- 'No run report was produced. The run may have failed before writing reports.',
99
- '',
169
+ /** Must match P_FALLBACK_GATE in the action and P_TRUE_POSITIVE_THRESHOLD in src/cli.ts. */
170
+ const P_FALLBACK_GATE = 0.7;
171
+ /** Same fence rule as the action's extractSuggestion: a committable block is non-empty. */
172
+ function hasSuggestion(body) {
173
+ const m = /\r?\n(`{4,})suggestion\r?\n([\s\S]*?)\r?\n\1/.exec(body);
174
+ return m !== null && m[2] !== '';
175
+ }
176
+ const NO_REVIEW_REPORT = 'No code review report was found; check the action logs before merging.';
177
+ const NO_REVIEW_ATTACHED = 'No code review report is attached to this run.';
178
+ function findingsLine(cr, missing = NO_REVIEW_REPORT) {
179
+ if (cr === undefined)
180
+ return missing;
181
+ if (cr.skipped === true)
182
+ return `Code review skipped: ${cell(cr.summary, 300)}`;
183
+ const findings = findingsOf(cr);
184
+ const sev = { bug: 0, risk: 0, nit: 0, q: 0 };
185
+ for (const f of findings) {
186
+ if (f.severity !== undefined && Object.hasOwn(sev, f.severity))
187
+ sev[f.severity] += 1;
188
+ }
189
+ const parts = [
190
+ `${SEVERITY_GLYPH.bug} ${plural(sev.bug, 'bug')}`,
191
+ `${SEVERITY_GLYPH.risk} ${plural(sev.risk, 'risk')}`,
192
+ `${SEVERITY_GLYPH.nit} ${plural(sev.nit, 'nit')}`,
100
193
  ];
194
+ if (sev.q > 0)
195
+ parts.push(`${SEVERITY_GLYPH.q} ${plural(sev.q, 'question')}`);
196
+ const confident = typeof cr.highConfidenceBlockers === 'number'
197
+ ? cr.highConfidenceBlockers
198
+ : findings.filter((f) => typeof f.p === 'number' && f.p >= P_FALLBACK_GATE).length;
199
+ if (confident > 0)
200
+ parts.push(`${confident} high-confidence`);
201
+ const suggestions = Array.isArray(cr.reviewComments)
202
+ ? cr.reviewComments.filter((c) => hasSuggestion(c.body ?? '')).length
203
+ : findings.filter((f) => typeof f.suggestion === 'string' && f.suggestion !== '').length;
204
+ if (suggestions > 0)
205
+ parts.push(`${plural(suggestions, 'suggestion')} ready to commit`);
206
+ return parts.join(' · ');
207
+ }
208
+ function footer(meta) {
209
+ const bits = [`Argus ${meta.version}`];
210
+ if (meta.runUrl !== undefined)
211
+ bits.push(`[workflow run and evidence](${meta.runUrl})`);
212
+ if (meta.reportHtml !== undefined)
213
+ bits.push(`report ${code(meta.reportHtml)} in the run artifacts`);
214
+ bits.push('self-hosted, BYOK');
215
+ return `<sub>${bits.join(' · ')}</sub>`;
101
216
  }
102
- /** Render the sticky PR comment markdown from a run report (or a missing-key state). */
103
- export function renderComment(report, opts = {}) {
104
- const lines = [SENTINEL, ''];
105
- lines.push(statusLine(report, opts.missingKey ?? false));
106
- lines.push('');
107
- if (opts.missingKey) {
108
- lines.push(...missingKeyBody());
217
+ function head(status, verdict, rows, summary) {
218
+ return [SENTINEL, `### Argus: ${status}`, '', verdict, '', ...laneTable(rows), '', summary, ''];
219
+ }
220
+ function fold(lines, title, body) {
221
+ if (body.length === 0)
222
+ return;
223
+ lines.push('<details>', `<summary>${title}</summary>`, '', ...body);
224
+ if (body[body.length - 1] !== '')
225
+ lines.push('');
226
+ lines.push('</details>', '');
227
+ }
228
+ const ALL_LANES = ['review', 'flow', 'app', 'a0'];
229
+ export function manifestDuration(m) {
230
+ const ms = Date.parse(m.finishedAt) - Date.parse(m.startedAt);
231
+ return Number.isFinite(ms) ? ms : undefined;
232
+ }
233
+ function headLines(input) {
234
+ const { manifest, codeReview: cr, report } = input;
235
+ const reviewed = cr !== undefined && cr.skipped !== true;
236
+ switch (input.body) {
237
+ case 'missing-key':
238
+ return head(statusText('skipped'), '**Not run:** `OPENROUTER_API_KEY` is not configured, so no lane ran. This status is neutral, not a failure.', ALL_LANES.map((lane) => ({ lane, status: 'skipped', result: 'no API key', proof: null, spend: '' })), 'Fix: add the key as a repository secret, then re-run the workflow: `gh secret set OPENROUTER_API_KEY`');
239
+ case 'no-report':
240
+ return head(statusText('failed'), `**No report:** the run step produced no \`run.json\` under ${code(input.reportDir)}. ` +
241
+ 'The commit status fails closed.', [{ lane: 'flow', status: 'failed', result: 'no run.json', proof: 'none', spend: '' }], 'Check the action logs before merging.');
242
+ case 'manifest': {
243
+ if (manifest === undefined)
244
+ throw new Error('a manifest body needs a manifest');
245
+ const m = manifest;
246
+ const rows = manifestToRunView(m).lanes.map((l) => manifestRow(l, cr));
247
+ return head(headline(m.aggregate.ok, m.aggregate.status, cr), verdictLine({
248
+ rows,
249
+ cr,
250
+ headSha: m.identity.intendedHeadSha,
251
+ binding: m.lanes.review.headBinding,
252
+ costUsd: m.aggregate.costUsd,
253
+ durationMs: manifestDuration(m),
254
+ }), rows, findingsLine(cr, NO_REVIEW_ATTACHED));
255
+ }
256
+ case 'full':
257
+ case 'review-only': {
258
+ const flowReport = input.body === 'full' ? report : undefined;
259
+ const rows = manifest !== undefined
260
+ ? manifestToRunView(manifest).lanes.map((l) => manifestRow(l, cr))
261
+ : [reviewRow(cr), flowRow(flowReport)];
262
+ const reviewSpend = reviewed ? (cr.visionCostUsd ?? 0) : 0;
263
+ const costUsd = manifest !== undefined
264
+ ? manifest.aggregate.costUsd
265
+ : input.body === 'full'
266
+ ? (report?.totals.visionCostUsd ?? 0) + reviewSpend
267
+ : reviewSpend;
268
+ const durationMs = manifest !== undefined ? manifestDuration(manifest) : input.body === 'full' ? report?.durationMs : undefined;
269
+ return head(headline(input.ok, manifest?.aggregate.status, cr), verdictLine({
270
+ rows,
271
+ cr,
272
+ headSha: manifest?.identity.intendedHeadSha ?? (reviewed ? cr.headBinding?.intendedSha : undefined),
273
+ binding: manifest?.lanes.review.headBinding ?? (reviewed ? cr.headBinding : undefined),
274
+ costUsd,
275
+ durationMs,
276
+ }), rows, findingsLine(cr));
277
+ }
109
278
  }
110
- else if (!report) {
111
- lines.push(...noReportBody());
279
+ }
280
+ /** First screen of any sticky body: sentinel through the findings summary. */
281
+ export function renderCommentHead(input) {
282
+ return headLines(input).join('\n');
283
+ }
284
+ export function renderMissingKeyComment(meta) {
285
+ return [...headLines({ body: 'missing-key' }), footer(meta), ''].join('\n');
286
+ }
287
+ /**
288
+ * Whole manifest-only body (no code-review folds): lane names, status labels,
289
+ * model/cost and head identity come from the shared view-model so the
290
+ * comment agrees with the TUI and dashboard under the contract test.
291
+ */
292
+ export function renderManifestComment(manifest, meta) {
293
+ const view = manifestToRunView(manifest);
294
+ const lines = headLines({ body: 'manifest', manifest });
295
+ const spend = ['| Lane | Model | Calls | Tokens | Spend |', '|---|---|--:|--:|--:|'];
296
+ for (const lane of view.selectedLanes) {
297
+ const cost = lane.usage.metered ? formatUsd(lane.usage.costUsd) : 'unmetered';
298
+ spend.push(`| ${cell(lane.lane)} | ${cell(lane.model ?? '')} | ${lane.usage.calls} | ${lane.usage.tokens} | ${cost} |`);
112
299
  }
113
- else {
114
- lines.push(`**Summary:** ${report.totals.passed}/${report.totals.tests} passed · ` +
115
- `${report.totals.visionCalls} vision calls · ` +
116
- `${formatUsd(report.totals.visionCostUsd)} spend · ` +
117
- `${report.totals.sandboxSeconds.toFixed(1)}s sandbox · ` +
118
- `${(report.durationMs / 1000).toFixed(1)}s wall`);
119
- lines.push('');
120
- lines.push(...testRows(report.tests));
121
- lines.push(...costRows(report.totals));
122
- lines.push(...healRows(report.tests));
123
- lines.push(...assertRows(report.tests));
124
- lines.push(...evidenceRows(report.artifacts.videos, opts.runUrl));
300
+ spend.push(`| Total | | ${view.calls} | ${view.tokens} | ${formatUsd(view.costUsd)} |`, '');
301
+ const over = view.selectedLanes.filter((l) => l.budget.exceeded);
302
+ if (over.length > 0)
303
+ spend.push(`**Budget exceeded:** ${over.map((l) => cell(l.lane)).join(', ')}`, '');
304
+ const cache = view.lanes.find((l) => l.lane === 'flow')?.cache;
305
+ if (cache !== undefined) {
306
+ spend.push(`**Fingerprint cache:** ${cache.hits} hit(s) · ${cache.misses} miss(es) · ${cache.heals} heal(s)`, '');
307
+ }
308
+ fold(lines, 'Spend ledger', spend);
309
+ const diagnostics = [];
310
+ if (view.headBinding !== undefined) {
311
+ diagnostics.push(`- Head binding: ${cell(view.headBinding.status)}, ${cell(view.headBinding.detail)}`);
125
312
  }
313
+ fold(lines, 'Diagnostics', diagnostics);
314
+ lines.push(footer(meta), '');
126
315
  return lines.join('\n');
127
316
  }
128
317
  /** Map a run report (and optional missing-key flag) to a check-run conclusion. */
@@ -0,0 +1,50 @@
1
+ import { type LaneId, type RunManifest } from './manifest.js';
2
+ /**
3
+ * Offline HTML evidence report (plan U14, R22; DESIGN.md 7.6, A17).
4
+ *
5
+ * One self-contained `report.html` beside `run-manifest.json`: verdict,
6
+ * lanes, findings, flow timeline, heals and spend ledger. Every asset is
7
+ * inline (tokens, WOFF2 subsets as data URIs, the glyph sprite), so the file
8
+ * opens from an artifact zip with no network. Assets come from the generated
9
+ * module, never from assets/ on disk, so a packed install renders it too.
10
+ *
11
+ * Pure: the caller reads the files, this shapes them. Every interpolated
12
+ * string goes through `esc`, which masks secret-shaped tokens and escapes
13
+ * HTML. Inputs are untrusted JSON; a corrupt manifest renders the unreadable
14
+ * state and a missing lane renders unavailable, never a throw.
15
+ */
16
+ export declare const REPORT_HTML = "report.html";
17
+ export interface ReportHtmlInput {
18
+ /** run-manifest.json file text; undefined when the file is absent. */
19
+ manifestText: string | undefined;
20
+ /** Parsed code-review.json of this run, when the review lane wrote one. */
21
+ codeReview?: unknown;
22
+ /** Parsed run.json of this run, when the flow lane wrote one. */
23
+ run?: unknown;
24
+ /** Argus version shown in the footer. */
25
+ version: string;
26
+ /** Workflow run page (evidence and artifacts). */
27
+ runUrl?: string;
28
+ }
29
+ /** Mask secret-shaped tokens, then escape for HTML text and attributes. */
30
+ export declare function esc(v: unknown): string;
31
+ /** `450ms`, `12.3s`, `4m 05s`, `2h 14m` (DESIGN.md 6.2). */
32
+ export declare function formatDuration(ms: number | undefined): string | undefined;
33
+ type ManifestState = {
34
+ state: 'ok';
35
+ manifest: RunManifest;
36
+ missing: LaneId[];
37
+ } | {
38
+ state: 'missing';
39
+ } | {
40
+ state: 'unreadable';
41
+ detail: string;
42
+ };
43
+ /**
44
+ * Parse the manifest text, degrading per lane: the run-level fields must
45
+ * pass the shared guard, and each lane that fails `isLaneManifest` is
46
+ * reported missing rather than sinking the whole report.
47
+ */
48
+ export declare function readManifest(text: string | undefined): ManifestState;
49
+ export declare function renderReportHtml(input: ReportHtmlInput): string;
50
+ export {};