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