triad-plus 1.11.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.12.1 — 2026-09-26
6
+
7
+ - Bind approved human-readable Card reports to the independently verified
8
+ candidate manifest, preserving the pre-commit verifier fingerprint while
9
+ recording the committed fingerprint separately.
10
+ - Preserve raw candidate bytes for commit-derived hashes, fail closed on
11
+ post-verification path/content changes, and retain legacy no-manifest
12
+ behavior without silently widening the verified candidate.
13
+
14
+ ## 1.12.0 — 2026-09-23
15
+
16
+ - Add deterministic human-readable Markdown views for terminal Cards and the
17
+ final delivery handoff, derived from canonical evidence with baseline-based
18
+ changed-path attribution and truthful blocked/not-delivered reporting.
19
+
3
20
  ## 1.11.0 — 2026-09-17
4
21
 
5
22
  - Add an installation manifest that records Triad-owned assets, materialized
package/README.md CHANGED
@@ -167,6 +167,15 @@ planning; Triad owns execution, verification, review, and delivery.
167
167
  ready-for-dev Stories, debugging, and automation. It is no longer the primary
168
168
  BMAD workflow. See the [BMAD integration guide](docs/bmad-integration.md).
169
169
 
170
+ ## Human-readable delivery reports
171
+
172
+ Triad+ can materialize deterministic Markdown views from canonical control-plane
173
+ records: one report for each terminal Card and a human-first final handoff. The
174
+ views include baseline-attributed changed paths, attempts, verifier gates,
175
+ independent review, provenance, residual risks, and practical-test links. They
176
+ never replace the JSON/YAML evidence or add an LLM reporting call. See the
177
+ [human-readable reports guide](docs/human-readable-reports.md).
178
+
170
179
  ## Quick start for every runtime
171
180
 
172
181
  Requirements: Node.js 20+ and one supported coding-agent host.
@@ -0,0 +1,55 @@
1
+ # Human-readable reports
2
+
3
+ Triad+ keeps JSON/YAML control records, verification evidence, review reports,
4
+ candidate fingerprints, and delivery records as the source of truth. From those
5
+ records the Orchestrator can materialize two portable Markdown views:
6
+
7
+ - one `card-reports/<card-id>.md` for every terminal Card;
8
+ - one final delivery handoff with a human-first summary and links to the Card
9
+ reports.
10
+
11
+ The report renderer is deterministic and does not make an additional LLM call.
12
+ It attributes changed paths to the complete Card baseline, so a rework attempt
13
+ does not hide files changed by an earlier attempt. Reports contain relative
14
+ evidence references and redact unrelated absolute paths.
15
+
16
+ ## Materialize a Card report
17
+
18
+ The Orchestrator first writes a small derived JSON context from the canonical
19
+ Card, attempt, verifier, Reviewer, and delivery records. The source Card and
20
+ those records remain read-only. Then run:
21
+
22
+ ```bash
23
+ node .triad-runtime/triad-human-report.mjs \
24
+ --mode card \
25
+ --project /absolute/path/to/control-workspace \
26
+ --input .loop/runtime/report-context/CARD-001.json \
27
+ --output card-reports/CARD-001.md \
28
+ --worktree /absolute/path/to/product-worktree \
29
+ --base-commit <card-baseline-commit>
30
+ ```
31
+
32
+ For an approved Card the renderer requires a final commit, the candidate
33
+ fingerprint recorded by passing verifier evidence, independent Reviewer
34
+ approval, and the complete changed-path manifest. Current verifier evidence
35
+ also carries a `candidate_manifest` with the verified paths and content hashes.
36
+ The renderer binds that manifest to the final commit delta (allowing the
37
+ expected `git_head` change caused by the commit) and records the resulting
38
+ committed fingerprint separately. A blocked or not-delivered terminal Card must
39
+ include a truthful reason and never claims delivery. Writes are atomic and
40
+ re-running the command with the same context is idempotent.
41
+
42
+ ## Materialize the final handoff
43
+
44
+ ```bash
45
+ node .triad-runtime/triad-human-report.mjs \
46
+ --mode handoff \
47
+ --project /absolute/path/to/control-workspace \
48
+ --input .loop/runtime/report-context/delivery.json \
49
+ --output handoff.md
50
+ ```
51
+
52
+ The handoff opens with the executive summary and Card results, then preserves
53
+ links to the technical evidence, branch/commit map, quality-contract closure,
54
+ Evaluator+ result, delivery gates, demo details, risks, and practical test.
55
+ It is a view for people, not an alternative delivery state machine.
@@ -96,6 +96,15 @@ inserire token o segreti. Gli hook sono un’ottimizzazione, non autorità: il
96
96
  dispatch esplicito della verification resta sempre disponibile. Vedi la
97
97
  [matrice di compatibilità](compatibility.md).
98
98
 
99
+ Per ogni Card terminale l’Orchestrator può generare una vista deterministica
100
+ `card-reports/<card-id>.md` dai record canonici di Card, attempt, verifier e
101
+ Reviewer. Il renderer attribuisce al baseline completo della Card il delta dei
102
+ path modificati, inclusi rework, rinomina e cancellazione. L’handoff finale si
103
+ apre con un riepilogo leggibile e collega questi report, mantenendo anche le
104
+ evidence tecniche di delivery. I report sono scritti atomicamente, sono
105
+ ripetibili e usano riferimenti relativi: non sono una nuova source of truth né
106
+ una chiamata aggiuntiva a un agente. Vedi la [guida ai report leggibili](human-readable-reports.md).
107
+
99
108
  Per ogni demo configurata, registra nel progetto e nell’handoff comando, URL
100
109
  locale, modalità di accesso remoto e URL remoto. `localhost` è solo locale: non
101
110
  va indicato a chi prova da remoto. Avvia il servizio soltanto su richiesta del
@@ -95,6 +95,15 @@ reports, and handoff in a separate project-control workspace. Never place tokens
95
95
  or secrets there. Hooks are an optimization, not authority; explicit verification
96
96
  is always the fallback. See the [compatibility matrix](compatibility.md).
97
97
 
98
+ For every terminal Card, the Orchestrator may render a deterministic
99
+ `card-reports/<card-id>.md` view from the canonical Card, attempt, verifier, and
100
+ Reviewer records. The renderer attributes the complete changed-path delta to the
101
+ Card baseline, including rework, renames, and deletions. The final handoff opens
102
+ with a human-readable summary and links to those reports while retaining the
103
+ technical delivery evidence. Reports are atomic, repeatable, relative-path
104
+ views; they are not a new source of truth or an additional agent call. See the
105
+ [human-readable reports guide](human-readable-reports.md).
106
+
98
107
  For a configured demo service, record its command, local URL, remote-access mode,
99
108
  and remote URL in the project and handoff. `localhost` is local-only; do not give
100
109
  it to a remote tester as a reachable endpoint. Start the service only on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "description": "A lightweight, evidence-backed engineering loop for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=20"
