@tea-agent/loop-agent 0.15.0 → 0.16.0

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.
Files changed (86) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +59 -11
  3. package/README.md +1 -1
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/eval.js +1176 -13
  24. package/dist/commands/init.js +4 -1
  25. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  26. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  27. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  28. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  29. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  30. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  31. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  32. package/dist/worker/cli.js +6 -3
  33. package/dist/worker/delivery/final-verification.js +96 -8
  34. package/dist/worker/delivery/package.js +23 -4
  35. package/dist/worker/delivery/verification-bundle.js +521 -0
  36. package/dist/worker/feature/fullstack-validate.js +337 -0
  37. package/dist/worker/feature/profile-schema.js +44 -0
  38. package/dist/worker/feature/ready-plan-projection.js +1 -0
  39. package/dist/worker/feature/reducer.js +2 -0
  40. package/dist/worker/feature/review.js +106 -11
  41. package/dist/worker/materialize/harness-task-materializer.js +5 -0
  42. package/dist/worker/observability/read-model.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +1 -0
  44. package/dist/worker/outcomes/adapters.js +144 -0
  45. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  46. package/dist/worker/outcomes/gate.js +40 -0
  47. package/dist/worker/outcomes/projector.js +185 -0
  48. package/dist/worker/outcomes/registry.js +1 -0
  49. package/dist/worker/outcomes/store.js +131 -0
  50. package/dist/worker/outcomes/types.js +79 -0
  51. package/dist/worker/report/morning-report.js +4 -3
  52. package/dist/worker/run-task/run-task.js +85 -2
  53. package/dist/worker/runner/run-ready.js +32 -1
  54. package/dist/worker/task-graph/acceptance-schema.js +12 -0
  55. package/dist/worker/task-graph/ready-planner.js +131 -0
  56. package/dist/worker/task-graph/task-graph-schema.js +31 -0
  57. package/dist/worker/task-graph/validate.js +44 -4
  58. package/dist/worker/task-spec/schema.js +9 -0
  59. package/dist/worker/task-spec/validate.js +39 -0
  60. package/dist/worker/task-spec/workflow-routing.js +149 -0
  61. package/dist/workflows/dag/budget-enforcement.js +67 -0
  62. package/dist/workflows/dag/context-policy.js +137 -0
  63. package/dist/workflows/dag/knowledge-curator.js +3 -0
  64. package/dist/workflows/dag/node-execution.js +11 -4
  65. package/dist/workflows/dag/prompt.js +1 -1
  66. package/dist/workflows/dag/runner.js +43 -16
  67. package/dist/workflows/dag/skill-snapshot.js +11 -7
  68. package/dist/workflows/dag/types.js +18 -0
  69. package/docs/init-surface.manifest.json +3 -0
  70. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  71. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  72. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  73. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  74. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  75. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  76. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  77. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  78. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  79. package/docs/templates/product-line/AGENTS.md +1 -0
  80. package/docs/templates/product-line/README.md +17 -0
  81. package/docs/templates/product-line/acceptance.yaml +9 -0
  82. package/docs/templates/product-line/feature.yaml +11 -0
  83. package/docs/templates/product-line/task-graph.yaml +8 -0
  84. package/docs/templates/product-line/task.yaml +4 -0
  85. package/harness.json +1 -1
  86. package/package.json +1 -1
