faberun 0.20.0 → 0.22.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.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Campaign Brief Markdown: the portable review surface rendered from the
3
+ * deterministic model in `src/campaign/campaign-brief.mjs`.
4
+ *
5
+ * Rendering is a pure function of the model. It adds no timestamp and reads no
6
+ * live value, so the same spec, plan, contract and recorded-data snapshots
7
+ * produce the same bytes. The opening before the coverage matrix is bounded at
8
+ * 250 words; the coverage matrix, work graph, gaps, estimate and provenance
9
+ * follow it.
10
+ */
11
+ /** @typedef {import("../campaign/campaign-brief.mjs").BriefModel} BriefModel */
12
+ /** @typedef {import("../campaign/campaign-brief.mjs").BriefCoverageRow} BriefCoverageRow */
13
+ /** @typedef {import("../campaign/campaign-brief.mjs").BriefMeasure} BriefMeasure */
14
+
15
+ const OPENING_WORD_LIMIT = 250;
16
+
17
+ /**
18
+ * Render the Campaign Brief as Markdown. The result is portable: it links to
19
+ * the spec and frozen plan by path and contains every review fact as text.
20
+ *
21
+ * @param {BriefModel} model
22
+ * @returns {string}
23
+ */
24
+ export function renderCampaignBriefMarkdown(model) {
25
+ const opening = fitAtoms(openingAtoms(model), OPENING_WORD_LIMIT);
26
+ const body = [
27
+ renderCoverage(model),
28
+ renderWorkGraph(model),
29
+ renderDecisions(model),
30
+ renderRisksAndEvals(model),
31
+ renderGaps(model),
32
+ renderEstimate(model),
33
+ renderProvenance(model),
34
+ ].join("\n\n");
35
+ return `${opening}\n\n${body}\n`;
36
+ }
37
+
38
+ /**
39
+ * @param {BriefModel} model
40
+ * @returns {{text: string, priority: number}[]}
41
+ */
42
+ function openingAtoms(model) {
43
+ return [
44
+ { text: `# Campaign brief — ${model.identity.campaign}`, priority: 0 },
45
+ { text: renderIdentity(model), priority: 0 },
46
+ { text: renderDecision(model), priority: 0 },
47
+ { text: renderIntent(model), priority: 0 },
48
+ { text: renderExpectedOutcome(model), priority: 0 },
49
+ { text: renderSuccessCriteria(model), priority: 1 },
50
+ { text: renderHumanFacts(model), priority: 2 },
51
+ { text: renderCalculatedFacts(model), priority: 3 },
52
+ { text: renderOpenGapSummary(model), priority: 4 },
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * Keep whole atoms while the opening stays within the word limit, dropping the
58
+ * lowest-priority atoms from the end first. Identity, decision, intent and
59
+ * expected outcome are never dropped.
60
+ *
61
+ * @param {{text: string, priority: number}[]} atoms
62
+ * @param {number} limit
63
+ * @returns {string}
64
+ */
65
+ function fitAtoms(atoms, limit) {
66
+ const current = atoms.filter((atom) => atom.text.length > 0);
67
+ while (wordCount(current.map((atom) => atom.text).join("\n\n")) > limit) {
68
+ const maxPriority = Math.max(...current.map((atom) => atom.priority));
69
+ if (maxPriority === 0) break;
70
+ const index = current.map((atom) => atom.priority).lastIndexOf(maxPriority);
71
+ if (index < 0) break;
72
+ current.splice(index, 1);
73
+ }
74
+ return current.map((atom) => atom.text).join("\n\n");
75
+ }
76
+
77
+ /**
78
+ * @param {BriefModel} model
79
+ * @returns {string}
80
+ */
81
+ function renderIdentity(model) {
82
+ const { identity } = model;
83
+ return [
84
+ "## Identity",
85
+ `- Campaign: \`${identity.campaign}\``,
86
+ `- Spec baseline: \`${identity.specBaseline ?? "not recorded"}\``,
87
+ `- Spec digest: \`${identity.specDigest}\``,
88
+ `- Spec: ${link(identity.specPath)}`,
89
+ `- Target git head: \`${identity.targetGitHead ?? "not recorded"}\``,
90
+ `- Frozen plan: ${link(identity.planPath)}`,
91
+ `- Plan digest: \`${identity.planDigest}\``,
92
+ `- Contract digest: \`${identity.contractDigest}\``,
93
+ `- Journal cursor: \`${identity.journalCursor}\``,
94
+ `- Usage sample cutoff: \`${identity.usageSampleCutoff ?? "not recorded"}\``,
95
+ ].join("\n");
96
+ }
97
+
98
+ /**
99
+ * @param {BriefModel} model
100
+ * @returns {string}
101
+ */
102
+ function renderDecision(model) {
103
+ return [
104
+ "## Decision",
105
+ `- State: **${model.decisionState}**`,
106
+ `- Technical plan: ${link(model.identity.planPath)}`,
107
+ `- Note: ${decisionNote(model)}`,
108
+ ].join("\n");
109
+ }
110
+
111
+ /**
112
+ * @param {BriefModel} model
113
+ * @returns {string}
114
+ */
115
+ function decisionNote(model) {
116
+ const gaps = allGaps(model);
117
+ if (gaps.length === 0) {
118
+ return "No coverage, work-graph or estimate gap is open; this is a review state, not approval to execute.";
119
+ }
120
+ return `${gaps.length} gap${gaps.length === 1 ? "" : "s"} must be resolved before this brief is ready; this is not approval to execute.`;
121
+ }
122
+
123
+ /**
124
+ * @param {BriefModel} model
125
+ * @returns {string}
126
+ */
127
+ function renderIntent(model) {
128
+ return `## Intent\n${model.opening.intent ?? "not recorded"}`;
129
+ }
130
+
131
+ /**
132
+ * @param {BriefModel} model
133
+ * @returns {string}
134
+ */
135
+ function renderExpectedOutcome(model) {
136
+ return `## Expected outcome\n${model.opening.expectedOutcome ?? "not recorded"}`;
137
+ }
138
+
139
+ /**
140
+ * @param {BriefModel} model
141
+ * @returns {string}
142
+ */
143
+ function renderSuccessCriteria(model) {
144
+ const lines = (model.opening.successCriteria.length > 0 ? model.opening.successCriteria : [])
145
+ .map((criterion) => `- ${criterion.measure}: ${criterion.target}${criterion.evidence ? ` (evidence: ${criterion.evidence})` : ""}`);
146
+ return ["## Success criteria", ...(lines.length > 0 ? lines : ["- none recorded"])].join("\n");
147
+ }
148
+
149
+ /**
150
+ * @param {BriefModel} model
151
+ * @returns {string}
152
+ */
153
+ function renderHumanFacts(model) {
154
+ const facts = model.opening.humanFacts;
155
+ return ["## Human-authored facts", ...(facts.length > 0 ? facts.map((fact) => `- ${fact}`) : ["- none recorded"])].join("\n");
156
+ }
157
+
158
+ /**
159
+ * @param {BriefModel} model
160
+ * @returns {string}
161
+ */
162
+ function renderCalculatedFacts(model) {
163
+ const facts = model.opening.calculatedFacts;
164
+ return ["## Calculated facts", ...(facts.length > 0 ? facts.map((fact) => `- ${fact}`) : ["- none recorded"])].join("\n");
165
+ }
166
+
167
+ /**
168
+ * @param {BriefModel} model
169
+ * @returns {string}
170
+ */
171
+ function renderOpenGapSummary(model) {
172
+ const gaps = allGaps(model);
173
+ const line = gaps.length === 0
174
+ ? "- none"
175
+ : `- ${gaps.length} open gap${gaps.length === 1 ? "" : "s"}; the full list follows the coverage matrix.`;
176
+ return ["## Open gaps", line].join("\n");
177
+ }
178
+
179
+ /**
180
+ * @param {BriefModel} model
181
+ * @returns {string}
182
+ */
183
+ function renderCoverage(model) {
184
+ const { coverage } = model;
185
+ const header = "| Requirement | Frozen nodes | Declared proof or verification | State |";
186
+ const separator = "| --- | --- | --- | --- |";
187
+ const rows = coverage.rows.map((row) => renderCoverageRow(row));
188
+ const unknown = coverage.unknownIds.length > 0
189
+ ? `Unknown requirement ids: ${coverage.unknownIds.map((id) => `\`${id}\``).join(", ")}.`
190
+ : "Unknown requirement ids: none.";
191
+ return [
192
+ "## Coverage matrix",
193
+ `Spec: ${link(coverage.specPath)} · Plan: ${link(coverage.planPath)}`,
194
+ "Each spec requirement is cross-checked against the frozen declarations' nodeIds and each contract node's stamped requirementIds.",
195
+ "",
196
+ header,
197
+ separator,
198
+ ...(rows.length > 0 ? rows : [`| _no spec requirements_ | — | — | outside this plan |`]),
199
+ "",
200
+ unknown,
201
+ ].join("\n");
202
+ }
203
+
204
+ /**
205
+ * @param {BriefCoverageRow} row
206
+ * @returns {string}
207
+ */
208
+ function renderCoverageRow(row) {
209
+ const nodeIds = row.nodes.length > 0
210
+ ? row.nodes.map((node) => `\`${node.id}\``).join(", ")
211
+ : row.declaredNodeIds.length > 0 ? `none (declared: ${row.declaredNodeIds.map((id) => `\`${id}\``).join(", ")})` : "—";
212
+ const proofs = row.nodes.length > 0
213
+ ? row.nodes.map((node) => `${node.id}: ${cell(node.proof)}`).join("; ")
214
+ : row.reasons.length > 0 ? cell(row.reasons.join("; ")) : "—";
215
+ return `| \`${row.requirementId}\` | ${nodeIds} | ${proofs} | ${row.state} |`;
216
+ }
217
+
218
+ /**
219
+ * @param {BriefModel} model
220
+ * @returns {string}
221
+ */
222
+ function renderWorkGraph(model) {
223
+ const { graph } = model;
224
+ const edgeLines = graph.edges.length > 0
225
+ ? graph.edges.map((edge) => `- \`${edge.from}\` → \`${edge.to}\``)
226
+ : ["- Dependency edges: none"];
227
+ const independent = graph.independent.length > 0 ? graph.independent.map((id) => `\`${id}\``).join(", ") : "none";
228
+ const blocking = graph.blocking.length > 0
229
+ ? graph.blocking.map((entry) => `\`${entry.node}\` depends on ${entry.prerequisites.map((id) => `\`${id}\``).join(", ")}`).join("; ")
230
+ : "none";
231
+ const concurrent = Object.entries(graph.maxConcurrent)
232
+ .map(([runtimeId, limit]) => `\`${runtimeId}\` ${limit}`)
233
+ .join(", ");
234
+ return [
235
+ "## Work graph",
236
+ ...edgeLines,
237
+ `- Dependency-independent nodes: ${independent}`,
238
+ `- Blocking prerequisites: ${blocking}`,
239
+ `- maxParallel: ${graph.maxParallel}`,
240
+ `- maxConcurrent: ${concurrent || "none"}`,
241
+ `- Effective concurrency: ${graph.effectiveConcurrency}`,
242
+ `- ${renderDispatchable(graph)}`,
243
+ ].join("\n");
244
+ }
245
+
246
+ /**
247
+ * The capacity reading, kept distinct from the graph reading: a node with no
248
+ * prerequisites is dependency-independent, but that never means it can run at
249
+ * the same time as a sibling. With `maxParallel` 1 the line says plainly that
250
+ * independent nodes are not simultaneously dispatchable.
251
+ *
252
+ * @param {BriefModel["graph"]} graph
253
+ * @returns {string}
254
+ */
255
+ function renderDispatchable(graph) {
256
+ if (graph.dispatchableTogether.length >= 2) {
257
+ return `Workers that can run at the same time: ${graph.dispatchableTogether.map((id) => `\`${id}\``).join(", ")}.`;
258
+ }
259
+ return `Workers that can run at the same time: none — ${graph.dispatchNote}.`;
260
+ }
261
+
262
+ /**
263
+ * Human and delegated decisions from this campaign's spec sections and the
264
+ * active campaign journal projection, and nothing else. The section repeats the
265
+ * facts the opening carries so they survive the 250-word opening limit.
266
+ *
267
+ * @param {BriefModel} model
268
+ * @returns {string}
269
+ */
270
+ function renderDecisions(model) {
271
+ const { decisions } = model;
272
+ const lines = [
273
+ "## Decisions",
274
+ "(sources: this campaign's spec sections and the active campaign journal projection; nothing inferred from the graph)",
275
+ ...decisions.human.map((text) => `- Human decision (spec): ${text}`),
276
+ ...decisions.delegated.map((text) => `- Delegated decision (spec): ${text}`),
277
+ ...decisions.journal.map((decision) => `- Journal decision [${decision.id}]: ${decision.text}${decision.at ? ` · ${decision.at}` : ""}`),
278
+ ];
279
+ if (lines.length === 2) lines.push("- none recorded");
280
+ return lines.join("\n");
281
+ }
282
+
283
+ /**
284
+ * Risks and planned evals, from this campaign's spec only.
285
+ *
286
+ * @param {BriefModel} model
287
+ * @returns {string}
288
+ */
289
+ function renderRisksAndEvals(model) {
290
+ const { decisions } = model;
291
+ const lines = [
292
+ "## Risks and planned evals",
293
+ ...decisions.risks.map((risk) => `- Risk: ${risk.risk} — impact: ${risk.impact}; mitigation: ${risk.mitigation}`),
294
+ ...decisions.evals.map((text) => `- Planned eval: ${text}`),
295
+ ];
296
+ if (lines.length === 1) lines.push("- none recorded");
297
+ return lines.join("\n");
298
+ }
299
+
300
+ /**
301
+ * @param {BriefModel} model
302
+ * @returns {string}
303
+ */
304
+ function renderGaps(model) {
305
+ const gaps = allGaps(model);
306
+ const lines = gaps.length > 0 ? gaps.map((gap) => `- ${gap}`) : ["- none"];
307
+ return ["## Gaps", ...lines].join("\n");
308
+ }
309
+
310
+ /**
311
+ * @param {BriefModel} model
312
+ * @returns {string}
313
+ */
314
+ function renderEstimate(model) {
315
+ const { estimate } = model;
316
+ const method = estimate.method.length > 0 ? estimate.method.join("; ") : "not recorded";
317
+ const assumptions = estimate.assumptions.length > 0 ? estimate.assumptions.join("; ") : "not recorded";
318
+ return [
319
+ "## Estimate",
320
+ `- Nodes: ${estimate.nodeCount}`,
321
+ `- Workers: ${estimate.workerCount}`,
322
+ `- Cost: ${renderMeasure(estimate.cost, "usd")}`,
323
+ `- Cost provenance: ${estimate.cost.provenance ?? "not recorded"}`,
324
+ `- Duration: ${renderMeasure(estimate.duration, "minutes")}`,
325
+ `- Duration provenance: ${estimate.duration.provenance ?? "not recorded"}`,
326
+ `- Runtimes: ${estimate.runtimes.length > 0 ? estimate.runtimes.map((id) => `\`${id}\``).join(", ") : "none"}`,
327
+ `- Models: ${estimate.models.length > 0 ? estimate.models.map((model_) => `\`${model_}\``).join(", ") : "none"}`,
328
+ `- Effective worker concurrency: ${estimate.effectiveConcurrency}`,
329
+ `- Method: ${method}`,
330
+ `- Assumptions: ${assumptions}`,
331
+ `- Sample cutoff: ${estimate.sampleCutoff ?? "not recorded"}`,
332
+ `- Advisory only: ranges are not spend or time ceilings.`,
333
+ ].join("\n");
334
+ }
335
+
336
+ /**
337
+ * @param {BriefMeasure} measure
338
+ * @param {"usd"|"minutes"} unit
339
+ * @returns {string}
340
+ */
341
+ function renderMeasure(measure, unit) {
342
+ if (measure.status !== "range" || measure.min === null || measure.max === null) {
343
+ const samples = typeof measure.samples === "number" ? ` (${measure.samples} comparable sample${measure.samples === 1 ? "" : "s"})` : "";
344
+ return `insufficient data — ${measure.reason ?? "no reason recorded"}${samples}`;
345
+ }
346
+ const range = unit === "usd" ? `$${measure.min}–$${measure.max}` : `${measure.min}–${measure.max} minutes`;
347
+ const provenance = measure.sourceRuns.length > 0 ? ` (source runs: ${measure.sourceRuns.join(", ")})` : "";
348
+ return `${range} from ${measure.samples} comparable samples${provenance}`;
349
+ }
350
+
351
+ /**
352
+ * @param {BriefModel} model
353
+ * @returns {string}
354
+ */
355
+ function renderProvenance(model) {
356
+ return [
357
+ "## Provenance",
358
+ `- Journal cursor: \`${model.identity.journalCursor}\``,
359
+ `- Usage sample cutoff: \`${model.identity.usageSampleCutoff ?? "not recorded"}\``,
360
+ "- Deterministic: the same spec, plan, contract and recorded-data snapshots produce these bytes.",
361
+ ].join("\n");
362
+ }
363
+
364
+ /**
365
+ * Every gap the decision state considers, in the model's own order: coverage,
366
+ * work graph, decisions, then the estimate measures.
367
+ *
368
+ * @param {BriefModel} model
369
+ * @returns {string[]}
370
+ */
371
+ function allGaps(model) {
372
+ return [
373
+ ...model.coverage.gaps,
374
+ ...model.graph.gaps,
375
+ ...model.decisions.gaps,
376
+ ...model.opening.gaps,
377
+ ...model.estimate.gaps,
378
+ ];
379
+ }
380
+
381
+ /**
382
+ * A path rendered as a Markdown link; the link text is the path itself so the
383
+ * brief stays readable without following it.
384
+ *
385
+ * @param {string} path
386
+ * @returns {string}
387
+ */
388
+ function link(path) {
389
+ return `[${cell(path)}](${path})`;
390
+ }
391
+
392
+ /**
393
+ * Escape a table cell and collapse it to one line.
394
+ *
395
+ * @param {string} value
396
+ * @returns {string}
397
+ */
398
+ function cell(value) {
399
+ return value.replace(/\|/gu, "\\|").replace(/\s+/gu, " ").trim();
400
+ }
401
+
402
+ /**
403
+ * @param {string} text
404
+ * @returns {number}
405
+ */
406
+ function wordCount(text) {
407
+ const trimmed = text.trim();
408
+ return trimmed ? trimmed.split(/\s+/u).length : 0;
409
+ }
@@ -9,7 +9,7 @@
9
9
  import { compactCost, compactTokens, errorCode } from "../util.mjs";
