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 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, passing verifier evidence, independent Reviewer approval, and the
34
- complete changed-path manifest. A blocked or not-delivered terminal Card must
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.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 && 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 { algorithm: "sha256", value: digest(canonical), 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
+ };
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 { 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
+ };
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- Candidate fingerprint: \`${tableCell(report.final.candidate_fingerprint)}\`\n- Reviewer decision: \`${tableCell(review?.decision)}\``
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
- if (actualFingerprint.value !== preliminary.final.candidate_fingerprint) {
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 = { ...preliminary, changed_paths: changedPaths ?? [], changed_paths_base: changedPathsBase };
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 {
@@ -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
  }
@@ -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"],
@@ -20,6 +20,7 @@
20
20
  "commit": "REPLACE_ME",
21
21
  "base_commit": "REPLACE_ME",
22
22
  "candidate_fingerprint": "REPLACE_ME",
23
+ "committed_candidate_fingerprint": null,
23
24
  "worktree": "REPLACE_ME_ABSOLUTE_PRODUCT_WORKTREE"
24
25
  },
25
26
  "evidence_refs": [],
@@ -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. Materialize the report with the installed
217
- runtime command, which collects the complete baseline delta from Git:
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 \