@@ -0,0 +1,82 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ignitionIdSchema, ignitionReportSchema, } from "../../application/evaluation/types.js";
4
+ import { sha256Hex } from "../../application/evaluation/candidate-hash.js";
5
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
6
+ import { EVALUATION_ROOT } from "./store.js";
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
9
+ export function assertIgnitionId(value) {
10
+ ignitionIdSchema.parse(value);
11
+ }
12
+ export function ignitionDir(repoRoot, ignitionId) {
13
+ assertIgnitionId(ignitionId);
14
+ return path.join(repoRoot, EVALUATION_ROOT, "ignition", ignitionId);
15
+ }
16
+ export function ignitionReportPath(repoRoot, ignitionId) {
17
+ return path.join(ignitionDir(repoRoot, ignitionId), "report.json");
18
+ }
19
+ async function pathExists(filePath) {
20
+ try {
21
+ await access(filePath);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function serialize(report) {
29
+ return `${JSON.stringify(report, null, 2)}\n`;
30
+ }
31
+ export async function readIgnitionReport(repoRoot, ignitionId) {
32
+ assertIgnitionId(ignitionId);
33
+ const reportPath = ignitionReportPath(repoRoot, ignitionId);
34
+ if (!(await pathExists(reportPath)))
35
+ return undefined;
36
+ const rawText = await readFile(reportPath, "utf-8");
37
+ const expected = (await readFile(path.join(ignitionDir(repoRoot, ignitionId), MANIFEST_INTEGRITY_FILE), "utf-8")).trim();
38
+ if (sha256Hex(rawText) !== expected) {
39
+ throw new Error(`ignition report integrity mismatch for ${ignitionId}`);
40
+ }
41
+ return ignitionReportSchema.parse(JSON.parse(rawText));
42
+ }
43
+ export async function writeIgnitionReport(input) {
44
+ const parsed = ignitionReportSchema.parse(input.report);
45
+ const reportPath = ignitionReportPath(input.repoRoot, parsed.ignitionId);
46
+ const root = path.dirname(ignitionDir(input.repoRoot, parsed.ignitionId));
47
+ await mkdir(root, { recursive: true });
48
+ if (await pathExists(reportPath)) {
49
+ await writeJsonAtomic(reportPath, parsed, { repoRoot: input.repoRoot });
50
+ await writeTextAtomic(path.join(ignitionDir(input.repoRoot, parsed.ignitionId), MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
51
+ return reportPath;
52
+ }
53
+ const stagingDir = await mkdtemp(path.join(root, `.${parsed.ignitionId}.register-`));
54
+ try {
55
+ await writeJsonAtomic(path.join(stagingDir, "report.json"), parsed, {
56
+ repoRoot: input.repoRoot,
57
+ });
58
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
59
+ await rename(stagingDir, ignitionDir(input.repoRoot, parsed.ignitionId));
60
+ }
61
+ catch (error) {
62
+ await rm(stagingDir, { recursive: true, force: true });
63
+ throw error;
64
+ }
65
+ return reportPath;
66
+ }
67
+ export async function listIgnitionIds(repoRoot) {
68
+ const root = path.join(repoRoot, EVALUATION_ROOT, "ignition");
69
+ try {
70
+ const entries = await readdir(root, { withFileTypes: true });
71
+ return entries
72
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
73
+ .map((entry) => entry.name)
74
+ .sort();
75
+ }
76
+ catch (error) {
77
+ const code = error.code;
78
+ if (code === "ENOENT")
79
+ return [];
80
+ throw error;
81
+ }
82
+ }
@@ -0,0 +1,145 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertPrivateVerifierHash, computePrivateVerifierManifestHash, } from "../../application/evaluation/campaign-hash.js";
4
+ import { formatContentSha, normalizeContentSha, sha256Hex, } from "../../application/evaluation/candidate-hash.js";
5
+ import { privateVerifierIdSchema, privateVerifierManifestInputSchema, privateVerifierManifestSchema, } from "../../application/evaluation/types.js";
6
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
7
+ import { EVALUATION_ROOT } from "./store.js";
8
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
10
+ export function assertPrivateVerifierId(value) {
11
+ privateVerifierIdSchema.parse(value);
12
+ }
13
+ export function privateVerifierDir(repoRoot, privateVerifierId) {
14
+ assertPrivateVerifierId(privateVerifierId);
15
+ return path.join(repoRoot, EVALUATION_ROOT, "private-verifiers", privateVerifierId);
16
+ }
17
+ export function privateVerifierManifestPath(repoRoot, privateVerifierId) {
18
+ return path.join(privateVerifierDir(repoRoot, privateVerifierId), "manifest.json");
19
+ }
20
+ async function pathExists(filePath) {
21
+ try {
22
+ await access(filePath);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function serializeManifest(manifest) {
30
+ return `${JSON.stringify(manifest, null, 2)}\n`;
31
+ }
32
+ export async function materializePrivateVerifierManifest(raw) {
33
+ const input = privateVerifierManifestInputSchema.parse(raw);
34
+ const withoutHash = {
35
+ schemaVersion: 1,
36
+ privateVerifierId: input.privateVerifierId,
37
+ createdAt: input.createdAt,
38
+ ...(input.description !== undefined
39
+ ? { description: input.description }
40
+ : {}),
41
+ checks: input.checks,
42
+ };
43
+ const manifestHash = computePrivateVerifierManifestHash(withoutHash);
44
+ if (input.manifestHash) {
45
+ const provided = formatContentSha(normalizeContentSha(input.manifestHash));
46
+ if (provided !== manifestHash) {
47
+ throw new Error(`privateVerifier manifestHash mismatch: expected ${manifestHash}, got ${provided}`);
48
+ }
49
+ }
50
+ return privateVerifierManifestSchema.parse({
51
+ ...withoutHash,
52
+ manifestHash,
53
+ });
54
+ }
55
+ export async function loadPrivateVerifierManifestFromPath(repoRoot, manifestPath) {
56
+ const resolved = path.isAbsolute(manifestPath)
57
+ ? manifestPath
58
+ : path.resolve(repoRoot, manifestPath);
59
+ const raw = JSON.parse(await readFile(resolved, "utf-8"));
60
+ return privateVerifierManifestInputSchema.parse(raw);
61
+ }
62
+ export async function readPrivateVerifierManifest(repoRoot, privateVerifierId) {
63
+ assertPrivateVerifierId(privateVerifierId);
64
+ const manifestPath = privateVerifierManifestPath(repoRoot, privateVerifierId);
65
+ const rawText = await readFile(manifestPath, "utf-8");
66
+ const integrityPath = path.join(privateVerifierDir(repoRoot, privateVerifierId), MANIFEST_INTEGRITY_FILE);
67
+ const expected = (await readFile(integrityPath, "utf-8")).trim();
68
+ const actual = sha256Hex(rawText);
69
+ if (expected !== actual) {
70
+ throw new Error(`privateVerifier manifest integrity mismatch for ${privateVerifierId}`);
71
+ }
72
+ const stored = privateVerifierManifestSchema.parse(JSON.parse(rawText));
73
+ if (stored.privateVerifierId !== privateVerifierId) {
74
+ throw new Error(`privateVerifier identity mismatch: directory=${privateVerifierId}, manifest=${stored.privateVerifierId}`);
75
+ }
76
+ assertPrivateVerifierHash(stored);
77
+ return stored;
78
+ }
79
+ export async function listPrivateVerifierIds(repoRoot) {
80
+ const root = path.join(repoRoot, EVALUATION_ROOT, "private-verifiers");
81
+ try {
82
+ const entries = await readdir(root, { withFileTypes: true });
83
+ return entries
84
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
85
+ .map((entry) => entry.name)
86
+ .sort();
87
+ }
88
+ catch (error) {
89
+ const code = error.code;
90
+ if (code === "ENOENT")
91
+ return [];
92
+ throw error;
93
+ }
94
+ }
95
+ function manifestsEqual(a, b) {
96
+ return (a.privateVerifierId === b.privateVerifierId &&
97
+ a.manifestHash === b.manifestHash &&
98
+ a.createdAt === b.createdAt &&
99
+ (a.description ?? "") === (b.description ?? "") &&
100
+ JSON.stringify(a.checks) === JSON.stringify(b.checks));
101
+ }
102
+ export async function registerPrivateVerifierManifest(input) {
103
+ const { repoRoot, manifest } = input;
104
+ assertPrivateVerifierId(manifest.privateVerifierId);
105
+ const manifestPath = privateVerifierManifestPath(repoRoot, manifest.privateVerifierId);
106
+ if (await pathExists(manifestPath)) {
107
+ const existing = await readPrivateVerifierManifest(repoRoot, manifest.privateVerifierId);
108
+ if (!manifestsEqual(existing, manifest)) {
109
+ throw new Error(`privateVerifier already exists with different content: ${manifest.privateVerifierId}`);
110
+ }
111
+ return { manifest: existing, idempotent: true, manifestPath };
112
+ }
113
+ const root = path.dirname(privateVerifierDir(repoRoot, manifest.privateVerifierId));
114
+ await mkdir(root, { recursive: true });
115
+ const stagingDir = await mkdtemp(path.join(root, `.${manifest.privateVerifierId}.register-`));
116
+ try {
117
+ await writeJsonAtomic(path.join(stagingDir, "manifest.json"), manifest, {
118
+ repoRoot,
119
+ });
120
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serializeManifest(manifest))}\n`, { repoRoot });
121
+ try {
122
+ await rename(stagingDir, privateVerifierDir(repoRoot, manifest.privateVerifierId));
123
+ }
124
+ catch (error) {
125
+ const code = error.code;
126
+ const isCreateRace = code === "EEXIST" ||
127
+ code === "ENOTEMPTY" ||
128
+ (code === "EPERM" &&
129
+ (await pathExists(privateVerifierDir(repoRoot, manifest.privateVerifierId))));
130
+ if (!isCreateRace)
131
+ throw error;
132
+ await rm(stagingDir, { recursive: true, force: true });
133
+ const existing = await readPrivateVerifierManifest(repoRoot, manifest.privateVerifierId);
134
+ if (!manifestsEqual(existing, manifest)) {
135
+ throw new Error(`privateVerifier already exists with different content: ${manifest.privateVerifierId}`);
136
+ }
137
+ return { manifest: existing, idempotent: true, manifestPath };
138
+ }
139
+ }
140
+ catch (error) {
141
+ await rm(stagingDir, { recursive: true, force: true });
142
+ throw error;
143
+ }
144
+ return { manifest, idempotent: false, manifestPath };
145
+ }
@@ -0,0 +1,78 @@
1
+ import { access, mkdir, mkdtemp, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { proposerRunIdSchema, proposerRunSchema, } from "../../application/evaluation/types.js";
4
+ import { sha256Hex } from "../../application/evaluation/candidate-hash.js";
5
+ import { writeJsonAtomic, writeTextAtomic } from "../harness/atomic-write.js";
6
+ import { EVALUATION_ROOT } from "./store.js";
7
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
+ const MANIFEST_INTEGRITY_FILE = "manifest.sha256";
9
+ export function assertProposerRunId(value) {
10
+ proposerRunIdSchema.parse(value);
11
+ }
12
+ export function proposerRunDir(repoRoot, proposerRunId) {
13
+ assertProposerRunId(proposerRunId);
14
+ return path.join(repoRoot, EVALUATION_ROOT, "proposer-runs", proposerRunId);
15
+ }
16
+ export function proposerRunManifestPath(repoRoot, proposerRunId) {
17
+ return path.join(proposerRunDir(repoRoot, proposerRunId), "manifest.json");
18
+ }
19
+ async function pathExists(filePath) {
20
+ try {
21
+ await access(filePath);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function serialize(record) {
29
+ return `${JSON.stringify(record, null, 2)}\n`;
30
+ }
31
+ export async function writeProposerRun(input) {
32
+ const parsed = proposerRunSchema.parse(input.run);
33
+ const root = path.dirname(proposerRunDir(input.repoRoot, parsed.proposerRunId));
34
+ await mkdir(root, { recursive: true });
35
+ const stagingDir = await mkdtemp(path.join(root, `.${parsed.proposerRunId}.register-`));
36
+ const manifestPath = proposerRunManifestPath(input.repoRoot, parsed.proposerRunId);
37
+ try {
38
+ if (await pathExists(manifestPath)) {
39
+ throw new Error(`proposer run already exists: ${parsed.proposerRunId}`);
40
+ }
41
+ await writeJsonAtomic(path.join(stagingDir, "manifest.json"), parsed, {
42
+ repoRoot: input.repoRoot,
43
+ });
44
+ await writeTextAtomic(path.join(stagingDir, MANIFEST_INTEGRITY_FILE), `${sha256Hex(serialize(parsed))}\n`, { repoRoot: input.repoRoot });
45
+ await rename(stagingDir, proposerRunDir(input.repoRoot, parsed.proposerRunId));
46
+ }
47
+ catch (error) {
48
+ await rm(stagingDir, { recursive: true, force: true });
49
+ throw error;
50
+ }
51
+ return manifestPath;
52
+ }
53
+ export async function readProposerRun(repoRoot, proposerRunId) {
54
+ assertProposerRunId(proposerRunId);
55
+ const manifestPath = proposerRunManifestPath(repoRoot, proposerRunId);
56
+ const rawText = await readFile(manifestPath, "utf-8");
57
+ const expected = (await readFile(path.join(proposerRunDir(repoRoot, proposerRunId), MANIFEST_INTEGRITY_FILE), "utf-8")).trim();
58
+ if (sha256Hex(rawText) !== expected) {
59
+ throw new Error(`proposer run integrity mismatch for ${proposerRunId}`);
60
+ }
61
+ return proposerRunSchema.parse(JSON.parse(rawText));
62
+ }
63
+ export async function listProposerRunIds(repoRoot) {
64
+ const root = path.join(repoRoot, EVALUATION_ROOT, "proposer-runs");
65
+ try {
66
+ const entries = await readdir(root, { withFileTypes: true });
67
+ return entries
68
+ .filter((entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
69
+ .map((entry) => entry.name)
70
+ .sort();
71
+ }
72
+ catch (error) {
73
+ const code = error.code;
74
+ if (code === "ENOENT")
75
+ return [];
76
+ throw error;
77
+ }
78
+ }
@@ -176,17 +176,20 @@ export function buildAgentWorkerProgram() {
176
176
  .command("verify-final")
177
177
  .requiredOption("--feature-dir <dir>", "Feature directory containing acceptance.yaml and tasks/")
178
178
  .requiredOption("--repo <repo-root>", "Target repo root")
179
- .requiredOption("--task-id <id>", "Completed qa-execute TaskSpec to rerun as dedicated final verification")
179
+ .requiredOption("--task-id <id>", "Completed final verification TaskSpec; typed backend/frontend outcomes are aggregated when present")
180
180
  .option("--loop-agent-bin <bin>", "loop-agent binary", "loop-agent")
181
181
  .option("--json", "Emit stable JSON")
182
+ .option("--evidence-mode <mode>", "Evidence aggregate: auto, qa-execute, or typed", "auto")
182
183
  .option("--expected-controller-version <version>", "Exact expected controller version (fail before writes if mismatched)")
183
184
  .option("--expected-controller-fingerprint <value>", "Exact expected controller fingerprint sha256:<hex> (fail before writes if mismatched)")
184
185
  .description("Run an independent HEAD-bound final QA verification and project Delivery evidence")
185
186
  .action(async (options) => {
187
+ if (options.evidenceMode !== "auto" && options.evidenceMode !== "qa-execute" && options.evidenceMode !== "typed")
188
+ throw new Error("feature verify-final --evidence-mode must be auto, qa-execute, or typed");
186
189
  const repoRoot = path.resolve(options.repo);
187
190
  const client = new LoopAgentClient({ loopAgentBin: options.loopAgentBin, artifactRoot: path.join(getTaskPoolRoot(repoRoot), "artifacts", `final-verification-${Date.now()}`), resolveIdentity: true });
188
- const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client, controllerIdentity: client.getIdentity(), controllerExpectation: buildIdentityExpectation(options) });
189
- process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}\n`);
191
+ const result = await runFeatureFinalVerification({ featureDir: path.resolve(options.featureDir), repoRoot, taskId: options.taskId, client, controllerIdentity: client.getIdentity(), controllerExpectation: buildIdentityExpectation(options), evidenceMode: options.evidenceMode });
192
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `Feature: ${result.featureId}\nFinal verification: ${result.workerRunId}\nEvidence mode: ${result.evidenceMode ?? "qa-execute"}\nQA evidence: ${result.qaEvidencePath}\nFinal evidence: ${result.finalVerificationPath}${result.bundleEvidencePath ? `\nVerification bundle: ${result.bundleEvidencePath}` : ""}\n`);
190
193
  });
191
194
  feature
192
195
  .command("delivery")
@@ -8,11 +8,13 @@ import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolv
8
8
  import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
9
9
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
10
10
  import { validateFeatureTaskGraph } from "../task-graph/validate.js";
11
- import { getRunsJsonlPath, getTaskPoolRoot, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
11
+ import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile, recordTaskPoolRun } from "../pool/run-store.js";
12
+ import { readVerifiedOutcome } from "../outcomes/store.js";
12
13
  import { buildWorkerRunId, runTaskSpec } from "../run-task/run-task.js";
13
14
  import { taskSpecSchema } from "../task-spec/schema.js";
14
15
  import { preflightTargetRepo } from "../preflight.js";
15
16
  import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
17
+ import { buildFeatureVerificationBundle, writeFeatureVerificationBundle, } from "./verification-bundle.js";
16
18
  const execFileAsync = promisify(execFile);
17
19
  export async function runFeatureFinalVerification(input) {
18
20
  const dependencies = {
@@ -103,11 +105,60 @@ export async function runFeatureFinalVerification(input) {
103
105
  allSpecs.set(graphNode.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", graphNode.task), "utf-8"))));
104
106
  }
105
107
  const acceptance = acceptanceSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8")));
106
- const qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
107
- if (qaRuns.length === 0)
108
- throw new Error("no prior successful qa-execute runs are available for the QA aggregate");
109
- const coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
110
108
  const requiredAcIds = acceptance.acceptance.filter((item) => item.priority === "must").map((item) => item.id);
109
+ const states = await readFeatureTaskPoolStates(repoRoot, featureId);
110
+ for (const [taskId, spec] of allSpecs) {
111
+ if ((spec.execution?.workflow === "backend-test" || spec.execution?.workflow === "frontend-test") && ["Failed", "Blocked"].includes(states[taskId]?.status ?? "")) {
112
+ throw new Error(`final verification has an unresolved typed test task: ${taskId}`);
113
+ }
114
+ }
115
+ const evidenceMode = input.evidenceMode ?? "auto";
116
+ let bundleRef;
117
+ let bundlePayload;
118
+ let qaRuns = latestSuccessfulQaRuns(runs, allSpecs, taskSpec.id, featureId);
119
+ let coveredAcIds = [...new Set(qaRuns.flatMap((run) => allSpecs.get(run.taskId)?.acceptance_refs ?? []))];
120
+ const typedCapable = requiredAcIds.length > 0 && requiredAcIds.every((acId) => {
121
+ const item = acceptance.acceptance.find((candidate) => candidate.id === acId);
122
+ const refs = item?.verification.verification_task_refs ?? [];
123
+ return refs.length > 0 && refs.every((taskId) => {
124
+ const workflow = allSpecs.get(taskId)?.execution?.workflow;
125
+ return workflow === "backend-test" || workflow === "frontend-test";
126
+ });
127
+ });
128
+ if (evidenceMode === "qa-execute" && !qaRuns.length) {
129
+ throw new Error("evidence-mode qa-execute requires at least one successful qa-execute run");
130
+ }
131
+ if ((evidenceMode === "auto" && typedCapable) || evidenceMode === "typed") {
132
+ const typedRuns = selectCurrentTypedVerificationRuns({
133
+ featureId,
134
+ acceptance: acceptance.acceptance,
135
+ specs: allSpecs,
136
+ states,
137
+ runs,
138
+ });
139
+ const outcomes = new Map();
140
+ for (const run of typedRuns) {
141
+ const outcome = await readVerifiedOutcome({ repoRoot, workerRunId: run.workerRunId, outcomePath: run.outcomePath, outcomeSha256: run.outcomeSha256 });
142
+ if (outcome)
143
+ outcomes.set(run.workerRunId, outcome);
144
+ }
145
+ const bundle = buildFeatureVerificationBundle({
146
+ featureId,
147
+ headSha,
148
+ runs: typedRuns,
149
+ specs: allSpecs,
150
+ outcomes,
151
+ acceptance: acceptance.acceptance,
152
+ implementationTaskIds: new Set(acceptance.acceptance.flatMap((item) => item.verification.implementation_task_refs ?? []).filter((taskId) => states[taskId]?.status === "Done")),
153
+ controllerIdentity,
154
+ });
155
+ bundleRef = await writeFeatureVerificationBundle(repoRoot, bundle);
156
+ bundlePayload = bundle;
157
+ qaRuns = typedRuns;
158
+ coveredAcIds = bundle.acceptance.filter((item) => item.status === "covered").map((item) => item.acId);
159
+ }
160
+ if (qaRuns.length === 0)
161
+ throw new Error("no prior successful typed test or qa-execute runs are available for the QA aggregate");
111
162
  for (const acId of requiredAcIds)
112
163
  if (!coveredAcIds.includes(acId))
113
164
  throw new Error(`QA aggregate does not cover required acceptance: ${acId}`);
@@ -118,10 +169,11 @@ export async function runFeatureFinalVerification(input) {
118
169
  const qaEvidencePath = path.join(evidenceDir, "qa-pass.json");
119
170
  const finalVerificationPath = path.join(evidenceDir, "final-verification.json");
120
171
  await writeEvidencePairAtomic(evidenceDir, {
121
- qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef) },
122
- final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") } },
172
+ qa: { schemaVersion: 1, featureId, verdict: "passed", acIds: requiredAcIds, runs: qaRuns.map(runRef), ...(bundleRef ? { bundle: bundleRef } : {}) },
173
+ final: { schemaVersion: 1, featureId, kind: "final-verification", status: "passed", headSha, run: runRef(finalRun), shellSummary: { path: summaryRelative, sha256: createHash("sha256").update(summary).digest("hex") }, ...(bundleRef ? { bundle: bundleRef } : {}) },
174
+ ...(bundlePayload ? { bundle: bundlePayload } : {}),
123
175
  });
124
- return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length, ...(controllerIdentity ? { controllerIdentity } : {}) };
176
+ return { schemaVersion: 1, featureId, taskId: taskSpec.id, workerRunId: finalRun.workerRunId, headSha, qaEvidencePath: repoRef(repoRoot, qaEvidencePath), finalVerificationPath: repoRef(repoRoot, finalVerificationPath), qaRunCount: qaRuns.length, ...(bundleRef ? { bundleEvidencePath: bundleRef.path, bundleSha256: bundleRef.sha256, evidenceMode: "typed" } : { evidenceMode: "qa-execute" }), ...(controllerIdentity ? { controllerIdentity } : {}) };
125
177
  }
126
178
  export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
127
179
  const byTask = new Map();
@@ -130,6 +182,40 @@ export function latestSuccessfulQaRuns(runs, specs, excludedTaskId, featureId) {
130
182
  byTask.set(run.taskId, run);
131
183
  return [...byTask.values()].sort((a, b) => a.taskId.localeCompare(b.taskId));
132
184
  }
185
+ /**
186
+ * Typed verification is bound to the canonical Feature-scoped Task Pool state,
187
+ * never to an arbitrary historical success in runs.jsonl. Each required AC
188
+ * verification task must be Done and identify exactly one succeeded run.
189
+ */
190
+ function selectCurrentTypedVerificationRuns(input) {
191
+ const requiredTaskIds = [
192
+ ...new Set(input.acceptance.flatMap((item) => item.verification.verification_task_refs ?? [])),
193
+ ];
194
+ const selected = [];
195
+ for (const taskId of requiredTaskIds) {
196
+ const spec = input.specs.get(taskId);
197
+ const workflow = spec?.execution?.workflow;
198
+ if (workflow !== "backend-test" && workflow !== "frontend-test") {
199
+ throw new Error(`typed verification TaskSpec is not a typed test: ${taskId}`);
200
+ }
201
+ const state = input.states[taskId];
202
+ if (state?.status !== "Done" || !state.workerRunId) {
203
+ throw new Error(`required typed verification task is not Done: ${taskId}`);
204
+ }
205
+ const matches = input.runs.filter((run) => run.featureId === input.featureId &&
206
+ run.taskId === taskId &&
207
+ run.workerRunId === state.workerRunId);
208
+ if (matches.length !== 1) {
209
+ throw new Error(`current Task Pool run is missing or ambiguous: ${taskId}`);
210
+ }
211
+ const run = matches[0];
212
+ if (run.status !== "succeeded" || run.workflow !== workflow) {
213
+ throw new Error(`current Task Pool run is not a succeeded typed test: ${taskId}`);
214
+ }
215
+ selected.push(run);
216
+ }
217
+ return selected;
218
+ }
133
219
  async function reusableFinalRun(repoRoot, runs, taskId, featureId, record, now, controllerIdentity) {
134
220
  const latestCheckpointAt = Math.max(...record.checkpoints.map((entry) => new Date(entry.createdAt).getTime()));
135
221
  for (const run of [...runs].reverse()) {
@@ -162,6 +248,8 @@ export async function writeEvidencePairAtomic(evidenceDir, value, fs = { rm }) {
162
248
  await mkdir(staging, { recursive: true });
163
249
  await writeFile(path.join(staging, "qa-pass.json"), `${JSON.stringify(value.qa, null, 2)}\n`);
164
250
  await writeFile(path.join(staging, "final-verification.json"), `${JSON.stringify(value.final, null, 2)}\n`);
251
+ if (value.bundle)
252
+ await writeFile(path.join(staging, "feature-verification-bundle.json"), `${JSON.stringify(value.bundle, null, 2)}\n`);
165
253
  let backedUp = false;
166
254
  try {
167
255
  await rename(evidenceDir, backup);
@@ -12,6 +12,7 @@ import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
12
12
  import { validateFeatureTaskGraph } from "../task-graph/validate.js";
13
13
  import { taskSpecSchema } from "../task-spec/schema.js";
14
14
  import { gitTransactionRecordSchema, transactionRecordPath } from "./git-transaction.js";
15
+ import { readVerifiedBundle, verifyBundleCurrentStateBindings, verifyBundleOutcomes, verifyBundleTaskSpecBindings } from "./verification-bundle.js";
15
16
  const execFileAsync = promisify(execFile);
16
17
  const hashedRefSchema = z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
17
18
  export const acceptanceCoverageArtifactSchema = z.object({
@@ -76,8 +77,8 @@ const evidenceRunSchema = z.object({
76
77
  recordedAt: z.string().datetime(),
77
78
  controllerIdentity: controllerIdentityEvidenceSchema.optional(),
78
79
  }).strict();
79
- const qaEvidenceSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), verdict: z.literal("passed"), acIds: z.array(z.string()).min(1), runs: z.array(evidenceRunSchema).min(1) }).strict();
80
- const finalVerificationSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), kind: z.literal("final-verification"), status: z.literal("passed"), headSha: z.string().regex(/^[a-f0-9]{40}$/), run: evidenceRunSchema, shellSummary: hashedRefSchema }).strict();
80
+ const qaEvidenceSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), verdict: z.literal("passed"), acIds: z.array(z.string()).min(1), runs: z.array(evidenceRunSchema).min(1), bundle: hashedRefSchema.optional() }).strict();
81
+ const finalVerificationSchema = z.object({ schemaVersion: z.literal(1), featureId: z.string(), kind: z.literal("final-verification"), status: z.literal("passed"), headSha: z.string().regex(/^[a-f0-9]{40}$/), run: evidenceRunSchema, shellSummary: hashedRefSchema, bundle: hashedRefSchema.optional() }).strict();
81
82
  const workerRunEvidenceSchema = z.object({ schemaVersion: z.literal(1), status: z.literal("succeeded"), workerRunId: z.string(), businessId: z.string(), featureId: z.string(), reportDecision: z.object({ succeeded: z.literal(true) }).passthrough(), commands: z.array(z.object({ name: z.string(), result: z.object({ ok: z.literal(true) }).passthrough() }).passthrough()).min(1), controllerIdentity: controllerIdentityEvidenceSchema.optional() }).passthrough();
