@tea-agent/loop-agent 0.15.0 → 0.16.1-beta.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 (37) hide show
  1. package/CHANGELOG.md +7 -11
  2. package/dist/executors/dag-pi-executor.js +44 -4
  3. package/dist/worker/cli.js +6 -3
  4. package/dist/worker/delivery/final-verification.js +96 -8
  5. package/dist/worker/delivery/package.js +23 -4
  6. package/dist/worker/delivery/verification-bundle.js +510 -0
  7. package/dist/worker/feature/fullstack-validate.js +337 -0
  8. package/dist/worker/feature/profile-schema.js +44 -0
  9. package/dist/worker/feature/ready-plan-projection.js +1 -0
  10. package/dist/worker/feature/reducer.js +2 -0
  11. package/dist/worker/feature/review.js +105 -11
  12. package/dist/worker/materialize/harness-task-materializer.js +5 -0
  13. package/dist/worker/observability/read-model.js +7 -0
  14. package/dist/worker/observe/static/views/task.js +1 -0
  15. package/dist/worker/outcomes/adapters.js +141 -0
  16. package/dist/worker/outcomes/gate.js +41 -0
  17. package/dist/worker/outcomes/projector.js +176 -0
  18. package/dist/worker/outcomes/registry.js +1 -0
  19. package/dist/worker/outcomes/store.js +131 -0
  20. package/dist/worker/outcomes/types.js +76 -0
  21. package/dist/worker/report/morning-report.js +4 -3
  22. package/dist/worker/run-task/run-task.js +66 -2
  23. package/dist/worker/runner/run-ready.js +32 -1
  24. package/dist/worker/task-graph/acceptance-schema.js +12 -0
  25. package/dist/worker/task-graph/ready-planner.js +125 -0
  26. package/dist/worker/task-graph/task-graph-schema.js +29 -0
  27. package/dist/worker/task-graph/validate.js +44 -4
  28. package/dist/worker/task-spec/schema.js +9 -0
  29. package/dist/worker/task-spec/validate.js +39 -0
  30. package/dist/worker/task-spec/workflow-routing.js +149 -0
  31. package/dist/workflows/dag/init-hybrid.js +3 -2
  32. package/dist/workflows/dag/types.js +1 -0
  33. package/docs/templates/agent-dag.schema.json +5 -0
  34. package/harness.json +1 -1
  35. package/package.json +1 -1
  36. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  37. package/skills/loop-agent/references/hybrid-dag.md +1 -1
package/CHANGELOG.md CHANGED
@@ -59,17 +59,13 @@
59
59
 
60
60
  ## [Unreleased]
61
61
 
62
- - 新增 Pull Request 的只读 AI 自动审查:在创建、重新打开、转为 ready 或更新提交时检查 bug、测试缺口、runtime boundary、command registry、初始化投影与文档收敛,并更新单条 PR 评论;Fork PR 无模型密钥时安全跳过。
63
- - 修复私有仓库 AI PR 审查因通过网页 `*.diff` 地址拉取差异导致 404 的问题,改为经 GitHub API 获取 diff。
64
- - 新增 `.editorconfig` 与 `.gitattributes`,统一常见文本文件的 UTF-8LF、缩进和文件末尾换行约定,减少不同 IDE 与操作系统产生的无意义差异;本轮不引入 Git hooks 或格式化依赖。
65
- - 新增面向内部研发人员的轻量 GitHub 协作指南和简短 Pull Request 模板,统一采用短分支、CI Squash Merge 的最小协作闭环,不改变现有 Agent DAG 和发布流程。
66
- - 收紧 npm 发布包中的 `docs/` 范围:只保留 init/runtime 所需的治理文档、architecture、skills templates;源仓库专属说明与 progress/report/exec-plan 等目录契约不再打进包内。
67
- - 收紧 npm 发布包中的 `scripts/`:只保留 `loop-agent knowledge` 实际调用的知识库脚本;源仓治理检查与 `.sh` 薄包装不再打进包内。
68
- - 新增内部测试版本夜间自动发布:达到提交门槛并通过发布检查后,自动更新版本、生成中文说明并发布 GitHub Release npm;手动运行默认只做 dry-run。
69
- - 发布说明支持 OpenAI-compatible 模型,模型不可用时自动使用保守摘要。
70
- - 修复 PR #34 发布风险:合并到 main 后包版本被错误提升为 `0.14.1-beta.0`,现已恢复为 `0.14.0`;发布门禁由仅按 frontend 分支判断改为按版本号的 prerelease 状态限制 dist-tag(beta prerelease 只允许 `beta` tag,使用 `latest`/空/默认 tag 时 fail-closed;非 beta prerelease 如 alpha/rc 全局拒绝),避免在 main 上执行默认 `npm publish` 时把 prerelease 误发布到 `latest`。
71
- - `frontend` 分支继续保留本地 npm beta 发布门禁,并在合入 main 0.14.0 后把测试版本线调整为 `0.14.1-beta.0`,避免误推正式版本或 `latest`。
72
- - 前端实现 DAG 的 `frontendMock.policy=auto` 不再因为目标项目缺少 Mock 能力而阻塞;无确认 Mock 能力时跳过 Mock、保留真实请求默认路径,并在未真实联调时记录 `Frontend status: locally-validated` 与 `Real integration: pending`。
62
+ - 修复前端 Mock 策略节点偶尔因解释性前言出现在 `MOCK_STRATEGY:` 之前而被 contract gate 误判失败的问题;节点现在通过显式首行协议生成稳定 canonical 输出,同时保留全局 `first-non-empty` 精确校验和原始 Pi 执行证据。
63
+ - `agent-worker feature verify-final` 现可聚合 hash-bound `backend-test` / `frontend-test` Outcome,生成 Feature Verification Bundle v1;Delivery 与 Closeout 会重新验证 bundle、typed outcome、Feature/Task/workflow/controller identity 和 Delivery HEAD,任一漂移或篡改都会阻止交付。既有 `qa-execute` QA aggregate 继续兼容。
64
+ - 新增 `fullstack-v1` Feature Profile:Feature Packet 可选 `feature.yaml` 声明 profile scope,`agent-worker task validate-feature` 对 `fullstack-v1` packet 执行确定性结构门禁(required backend/frontend/backend-test/frontend-test workflowfrontend-test 依赖实现与后端验证、final-verify 覆盖、测试 workflow 不含产品写路径、writer writeSet 重叠须串行)。fullstack profile 必须有 scope;required AC 必须声明实现、验证与 evidence refs,任一门禁失败 fail closed 并输出阶段缺口与修复建议;未声明 profile 的 generic/legacy packet 行为零变化。
65
+ - AcceptanceSpec `verification` 新增全部 optional `implementation_task_refs`、`verification_task_refs`、`required_evidence`、`integration`(`not-applicable`/`mock-allowed`/`real-required`),保留 legacy `expected_task_refs`,schema 仍为 `.strict()`;Feature review coverage 投影在 dual-mode 下复用 Task Outcome envelope 的 `integrationStatus`:实现 Done 但缺验证 evidence、或 `real-required` 仅由 mock/local 满足时,required AC 派生为 `awaiting-verification`(只读派生,非新状态机),Feature 不得被报告为 deliverable。
66
+ - TaskGraph 节点新增可选 `consumes`/`produces` 声明与 Ready Planner artifact gate:上游 Done 但必需产物(路径/sha256/schema/Feature/producer/source binding)不可验证时,下游以 `required-artifact-missing` 阻塞;artifact 资格是额外门禁,priority 不得越过 gate。gate 原因经 `ReadyExecutionPlan.blocked[*].artifactGate` 投影,供 Feature review、晨报与 Observe 共用。
67
+ - Worker 现为四类 TaskSpec workflow 生成 hash-anchored Task Outcome,并在 promotion 前校验 `run_record`、`dag_json`、`shell_verification` 等已知 required output;Feature review、晨报与 Observe 可引用 outcome 证据,缺失或不一致会以 ContractMismatch/EnvFailure 失败收口。
68
+ - TaskSpec 现支持可选 `execution.workflow`,可确定性物化为任务 DAG 类型并展示在 Worker、Task Pool、晨报与 Observe;旧 QA 任务保持原有执行行为,同时给出需人工选择测试 workflow 的迁移提示。
73
69
 
