triad-plus 1.10.0 → 1.12.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,513 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { calculateCandidateFingerprintAtCommit, collectCandidateChangesAtCommit } from "./fingerprint.mjs";
4
+ import { writeAtomicText } from "./evidence.mjs";
5
+
6
+ export const HUMAN_REPORT_SCHEMA_VERSION = 1;
7
+
8
+ function reportError(code, message) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ return error;
12
+ }
13
+
14
+ function objectLike(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ function requiredString(value, label) {
19
+ if (typeof value !== "string" || !value.trim()) throw reportError("human_report_invalid", `${label} must be a non-empty string`);
20
+ return value.trim();
21
+ }
22
+
23
+ function optionalString(value, label) {
24
+ if (value === null || value === undefined) return null;
25
+ return requiredString(value, label);
26
+ }
27
+
28
+ function statusLabel(value) {
29
+ return {
30
+ approved: "APPROVED",
31
+ blocked: "BLOCKED",
32
+ not_delivered: "NOT DELIVERED",
33
+ delivery_blocked: "DELIVERY BLOCKED",
34
+ rework: "REWORK",
35
+ }[value] ?? String(value ?? "UNKNOWN").toUpperCase();
36
+ }
37
+
38
+ function text(value, fallback = "Not recorded.") {
39
+ if (value === null || value === undefined || value === "") return fallback;
40
+ return String(value).trim() || fallback;
41
+ }
42
+
43
+ function list(value) {
44
+ return Array.isArray(value) ? value : [];
45
+ }
46
+
47
+ function tableCell(value) {
48
+ return text(value, "—").replaceAll("|", "\\|").replaceAll("\n", "<br>");
49
+ }
50
+
51
+ function markdownBullet(value) {
52
+ return `- ${text(value)}`;
53
+ }
54
+
55
+ function within(root, target) {
56
+ const base = path.resolve(root);
57
+ const resolved = path.resolve(target);
58
+ return resolved === base || resolved.startsWith(`${base}${path.sep}`);
59
+ }
60
+
61
+ /**
62
+ * Reports are views. Absolute machine paths are never emitted in them. A path
63
+ * inside a declared project/worktree is converted to a portable relative ref;
64
+ * an unrelated path is deliberately redacted rather than leaked.
65
+ */
66
+ export function reportPath(value, { projectRoot = null, worktree = null } = {}) {
67
+ if (value === null || value === undefined || value === "") return "—";
68
+ const raw = String(value);
69
+ if (!path.isAbsolute(raw)) return raw.replaceAll(path.sep, "/");
70
+ const roots = [projectRoot, worktree].filter(Boolean).map((root) => path.resolve(root));
71
+ for (const root of roots) {
72
+ if (within(root, raw)) {
73
+ const relative = path.relative(root, path.resolve(raw));
74
+ return (relative || ".").split(path.sep).join("/");
75
+ }
76
+ }
77
+ return "<external path>";
78
+ }
79
+
80
+ function changedPathLabel(change, context) {
81
+ if (change?.status === "renamed") {
82
+ return `${reportPath(change.source, context)} → ${reportPath(change.destination, context)}`;
83
+ }
84
+ return reportPath(change?.path, context);
85
+ }
86
+
87
+ function normalizeChangedPaths(changes, context) {
88
+ return list(changes).map((change) => ({
89
+ status: text(change?.status, "modified"),
90
+ path: change?.path,
91
+ source: change?.source,
92
+ destination: change?.destination,
93
+ repository: change?.repository ?? null,
94
+ label: changedPathLabel(change, context),
95
+ }));
96
+ }
97
+
98
+ function validateAttempt(attempt, index, context = {}) {
99
+ if (!objectLike(attempt)) throw reportError("human_report_invalid", `attempt ${index + 1} must be an object`);
100
+ return {
101
+ number: attempt.number ?? attempt.attempt ?? index + 1,
102
+ outcome: text(attempt.outcome ?? attempt.status, "not recorded"),
103
+ resolution: text(attempt.resolution ?? attempt.resolution_kind, "none"),
104
+ candidate_fingerprint: optionalString(attempt.candidate_fingerprint, `attempt ${index + 1} candidate_fingerprint`),
105
+ evidence_refs: list(attempt.evidence_refs).map((ref) => reportPath(ref, context)),
106
+ notes: text(attempt.notes, "—"),
107
+ };
108
+ }
109
+
110
+ function validateVerification(run, index, context = {}) {
111
+ if (!objectLike(run)) throw reportError("human_report_invalid", `verification ${index + 1} must be an object`);
112
+ return {
113
+ run_id: text(run.run_id ?? run.id, `verification-${index + 1}`),
114
+ status: text(run.status, "not recorded"),
115
+ gates: list(run.gates).map((gate) => ({
116
+ id: text(gate?.id, "unknown"),
117
+ status: text(gate?.status, "not recorded"),
118
+ evidence_refs: list(gate?.evidence_refs).map((ref) => reportPath(ref, context)),
119
+ exit_code: gate?.exit_code ?? "—",
120
+ duration_ms: gate?.duration_ms ?? "—",
121
+ })),
122
+ evidence_path: reportPath(run.evidence_path ?? run.path, context),
123
+ candidate_fingerprint: optionalString(run.candidate_fingerprint, `verification ${index + 1} candidate_fingerprint`),
124
+ notes: text(run.notes, "—"),
125
+ };
126
+ }
127
+
128
+ function validateReview(review, context = {}) {
129
+ if (review === null || review === undefined) return null;
130
+ if (!objectLike(review)) throw reportError("human_report_invalid", "review must be an object");
131
+ return {
132
+ decision: text(review.decision, "not recorded"),
133
+ reviewer: text(review.reviewer, "independent Reviewer"),
134
+ evidence_path: reportPath(review.evidence_path ?? review.path, context),
135
+ candidate_fingerprint: optionalString(review.candidate_fingerprint, "review candidate_fingerprint"),
136
+ findings: list(review.findings).map((finding) => ({
137
+ severity: text(finding?.severity, "unspecified"),
138
+ finding: text(finding?.finding, "not recorded"),
139
+ resolution: text(finding?.resolution, "none"),
140
+ evidence: text(finding?.evidence, "not recorded"),
141
+ })),
142
+ risks: list(review.risks),
143
+ };
144
+ }
145
+
146
+ function validateFinal(final, status) {
147
+ if (!objectLike(final)) throw reportError("human_report_invalid", "final must be an object");
148
+ const normalized = {
149
+ branch: optionalString(final.branch, "final branch"),
150
+ commit: optionalString(final.commit ?? final.final_commit, "final commit"),
151
+ base_commit: optionalString(final.base_commit, "final base_commit"),
152
+ candidate_fingerprint: optionalString(final.candidate_fingerprint, "final candidate_fingerprint"),
153
+ worktree: optionalString(final.worktree, "final worktree"),
154
+ repository: optionalString(final.repository, "final repository"),
155
+ };
156
+ if (status === "approved") {
157
+ for (const [key, value] of Object.entries(normalized)) {
158
+ if (["branch", "commit", "candidate_fingerprint", "repository"].includes(key) && !value) {
159
+ throw reportError("human_report_invalid", `approved report requires final.${key}`);
160
+ }
161
+ }
162
+ }
163
+ return normalized;
164
+ }
165
+
166
+ /** Validate and normalize the derived context supplied by the Orchestrator. */
167
+ export function normalizeCardReportInput(input, { projectRoot = null, worktree = null } = {}) {
168
+ if (!objectLike(input)) throw reportError("human_report_invalid", "card report input must be an object");
169
+ const card = objectLike(input.card) ? input.card : input;
170
+ const cardId = requiredString(card.id ?? input.card_id, "card id");
171
+ const status = requiredString(input.status ?? input.terminal_status, "terminal status").toLowerCase();
172
+ if (!new Set(["approved", "blocked", "not_delivered", "delivery_blocked"]).has(status)) {
173
+ throw reportError("human_report_invalid", `unsupported terminal status: ${status}`);
174
+ }
175
+ const context = { projectRoot, worktree };
176
+ const final = validateFinal(input.final ?? {}, status);
177
+ const attempts = list(input.attempts).map((attempt, index) => validateAttempt(attempt, index, context));
178
+ const verification = list(input.verification ?? input.verification_runs).map((run, index) => validateVerification(run, index, context));
179
+ const review = validateReview(input.review, context);
180
+ if (status === "approved") {
181
+ const passingRuns = verification.filter((run) => run.status === "pass" || run.status === "PASS");
182
+ if (!passingRuns.length) {
183
+ throw reportError("human_report_invalid", "approved report requires a passing verification run");
184
+ }
185
+ if (!review || !["approved", "APPROVED"].includes(review.decision)) {
186
+ throw reportError("human_report_invalid", "approved report requires an independent Reviewer approval");
187
+ }
188
+ if (!passingRuns.some((run) => run.candidate_fingerprint === final.candidate_fingerprint)) {
189
+ throw reportError("human_report_invalid", "approved report requires passing verifier evidence bound to the final candidate fingerprint");
190
+ }
191
+ if (review.candidate_fingerprint && review.candidate_fingerprint !== final.candidate_fingerprint) {
192
+ throw reportError("human_report_invalid", "Reviewer evidence is bound to a different candidate fingerprint");
193
+ }
194
+ }
195
+ if (status !== "approved" && !text(input.block_reason ?? input.reason, "").trim()) {
196
+ throw reportError("human_report_invalid", "non-approved terminal report requires a truthful reason");
197
+ }
198
+ return {
199
+ schema_version: HUMAN_REPORT_SCHEMA_VERSION,
200
+ project_id: text(input.project_id, "unnamed-project"),
201
+ card: {
202
+ id: cardId,
203
+ title: text(card.title, cardId),
204
+ goal: text(card.goal ?? card.observable_goal ?? card.outcome, "Not recorded."),
205
+ outcome: text(card.outcome ?? input.outcome, "Not recorded."),
206
+ repository: text(card.target_repository ?? card.repository ?? final.repository, "Not recorded."),
207
+ card_path: reportPath(card.card_path, context),
208
+ context: text(card.context, "Not recorded."),
209
+ },
210
+ status,
211
+ block_reason: text(input.block_reason ?? input.reason, "None recorded."),
212
+ implementation: {
213
+ summary: text(input.implementation?.summary ?? input.implemented, status === "approved" ? "Implementation completed." : "No delivered implementation claimed."),
214
+ paths: normalizeChangedPaths(input.implementation?.paths ?? [], context),
215
+ },
216
+ attempts,
217
+ verification,
218
+ review,
219
+ final,
220
+ provenance: {
221
+ assignment_path: reportPath(input.provenance?.assignment_path, context),
222
+ assignment_sha256: text(input.provenance?.assignment_sha256, "not recorded"),
223
+ packet_path: reportPath(input.provenance?.packet_path, context),
224
+ packet_sha256: text(input.provenance?.packet_sha256, "not recorded"),
225
+ verification_paths: list(input.provenance?.verification_paths).map((ref) => reportPath(ref, context)),
226
+ review_path: reportPath(input.provenance?.review_path, context),
227
+ },
228
+ risks: list(input.risks),
229
+ deferred: list(input.deferred),
230
+ evidence_refs: list(input.evidence_refs).map((ref) => reportPath(ref, context)),
231
+ generated_from: text(input.generated_from, "canonical control-plane evidence"),
232
+ changed_paths: input.changed_paths === undefined ? null : normalizeChangedPaths(input.changed_paths, context),
233
+ changed_paths_base: input.changed_paths_base ?? final.base_commit ?? null,
234
+ context,
235
+ };
236
+ }
237
+
238
+ function changedPathsForReport(report) {
239
+ if (report.changed_paths !== null) return report.changed_paths;
240
+ return [];
241
+ }
242
+
243
+ export function renderCardReport(input, options = {}) {
244
+ const report = input.schema_version === HUMAN_REPORT_SCHEMA_VERSION && input.card?.id
245
+ ? input
246
+ : normalizeCardReportInput(input, options);
247
+ const changed = changedPathsForReport(report);
248
+ const status = statusLabel(report.status);
249
+ const verificationRows = report.verification.flatMap((run) => run.gates.length
250
+ ? run.gates.map((gate) => `| ${tableCell(run.run_id)} | ${tableCell(gate.id)} | ${tableCell(gate.status)} | ${tableCell(gate.exit_code)} | ${tableCell(gate.duration_ms)} | ${tableCell([run.evidence_path, ...gate.evidence_refs].filter((ref) => ref && ref !== "—").join(", "))} |`)
251
+ : [`| ${tableCell(run.run_id)} | — | ${tableCell(run.status)} | — | — | ${tableCell(run.evidence_path)} |`]);
252
+ const attemptRows = report.attempts.length
253
+ ? report.attempts.map((attempt) => `| ${tableCell(attempt.number)} | ${tableCell(attempt.outcome)} | ${tableCell(attempt.resolution)} | ${tableCell(attempt.candidate_fingerprint)} | ${tableCell(attempt.notes)} | ${tableCell(attempt.evidence_refs.join(", "))} |`)
254
+ : ["| — | No attempts recorded | — | — | — | — |"];
255
+ const changedRows = changed.length
256
+ ? changed.map((entry) => `| ${tableCell(entry.status)} | ${tableCell(entry.label)} | ${tableCell(entry.repository ?? report.card.repository)} |`)
257
+ : ["| — | No changed paths recorded from the card baseline | — |"];
258
+ const review = report.review;
259
+ const findings = review?.findings?.length
260
+ ? review.findings.map((finding) => `| ${tableCell(finding.severity)} | ${tableCell(finding.finding)} | ${tableCell(finding.evidence)} | ${tableCell(finding.resolution)} |`)
261
+ : ["| — | No findings recorded | — | None |"];
262
+ const finalEvidence = report.status === "approved"
263
+ ? `- Final commit: \`${tableCell(report.final.commit)}\`\n- Candidate fingerprint: \`${tableCell(report.final.candidate_fingerprint)}\`\n- Reviewer decision: \`${tableCell(review?.decision)}\``
264
+ : `- Terminal state: \`${status}\`\n- Reason: ${text(report.block_reason)}`;
265
+ return `# Card report — ${report.card.id}: ${report.card.title}
266
+
267
+ > Derived human-readable view of canonical Triad+ evidence. This report is not a new source of truth.
268
+
269
+ ## Result
270
+
271
+ **${status}**
272
+
273
+ - Project: ${tableCell(report.project_id)}
274
+ - Repository: ${tableCell(report.card.repository)}
275
+ - Goal: ${tableCell(report.card.goal)}
276
+ - Outcome: ${tableCell(report.card.outcome)}
277
+ - Card path: \`${tableCell(report.card.card_path)}\`
278
+
279
+ ## What was implemented
280
+
281
+ ${text(report.implementation.summary)}
282
+
283
+ ## Card-attributable changed paths
284
+
285
+ Baseline: \`${tableCell(report.changed_paths_base)}\`
286
+
287
+ | Status | Path | Repository |
288
+ | --- | --- | --- |
289
+ ${changedRows.join("\n")}
290
+
291
+ ## Verification and gates
292
+
293
+ | Verification run | Gate | Status | Exit code | Duration (ms) | Evidence |
294
+ | --- | --- | --- | --- | --- | --- |
295
+ ${verificationRows.join("\n")}
296
+
297
+ ## Attempts and rework
298
+
299
+ | Attempt | Outcome | Resolution | Candidate fingerprint | Notes | Evidence |
300
+ | --- | --- | --- | --- | --- | --- |
301
+ ${attemptRows.join("\n")}
302
+
303
+ ## Independent Reviewer
304
+
305
+ - Reviewer: ${tableCell(review?.reviewer ?? "Not dispatched")}
306
+ - Decision: **${tableCell(review?.decision ?? "not recorded")}**
307
+ - Evidence: \`${tableCell(review?.evidence_path ?? "not recorded")}\`
308
+
309
+ | Severity | Finding | Evidence | Resolution |
310
+ | --- | --- | --- | --- |
311
+ ${findings.join("\n")}
312
+
313
+ ${review?.risks?.length ? `Reviewer risks:\n${review.risks.map(markdownBullet).join("\n")}` : "Reviewer risks: none recorded."}
314
+
315
+ ## Final evidence and provenance
316
+
317
+ ${finalEvidence}
318
+
319
+ - Branch: \`${tableCell(report.final.branch)}\`
320
+ - Repository baseline: \`${tableCell(report.final.base_commit)}\`
321
+ - Source: ${tableCell(report.generated_from)}
322
+ - Evidence references: ${report.evidence_refs.length ? report.evidence_refs.map((ref) => `\`${tableCell(ref)}\``).join(", ") : "none recorded"}
323
+ - Assignment: \`${tableCell(report.provenance.assignment_path)}\` (${tableCell(report.provenance.assignment_sha256)})
324
+ - Assignment Packet: \`${tableCell(report.provenance.packet_path)}\` (${tableCell(report.provenance.packet_sha256)})
325
+ - Verification references: ${report.provenance.verification_paths.length ? report.provenance.verification_paths.map((ref) => `\`${tableCell(ref)}\``).join(", ") : "none recorded"}
326
+
327
+ ## Risks, deferred work, and terminal notes
328
+
329
+ ${report.risks.length ? report.risks.map(markdownBullet).join("\n") : "- No residual risks recorded."}
330
+ ${report.deferred.length ? report.deferred.map((item) => `- Deferred: ${text(item)}`).join("\n") : "- No deferred items recorded."}
331
+ ${report.status !== "approved" ? `- Terminal reason: ${text(report.block_reason)}` : "- The card reached approved status after current verification and independent review."}
332
+ `;
333
+ }
334
+
335
+ function normalizeHandoffInput(input, options = {}) {
336
+ if (!objectLike(input)) throw reportError("human_report_invalid", "handoff input must be an object");
337
+ const cards = list(input.cards).map((card) => ({
338
+ id: requiredString(card.id, "handoff card id"),
339
+ title: text(card.title, card.id),
340
+ status: text(card.status, "not recorded"),
341
+ summary: text(card.summary ?? card.outcome, "Not recorded."),
342
+ report_path: reportPath(card.report_path, options),
343
+ commit: optionalString(card.commit, `handoff card ${card.id} commit`),
344
+ candidate_fingerprint: optionalString(card.candidate_fingerprint, `handoff card ${card.id} candidate_fingerprint`),
345
+ verification: text(card.verification, "Not recorded."),
346
+ review: text(card.review, "Not recorded."),
347
+ evaluator_report: reportPath(card.evaluator_report, options),
348
+ evidence_refs: list(card.evidence_refs).map((ref) => reportPath(ref, options)),
349
+ }));
350
+ if (!cards.length) throw reportError("human_report_invalid", "handoff requires at least one card result");
351
+ return {
352
+ project_id: text(input.project_id, "unnamed-project"),
353
+ decision: requiredString(input.decision, "handoff decision"),
354
+ executive_summary: text(input.executive_summary ?? input.summary, "Not recorded."),
355
+ cards,
356
+ code_areas: list(input.code_areas),
357
+ residual: list(input.residual ?? input.deferred),
358
+ verification: text(input.verification, "Not recorded."),
359
+ review: text(input.review, "Not recorded."),
360
+ branch_commits: list(input.branch_commits),
361
+ practical_test: list(input.practical_test),
362
+ evaluator: text(input.evaluator, "Not configured."),
363
+ delivery: text(input.delivery, "Not recorded."),
364
+ quality_contract: text(input.quality_contract, "Not configured."),
365
+ delivery_criteria: list(input.delivery_criteria).map((criterion) => ({
366
+ ...criterion,
367
+ evidence_refs: list(criterion?.evidence_refs).map((ref) => reportPath(ref, options)),
368
+ })),
369
+ demo: text(input.demo, "Not configured."),
370
+ evidence_refs: list(input.evidence_refs).map((ref) => reportPath(ref, options)),
371
+ exceptions: list(input.exceptions),
372
+ prd_baseline: text(input.prd_baseline, "Not recorded."),
373
+ approved_cards: list(input.approved_cards),
374
+ push_evidence: list(input.push_evidence),
375
+ gate_metrics: list(input.gate_metrics),
376
+ local_worktree_integration: list(input.local_worktree_integration),
377
+ delivery_closure_record: text(input.delivery_closure_record, "Not recorded."),
378
+ final_message: text(input.final_message, "Not recorded."),
379
+ risks: list(input.risks),
380
+ generated_from: text(input.generated_from, "canonical control-plane evidence"),
381
+ };
382
+ }
383
+
384
+ export function renderHandoffReport(input, options = {}) {
385
+ const handoff = input.project_id && Array.isArray(input.cards) && input.cards[0]?.report_path !== undefined
386
+ ? input
387
+ : normalizeHandoffInput(input, options);
388
+ const cardRows = handoff.cards.map((card) => `| ${tableCell(card.id)} | ${tableCell(card.title)} | **${tableCell(statusLabel(card.status))}** | ${tableCell(card.summary)} | ${tableCell(card.commit)} | ${tableCell(card.candidate_fingerprint)} | ${tableCell(card.evaluator_report)} | [Card report](${tableCell(card.report_path)}) |`);
389
+ const links = handoff.cards.map((card) => `- [${card.id} card report](${card.report_path})`).join("\n");
390
+ return `# Delivery handoff — ${handoff.project_id}
391
+
392
+ ## Executive summary
393
+
394
+ **${tableCell(statusLabel(handoff.decision))}**
395
+
396
+ ${handoff.executive_summary}
397
+
398
+ ## Card-by-card results
399
+
400
+ | Card | Title | Result | Outcome | Commit | Candidate fingerprint | Evaluator+ | Human-readable report |
401
+ | --- | --- | --- | --- | --- | --- | --- | --- |
402
+ ${cardRows.join("\n")}
403
+
404
+ ${links}
405
+
406
+ ## Main code areas, problem, and resolution
407
+
408
+ ${handoff.code_areas.length ? handoff.code_areas.map((item) => `- ${text(item)}`).join("\n") : "- Not recorded."}
409
+
410
+ ## Residual, deferred, and blocked items
411
+
412
+ ${handoff.residual.length ? handoff.residual.map((item) => `- ${text(item)}`).join("\n") : "- None recorded."}
413
+
414
+ ## Verification and review status
415
+
416
+ - Verification: ${handoff.verification}
417
+ - Independent review: ${handoff.review}
418
+ - Evaluator+: ${handoff.evaluator}
419
+ - Delivery closure: ${handoff.delivery}
420
+
421
+ - PRD baseline: ${handoff.prd_baseline}
422
+ - Approved cards: ${handoff.approved_cards.length ? handoff.approved_cards.map((item) => text(item)).join(", ") : "Not recorded."}
423
+ - Push evidence: ${handoff.push_evidence.length ? handoff.push_evidence.map((item) => text(item)).join("; ") : "Not recorded."}
424
+ - Gates and metrics: ${handoff.gate_metrics.length ? handoff.gate_metrics.map((item) => text(item)).join("; ") : "Not recorded."}
425
+
426
+ ## Quality and delivery closure
427
+
428
+ - Quality Contract: ${handoff.quality_contract}
429
+ - Demo: ${handoff.demo}
430
+
431
+ ${handoff.delivery_criteria.length ? `| Criterion | Verdict | Evidence |\n| --- | --- | --- |\n${handoff.delivery_criteria.map((criterion) => `| ${tableCell(criterion?.id)} | ${tableCell(criterion?.verdict)} | ${tableCell(criterion?.evidence_refs?.join(", "))} |`).join("\n")}` : "No delivery criteria recorded."}
432
+
433
+ - Delivery closure record: ${handoff.delivery_closure_record}
434
+ - Final delivery message: ${handoff.final_message}
435
+
436
+ ## Branch, commit, and evidence map
437
+
438
+ ${handoff.branch_commits.length ? handoff.branch_commits.map((item) => `- ${text(item)}`).join("\n") : "- Not recorded."}
439
+
440
+ ${handoff.local_worktree_integration.length ? `### Local-worktree integration\n${handoff.local_worktree_integration.map((item) => `- ${text(item)}`).join("\n")}` : "### Local-worktree integration\n- Not recorded."}
441
+
442
+ ## Practical test / follow-up
443
+
444
+ ${handoff.practical_test.length ? handoff.practical_test.map((item) => `- ${text(item)}`).join("\n") : "- No practical test recorded."}
445
+
446
+ ## Exceptions and evidence references
447
+
448
+ ${handoff.exceptions.length ? handoff.exceptions.map((item) => `- ${text(item)}`).join("\n") : "- No exceptions recorded."}
449
+ ${handoff.evidence_refs.length ? handoff.evidence_refs.map((item) => `- \`${tableCell(item)}\``).join("\n") : "- No additional evidence references recorded."}
450
+
451
+ ## Risks
452
+
453
+ ${handoff.risks.length ? handoff.risks.map((item) => `- ${text(item)}`).join("\n") : "- None recorded."}
454
+
455
+ ## Canonical record boundary
456
+
457
+ This Markdown handoff is a derived view generated from ${handoff.generated_from}. Technical delivery gates, evaluator results, evidence paths, branch/commit data, demo details, and quality-contract closure remain authoritative in the control records referenced above.
458
+ `;
459
+ }
460
+
461
+ /**
462
+ * Generate a card report while attributing paths to the complete card baseline.
463
+ * The changed-path manifest is collected from Git, never from Developer prose.
464
+ */
465
+ export async function writeCardReport(input, { outputPath, projectRoot = null, worktree = null, baseCommit = null } = {}) {
466
+ const preliminary = normalizeCardReportInput(input, { projectRoot, worktree });
467
+ let changedPaths = preliminary.changed_paths;
468
+ let changedPathsBase = preliminary.changed_paths_base ?? baseCommit;
469
+ if (preliminary.status === "approved") {
470
+ if (!worktree || !(baseCommit ?? preliminary.final.base_commit) || !preliminary.final.commit) {
471
+ throw reportError("human_report_invalid", "approved card report requires a worktree, card baseline, and final commit for canonical evidence");
472
+ }
473
+ const candidate = await collectCandidateChangesAtCommit(worktree, {
474
+ baseCommit: baseCommit ?? preliminary.final.base_commit,
475
+ commit: preliminary.final.commit,
476
+ });
477
+ changedPaths = normalizeChangedPaths(candidate.changes, { projectRoot, worktree });
478
+ changedPathsBase = candidate.base_commit;
479
+ const actualFingerprint = await calculateCandidateFingerprintAtCommit(worktree, {
480
+ baseCommit: candidate.base_commit,
481
+ commit: candidate.git_head,
482
+ });
483
+ if (actualFingerprint.value !== preliminary.final.candidate_fingerprint) {
484
+ throw reportError("human_report_invalid", "final candidate fingerprint does not match the final commit delta");
485
+ }
486
+ }
487
+ const report = { ...preliminary, changed_paths: changedPaths ?? [], changed_paths_base: changedPathsBase };
488
+ const markdown = renderCardReport(report, { projectRoot, worktree });
489
+ if (outputPath) {
490
+ try {
491
+ const previous = await readFile(outputPath, "utf8");
492
+ const previousApproved = /\n\*\*APPROVED\*\*\n/.test(previous);
493
+ if (previousApproved && report.status !== "approved") {
494
+ throw reportError("human_report_immutable", "an approved Card report cannot be overwritten by a non-approved terminal view");
495
+ }
496
+ if (previousApproved && previous !== markdown) {
497
+ throw reportError("human_report_immutable", "an approved Card report cannot be changed after publication");
498
+ }
499
+ if (previous === markdown) return { report, markdown, outputPath };
500
+ } catch (error) {
501
+ if (error.code !== "ENOENT") throw error;
502
+ }
503
+ await writeAtomicText(outputPath, markdown);
504
+ }
505
+ return { report, markdown, outputPath: outputPath ?? null };
506
+ }
507
+
508
+ export async function writeHandoffReport(input, { outputPath, projectRoot = null } = {}) {
509
+ const handoff = normalizeHandoffInput(input, { projectRoot });
510
+ const markdown = renderHandoffReport(handoff, { projectRoot });
511
+ if (outputPath) await writeAtomicText(outputPath, markdown);
512
+ return { handoff, markdown, outputPath: outputPath ?? null };
513
+ }