82
83
  export async function prepareFeatureDelivery(input) {
83
84
  const repoRoot = path.resolve(input.repoRoot);
@@ -111,6 +112,10 @@ export async function prepareFeatureDelivery(input) {
111
112
  taskSpecs.set(node.id, taskSpecSchema.parse(YAML.parse(await readFile(path.join(featureDir, "tasks", node.task), "utf-8"))));
112
113
  const qaFact = await canonicalQaEvidence(repoRoot, input.qaEvidencePath, featureId, runs, taskSpecs, now, blockers);
113
114
  const finalFact = record ? await canonicalFinalEvidence(repoRoot, input.finalVerificationPath, featureId, runs, taskSpecs, record, now, blockers) : undefined;
115
+ if (qaFact?.data.bundle || finalFact?.data.bundle) {
116
+ if (!qaFact?.data.bundle || !finalFact?.data.bundle || qaFact.data.bundle.path !== finalFact.data.bundle.path || qaFact.data.bundle.sha256 !== finalFact.data.bundle.sha256)
117
+ blockers.push("QA and final verification do not bind the same Feature Verification Bundle");
118
+ }
114
119
  if (qaFact && finalFact && qaFact.data.runs.some((entry) => entry.workerRunId === finalFact.data.run.workerRunId))
115
120
  blockers.push("final verification must use a dedicated run not included in the QA aggregate");
116
121
  const qaEvidence = qaFact?.ref;
@@ -340,11 +345,20 @@ async function canonicalQaEvidence(repoRoot, ref, featureId, runs, specs, now, b
340
345
  const data = qaEvidenceSchema.parse(JSON.parse(await readFile(await safeRepoPath(repoRoot, ref), "utf-8")));
341
346
  if (data.featureId !== featureId)
342
347
  throw new Error("Feature ownership mismatch");
348
+ const bundle = data.bundle ? await readVerifiedBundle({ repoRoot, ref: data.bundle }) : undefined;
349
+ if (data.bundle && (!bundle || bundle.featureId !== featureId || !await verifyBundleOutcomes(repoRoot, bundle) || !verifyBundleTaskSpecBindings(bundle, specs) || !await verifyBundleCurrentStateBindings(repoRoot, bundle)))
350
+ throw new Error("Feature Verification Bundle is missing, changed, has invalid typed outcomes, no longer matches TaskSpecs, or no longer matches current Task Pool state");
351
+ const typedByRun = new Map((bundle ? [...bundle.backendTests, ...bundle.frontendTests] : []).map((entry) => [entry.workerRunId, entry]));
343
352
  for (const entry of data.runs) {
344
- const run = assertEvidenceRun(entry, featureId, runs, specs, now, true);
353
+ const run = assertEvidenceRun(entry, featureId, runs, specs, now, !bundle);
354
+ if (bundle) {
355
+ const typed = typedByRun.get(entry.workerRunId);
356
+ if (!typed || typed.taskId !== entry.taskId || run.workflow !== typed.workflow)
357
+ throw new Error(`QA bundle run is not a declared typed outcome: ${entry.workerRunId}`);
358
+ }
345
359
  await readCanonicalWorkerRun(repoRoot, run);
346
360
  }
347
- const allowedAcIds = new Set(data.runs.flatMap((entry) => specs.get(entry.taskId)?.acceptance_refs ?? []));
361
+ const allowedAcIds = bundle ? new Set(bundle.acceptance.filter((item) => item.status === "covered").map((item) => item.acId)) : new Set(data.runs.flatMap((entry) => specs.get(entry.taskId)?.acceptance_refs ?? []));
348
362
  for (const acId of data.acIds)
349
363
  if (!allowedAcIds.has(acId))
350
364
  throw new Error(`QA run scope does not cover ${acId}`);
@@ -363,6 +377,11 @@ async function canonicalFinalEvidence(repoRoot, ref, featureId, runs, specs, rec
363
377
  const data = finalVerificationSchema.parse(JSON.parse(await readFile(await safeRepoPath(repoRoot, ref), "utf-8")));
364
378
  if (data.featureId !== featureId)
365
379
  throw new Error("Feature ownership mismatch");
380
+ if (data.bundle) {
381
+ const bundle = await readVerifiedBundle({ repoRoot, ref: data.bundle });
382
+ if (!bundle || bundle.featureId !== featureId || bundle.headSha !== record.lastCheckpoint || !await verifyBundleOutcomes(repoRoot, bundle) || !verifyBundleTaskSpecBindings(bundle, specs) || !await verifyBundleCurrentStateBindings(repoRoot, bundle))
383
+ throw new Error("Feature Verification Bundle is missing, changed, does not bind the Delivery HEAD, no longer matches TaskSpecs, or no longer matches current Task Pool state");
384
+ }
366
385
  const taskPoolRun = assertEvidenceRun(data.run, featureId, runs, specs, now, true);
367
386
  if (data.headSha !== record.lastCheckpoint)
368
387
  throw new Error("final verification is not bound to Delivery HEAD");