10
10
  import { basename, join } from "node:path";
11
11
  import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
12
- import { scopeFindingsNote } from "../contract/scope-findings.mjs";
12
+ import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
13
13
  import { MARK, advisoryFindingCount, fit, judgeRuntimeLabel, roleCosts, roleUsage, statusNote, workerRuntimeLabel, writeStatusArtifacts } from "./render.mjs";
14
14
  import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
15
15
  import { unlinkSync } from "node:fs";
@@ -131,9 +131,10 @@ export function renderFinalReport(runDir, contract, states) {
131
131
  const judge = judgeRuntimeLabel(node) ?? "-";
132
132
  const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
133
133
  const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
134
- // The advisory scope finding leads the note, as it does in STATUS.md.
135
- const note = scopeFindingsNote(node.scopeFindings)
136
- ? `${scopeFindingsNote(node.scopeFindings)} · ${detail}`
134
+ // The advisory findings lead the note, as they do in STATUS.md.
135
+ const findings = [scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)].filter(Boolean).join(" · ");
136
+ const note = findings
137
+ ? `${findings} · ${detail}`
137
138
  : `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
138
139
  lines.push(row([
139
140
  MARK[node.status] ?? "[?]",
@@ -0,0 +1,30 @@
1
+ {
2
+ "schema": "faberun/mdhtml-release/1",
3
+ "repo": "feliperun/md.html",
4
+ "tag": "v1.1.3",
5
+ "version": "1.1.3",
6
+ "major": 1,
7
+ "minimumVersion": "1.1.3",
8
+ "assets": {
9
+ "darwin-arm64": {
10
+ "file": "mdhtml-1.1.3-darwin-arm64.tar.gz",
11
+ "sha256": "14cb7921ea75d6732974a32450f3f2abb06a9f69b464aab486de7312b94593a2"
12
+ },
13
+ "darwin-x64": {
14
+ "file": "mdhtml-1.1.3-darwin-x64.tar.gz",
15
+ "sha256": "dfb348c02f562bd721bc6a64ebd2b5d5b5ac56f2b19dfa7fd9705e7fdaf22a0d"
16
+ },
17
+ "linux-x64-gnu": {
18
+ "file": "mdhtml-1.1.3-linux-x64-gnu.tar.gz",
19
+ "sha256": "29d6a6f719f683d4aaebe4112d1e6b7c984a0125826fa12e3c3e7675046fdf15"
20
+ },
21
+ "linux-x64-musl": {
22
+ "file": "mdhtml-1.1.3-linux-x64-musl.tar.gz",
23
+ "sha256": "eba8b398a6eb6ec17fee5a8fbebdc6cab9511d66dc80ae50351df982c6b7dbf3"
24
+ },
25
+ "windows-x64": {
26
+ "file": "mdhtml-1.1.3-windows-x64.tar.gz",
27
+ "sha256": "2f68f85ea48bb7797a4c2c910e005fb02332d3f2aa548da1484d08c817a8d0fc"
28
+ }
29
+ }
30
+ }
@@ -3,7 +3,7 @@ import { basename, join } from "node:path";
3
3
  import { validateContract } from "../contract/index.mjs";
4
4
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
5
5
  import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
6
- import { scopeFindingsNote } from "../contract/scope-findings.mjs";
6
+ import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
7
7
  import { reviewNote } from "../contract/review-modes.mjs";
8
8
  import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
9
9
  import { compactCost, compactTokens, truncateChars } from "../util.mjs";
@@ -32,7 +32,7 @@ const POINTER_ATTENTION_CHARS = 80;
32
32
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
33
33
  /** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
34
34
  /** @typedef {{verdict: string, maxSeverity: string, findingCount: number, summary: string|null}} StatusPayloadGate */
35
- /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
35
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, verificationArtifacts: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
36
36
  /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
37
37
 
38
38
  /** The glyph each terminal state prints in a status table. */
@@ -296,6 +296,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
296
296
  pendingHandoff: pendingHandoff(node),
297
297
  note: statusNote(node),
298
298
  scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
299
+ verificationArtifacts: node.verificationArtifacts ?? null,
299
300
  errorCode: node.error?.code ?? null,
300
301
  blockedBy: node.blockedBy ?? [],
301
302
  verificationProgress: progress,
@@ -723,7 +724,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
723
724
  * @returns {string|null}
724
725
  */
725
726
  export function statusNote(node) {
726
- const scope = scopeFindingsNote(node.scopeFindings);
727
+ const scope = boundedNote([scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)]);
727
728
  const review = reviewNote(node);
728
729
  const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
729
730
  const note = boundedNote([review, detail]);