triad-plus 1.12.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 +9 -0
- package/docs/human-readable-reports.md +7 -4
- package/package.json +2 -2
- package/runtime/lib/fingerprint.mjs +76 -3
- package/runtime/lib/human-reports.mjs +21 -4
- package/runtime/lib/process.mjs +7 -4
- package/runtime/triad-verify.mjs +6 -4
- package/schemas/verification-evidence.schema.json +33 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/report-context/card.template.json +1 -0
- package/skills/triad-loop-orchestrator/SKILL.md +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
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
|
+
|
|
5
14
|
## 1.12.0 — 2026-09-23
|
|
6
15
|
|
|
7
16
|
- Add deterministic human-readable Markdown views for terminal Cards and the
|
|
@@ -29,9 +29,13 @@ node .triad-runtime/triad-human-report.mjs \
|
|
|
29
29
|
--base-commit <card-baseline-commit>
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
For an approved Card the renderer requires a final commit, candidate
|
|
33
|
-
fingerprint
|
|
34
|
-
complete changed-path manifest.
|
|
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
|
|
35
39
|
include a truthful reason and never claims delivery. Writes are atomic and
|
|
36
40
|
re-running the command with the same context is idempotent.
|
|
37
41
|
|
|
@@ -49,4 +53,3 @@ The handoff opens with the executive summary and Card results, then preserves
|
|
|
49
53
|
links to the technical evidence, branch/commit map, quality-contract closure,
|
|
50
54
|
Evaluator+ result, delivery gates, demo details, risks, and practical test.
|
|
51
55
|
It is a view for people, not an alternative delivery state machine.
|
|
52
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triad-plus",
|
|
3
|
-
"version": "1.12.
|
|
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 && node tests/human-reports-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": {
|
|
@@ -63,6 +63,67 @@ function pathsFor(entry) {
|
|
|
63
63
|
return entry.status === "renamed" ? [entry.source, entry.destination] : [entry.path];
|
|
64
64
|
}
|
|
65
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
|
+
|
|
66
127
|
export async function collectCandidateChanges(worktree, { baseCommit = null } = {}) {
|
|
67
128
|
const root = await realpath(worktree);
|
|
68
129
|
const head = (await gitLines(root, ["rev-parse", "HEAD"]))[0] ?? "NO_HEAD";
|
|
@@ -120,7 +181,7 @@ export async function collectCandidateChangesAtCommit(worktree, { baseCommit, co
|
|
|
120
181
|
}
|
|
121
182
|
|
|
122
183
|
async function commitFileHash(root, commit, relativePath) {
|
|
123
|
-
const result = await runProcess("git", ["show", `${commit}:${relativePath}`], { cwd: root, timeoutMs: 15_000 });
|
|
184
|
+
const result = await runProcess("git", ["show", `${commit}:${relativePath}`], { cwd: root, timeoutMs: 15_000, encoding: null });
|
|
124
185
|
if (result.exitCode !== 0) throw new Error(`git show failed for ${commit}:${relativePath}`);
|
|
125
186
|
return digest(result.stdout);
|
|
126
187
|
}
|
|
@@ -144,7 +205,13 @@ export async function calculateCandidateFingerprintAtCommit(worktree, { baseComm
|
|
|
144
205
|
files.push({ path: relativePath, sha256: contentHash });
|
|
145
206
|
}
|
|
146
207
|
const canonical = JSON.stringify({ git_head: candidate.git_head, files });
|
|
147
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
algorithm: "sha256",
|
|
210
|
+
value: digest(canonical),
|
|
211
|
+
git_head: candidate.git_head,
|
|
212
|
+
files,
|
|
213
|
+
manifest: candidateManifest(candidate, files),
|
|
214
|
+
};
|
|
148
215
|
}
|
|
149
216
|
|
|
150
217
|
export async function worktreeBranch(worktree) {
|
|
@@ -181,5 +248,11 @@ export async function calculateCandidateFingerprint(worktree) {
|
|
|
181
248
|
files.push({ path: relativePath, sha256: contentHash });
|
|
182
249
|
}
|
|
183
250
|
const canonical = JSON.stringify({ git_head: candidate.git_head, files });
|
|
184
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
algorithm: "sha256",
|
|
253
|
+
value: digest(canonical),
|
|
254
|
+
git_head: candidate.git_head,
|
|
255
|
+
files,
|
|
256
|
+
manifest: candidateManifest(candidate, files),
|
|
257
|
+
};
|
|
185
258
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { calculateCandidateFingerprintAtCommit, collectCandidateChangesAtCommit } from "./fingerprint.mjs";
|
|
3
|
+
import { calculateCandidateFingerprintAtCommit, candidateManifestsBind, collectCandidateChangesAtCommit } from "./fingerprint.mjs";
|
|
4
4
|
import { writeAtomicText } from "./evidence.mjs";
|
|
5
5
|
|
|
6
6
|
export const HUMAN_REPORT_SCHEMA_VERSION = 1;
|
|
@@ -121,6 +121,7 @@ function validateVerification(run, index, context = {}) {
|
|
|
121
121
|
})),
|
|
122
122
|
evidence_path: reportPath(run.evidence_path ?? run.path, context),
|
|
123
123
|
candidate_fingerprint: optionalString(run.candidate_fingerprint, `verification ${index + 1} candidate_fingerprint`),
|
|
124
|
+
candidate_manifest: run.candidate_manifest ?? run.baseline?.candidate_manifest ?? null,
|
|
124
125
|
notes: text(run.notes, "—"),
|
|
125
126
|
};
|
|
126
127
|
}
|
|
@@ -150,6 +151,7 @@ function validateFinal(final, status) {
|
|
|
150
151
|
commit: optionalString(final.commit ?? final.final_commit, "final commit"),
|
|
151
152
|
base_commit: optionalString(final.base_commit, "final base_commit"),
|
|
152
153
|
candidate_fingerprint: optionalString(final.candidate_fingerprint, "final candidate_fingerprint"),
|
|
154
|
+
committed_candidate_fingerprint: optionalString(final.committed_candidate_fingerprint, "final committed_candidate_fingerprint"),
|
|
153
155
|
worktree: optionalString(final.worktree, "final worktree"),
|
|
154
156
|
repository: optionalString(final.repository, "final repository"),
|
|
155
157
|
};
|
|
@@ -260,7 +262,7 @@ export function renderCardReport(input, options = {}) {
|
|
|
260
262
|
? review.findings.map((finding) => `| ${tableCell(finding.severity)} | ${tableCell(finding.finding)} | ${tableCell(finding.evidence)} | ${tableCell(finding.resolution)} |`)
|
|
261
263
|
: ["| — | No findings recorded | — | None |"];
|
|
262
264
|
const finalEvidence = report.status === "approved"
|
|
263
|
-
? `- Final commit: \`${tableCell(report.final.commit)}\`\n-
|
|
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)}\``
|
|
264
266
|
: `- Terminal state: \`${status}\`\n- Reason: ${text(report.block_reason)}`;
|
|
265
267
|
return `# Card report — ${report.card.id}: ${report.card.title}
|
|
266
268
|
|
|
@@ -466,6 +468,7 @@ export async function writeCardReport(input, { outputPath, projectRoot = null, w
|
|
|
466
468
|
const preliminary = normalizeCardReportInput(input, { projectRoot, worktree });
|
|
467
469
|
let changedPaths = preliminary.changed_paths;
|
|
468
470
|
let changedPathsBase = preliminary.changed_paths_base ?? baseCommit;
|
|
471
|
+
let committedCandidateFingerprint = preliminary.final.committed_candidate_fingerprint;
|
|
469
472
|
if (preliminary.status === "approved") {
|
|
470
473
|
if (!worktree || !(baseCommit ?? preliminary.final.base_commit) || !preliminary.final.commit) {
|
|
471
474
|
throw reportError("human_report_invalid", "approved card report requires a worktree, card baseline, and final commit for canonical evidence");
|
|
@@ -480,11 +483,25 @@ export async function writeCardReport(input, { outputPath, projectRoot = null, w
|
|
|
480
483
|
baseCommit: candidate.base_commit,
|
|
481
484
|
commit: candidate.git_head,
|
|
482
485
|
});
|
|
483
|
-
|
|
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) {
|
|
484
495
|
throw reportError("human_report_invalid", "final candidate fingerprint does not match the final commit delta");
|
|
485
496
|
}
|
|
497
|
+
committedCandidateFingerprint = actualFingerprint.value;
|
|
486
498
|
}
|
|
487
|
-
const report = {
|
|
499
|
+
const report = {
|
|
500
|
+
...preliminary,
|
|
501
|
+
final: { ...preliminary.final, committed_candidate_fingerprint: committedCandidateFingerprint ?? null },
|
|
502
|
+
changed_paths: changedPaths ?? [],
|
|
503
|
+
changed_paths_base: changedPathsBase
|
|
504
|
+
};
|
|
488
505
|
const markdown = renderCardReport(report, { projectRoot, worktree });
|
|
489
506
|
if (outputPath) {
|
|
490
507
|
try {
|
package/runtime/lib/process.mjs
CHANGED
|
@@ -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) => {
|
|
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
|
}
|
package/runtime/triad-verify.mjs
CHANGED
|
@@ -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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"],
|
|
@@ -213,8 +213,17 @@ candidate fingerprint. Do not copy Developer prose as changed-path evidence.
|
|
|
213
213
|
|
|
214
214
|
For an approved Card, the context must include the final commit and fingerprint,
|
|
215
215
|
at least one current passing verifier record, an independent Reviewer approval,
|
|
216
|
-
and the complete Card-baseline commit.
|
|
217
|
-
|
|
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:
|
|
218
227
|
|
|
219
228
|
```bash
|
|
220
229
|
node .triad-runtime/triad-human-report.mjs \
|