74
70
  ## [0.13.0] - 2026-07-18
75
71
 
@@ -272,7 +272,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
272
272
  persona,
273
273
  step,
274
274
  });
275
- const mapped = mapPiResultToDagNodeResult(result);
275
+ const mapped = mapPiResultToDagNodeResult(result, input.task.firstProtocolLine);
276
276
  if (!isWriteTask) {
277
277
  return mapped;
278
278
  }
@@ -313,17 +313,57 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
313
313
  durationMs: mapped.durationMs || Date.now() - started,
314
314
  };
315
315
  }
316
- export function mapPiResultToDagNodeResult(result) {
316
+ export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
317
+ const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
317
318
  return {
318
319
  ok: result.ok,
319
- stdout: result.assistantText || result.stdout,
320
+ stdout: assistantText || result.stdout,
320
321
  stderr: result.stderr,
321
322
  failureCategory: result.failureCategory,
322
323
  durationMs: result.durationMs,
323
- assistantText: result.assistantText,
324
+ assistantText,
324
325
  backend: result.backend,
325
326
  sdkAttempted: result.sdkAttempted,
326
327
  tokensUsed: result.tokensUsed,
327
328
  parsedEvents: result.parsedEvents,
328
329
  };
329
330
  }
331
+ function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
332
+ if (!assistantText || !firstProtocolLine)
333
+ return assistantText;
334
+ const lines = assistantText.split(/\r?\n/);
335
+ let protocolIndex = -1;
336
+ let protocolLine = "";
337
+ for (const [index, line] of lines.entries()) {
338
+ const normalized = normalizeProtocolLine(line);
339
+ if (normalized.startsWith(firstProtocolLine)) {
340
+ protocolIndex = index;
341
+ protocolLine = normalized;
342
+ break;
343
+ }
344
+ }
345
+ if (protocolIndex < 0)
346
+ return assistantText;
347
+ if (protocolIndex === 0) {
348
+ return [protocolLine, ...lines.slice(1)].join("\n");
349
+ }
350
+ const before = lines.slice(0, protocolIndex);
351
+ const after = lines.slice(protocolIndex + 1);
352
+ while (before.at(-1)?.trim() === "" &&
353
+ after.at(0)?.trim() === "") {
354
+ after.shift();
355
+ }
356
+ const bodyLines = [...before, ...after];
357
+ while (bodyLines.at(0)?.trim() === "")
358
+ bodyLines.shift();
359
+ while (bodyLines.at(-1)?.trim() === "")
360
+ bodyLines.pop();
361
+ return bodyLines.length > 0
362
+ ? `${protocolLine}\n\n${bodyLines.join("\n")}`
363
+ : protocolLine;
364
+ }
365
+ function normalizeProtocolLine(line) {
366
+ const trimmed = line.trim();
367
+ const emphasized = trimmed.match(/^(\*{1,3})\s*(.*?)\s*\1$/);
368
+ return (emphasized?.[2] ?? trimmed).trim();
369
+ }
@@ -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");