29
29
  },
30
30
  "scripts": {
31
- "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/installation-manifest-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
31
+ "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/installation-manifest-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs && node tests/human-reports-test.mjs && node tests/human-report-fingerprint-binding-test.mjs",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {
@@ -29,3 +29,14 @@ export async function writeAtomicJson(targetPath, value) {
29
29
  await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
30
30
  await rename(temporaryPath, targetPath);
31
31
  }
32
+
33
+ /**
34
+ * Persist a derived human-readable artifact without exposing a partially
35
+ * written report to the owner or to a resumed Orchestrator.
36
+ */
37
+ export async function writeAtomicText(targetPath, value) {
38
+ await mkdir(path.dirname(targetPath), { recursive: true });
39
+ const temporaryPath = `${targetPath}.tmp`;
40
+ await writeFile(temporaryPath, String(value), "utf8");
41
+ await rename(temporaryPath, targetPath);
42
+ }
@@ -26,6 +26,12 @@ async function gitOutput(worktree, args) {
26
26
  return result.stdout;
27
27
  }
28
28
 
29
+ async function verifiedCommit(root, commit, label) {
30
+ const resolved = (await gitLines(root, ["rev-parse", "--verify", `${commit}^{commit}`]))[0];
31
+ if (!resolved) throw new Error(`${label} does not resolve to a commit`);
32
+ return resolved;
33
+ }
34
+
29
35
  function ignored(relativePath) {
30
36
  return SENSITIVE_PATH.test(relativePath) || IGNORED_PATH.test(relativePath);
31
37
  }
@@ -57,6 +63,67 @@ function pathsFor(entry) {
57
63
  return entry.status === "renamed" ? [entry.source, entry.destination] : [entry.path];
58
64
  }
59
65
 
66
+ function candidateManifest(candidate, files) {
67
+ return {
68
+ base_commit: candidate.base_commit,
69
+ git_head: candidate.git_head,
70
+ changes: candidate.changes,
71
+ ignored_paths: candidate.ignored_paths,
72
+ files,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Return the content/path binding captured for a candidate independently of
78
+ * the Git commit identity used to produce it. The fingerprint value below
79
+ * intentionally retains its historical git_head binding; reports use this
80
+ * manifest to bind that verified candidate to the later commit tree.
81
+ */
82
+ export function candidateManifestFor(value) {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
84
+ if (!Array.isArray(value.files) || !Array.isArray(value.changes)) return null;
85
+ return {
86
+ base_commit: value.base_commit ?? null,
87
+ git_head: value.git_head ?? null,
88
+ changes: value.changes,
89
+ ignored_paths: Array.isArray(value.ignored_paths) ? value.ignored_paths : [],
90
+ files: value.files,
91
+ };
92
+ }
93
+
94
+ function changePaths(manifest) {
95
+ return (manifest?.changes ?? []).flatMap((change) => change?.status === "renamed"
96
+ ? [change.source, change.destination]
97
+ : [change.path]).filter((value) => typeof value === "string").sort();
98
+ }
99
+
100
+ function fileEntries(manifest) {
101
+ return (manifest?.files ?? [])
102
+ .map((file) => ({ path: file?.path, sha256: file?.sha256 }))
103
+ .sort((left, right) => String(left.path).localeCompare(String(right.path)));
104
+ }
105
+
106
+ function ignoredPaths(manifest) {
107
+ return [...new Set(manifest?.ignored_paths ?? [])].filter((value) => typeof value === "string").sort();
108
+ }
109
+
110
+ /**
111
+ * Compare a verifier's pre-commit manifest with a commit-derived manifest.
112
+ * git_head is deliberately excluded: committing an unchanged candidate is
113
+ * expected to change that identity. Paths and content hashes are not ignored,
114
+ * so a file added or modified after verification fails closed.
115
+ */
116
+ export function candidateManifestsBind(verified, committed, { expectedBaseCommit = null } = {}) {
117
+ const left = candidateManifestFor(verified);
118
+ const right = candidateManifestFor(committed);
119
+ if (!left || !right) return false;
120
+ if (expectedBaseCommit && (left.base_commit !== expectedBaseCommit || right.base_commit !== expectedBaseCommit)) return false;
121
+ if (left.base_commit !== right.base_commit) return false;
122
+ if (JSON.stringify(ignoredPaths(left)) !== JSON.stringify(ignoredPaths(right))) return false;
123
+ if (JSON.stringify(fileEntries(left)) !== JSON.stringify(fileEntries(right))) return false;
124
+ return JSON.stringify(changePaths(left)) === JSON.stringify(changePaths(right));
125
+ }
126
+
60
127
  export async function collectCandidateChanges(worktree, { baseCommit = null } = {}) {
61
128
  const root = await realpath(worktree);
62
129
  const head = (await gitLines(root, ["rev-parse", "HEAD"]))[0] ?? "NO_HEAD";
@@ -84,6 +151,69 @@ export async function collectCandidateChanges(worktree, { baseCommit = null } =
84
151
  return { git_head: head, base_commit: base, changes, ignored_paths: [...new Set(ignored_paths)].sort() };
85
152
  }
86
153
 
154
+ /**
155
+ * Collect a complete candidate delta from an immutable Git commit rather than
156
+ * from whatever happens to be in the current worktree. This is used by derived
157
+ * reports so a later Card on the same branch cannot contaminate an earlier one.
158
+ */
159
+ export async function collectCandidateChangesAtCommit(worktree, { baseCommit, commit }) {
160
+ const root = await realpath(worktree);
161
+ const base = await verifiedCommit(root, baseCommit, "base commit");
162
+ const head = await verifiedCommit(root, commit, "final commit");
163
+ const ancestry = await runProcess("git", ["merge-base", "--is-ancestor", base, head], { cwd: root, timeoutMs: 15_000 });
164
+ if (ancestry.exitCode !== 0) throw new Error("card baseline is not an ancestor of the final commit");
165
+ const tracked = parseNameStatus(await gitOutput(root, ["diff", "--name-status", "--find-renames", base, head]));
166
+ const changes = [];
167
+ const ignored_paths = [];
168
+ for (const entry of tracked) {
169
+ const entryPaths = pathsFor(entry);
170
+ if (entryPaths.some(ignored)) {
171
+ ignored_paths.push(...entryPaths.filter(ignored));
172
+ continue;
173
+ }
174
+ for (const relativePath of entryPaths) {
175
+ const absolutePath = path.resolve(root, relativePath);
176
+ if (!absolutePath.startsWith(`${root}${path.sep}`)) throw new Error(`unsafe changed path: ${relativePath}`);
177
+ }
178
+ changes.push(entry);
179
+ }
180
+ return { git_head: head, base_commit: base, changes, ignored_paths: [...new Set(ignored_paths)].sort() };
181
+ }
182
+
183
+ async function commitFileHash(root, commit, relativePath) {
184
+ const result = await runProcess("git", ["show", `${commit}:${relativePath}`], { cwd: root, timeoutMs: 15_000, encoding: null });
185
+ if (result.exitCode !== 0) throw new Error(`git show failed for ${commit}:${relativePath}`);
186
+ return digest(result.stdout);
187
+ }
188
+
189
+ /** Calculate the same candidate fingerprint algorithm at a specific commit. */
190
+ export async function calculateCandidateFingerprintAtCommit(worktree, { baseCommit, commit }) {
191
+ const root = await realpath(worktree);
192
+ const candidate = await collectCandidateChangesAtCommit(root, { baseCommit, commit });
193
+ const files = [];
194
+ const changed = new Map();
195
+ for (const entry of candidate.changes) {
196
+ if (entry.status === "renamed") {
197
+ changed.set(entry.source, "DELETED");
198
+ changed.set(entry.destination, null);
199
+ } else {
200
+ changed.set(entry.path, entry.status === "deleted" ? "DELETED" : null);
201
+ }
202
+ }
203
+ for (const [relativePath, knownHash] of [...changed.entries()].sort(([left], [right]) => left.localeCompare(right))) {
204
+ const contentHash = knownHash === null ? await commitFileHash(root, candidate.git_head, relativePath) : knownHash;
205
+ files.push({ path: relativePath, sha256: contentHash });
206
+ }
207
+ const canonical = JSON.stringify({ git_head: candidate.git_head, files });
208
+ return {
209
+ algorithm: "sha256",
210
+ value: digest(canonical),
211
+ git_head: candidate.git_head,
212
+ files,
213
+ manifest: candidateManifest(candidate, files),
214
+ };
215
+ }
216
+
87
217
  export async function worktreeBranch(worktree) {
88
218
  const root = await realpath(worktree);
89
219
  return (await gitLines(root, ["branch", "--show-current"]))[0] ?? "DETACHED";
@@ -118,5 +248,11 @@ export async function calculateCandidateFingerprint(worktree) {
118
248
  files.push({ path: relativePath, sha256: contentHash });
119
249
  }
120
250
  const canonical = JSON.stringify({ git_head: candidate.git_head, files });
121
- return { algorithm: "sha256", value: digest(canonical), git_head: candidate.git_head, files };
251
+ return {
252
+ algorithm: "sha256",
253
+ value: digest(canonical),
254
+ git_head: candidate.git_head,
255
+ files,
256
+ manifest: candidateManifest(candidate, files),
257
+ };
122
258
  }
@@ -0,0 +1,530 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { calculateCandidateFingerprintAtCommit, candidateManifestsBind, 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
+ candidate_manifest: run.candidate_manifest ?? run.baseline?.candidate_manifest ?? null,
125
+ notes: text(run.notes, "—"),
126
+ };
127
+ }
128
+
129
+ function validateReview(review, context = {}) {
130
+ if (review === null || review === undefined) return null;
131
+ if (!objectLike(review)) throw reportError("human_report_invalid", "review must be an object");
132
+ return {
133
+ decision: text(review.decision, "not recorded"),
134
+ reviewer: text(review.reviewer, "independent Reviewer"),
135
+ evidence_path: reportPath(review.evidence_path ?? review.path, context),
136
+ candidate_fingerprint: optionalString(review.candidate_fingerprint, "review candidate_fingerprint"),
137
+ findings: list(review.findings).map((finding) => ({
138
+ severity: text(finding?.severity, "unspecified"),
139
+ finding: text(finding?.finding, "not recorded"),
140
+ resolution: text(finding?.resolution, "none"),
141
+ evidence: text(finding?.evidence, "not recorded"),
142
+ })),
143
+ risks: list(review.risks),
144
+ };
145
+ }
146
+
147
+ function validateFinal(final, status) {
148
+ if (!objectLike(final)) throw reportError("human_report_invalid", "final must be an object");
149
+ const normalized = {
150
+ branch: optionalString(final.branch, "final branch"),
151
+ commit: optionalString(final.commit ?? final.final_commit, "final commit"),
152
+ base_commit: optionalString(final.base_commit, "final base_commit"),
153
+ candidate_fingerprint: optionalString(final.candidate_fingerprint, "final candidate_fingerprint"),
154
+ committed_candidate_fingerprint: optionalString(final.committed_candidate_fingerprint, "final committed_candidate_fingerprint"),
155
+ worktree: optionalString(final.worktree, "final worktree"),
156
+ repository: optionalString(final.repository, "final repository"),
157
+ };
158
+ if (status === "approved") {
159
+ for (const [key, value] of Object.entries(normalized)) {
160
+ if (["branch", "commit", "candidate_fingerprint", "repository"].includes(key) && !value) {
161
+ throw reportError("human_report_invalid", `approved report requires final.${key}`);
162
+ }
163
+ }
164
+ }
165
+ return normalized;
166
+ }
167
+
168
+ /** Validate and normalize the derived context supplied by the Orchestrator. */
169
+ export function normalizeCardReportInput(input, { projectRoot = null, worktree = null } = {}) {
170
+ if (!objectLike(input)) throw reportError("human_report_invalid", "card report input must be an object");
171
+ const card = objectLike(input.card) ? input.card : input;
172
+ const cardId = requiredString(card.id ?? input.card_id, "card id");
173
+ const status = requiredString(input.status ?? input.terminal_status, "terminal status").toLowerCase();
174
+ if (!new Set(["approved", "blocked", "not_delivered", "delivery_blocked"]).has(status)) {
175
+ throw reportError("human_report_invalid", `unsupported terminal status: ${status}`);
176
+ }
177
+ const context = { projectRoot, worktree };
178
+ const final = validateFinal(input.final ?? {}, status);
179
+ const attempts = list(input.attempts).map((attempt, index) => validateAttempt(attempt, index, context));
180
+ const verification = list(input.verification ?? input.verification_runs).map((run, index) => validateVerification(run, index, context));
181
+ const review = validateReview(input.review, context);
182
+ if (status === "approved") {
183
+ const passingRuns = verification.filter((run) => run.status === "pass" || run.status === "PASS");
184
+ if (!passingRuns.length) {
185
+ throw reportError("human_report_invalid", "approved report requires a passing verification run");
186
+ }
187
+ if (!review || !["approved", "APPROVED"].includes(review.decision)) {
188
+ throw reportError("human_report_invalid", "approved report requires an independent Reviewer approval");
189
+ }
190
+ if (!passingRuns.some((run) => run.candidate_fingerprint === final.candidate_fingerprint)) {
191
+ throw reportError("human_report_invalid", "approved report requires passing verifier evidence bound to the final candidate fingerprint");
192
+ }
193
+ if (review.candidate_fingerprint && review.candidate_fingerprint !== final.candidate_fingerprint) {
194
+ throw reportError("human_report_invalid", "Reviewer evidence is bound to a different candidate fingerprint");
195
+ }
196
+ }
197
+ if (status !== "approved" && !text(input.block_reason ?? input.reason, "").trim()) {
198
+ throw reportError("human_report_invalid", "non-approved terminal report requires a truthful reason");
199
+ }
200
+ return {
201
+ schema_version: HUMAN_REPORT_SCHEMA_VERSION,
202
+ project_id: text(input.project_id, "unnamed-project"),
203
+ card: {
204
+ id: cardId,
205
+ title: text(card.title, cardId),
206
+ goal: text(card.goal ?? card.observable_goal ?? card.outcome, "Not recorded."),
207
+ outcome: text(card.outcome ?? input.outcome, "Not recorded."),
208
+ repository: text(card.target_repository ?? card.repository ?? final.repository, "Not recorded."),
209
+ card_path: reportPath(card.card_path, context),
210
+ context: text(card.context, "Not recorded."),
211
+ },
212
+ status,
213
+ block_reason: text(input.block_reason ?? input.reason, "None recorded."),
214
+ implementation: {
215
+ summary: text(input.implementation?.summary ?? input.implemented, status === "approved" ? "Implementation completed." : "No delivered implementation claimed."),
216
+ paths: normalizeChangedPaths(input.implementation?.paths ?? [], context),
217
+ },
218
+ attempts,
219
+ verification,
220
+ review,
221
+ final,
222
+ provenance: {
223
+ assignment_path: reportPath(input.provenance?.assignment_path, context),
224
+ assignment_sha256: text(input.provenance?.assignment_sha256, "not recorded"),
225
+ packet_path: reportPath(input.provenance?.packet_path, context),
226
+ packet_sha256: text(input.provenance?.packet_sha256, "not recorded"),
227
+ verification_paths: list(input.provenance?.verification_paths).map((ref) => reportPath(ref, context)),
228
+ review_path: reportPath(input.provenance?.review_path, context),
229
+ },
230
+ risks: list(input.risks),
231
+ deferred: list(input.deferred),
232
+ evidence_refs: list(input.evidence_refs).map((ref) => reportPath(ref, context)),
233
+ generated_from: text(input.generated_from, "canonical control-plane evidence"),
234
+ changed_paths: input.changed_paths === undefined ? null : normalizeChangedPaths(input.changed_paths, context),
235
+ changed_paths_base: input.changed_paths_base ?? final.base_commit ?? null,
236
+ context,
237
+ };
238
+ }
239
+
240
+ function changedPathsForReport(report) {
241
+ if (report.changed_paths !== null) return report.changed_paths;
242
+ return [];
243
+ }
244
+
245
+ export function renderCardReport(input, options = {}) {
246
+ const report = input.schema_version === HUMAN_REPORT_SCHEMA_VERSION && input.card?.id
247
+ ? input
248
+ : normalizeCardReportInput(input, options);
249
+ const changed = changedPathsForReport(report);
250
+ const status = statusLabel(report.status);
251
+ const verificationRows = report.verification.flatMap((run) => run.gates.length
252
+ ? 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(", "))} |`)
253
+ : [`| ${tableCell(run.run_id)} | — | ${tableCell(run.status)} | — | — | ${tableCell(run.evidence_path)} |`]);
254
+ const attemptRows = report.attempts.length
255
+ ? 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(", "))} |`)
256
+ : ["| — | No attempts recorded | — | — | — | — |"];
257
+ const changedRows = changed.length
258
+ ? changed.map((entry) => `| ${tableCell(entry.status)} | ${tableCell(entry.label)} | ${tableCell(entry.repository ?? report.card.repository)} |`)
259
+ : ["| — | No changed paths recorded from the card baseline | — |"];
260
+ const review = report.review;
261
+ const findings = review?.findings?.length
262
+ ? review.findings.map((finding) => `| ${tableCell(finding.severity)} | ${tableCell(finding.finding)} | ${tableCell(finding.evidence)} | ${tableCell(finding.resolution)} |`)
263
+ : ["| — | No findings recorded | — | None |"];
264
+ const finalEvidence = report.status === "approved"
265
+ ? `- Final commit: \`${tableCell(report.final.commit)}\`\n- Verified candidate fingerprint: \`${tableCell(report.final.candidate_fingerprint)}\`\n- Committed candidate fingerprint: \`${tableCell(report.final.committed_candidate_fingerprint ?? "not recorded")}\`\n- Reviewer decision: \`${tableCell(review?.decision)}\``
266
+ : `- Terminal state: \`${status}\`\n- Reason: ${text(report.block_reason)}`;
267
+ return `# Card report — ${report.card.id}: ${report.card.title}
268
+
269
+ > Derived human-readable view of canonical Triad+ evidence. This report is not a new source of truth.
270
+
271
+ ## Result
272
+
273
+ **${status}**
274
+
275
+ - Project: ${tableCell(report.project_id)}
276
+ - Repository: ${tableCell(report.card.repository)}
277
+ - Goal: ${tableCell(report.card.goal)}
278
+ - Outcome: ${tableCell(report.card.outcome)}
279
+ - Card path: \`${tableCell(report.card.card_path)}\`
280
+
281
+ ## What was implemented
282
+
283
+ ${text(report.implementation.summary)}
284
+
285
+ ## Card-attributable changed paths
286
+
287
+ Baseline: \`${tableCell(report.changed_paths_base)}\`
288
+
289
+ | Status | Path | Repository |
290
+ | --- | --- | --- |
291
+ ${changedRows.join("\n")}
292
+
293
+ ## Verification and gates
294
+
295
+ | Verification run | Gate | Status | Exit code | Duration (ms) | Evidence |
296
+ | --- | --- | --- | --- | --- | --- |
297
+ ${verificationRows.join("\n")}
298
+
299
+ ## Attempts and rework
300
+
301
+ | Attempt | Outcome | Resolution | Candidate fingerprint | Notes | Evidence |
302
+ | --- | --- | --- | --- | --- | --- |
303
+ ${attemptRows.join("\n")}
304
+
305
+ ## Independent Reviewer
306
+
307
+ - Reviewer: ${tableCell(review?.reviewer ?? "Not dispatched")}
308
+ - Decision: **${tableCell(review?.decision ?? "not recorded")}**
309
+ - Evidence: \`${tableCell(review?.evidence_path ?? "not recorded")}\`
310
+
311
+ | Severity | Finding | Evidence | Resolution |
312
+ | --- | --- | --- | --- |
313
+ ${findings.join("\n")}
314
+
315
+ ${review?.risks?.length ? `Reviewer risks:\n${review.risks.map(markdownBullet).join("\n")}` : "Reviewer risks: none recorded."}
316
+
317
+ ## Final evidence and provenance
318
+
319
+ ${finalEvidence}
320
+
321
+ - Branch: \`${tableCell(report.final.branch)}\`
322
+ - Repository baseline: \`${tableCell(report.final.base_commit)}\`
323
+ - Source: ${tableCell(report.generated_from)}
324
+ - Evidence references: ${report.evidence_refs.length ? report.evidence_refs.map((ref) => `\`${tableCell(ref)}\``).join(", ") : "none recorded"}
325
+ - Assignment: \`${tableCell(report.provenance.assignment_path)}\` (${tableCell(report.provenance.assignment_sha256)})
326
+ - Assignment Packet: \`${tableCell(report.provenance.packet_path)}\` (${tableCell(report.provenance.packet_sha256)})
327
+ - Verification references: ${report.provenance.verification_paths.length ? report.provenance.verification_paths.map((ref) => `\`${tableCell(ref)}\``).join(", ") : "none recorded"}
328
+
329
+ ## Risks, deferred work, and terminal notes
330
+
331
+ ${report.risks.length ? report.risks.map(markdownBullet).join("\n") : "- No residual risks recorded."}
332
+ ${report.deferred.length ? report.deferred.map((item) => `- Deferred: ${text(item)}`).join("\n") : "- No deferred items recorded."}
333
+ ${report.status !== "approved" ? `- Terminal reason: ${text(report.block_reason)}` : "- The card reached approved status after current verification and independent review."}
334
+ `;
335
+ }
336
+
337
+ function normalizeHandoffInput(input, options = {}) {
338
+ if (!objectLike(input)) throw reportError("human_report_invalid", "handoff input must be an object");
339
+ const cards = list(input.cards).map((card) => ({
340
+ id: requiredString(card.id, "handoff card id"),
341
+ title: text(card.title, card.id),
342
+ status: text(card.status, "not recorded"),
343
+ summary: text(card.summary ?? card.outcome, "Not recorded."),
344
+ report_path: reportPath(card.report_path, options),
345
+ commit: optionalString(card.commit, `handoff card ${card.id} commit`),
346
+ candidate_fingerprint: optionalString(card.candidate_fingerprint, `handoff card ${card.id} candidate_fingerprint`),
347
+ verification: text(card.verification, "Not recorded."),
348
+ review: text(card.review, "Not recorded."),
349
+ evaluator_report: reportPath(card.evaluator_report, options),
350
+ evidence_refs: list(card.evidence_refs).map((ref) => reportPath(ref, options)),
351
+ }));
352
+ if (!cards.length) throw reportError("human_report_invalid", "handoff requires at least one card result");
353
+ return {
354
+ project_id: text(input.project_id, "unnamed-project"),
355
+ decision: requiredString(input.decision, "handoff decision"),
356
+ executive_summary: text(input.executive_summary ?? input.summary, "Not recorded."),
357
+ cards,
358
+ code_areas: list(input.code_areas),
359
+ residual: list(input.residual ?? input.deferred),
360
+ verification: text(input.verification, "Not recorded."),
361
+ review: text(input.review, "Not recorded."),
362
+ branch_commits: list(input.branch_commits),
363
+ practical_test: list(input.practical_test),
364
+ evaluator: text(input.evaluator, "Not configured."),
365
+ delivery: text(input.delivery, "Not recorded."),
366
+ quality_contract: text(input.quality_contract, "Not configured."),
367
+ delivery_criteria: list(input.delivery_criteria).map((criterion) => ({
368
+ ...criterion,
369
+ evidence_refs: list(criterion?.evidence_refs).map((ref) => reportPath(ref, options)),
370
+ })),
371
+ demo: text(input.demo, "Not configured."),
372
+ evidence_refs: list(input.evidence_refs).map((ref) => reportPath(ref, options)),
373
+ exceptions: list(input.exceptions),
374
+ prd_baseline: text(input.prd_baseline, "Not recorded."),
375
+ approved_cards: list(input.approved_cards),
376
+ push_evidence: list(input.push_evidence),
377
+ gate_metrics: list(input.gate_metrics),
378
+ local_worktree_integration: list(input.local_worktree_integration),
379
+ delivery_closure_record: text(input.delivery_closure_record, "Not recorded."),
380
+ final_message: text(input.final_message, "Not recorded."),
381
+ risks: list(input.risks),
382
+ generated_from: text(input.generated_from, "canonical control-plane evidence"),
383
+ };
384
+ }
385
+
386
+ export function renderHandoffReport(input, options = {}) {
387
+ const handoff = input.project_id && Array.isArray(input.cards) && input.cards[0]?.report_path !== undefined
388
+ ? input
389
+ : normalizeHandoffInput(input, options);
390
+ 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)}) |`);
391
+ const links = handoff.cards.map((card) => `- [${card.id} card report](${card.report_path})`).join("\n");
392
+ return `# Delivery handoff — ${handoff.project_id}
393
+
394
+ ## Executive summary
395
+
396
+ **${tableCell(statusLabel(handoff.decision))}**
397
+
398
+ ${handoff.executive_summary}
399
+
400
+ ## Card-by-card results
401
+
402
+ | Card | Title | Result | Outcome | Commit | Candidate fingerprint | Evaluator+ | Human-readable report |
403
+ | --- | --- | --- | --- | --- | --- | --- | --- |
404
+ ${cardRows.join("\n")}
405
+
406
+ ${links}
407
+
408
+ ## Main code areas, problem, and resolution
409
+
410
+ ${handoff.code_areas.length ? handoff.code_areas.map((item) => `- ${text(item)}`).join("\n") : "- Not recorded."}
411
+
412
+ ## Residual, deferred, and blocked items
413
+
414
+ ${handoff.residual.length ? handoff.residual.map((item) => `- ${text(item)}`).join("\n") : "- None recorded."}
415
+
416
+ ## Verification and review status
417
+
418
+ - Verification: ${handoff.verification}
419
+ - Independent review: ${handoff.review}
420
+ - Evaluator+: ${handoff.evaluator}
421
+ - Delivery closure: ${handoff.delivery}
422
+
423
+ - PRD baseline: ${handoff.prd_baseline}
424
+ - Approved cards: ${handoff.approved_cards.length ? handoff.approved_cards.map((item) => text(item)).join(", ") : "Not recorded."}
425
+ - Push evidence: ${handoff.push_evidence.length ? handoff.push_evidence.map((item) => text(item)).join("; ") : "Not recorded."}
426
+ - Gates and metrics: ${handoff.gate_metrics.length ? handoff.gate_metrics.map((item) => text(item)).join("; ") : "Not recorded."}
427
+
428
+ ## Quality and delivery closure
429
+
430
+ - Quality Contract: ${handoff.quality_contract}
431
+ - Demo: ${handoff.demo}
432
+
433
+ ${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."}
434
+
435
+ - Delivery closure record: ${handoff.delivery_closure_record}
436
+ - Final delivery message: ${handoff.final_message}
437
+
438
+ ## Branch, commit, and evidence map
439
+
440
+ ${handoff.branch_commits.length ? handoff.branch_commits.map((item) => `- ${text(item)}`).join("\n") : "- Not recorded."}
441
+
442
+ ${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."}
443
+
444
+ ## Practical test / follow-up
445
+
446
+ ${handoff.practical_test.length ? handoff.practical_test.map((item) => `- ${text(item)}`).join("\n") : "- No practical test recorded."}
447
+
448
+ ## Exceptions and evidence references
449
+
450
+ ${handoff.exceptions.length ? handoff.exceptions.map((item) => `- ${text(item)}`).join("\n") : "- No exceptions recorded."}
451
+ ${handoff.evidence_refs.length ? handoff.evidence_refs.map((item) => `- \`${tableCell(item)}\``).join("\n") : "- No additional evidence references recorded."}
452
+
453
+ ## Risks
454
+
455
+ ${handoff.risks.length ? handoff.risks.map((item) => `- ${text(item)}`).join("\n") : "- None recorded."}
456
+
457
+ ## Canonical record boundary
458
+
459
+ 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.
460
+ `;
461
+ }
462
+
463
+ /**
464
+ * Generate a card report while attributing paths to the complete card baseline.
465
+ * The changed-path manifest is collected from Git, never from Developer prose.
466
+ */
467
+ export async function writeCardReport(input, { outputPath, projectRoot = null, worktree = null, baseCommit = null } = {}) {
468
+ const preliminary = normalizeCardReportInput(input, { projectRoot, worktree });
469
+ let changedPaths = preliminary.changed_paths;
470
+ let changedPathsBase = preliminary.changed_paths_base ?? baseCommit;
471
+ let committedCandidateFingerprint = preliminary.final.committed_candidate_fingerprint;
472
+ if (preliminary.status === "approved") {
473
+ if (!worktree || !(baseCommit ?? preliminary.final.base_commit) || !preliminary.final.commit) {
474
+ throw reportError("human_report_invalid", "approved card report requires a worktree, card baseline, and final commit for canonical evidence");
475
+ }
476
+ const candidate = await collectCandidateChangesAtCommit(worktree, {
477
+ baseCommit: baseCommit ?? preliminary.final.base_commit,
478
+ commit: preliminary.final.commit,
479
+ });
480
+ changedPaths = normalizeChangedPaths(candidate.changes, { projectRoot, worktree });
481
+ changedPathsBase = candidate.base_commit;
482
+ const actualFingerprint = await calculateCandidateFingerprintAtCommit(worktree, {
483
+ baseCommit: candidate.base_commit,
484
+ commit: candidate.git_head,
485
+ });
486
+ const passingRuns = preliminary.verification.filter((run) =>
487
+ (run.status === "pass" || run.status === "PASS")
488
+ && run.candidate_fingerprint === preliminary.final.candidate_fingerprint);
489
+ const passingRun = passingRuns.find((run) => run.candidate_manifest) ?? passingRuns[0];
490
+ if (passingRun?.candidate_manifest) {
491
+ if (!candidateManifestsBind(passingRun.candidate_manifest, actualFingerprint.manifest, { expectedBaseCommit: candidate.base_commit })) {
492
+ throw reportError("human_report_invalid", "committed candidate does not match the independently verified candidate manifest");
493
+ }
494
+ } else if (actualFingerprint.value !== preliminary.final.candidate_fingerprint) {
495
+ throw reportError("human_report_invalid", "final candidate fingerprint does not match the final commit delta");
496
+ }
497
+ committedCandidateFingerprint = actualFingerprint.value;
498
+ }
499
+ const report = {
500
+ ...preliminary,
501
+ final: { ...preliminary.final, committed_candidate_fingerprint: committedCandidateFingerprint ?? null },
502
+ changed_paths: changedPaths ?? [],
503
+ changed_paths_base: changedPathsBase
504
+ };
505
+ const markdown = renderCardReport(report, { projectRoot, worktree });
506
+ if (outputPath) {
507
+ try {
508
+ const previous = await readFile(outputPath, "utf8");
509
+ const previousApproved = /\n\*\*APPROVED\*\*\n/.test(previous);
510
+ if (previousApproved && report.status !== "approved") {
511
+ throw reportError("human_report_immutable", "an approved Card report cannot be overwritten by a non-approved terminal view");
512
+ }
513
+ if (previousApproved && previous !== markdown) {
514
+ throw reportError("human_report_immutable", "an approved Card report cannot be changed after publication");
515
+ }
516
+ if (previous === markdown) return { report, markdown, outputPath };
517
+ } catch (error) {
518
+ if (error.code !== "ENOENT") throw error;
519
+ }
520
+ await writeAtomicText(outputPath, markdown);
521
+ }
522
+ return { report, markdown, outputPath: outputPath ?? null };
523
+ }
524
+
525
+ export async function writeHandoffReport(input, { outputPath, projectRoot = null } = {}) {
526
+ const handoff = normalizeHandoffInput(input, { projectRoot });
527
+ const markdown = renderHandoffReport(handoff, { projectRoot });
528
+ if (outputPath) await writeAtomicText(outputPath, markdown);
529
+ return { handoff, markdown, outputPath: outputPath ?? null };
530
+ }
@@ -2,11 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import process from "node:process";
3
3
 
4
4
  export function runProcess(command, args = [], options = {}) {
5
- const { cwd, timeoutMs = 600_000, shell = false, killGraceMs = 2_000 } = options;
5
+ const { cwd, timeoutMs = 600_000, shell = false, killGraceMs = 2_000, encoding = "utf8" } = options;
6
6
  return new Promise((resolve) => {
7
7
  const detached = process.platform !== "win32";
8
8
  const child = spawn(command, args, { cwd, shell, detached, stdio: ["ignore", "pipe", "pipe"] });
9
- let stdout = "";
9
+ let stdout = encoding === null ? [] : "";
10
10
  let stderr = "";
11
11
  let timedOut = false;
12
12
  let forceTimer;
@@ -15,13 +15,16 @@ export function runProcess(command, args = [], options = {}) {
15
15
  signalProcess(child, "SIGTERM", detached);
16
16
  forceTimer = setTimeout(() => signalProcess(child, "SIGKILL", detached), killGraceMs);
17
17
  }, timeoutMs);
18
- child.stdout.on("data", (chunk) => { stdout += chunk; });
18
+ child.stdout.on("data", (chunk) => {
19
+ if (encoding === null) stdout.push(Buffer.from(chunk));
20
+ else stdout += chunk;
21
+ });
19
22
  child.stderr.on("data", (chunk) => { stderr += chunk; });
20
23
  child.on("error", (error) => { stderr += `${error.message}\n`; });
21
24
  child.on("close", (exitCode, signal) => {
22
25
  clearTimeout(timer);
23
26
  clearTimeout(forceTimer);
24
- resolve({ exitCode: exitCode ?? 1, signal, timedOut, stdout, stderr });
27
+ resolve({ exitCode: exitCode ?? 1, signal, timedOut, stdout: encoding === null ? Buffer.concat(stdout) : stdout, stderr });
25
28
  });
26
29
  });
27
30
  }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { writeCardReport, writeHandoffReport } from "./lib/human-reports.mjs";
6
+
7
+ function parseArgs(argv) {
8
+ const args = { mode: null, input: null, output: null, project: process.cwd(), worktree: null, baseCommit: null };
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const flag = argv[index];
11
+ if (flag === "--help" || flag === "-h") {
12
+ process.stdout.write("Usage: node .triad-runtime/triad-human-report.mjs --mode <card|handoff> --input <derived-report-context.json> --output <report.md> [--project <control-workspace>] [--worktree <product-worktree>] [--base-commit <card-baseline> ]\n");
13
+ return null;
14
+ }
15
+ const key = { "--mode": "mode", "--input": "input", "--output": "output", "--project": "project", "--worktree": "worktree", "--base-commit": "baseCommit" }[flag];
16
+ if (!key) throw Object.assign(new Error(`unknown option: ${flag}`), { code: "human_report_cli_invalid" });
17
+ const value = argv[index + 1];
18
+ if (!value || value.startsWith("--")) throw Object.assign(new Error(`${flag} requires a value`), { code: "human_report_cli_invalid" });
19
+ args[key] = value;
20
+ index += 1;
21
+ }
22
+ if (!args.mode || !["card", "handoff"].includes(args.mode)) throw Object.assign(new Error("--mode must be card or handoff"), { code: "human_report_cli_invalid" });
23
+ if (!args.input || !args.output) throw Object.assign(new Error("--input and --output are required"), { code: "human_report_cli_invalid" });
24
+ return args;
25
+ }
26
+
27
+ async function main() {
28
+ const args = parseArgs(process.argv.slice(2));
29
+ if (!args) return;
30
+ const projectRoot = await realpath(args.project);
31
+ const inputPath = path.resolve(projectRoot, args.input);
32
+ const outputPath = path.resolve(projectRoot, args.output);
33
+ const input = JSON.parse(await readFile(inputPath, "utf8"));
34
+ const result = args.mode === "card"
35
+ ? await writeCardReport(input, {
36
+ outputPath,
37
+ projectRoot,
38
+ worktree: args.worktree ? await realpath(path.resolve(projectRoot, args.worktree)) : input?.final?.worktree ?? null,
39
+ baseCommit: args.baseCommit ?? input?.final?.base_commit ?? null,
40
+ })
41
+ : await writeHandoffReport(input, { outputPath, projectRoot });
42
+ process.stdout.write(`${JSON.stringify({ valid: true, mode: args.mode, output: path.relative(projectRoot, result.outputPath) })}\n`);
43
+ }
44
+
45
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
46
+ main().catch((error) => {
47
+ process.stderr.write(`${error.code ? `${error.code}: ` : ""}${error.message}\n`);
48
+ process.exitCode = 2;
49
+ });
50
+ }
@@ -236,6 +236,7 @@ async function main() {
236
236
  candidate_fingerprint: before.value,
237
237
  branch,
238
238
  },
239
+ candidate_manifest: before.manifest,
239
240
  assignment_packet: assignmentPacket,
240
241
  repository_skills: repositorySkills,
241
242
  scope,
@@ -266,14 +267,15 @@ async function main() {
266
267
  trigger,
267
268
  assignment_ref: path.relative(projectRoot, assignmentPath),
268
269
  baseline: {
269
- prd_sha256: assignment.expected_prd_sha256,
270
- card_sha256: assignment.expected_card_sha256,
271
- quality_baseline_fingerprint: qualityBaseline?.fingerprint ?? null,
272
- gates_sha256: trusted.actualHash,
270
+ prd_sha256: assignment.expected_prd_sha256,
271
+ card_sha256: assignment.expected_card_sha256,
272
+ quality_baseline_fingerprint: qualityBaseline?.fingerprint ?? null,
273
+ gates_sha256: trusted.actualHash,
273
274
  git_head: before.git_head,
274
275
  candidate_fingerprint: before.value,
275
276
  branch,
276
277
  },
278
+ candidate_manifest: before.manifest,
277
279
  assignment_packet: assignmentPacket,
278
280
  repository_skills: repositorySkills,
279
281
  scope,
@@ -13,6 +13,39 @@
13
13
  "assignment_sha256": { "type": ["string", "null"] },
14
14
  "trigger": { "type": "object", "required": ["event", "agent_id", "agent_type"], "properties": { "event": { "type": "string" }, "agent_id": { "type": ["string", "null"] }, "agent_type": { "type": ["string", "null"] } } },
15
15
  "baseline": { "type": "object", "required": ["prd_sha256", "card_sha256", "git_head", "candidate_fingerprint"], "properties": { "prd_sha256": { "type": ["string", "null"] }, "card_sha256": { "type": ["string", "null"] }, "quality_baseline_fingerprint": { "type": ["string", "null"], "pattern": "^[a-fA-F0-9]{64}$" }, "git_head": { "type": ["string", "null"] }, "candidate_fingerprint": { "type": ["string", "null"] }, "branch": { "type": ["string", "null"] } } },
16
+ "candidate_manifest": {
17
+ "type": ["object", "null"],
18
+ "required": ["base_commit", "git_head", "changes", "ignored_paths", "files"],
19
+ "properties": {
20
+ "base_commit": { "type": ["string", "null"] },
21
+ "git_head": { "type": ["string", "null"] },
22
+ "changes": {
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "required": ["status"],
27
+ "properties": {
28
+ "status": { "type": "string" },
29
+ "path": { "type": ["string", "null"] },
30
+ "source": { "type": ["string", "null"] },
31
+ "destination": { "type": ["string", "null"] }
32
+ }
33
+ }
34
+ },
35
+ "ignored_paths": { "type": "array", "items": { "type": "string" } },
36
+ "files": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "required": ["path", "sha256"],
41
+ "properties": {
42
+ "path": { "type": "string" },
43
+ "sha256": { "type": "string" }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ },
16
49
  "assignment_packet": {
17
50
  "type": ["object", "null"],
18
51
  "required": ["path", "sha256"],
@@ -30,7 +30,10 @@ authority after the planning handoff.
30
30
  repositories, branches, and worktrees.
31
31
  3. Copy the approved PRD to `artifacts/prd.md`; record source, collection time,
32
32
  snapshot, revision when available, and SHA-256.
33
- 4. Copy `assets/loop-template/` to `.loop/`. For ordinary PRD-only input,
33
+ 4. Copy `assets/loop-template/` to `.loop/`. This includes derived report
34
+ templates under `runtime/report-context/`; keep report contexts and
35
+ `card-reports/` in the control workspace, never in product source. For
36
+ ordinary PRD-only input,
34
37
  create bounded feature cards under `features/` and a complete
35
38
  `feature-plan.md`. For native BMAD input, ingest the canonical Stories and
36
39
  materialize one normal Card per Story instead; do not perform a second
@@ -0,0 +1,32 @@
1
+ # Card report — <feature ID>: <title>
2
+
3
+ > Derived human-readable view of canonical Triad+ evidence. This report is not a new source of truth.
4
+
5
+ ## Result
6
+
7
+ `APPROVED | BLOCKED | NOT DELIVERED`
8
+
9
+ ## What was implemented
10
+
11
+ `<bounded outcome and actual result>`
12
+
13
+ ## Card-attributable changed paths
14
+
15
+ `<complete delta from the immutable card baseline, including additions, modifications, renames, and deletions>`
16
+
17
+ ## Verification and gates
18
+
19
+ `<verification runs, gate results, evidence references, and attempt/rework history>`
20
+
21
+ ## Independent Reviewer
22
+
23
+ `<decision, findings, resolutions, risks, and review evidence>`
24
+
25
+ ## Final evidence and provenance
26
+
27
+ `<repository, branch, commit/range, card baseline, candidate fingerprint, packet, review, and evidence references>`
28
+
29
+ ## Risks, deferred work, and terminal notes
30
+
31
+ `<truthful residual information; never claim success for a blocked card>`
32
+
@@ -1,5 +1,19 @@
1
1
  # Delivery handoff — <project ID>
2
2
 
3
+ ## Executive summary
4
+
5
+ `<human-first summary of the delivery decision, product outcome, and any blocked or deferred work>`
6
+
7
+ ## Card-by-card results
8
+
9
+ | Card | Result | Outcome | Commit | Candidate fingerprint | Evaluator+ | Human-readable report |
10
+ | --- | --- | --- | --- | --- | --- | --- |
11
+ | `<ID>` | `<approved|blocked|not delivered>` | `<summary>` | `<commit>` | `<fingerprint>` | `<report or not configured>` | `<relative card-reports/<ID>.md path>` |
12
+
13
+ The Markdown reports are derived views. The control-workspace run state,
14
+ verification evidence, review records, delivery gates, and optional Evaluator+
15
+ report remain authoritative.
16
+
3
17
  ## Decision
4
18
 
5
19
  `delivered | delivery_blocked | delivered_without_demo`
@@ -0,0 +1,29 @@
1
+ {
2
+ "schema_version": 1,
3
+ "project_id": "REPLACE_ME",
4
+ "card": {
5
+ "id": "REPLACE_ME",
6
+ "title": "REPLACE_ME",
7
+ "goal": "REPLACE_ME",
8
+ "outcome": "REPLACE_ME",
9
+ "target_repository": "REPLACE_ME",
10
+ "card_path": "features/REPLACE_ME.md"
11
+ },
12
+ "status": "approved",
13
+ "implementation": { "summary": "REPLACE_ME" },
14
+ "attempts": [],
15
+ "verification": [],
16
+ "review": null,
17
+ "final": {
18
+ "repository": "REPLACE_ME",
19
+ "branch": "REPLACE_ME",
20
+ "commit": "REPLACE_ME",
21
+ "base_commit": "REPLACE_ME",
22
+ "candidate_fingerprint": "REPLACE_ME",
23
+ "committed_candidate_fingerprint": null,
24
+ "worktree": "REPLACE_ME_ABSOLUTE_PRODUCT_WORKTREE"
25
+ },
26
+ "evidence_refs": [],
27
+ "risks": [],
28
+ "deferred": []
29
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "schema_version": 1,
3
+ "project_id": "REPLACE_ME",
4
+ "decision": "delivered",
5
+ "executive_summary": "REPLACE_ME",
6
+ "cards": [],
7
+ "code_areas": [],
8
+ "residual": [],
9
+ "verification": "REPLACE_ME",
10
+ "review": "REPLACE_ME",
11
+ "branch_commits": [],
12
+ "practical_test": [],
13
+ "evaluator": "not configured",
14
+ "delivery": "REPLACE_ME",
15
+ "risks": []
16
+ }
@@ -204,6 +204,63 @@ fail-closed escalation; do not dispatch a Developer or consume retry budget.
204
204
  while a dependency-satisfied card remains `ready`; stop only for a declared
205
205
  escalation, a blocked card, or when every required card is terminal.
206
206
 
207
+ ## Human-readable terminal reports
208
+
209
+ Human-readable reports are derived views, not a second control plane and not a
210
+ new LLM task. For every terminal Card, assemble a small JSON context from the
211
+ canonical card, attempts, verifier evidence, Reviewer record, final commit, and
212
+ candidate fingerprint. Do not copy Developer prose as changed-path evidence.
213
+
214
+ For an approved Card, the context must include the final commit and fingerprint,
215
+ at least one current passing verifier record, an independent Reviewer approval,
216
+ and the complete Card-baseline commit. The final candidate fingerprint in this
217
+ context is the identity recorded by the passing verifier (the pre-commit
218
+ candidate), not a newly recalculated commit fingerprint. Preserve the verifier's
219
+ `candidate_manifest` alongside that record. The report runtime binds that
220
+ independently verified path/content manifest to the final commit delta and
221
+ records both the verified fingerprint and the committed fingerprint. It ignores
222
+ only the expected `git_head` identity change caused by committing; added,
223
+ removed, renamed, or modified paths and their content hashes must still match.
224
+ If a legacy verifier record has no manifest, the historical fingerprint equality
225
+ check remains fail-closed. Materialize the report with the installed runtime
226
+ command, which collects the complete baseline delta from Git:
227
+
228
+ ```bash
229
+ node .triad-runtime/triad-human-report.mjs \
230
+ --mode card \
231
+ --project /absolute/path/to/control-workspace \
232
+ --input .loop/runtime/report-context/<card-id>.json \
233
+ --output card-reports/<card-id>.md \
234
+ --worktree /absolute/path/to/product-worktree \
235
+ --base-commit <card-baseline-commit>
236
+ ```
237
+
238
+ Generate this only after the Card reaches its terminal state. A blocked or
239
+ not-delivered Card must include a truthful reason and must never be rendered as
240
+ approved. The renderer is atomic and idempotent; a resumed run may repeat the
241
+ same command without creating duplicate reports. It records additions,
242
+ modifications, deletions, and renames from the Card baseline, including paths
243
+ left by earlier rework attempts. Keep report paths relative to the control
244
+ workspace and never emit machine-specific absolute paths.
245
+
246
+ After all required Cards and delivery criteria are closed, assemble a derived
247
+ handoff context and run:
248
+
249
+ ```bash
250
+ node .triad-runtime/triad-human-report.mjs \
251
+ --mode handoff \
252
+ --project /absolute/path/to/control-workspace \
253
+ --input .loop/runtime/report-context/delivery.json \
254
+ --output handoff.md
255
+ ```
256
+
257
+ The handoff must open with an executive summary and Card-by-Card report links,
258
+ then preserve the technical branch/commit map, verifier/review/evaluator
259
+ evidence, delivery criteria, demos, risks, and practical-test instructions.
260
+ Missing or invalid final evidence is a report-generation error, not a success
261
+ claim. Do not add a second report agent or overwrite an approved report with a
262
+ later non-terminal candidate.
263
+
207
264
  ## Unattended continuation rule
208
265
 
209
266
  The normal chain is unattended: Developer completion → verification → Reviewer