triad-plus 1.12.0 → 1.12.2

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,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.12.2 — 2026-09-26
4
+
5
+ - Fix compatibility for historical assignments with `scope_contract: null` and
6
+ omit unconfigured scope contracts from new bootstrap templates while keeping
7
+ valid scope enforcement fail-closed.
8
+ - Clarify the exact explicit verifier identity/run-id invocation contract and
9
+ the omission rule for unconfigured repository-skill bindings.
10
+ - Clarify the bounded Card and delivery handoff report-context contract without
11
+ changing Reviewer independence or deterministic verification semantics.
12
+
13
+ ## 1.12.1 — 2026-09-26
14
+
15
+ - Bind approved human-readable Card reports to the independently verified
16
+ candidate manifest, preserving the pre-commit verifier fingerprint while
17
+ recording the committed fingerprint separately.
18
+ - Preserve raw candidate bytes for commit-derived hashes, fail closed on
19
+ post-verification path/content changes, and retain legacy no-manifest
20
+ behavior without silently widening the verified candidate.
4
21
 
5
22
  ## 1.12.0 — 2026-09-23
6
23
 
@@ -29,12 +29,35 @@ 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
 
42
+ ### Card context checklist
43
+
44
+ The derived card context should carry only canonical control-plane fields:
45
+
46
+ - `card.id` (and its title, goal/outcome, repository, and card path);
47
+ - terminal `status`, `attempts`, and verification runs;
48
+ - at least one passing verification run with its evidence path and candidate
49
+ fingerprint;
50
+ - independent `review.decision: approved` and its evidence/fingerprint;
51
+ - `final.branch`, `final.commit`, `final.repository`, `final.base_commit`, and
52
+ `final.candidate_fingerprint`;
53
+ - assignment/packet/verification provenance and bounded evidence references.
54
+
55
+ For an approved card, `final.candidate_fingerprint` is the pre-commit
56
+ fingerprint recorded by the passing verifier. The renderer computes and records
57
+ `final.committed_candidate_fingerprint` from the committed delta; do not replace
58
+ the verified fingerprint with a newly calculated value in the input context.
59
+ The source card and canonical evidence remain read-only.
60
+
38
61
  ## Materialize the final handoff
39
62
 
