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.
- package/CHANGELOG.md +19 -0
- package/README.md +41 -0
- package/bin/triad-plus.js +565 -20
- package/docs/configuration.md +31 -0
- package/docs/human-readable-reports.md +52 -0
- package/docs/npx-installation.md +61 -4
- package/docs/operating-guide.it.md +9 -0
- package/docs/operating-guide.md +9 -0
- package/docs/troubleshooting.md +41 -0
- package/package.json +2 -2
- package/runtime/lib/evidence.mjs +11 -0
- package/runtime/lib/fingerprint.mjs +63 -0
- package/runtime/lib/human-reports.mjs +513 -0
- package/runtime/lib/installation-manifest.mjs +269 -0
- package/runtime/triad-human-report.mjs +50 -0
- package/skills/triad-loop-bootstrap/SKILL.md +4 -1
- package/skills/triad-loop-bootstrap/assets/loop-template/card-report.template.md +32 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/handoff-report.template.md +14 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/report-context/card.template.json +28 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/report-context/delivery.template.json +16 -0
- package/skills/triad-loop-orchestrator/SKILL.md +48 -0
|
@@ -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
|
+
}
|