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 +18 -1
- package/docs/human-readable-reports.md +44 -3
- package/docs/verification.md +30 -0
- 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 +10 -5
- package/schemas/verification-evidence.schema.json +33 -0
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +0 -1
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/report-context/card.template.json +1 -0
- package/skills/triad-loop-orchestrator/SKILL.md +29 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
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
|
|
|
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.
|
package/docs/verification.md
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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"],
|
|
@@ -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
|
|
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.
|
|
217
|
-
|
|
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 \
|