40
63
  ```bash
@@ -50,3 +73,21 @@ links to the technical evidence, branch/commit map, quality-contract closure,
50
73
  Evaluator+ result, delivery gates, demo details, risks, and practical test.
51
74
  It is a view for people, not an alternative delivery state machine.
52
75
 
76
+ ### Handoff context checklist
77
+
78
+ The derived handoff context must include a non-empty `cards` list and a
79
+ `decision` plus `executive_summary`. Each card entry carries `id`, `title`,
80
+ `status`, `summary`, `report_path`, and, when available, `commit`,
81
+ `candidate_fingerprint`, `verification`, `review`, `evaluator_report`, and
82
+ `evidence_refs`. The remaining canonical handoff fields are:
83
+
84
+ `code_areas`, `residual`, `verification`, `review`, `branch_commits`,
85
+ `practical_test`, `evaluator`, `delivery`, `quality_contract`,
86
+ `delivery_criteria`, `demo`, `evidence_refs`, `exceptions`, `prd_baseline`,
87
+ `approved_cards`, `push_evidence`, `gate_metrics`,
88
+ `local_worktree_integration`, `delivery_closure_record`, `final_message`,
89
+ `risks`, and `generated_from`.
90
+
91
+ Populate fields from canonical run, card, verifier, Reviewer, Evaluator+, and
92
+ delivery records. Do not pass Developer reasoning, queue-control narrative, or
93
+ unbounded source documents merely to satisfy the renderer.
@@ -17,6 +17,36 @@ An agent-reported claim is not the same as verification evidence. A Developer ca
17
17
  report the commands it ran; `triad-verify` independently observes declared
18
18
  required `control-plane` gates and writes atomic evidence.
19
19
 
20
+ ## Explicit verifier dispatch contract
21
+
22
+ When the configured route is `explicit_dispatch`, invoke the installed verifier
23
+ with the active assignment's exact Developer identity. The trigger is supplied
24
+ as JSON on stdin; it is not inferred from the shell process or from the card:
25
+
26
+ ```bash
27
+ agent_id="<assignment.agent_id>"
28
+ run_id="<assignment.verification_run_id>"
29
+ printf '{"event":"manual_explicit_dispatch","agent_id":"%s","agent_type":"triad_developer","run_id":"%s"}' "$agent_id" "$run_id" |
30
+ node .triad-runtime/triad-verify.mjs \
31
+ --project /absolute/path/to/control-workspace \
32
+ --assignment /absolute/path/to/control-workspace/.loop/runtime/assignments/<assignment-file>.json \
33
+ --run-id "$run_id"
34
+ ```
35
+
36
+ `agent_id` must match the active assignment exactly and `agent_type` must be
37
+ `triad_developer`. The `run_id` on stdin and `--run-id` must match the active
38
+ assignment when one is bound. A missing or mismatched trigger is
39
+ `invalid_context`; no gate runs and no retry/remediation budget is consumed.
40
+
41
+ When the repository has no router or required repository skills, omit the
42
+ `required_repository_skills` property from the assignment. Do not represent an
43
+ unconfigured binding as a present empty array: a present array is a declared
44
+ binding and must contain at least one validated skill. This documents existing
45
+ runtime behavior; it does not add a new verifier mode or transition.
46
+
47
+ `triad-verify.mjs` does not expose a help screen. Use the assignment, project
48
+ configuration, and this explicit recipe rather than invoking it with `--help`.
49
+
20
50
  ## Immutable Quality Contract
21
51
 
22
52
  Projects may opt into `project.quality_contract` with a project-relative JSON
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
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
  }
@@ -115,7 +115,10 @@ function withinRoot(root, candidate, label) {
115
115
 
116
116
  async function scopePreflight(projectRoot, worktree, assignment) {
117
117
  const specification = assignment.scope_contract;
118
- if (specification === undefined) {
118
+ // v1.12.1's assignment template materialized an unconfigured scope as
119
+ // `null`. Treat that historical sentinel exactly like an omitted contract,
120
+ // while continuing to fail closed for every malformed non-null value.
121
+ if (specification === undefined || specification === null) {
119
122
  const candidate = await collectCandidateChanges(worktree);
120
123
  return {
121
124
  configured: false,
@@ -236,6 +239,7 @@ async function main() {
236
239
  candidate_fingerprint: before.value,
237
240
  branch,
238
241
  },
242
+ candidate_manifest: before.manifest,
239
243
  assignment_packet: assignmentPacket,
240
244
  repository_skills: repositorySkills,
241
245
  scope,
@@ -266,14 +270,15 @@ async function main() {
266
270
  trigger,
267
271
  assignment_ref: path.relative(projectRoot, assignmentPath),
268
272
  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,
273
+ prd_sha256: assignment.expected_prd_sha256,
274
+ card_sha256: assignment.expected_card_sha256,
275
+ quality_baseline_fingerprint: qualityBaseline?.fingerprint ?? null,
276
+ gates_sha256: trusted.actualHash,
273
277
  git_head: before.git_head,
274
278
  candidate_fingerprint: before.value,
275
279
  branch,
276
280
  },
281
+ candidate_manifest: before.manifest,
277
282
  assignment_packet: assignmentPacket,
278
283
  repository_skills: repositorySkills,
279
284
  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,7 +20,6 @@
20
20
  "quality_baseline_path": null,
21
21
  "expected_quality_baseline_fingerprint": null,
22
22
  "required_gate_ids": [],
23
- "scope_contract": null,
24
23
  "required_repository_skills": [
25
24
  {
26
25
  "path": ".agents/skills/router/SKILL.md",
@@ -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": [],
@@ -153,7 +153,24 @@ fail-closed escalation; do not dispatch a Developer or consume retry budget.
153
153
  for a missing detail or contradiction, not systematic startup context.
154
154
  4. After completion, move to `verifying`. Follow the recorded dispatch route:
155
155
  wait for a valid hook-produced file when one is configured, otherwise invoke
156
- the verifier explicitly. Accept only current evidence whose assignment ID,
156
+ the verifier with the exact active Developer identity. For explicit dispatch,
157
+ pipe the trigger JSON and bind the same run ID on stdin and argv:
158
+
159
+ ```bash
160
+ agent_id="<assignment.agent_id>"
161
+ run_id="<assignment.verification_run_id>"
162
+ printf '{"event":"manual_explicit_dispatch","agent_id":"%s","agent_type":"triad_developer","run_id":"%s"}' "$agent_id" "$run_id" |
163
+ node .triad-runtime/triad-verify.mjs \
164
+ --project /absolute/path/to/control-workspace \
165
+ --assignment /absolute/path/to/control-workspace/.loop/runtime/assignments/<assignment-file>.json \
166
+ --run-id "$run_id"
167
+ ```
168
+
169
+ Do not substitute a generic `manual` trigger or a different agent identity.
170
+ When no repository skills are configured, omit
171
+ `required_repository_skills` from the assignment; a present empty array is
172
+ a declared binding and fails closed. These are existing runtime semantics,
173
+ not a new verification behavior. Accept only current evidence whose assignment ID,
157
174
  feature, attempt, PRD/card/gate hashes, expected branch, and candidate
158
175
  fingerprint match the active candidate. When an opt-in scope contract is
159
176
  configured, `triad-verify` first compares the complete candidate delta from
@@ -213,8 +230,17 @@ candidate fingerprint. Do not copy Developer prose as changed-path evidence.
213
230
 
214
231
  For an approved Card, the context must include the final commit and fingerprint,
215
232
  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:
233
+ and the complete Card-baseline commit. The final candidate fingerprint in this
234
+ context is the identity recorded by the passing verifier (the pre-commit
235
+ candidate), not a newly recalculated commit fingerprint. Preserve the verifier's
236
+ `candidate_manifest` alongside that record. The report runtime binds that
237
+ independently verified path/content manifest to the final commit delta and
238
+ records both the verified fingerprint and the committed fingerprint. It ignores
239
+ only the expected `git_head` identity change caused by committing; added,
240
+ removed, renamed, or modified paths and their content hashes must still match.
241
+ If a legacy verifier record has no manifest, the historical fingerprint equality
242
+ check remains fail-closed. Materialize the report with the installed runtime
243
+ command, which collects the complete baseline delta from Git:
218
244
 
219
245
  ```bash
220
246
  node .triad-runtime/triad-human-report.mjs \