@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
@@ -0,0 +1,337 @@
1
+ import { resolveWorkflow } from "../task-spec/workflow-routing.js";
2
+ export function resolveNodeWorkflows(graph, taskSpecs) {
3
+ const index = new Map();
4
+ for (const node of graph.nodes) {
5
+ const spec = taskSpecs.get(node.id);
6
+ if (spec)
7
+ index.set(node.id, { workflow: resolveWorkflow(spec).workflow, type: spec.type });
8
+ }
9
+ return index;
10
+ }
11
+ export function validateFullstackStructure(input) {
12
+ const errors = [];
13
+ const { profile, acceptance, graph, taskSpecs } = input;
14
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
15
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
16
+ const workflows = resolveNodeWorkflows(graph, taskSpecs);
17
+ // Gate 1: authority/acceptance non-empty is already enforced by the acceptance
18
+ // schema (.min(1)) before fullstack gates run; nothing to add here.
19
+ // Gate 2: each AC must declare a contract source; required ACs require both
20
+ // implementation and verification evidence, plus the kinds they expect.
21
+ for (const item of acceptance.acceptance) {
22
+ const implRefs = item.verification.implementation_task_refs ?? [];
23
+ const verRefs = item.verification.verification_task_refs ?? [];
24
+ if (implRefs.length === 0 && verRefs.length === 0) {
25
+ // `expected_task_refs` only is allowed as legacy fallback, but for a
26
+ // fullstack-v1 AC we require explicit contract separation.
27
+ errors.push({
28
+ code: "fullstack-contract-source-missing",
29
+ message: `acceptance ${item.id} must declare implementation_task_refs and/or verification_task_refs for a fullstack-v1 packet`,
30
+ path: `acceptance.${item.id}.verification`,
31
+ });
32
+ }
33
+ if (item.priority === "must" && implRefs.length === 0) {
34
+ errors.push({
35
+ code: "fullstack-required-implementation-refs-missing",
36
+ message: `required acceptance ${item.id} must declare implementation_task_refs`,
37
+ path: `acceptance.${item.id}.verification.implementation_task_refs`,
38
+ });
39
+ }
40
+ if (item.priority === "must" && verRefs.length === 0) {
41
+ errors.push({
42
+ code: "fullstack-required-verification-refs-missing",
43
+ message: `required acceptance ${item.id} must declare verification_task_refs`,
44
+ path: `acceptance.${item.id}.verification.verification_task_refs`,
45
+ });
46
+ }
47
+ if (item.priority === "must" && (item.verification.required_evidence?.length ?? 0) === 0) {
48
+ errors.push({
49
+ code: "fullstack-required-evidence-missing",
50
+ message: `required acceptance ${item.id} must declare required_evidence`,
51
+ path: `acceptance.${item.id}.verification.required_evidence`,
52
+ });
53
+ }
54
+ }
55
+ // Refs existence + non-overlap between implementation and verification refs.
56
+ for (const item of acceptance.acceptance) {
57
+ const implRefs = item.verification.implementation_task_refs ?? [];
58
+ const verRefs = item.verification.verification_task_refs ?? [];
59
+ for (const ref of implRefs) {
60
+ if (!nodeIds.has(ref)) {
61
+ errors.push({
62
+ code: "acceptance-implementation-ref-missing",
63
+ message: `acceptance ${item.id} implementation_task_refs references missing task ${ref}`,
64
+ path: `acceptance.${item.id}.verification.implementation_task_refs`,
65
+ });
66
+ }
67
+ }
68
+ for (const ref of verRefs) {
69
+ if (!nodeIds.has(ref)) {
70
+ errors.push({
71
+ code: "acceptance-verification-ref-missing",
72
+ message: `acceptance ${item.id} verification_task_refs references missing task ${ref}`,
73
+ path: `acceptance.${item.id}.verification.verification_task_refs`,
74
+ });
75
+ }
76
+ }
77
+ const overlap = implRefs.filter((ref) => verRefs.includes(ref));
78
+ if (overlap.length > 0) {
79
+ errors.push({
80
+ code: "fullstack-impl-verification-refs-overlap",
81
+ message: `acceptance ${item.id} implementation and verification task refs must not overlap: ${overlap.join(", ")}`,
82
+ path: `acceptance.${item.id}.verification`,
83
+ });
84
+ }
85
+ }
86
+ const scope = profile.scope ?? {};
87
+ if (!Object.values(scope).some((value) => value === true)) {
88
+ errors.push({
89
+ code: "fullstack-scope-required",
90
+ message: "fullstack-v1 requires at least one enabled scope flag",
91
+ path: "feature.yaml:scope",
92
+ });
93
+ }
94
+ const hasWorkflow = (target) => [...workflows.values()].some((entry) => entry.workflow === target);
95
+ // Gate 3: scope.backend → at least one backend implementation agent-dag that
96
+ // is a development-type task (not QA, not architecture/review/final-verify).
97
+ if (scope.backend && !hasBackendImplementation(workflows)) {
98
+ errors.push({
99
+ code: "fullstack-required-workflow-missing",
100
+ message: "scope.backend is enabled but no backend implementation agent-dag task is present",
101
+ path: "feature.yaml:scope.backend",
102
+ });
103
+ }
104
+ // Gate 4: scope.frontend → at least one frontend-implementation.
105
+ if (scope.frontend && !hasWorkflow("frontend-implementation")) {
106
+ errors.push({
107
+ code: "fullstack-required-workflow-missing",
108
+ message: "scope.frontend is enabled but no frontend-implementation task is present",
109
+ path: "feature.yaml:scope.frontend",
110
+ });
111
+ }
112
+ // Gate 5: scope.backendVerification → at least one backend-test.
113
+ if (scope.backendVerification && !hasWorkflow("backend-test")) {
114
+ errors.push({
115
+ code: "fullstack-required-workflow-missing",
116
+ message: "scope.backendVerification is enabled but no backend-test task is present",
117
+ path: "feature.yaml:scope.backendVerification",
118
+ });
119
+ }
120
+ // Gate 6: scope.frontendVerification → at least one frontend-test.
121
+ if (scope.frontendVerification && !hasWorkflow("frontend-test")) {
122
+ errors.push({
123
+ code: "fullstack-required-workflow-missing",
124
+ message: "scope.frontendVerification is enabled but no frontend-test task is present",
125
+ path: "feature.yaml:scope.frontendVerification",
126
+ });
127
+ }
128
+ // Gate 7: frontend-test depends_on at least one frontend-implementation node.
129
+ const frontendImplIds = new Set(graph.nodes.filter((node) => workflows.get(node.id)?.workflow === "frontend-implementation").map((node) => node.id));
130
+ for (const node of graph.nodes) {
131
+ if (workflows.get(node.id)?.workflow !== "frontend-test")
132
+ continue;
133
+ const dependsOnImpl = node.depends_on.some((dep) => frontendImplIds.has(dep));
134
+ if (!dependsOnImpl) {
135
+ errors.push({
136
+ code: "fullstack-frontend-test-missing-impl-dep",
137
+ message: `frontend-test node ${node.id} must depend_on at least one frontend-implementation node`,
138
+ path: `nodes.${node.id}.depends_on`,
139
+ });
140
+ }
141
+ if (scope.backendVerification) {
142
+ const dependsOnBackendVerification = node.depends_on.some((dep) => workflows.get(dep)?.workflow === "backend-test");
143
+ if (!dependsOnBackendVerification) {
144
+ errors.push({
145
+ code: "fullstack-frontend-test-missing-backend-verification-dep",
146
+ message: `frontend-test node ${node.id} must depend_on a backend-test when scope.backendVerification is enabled`,
147
+ path: `nodes.${node.id}.depends_on`,
148
+ });
149
+ }
150
+ }
151
+ }
152
+ // Gate 8: if a final-verify / closeout-style node exists, it depends_on all
153
+ // required verification workflows (backend-test / frontend-test declared by scope).
154
+ const requiredVerificationKinds = [];
155
+ if (scope.backendVerification)
156
+ requiredVerificationKinds.push("backend-test");
157
+ if (scope.frontendVerification)
158
+ requiredVerificationKinds.push("frontend-test");
159
+ const finalVerifyNode = findFinalVerifyNode(graph, workflows);
160
+ if (finalVerifyNode && requiredVerificationKinds.length > 0) {
161
+ const coveredKinds = new Set();
162
+ for (const dep of finalVerifyNode.depends_on) {
163
+ const depEntry = workflows.get(dep);
164
+ if (depEntry && requiredVerificationKinds.includes(depEntry.workflow))
165
+ coveredKinds.add(depEntry.workflow);
166
+ }
167
+ const missing = requiredVerificationKinds.filter((kind) => !coveredKinds.has(kind));
168
+ if (missing.length > 0) {
169
+ errors.push({
170
+ code: "fullstack-final-verify-missing-verification-dep",
171
+ message: `final verification node ${finalVerifyNode.id} must depend_on required verification workflows: ${missing.join(", ")}`,
172
+ path: `nodes.${finalVerifyNode.id}.depends_on`,
173
+ });
174
+ }
175
+ }
176
+ // Gate 9: test workflows' allowed_paths must not include product write paths.
177
+ // Conservative heuristic (v1): test tasks should not own `src/**` non-test paths.
178
+ for (const node of graph.nodes) {
179
+ const entry = workflows.get(node.id);
180
+ if (!entry)
181
+ continue;
182
+ if (entry.workflow !== "backend-test" && entry.workflow !== "frontend-test")
183
+ continue;
184
+ const spec = taskSpecs.get(node.id);
185
+ if (!spec)
186
+ continue;
187
+ const writePaths = spec.constraints.allowed_paths ?? [];
188
+ const offending = writePaths.filter((p) => isProductWritePath(p));
189
+ if (offending.length > 0) {
190
+ errors.push({
191
+ code: "fullstack-test-workflow-allowed-paths-write-path",
192
+ message: `test workflow ${node.id} allowed_paths must not include product write paths: ${offending.join(", ")}`,
193
+ path: `${node.task}:constraints.allowed_paths`,
194
+ });
195
+ }
196
+ }
197
+ // Gate 10: any two writer nodes with overlapping writeSet must have a serial
198
+ // depends_on relationship (transitive).
199
+ const writerNodes = graph.nodes.filter((node) => {
200
+ const entry = workflows.get(node.id);
201
+ return entry?.workflow === "agent-dag" || entry?.workflow === "frontend-implementation";
202
+ });
203
+ for (let i = 0; i < writerNodes.length; i++) {
204
+ for (let j = i + 1; j < writerNodes.length; j++) {
205
+ const a = writerNodes[i];
206
+ const b = writerNodes[j];
207
+ const aPaths = collectWritePaths(taskSpecs.get(a.id));
208
+ const bPaths = collectWritePaths(taskSpecs.get(b.id));
209
+ if (!writeSetsOverlap(aPaths, bPaths))
210
+ continue;
211
+ if (!hasSerialDependency(nodeById, a.id, b.id)) {
212
+ errors.push({
213
+ code: "fullstack-writeset-overlap",
214
+ message: `writer nodes ${a.id} and ${b.id} have overlapping allowed_paths but no serial depends_on relationship`,
215
+ path: `nodes.${b.id}.allowed_paths`,
216
+ });
217
+ }
218
+ }
219
+ }
220
+ return errors;
221
+ }
222
+ function hasBackendImplementation(workflows) {
223
+ for (const entry of workflows.values()) {
224
+ if (entry.workflow === "agent-dag" && isDevelopmentType(entry.type)) {
225
+ // backend implementation = an agent-dag development-type task
226
+ // (not QA, not architecture/review, not a final-verify agent-dag).
227
+ return true;
228
+ }
229
+ }
230
+ return false;
231
+ }
232
+ /** Mirrors review.ts isDevelopmentTask: excludes QA, architecture, review. */
233
+ function isDevelopmentType(type) {
234
+ return !type.startsWith("qa-") && !["architecture", "review"].includes(type);
235
+ }
236
+ /**
237
+ * Identify a final-verification / closeout node. In the fullstack topology it
238
+ * is an `agent-dag` node whose id hints at final-verify/closeout, or any node
239
+ * that depends on all test workflows. We use a conservative id heuristic so the
240
+ * gate only fires for explicit final-verify nodes; absence is not an error.
241
+ */
242
+ function findFinalVerifyNode(graph, workflows) {
243
+ const hint = /(final[-_]?(verify|verification))|closeout/i;
244
+ for (const node of graph.nodes) {
245
+ if (hint.test(node.id))
246
+ return node;
247
+ }
248
+ // Fallback: a non-test node that depends on both a backend-test and frontend-test.
249
+ for (const node of graph.nodes) {
250
+ const kinds = node.depends_on
251
+ .map((dep) => workflows.get(dep)?.workflow)
252
+ .filter((value) => Boolean(value));
253
+ if (kinds.includes("backend-test") && kinds.includes("frontend-test")) {
254
+ return node;
255
+ }
256
+ }
257
+ return undefined;
258
+ }
259
+ /**
260
+ * Conservative v1 heuristic: a path is a product write path if it targets the
261
+ * `src/**` tree and is not a test-only subtree. Test fixtures living under
262
+ * `test/**` or `tests/**` are not flagged. This deliberately fails closed: an
263
+ * ambiguous path is reported so the packet declares its intent explicitly.
264
+ */
265
+ function isProductWritePath(p) {
266
+ if (!p)
267
+ return false;
268
+ // Normalize to forward slashes for glob comparison.
269
+ const normalized = p.replace(/\\/g, "/");
270
+ // Test-only subtrees are allowed for test tasks.
271
+ if (normalized.startsWith("test/") || normalized.startsWith("tests/"))
272
+ return false;
273
+ // `src/**` is treated as product code; everything else is advisory.
274
+ return normalized.startsWith("src/") || normalized === "src" || normalized.startsWith("src/**");
275
+ }
276
+ function collectWritePaths(spec) {
277
+ if (!spec)
278
+ return [];
279
+ return spec.constraints.allowed_paths ?? [];
280
+ }
281
+ /**
282
+ * Conservative writeSet overlap: treat globs minimally. Two paths overlap when
283
+ * one is a prefix of the other (after stripping trailing globstars) or they are
284
+ * equal. This avoids a glob-engine dependency while still catching the common
285
+ * case of two writers claiming `src/**`.
286
+ */
287
+ function writeSetsOverlap(a, b) {
288
+ for (const left of a) {
289
+ const leftBase = globBase(left);
290
+ for (const right of b) {
291
+ const rightBase = globBase(right);
292
+ if (leftBase === rightBase)
293
+ return true;
294
+ if (leftBase.startsWith(`${rightBase}/`) || rightBase.startsWith(`${leftBase}/`))
295
+ return true;
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+ function globBase(glob) {
301
+ const normalized = glob.replace(/\\/g, "/");
302
+ // Drop trailing /** and * suffixes to get the directory base.
303
+ return normalized
304
+ .replace(/\/\*\*$/, "")
305
+ .replace(/\/\*$/, "")
306
+ .replace(/\*$/, "");
307
+ }
308
+ /**
309
+ * Two writer nodes are serially ordered if one is reachable from the other via
310
+ * `depends_on` (in either direction). Parallel writers with overlapping
311
+ * writeSet and no ordering violate gate 10.
312
+ */
313
+ function hasSerialDependency(nodeById, a, b) {
314
+ const forward = reaches(nodeById, a, b);
315
+ const backward = reaches(nodeById, b, a);
316
+ return forward || backward;
317
+ }
318
+ function reaches(nodeById, from, to) {
319
+ const visited = new Set();
320
+ const stack = [from];
321
+ while (stack.length > 0) {
322
+ const current = stack.pop();
323
+ if (current === to)
324
+ return true;
325
+ if (visited.has(current))
326
+ continue;
327
+ visited.add(current);
328
+ const node = nodeById.get(current);
329
+ if (!node)
330
+ continue;
331
+ for (const dep of node.depends_on)
332
+ stack.push(dep);
333
+ }
334
+ return false;
335
+ }
336
+ /** Re-exported for read-model coverage projection to reuse workflow resolution. */
337
+ export { resolveWorkflow };
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Feature-level profile for a Feature Packet (M4 `fullstack-v1`).
4
+ *
5
+ * `fullstack-v1` is NOT a new DAG or taskKind. It is `agent-worker`'s
6
+ * deterministic structural validation + read-model projection policy over a
7
+ * Feature Packet. See
8
+ * `docs/design/agent-worker-fullstack-workflow-integration.md` §9.
9
+ *
10
+ * A Feature Packet declares its profile via an optional `feature.yaml` next to
11
+ * `acceptance.yaml`. A packet without `feature.yaml` (or one that does not set
12
+ * `profile`) defaults to `generic`, which keeps all existing legacy/generic
13
+ * validation behavior unchanged (no fullstack gates fire).
14
+ *
15
+ * The schema is `.strict()`: new fields must be declared explicitly, never
16
+ * passthrough, so validators and read models can read declared values safely.
17
+ */
18
+ export const FEATURE_PROFILE_FILENAME = "feature.yaml";
19
+ export const FULLSTACK_V1 = "fullstack-v1";
20
+ export const GENERIC = "generic";
21
+ export const featureProfileSchema = z
22
+ .object({
23
+ schema_version: z.literal(1),
24
+ feature_id: z.string().min(1),
25
+ profile: z.enum([GENERIC, FULLSTACK_V1]).optional().default(GENERIC),
26
+ /**
27
+ * Declared fullstack scope. Only consulted when `profile === "fullstack-v1"`.
28
+ * Each boolean enables a structural gate (see fullstack-validate.ts).
29
+ */
30
+ scope: z
31
+ .object({
32
+ backend: z.boolean().optional(),
33
+ frontend: z.boolean().optional(),
34
+ backendVerification: z.boolean().optional(),
35
+ frontendVerification: z.boolean().optional(),
36
+ })
37
+ .strict()
38
+ .optional(),
39
+ })
40
+ .strict();
41
+ /** Whether a parsed profile activates the fullstack-v1 gate set. */
42
+ export function isFullstackProfile(profile) {
43
+ return profile.profile === FULLSTACK_V1;
44
+ }
@@ -50,6 +50,7 @@ export function summarizePlanReasons(plan) {
50
50
  reasonCode: item.reasonCode,
51
51
  reason: item.reason,
52
52
  blockedBy: item.blockedBy,
53
+ artifactGate: item.artifactGate,
53
54
  })),
54
55
  };
55
56
  }
@@ -76,6 +76,8 @@ export function reduceFeature(input) {
76
76
  tasks: input.tasks.map((task) => ({
77
77
  taskId: task.taskId,
78
78
  type: task.type,
79
+ ...(task.workflow ? { workflow: task.workflow } : {}),
80
+ ...(task.outcome ? { outcome: task.outcome } : {}),
79
81
  status: task.status ?? (isReady(task.taskId, input) ? "Ready" : "Draft"),
80
82
  })),
81
83
  acceptanceCoverage: input.acceptance,
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import YAML from "yaml";
5
5
  import { z } from "zod";
6
6
  import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile } from "../pool/run-store.js";
7
+ import { readVerifiedOutcome } from "../outcomes/store.js";
7
8
  import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
8
9
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
9
10
  import { taskSpecSchema } from "../task-spec/schema.js";
@@ -21,11 +22,16 @@ export function renderFeatureReview(model) {
21
22
  `${model.summary.tasksSucceeded}/${model.summary.tasksTotal} tasks completed; ${model.summary.requiredAcCovered}/${model.summary.requiredAcTotal} required AC covered.`;
22
23
  const evidence = [model.evidence.morningReport, model.evidence.observeSnapshot, model.evidence.delivery, model.evidence.closeout, ...model.blockingItems.flatMap((item) => item.evidence)]
23
24
  .filter((item) => Boolean(item));
25
+ const workflows = model.tasks.map((task) => task.workflow).filter((value) => Boolean(value));
26
+ const distinctWorkflows = [...new Set(workflows)];
27
+ const outcomes = model.tasks.filter((task) => task.outcome).length;
24
28
  return [
25
29
  `Feature: ${model.featureId}`,
26
30
  `结果: ${model.statusLabel}`,
27
31
  `原因: ${reason}`,
28
32
  `任务: ${model.summary.tasksSucceeded}/${model.summary.tasksTotal} 完成,${model.summary.tasksReady} Ready,${model.summary.tasksFailed} 失败,${model.summary.tasksBlocked} 阻塞`,
33
+ `工作流: ${distinctWorkflows.length > 0 ? distinctWorkflows.join(", ") : "-"}`,
34
+ `Outcome: ${outcomes}/${model.tasks.length} 已投影`,
29
35
  `required AC: ${model.summary.requiredAcCovered}/${model.summary.requiredAcTotal} 已覆盖;缺口 ${model.acceptanceCoverage.filter((item) => item.required && item.status !== "covered" && item.status !== "waived").map((item) => item.acId).join(", ") || "无"}`,
30
36
  `风险: high=${model.riskSummary.high}, medium=${model.riskSummary.medium}, low=${model.riskSummary.low}`,
31
37
  `交付/Closeout: ${model.status === "deliverable" || model.status === "closed" ? "交付证据齐备" : "尚未就绪"}`,
@@ -87,6 +93,17 @@ async function loadFeatureProjection(input) {
87
93
  projectionWarnings.push("Task Pool run record is semantically invalid");
88
94
  return valid;
89
95
  });
96
+ const outcomes = new Map();
97
+ for (const run of runs) {
98
+ const outcome = await readVerifiedOutcome({
99
+ repoRoot,
100
+ workerRunId: run.workerRunId,
101
+ outcomePath: run.outcomePath,
102
+ outcomeSha256: run.outcomeSha256,
103
+ });
104
+ if (outcome)
105
+ outcomes.set(run.workerRunId, outcome);
106
+ }
90
107
  const graphNodes = graph.success ? graph.data.nodes : [];
91
108
  let planning;
92
109
  if (graph.success) {
@@ -96,7 +113,7 @@ async function loadFeatureProjection(input) {
96
113
  const value = YAML.parse(await readFile(path.join(featureDir, "tasks", node.task), "utf-8"));
97
114
  taskSpecs.set(node.id, taskSpecSchema.parse(value));
98
115
  }
99
- planning = projectReadyPlan({ featureId: validation.featureId, graph: graph.data, taskSpecs, states, selectionLimit: Math.max(graph.data.nodes.length, 1) });
116
+ planning = projectReadyPlan({ featureId: validation.featureId, graph: graph.data, taskSpecs, states, outcomes, selectionLimit: Math.max(graph.data.nodes.length, 1) });
100
117
  }
101
118
  catch (error) {
102
119
  projectionWarnings.push(`Ready Planner projection is unavailable: ${message(error)}`);
@@ -189,6 +206,8 @@ async function loadFeatureProjection(input) {
189
206
  ...(state?.status ? { status: state.status } : {}),
190
207
  ...(state?.workerRunId ? { workerRunId: state.workerRunId } : {}),
191
208
  ...(state?.failure?.category ? { failureCategory: state.failure.category } : {}),
209
+ ...(latest?.workflow ? { workflow: latest.workflow } : {}),
210
+ ...(latest?.outcomePath && latest.outcomeSha256 ? { outcome: { path: latest.outcomePath, sha256: latest.outcomeSha256 } } : {}),
192
211
  failureEvidence: Object.values(latest?.failureArtifacts ?? {}).filter((value) => typeof value === "string"),
193
212
  };
194
213
  });
@@ -206,15 +225,16 @@ async function loadFeatureProjection(input) {
206
225
  acId: item.id,
207
226
  required: item.priority === "must",
208
227
  expectedTaskRefs: item.verification.expected_task_refs,
209
- status: failed.length > 0
210
- ? "blocked"
211
- : explicit?.status === "covered" && explicit.evidence.length > 0
212
- ? "covered"
213
- : explicit?.status === "waived"
214
- ? "waived"
215
- : done.length === referenced.length && done.length > 0
216
- ? "partial"
217
- : "missing",
228
+ status: resolveAcceptanceCoverageStatus({
229
+ item,
230
+ states,
231
+ outcomes,
232
+ failed,
233
+ done,
234
+ referencedCount: referenced.length,
235
+ explicit,
236
+ expectedFeatureId: validation.featureId,
237
+ }),
218
238
  evidence: explicit?.evidence ?? [],
219
239
  blockedBy: failed,
220
240
  };
@@ -370,7 +390,7 @@ const explicitCoverageSchema = z.object({
370
390
  featureId: z.string().min(1),
371
391
  items: z.array(z.object({
372
392
  acId: z.string().min(1),
373
- status: z.enum(["covered", "partial", "blocked", "waived", "missing"]),
393
+ status: z.enum(["covered", "partial", "blocked", "waived", "missing", "awaiting-verification"]),
374
394
  evidence: z.array(z.object({
375
395
  path: z.string().min(1),
376
396
  sha256: z.string().regex(/^[a-f0-9]{64}$/),
@@ -517,3 +537,77 @@ function normalizeRepoRef(ref, repoRoot) {
517
537
  function isDevelopmentTask(type) {
518
538
  return !type.startsWith("qa-") && !["architecture", "review"].includes(type);
519
539
  }
540
+ export function resolveAcceptanceCoverageStatus(input) {
541
+ const dualCoverage = projectDualCoverage(input.item, input.states, input.outcomes, input.failed, input.done, input.expectedFeatureId);
542
+ if (input.failed.length > 0)
543
+ return "blocked";
544
+ if (input.explicit?.status === "covered" &&
545
+ input.explicit.evidence.length > 0 &&
546
+ (dualCoverage === undefined || dualCoverage === "covered")) {
547
+ return "covered";
548
+ }
549
+ if (input.explicit?.status === "waived")
550
+ return "waived";
551
+ if (dualCoverage === "covered")
552
+ return "awaiting-verification";
553
+ return dualCoverage ??
554
+ (input.done.length === input.referencedCount && input.done.length > 0
555
+ ? "partial"
556
+ : "missing");
557
+ }
558
+ export function projectDualCoverage(item, states, outcomes, failed, done, expectedFeatureId) {
559
+ const implRefs = item.verification.implementation_task_refs ?? [];
560
+ const verRefs = item.verification.verification_task_refs ?? [];
561
+ const integration = item.verification.integration;
562
+ const dualMode = implRefs.length > 0 || verRefs.length > 0 || integration !== undefined;
563
+ if (!dualMode)
564
+ return undefined;
565
+ const envByWorkerRunId = (workerRunId) => workerRunId ? outcomes.get(workerRunId) : undefined;
566
+ // 1. implementation refs must all be Done.
567
+ const implDone = implRefs.length > 0
568
+ ? implRefs.every((id) => states[id]?.status === "Done")
569
+ : done.length > 0;
570
+ // 2. verification refs must each have a succeeded outcome envelope.
571
+ let verSucceeded = false;
572
+ let verEnvelopes = [];
573
+ if (verRefs.length > 0) {
574
+ verEnvelopes = verRefs
575
+ .map((taskId) => ({ taskId, envelope: envByWorkerRunId(states[taskId]?.workerRunId) }))
576
+ .filter((value) => Boolean(value.envelope));
577
+ verSucceeded =
578
+ verEnvelopes.length === verRefs.length &&
579
+ verEnvelopes.every(({ envelope }) => envelope.outcomeStatus === "succeeded");
580
+ }
581
+ if (failed.length > 0)
582
+ return "blocked";
583
+ if (!implDone) {
584
+ return done.length > 0 ? "partial" : "missing";
585
+ }
586
+ if (verRefs.length === 0 || !verSucceeded)
587
+ return "awaiting-verification";
588
+ if (expectedFeatureId && verEnvelopes.some(({ taskId, envelope }) => envelope.identity?.featureId !== expectedFeatureId ||
589
+ envelope.identity?.taskId !== taskId ||
590
+ (envelope.identity?.workflow !== "backend-test" && envelope.identity?.workflow !== "frontend-test"))) {
591
+ return "awaiting-verification";
592
+ }
593
+ const requiredEvidence = item.verification.required_evidence ?? [];
594
+ if (requiredEvidence.some((kind) => !verEnvelopes.some(({ envelope }) => kind === "shell-verification"
595
+ ? envelope.shellVerification?.exitZero === true
596
+ : envelope.artifacts?.some((artifact) => artifact.kind === kind)))) {
597
+ return "awaiting-verification";
598
+ }
599
+ // 3. integration policy: real-required demands real evidence on every
600
+ // verification envelope. mock-only / locally-validated (real:false) cannot
601
+ // satisfy real-required.
602
+ if (integration === "real-required") {
603
+ if (verRefs.length === 0)
604
+ return "awaiting-verification";
605
+ const realOk = verEnvelopes.every(({ envelope }) => envelope.integrationStatus?.real === true);
606
+ if (!realOk)
607
+ return "awaiting-verification";
608
+ }
609
+ // 4. All dual-coverage prerequisites are satisfied. The caller still requires
610
+ // an explicit, hash-validated coverage artifact before publishing `covered`;
611
+ // without it, the read model remains awaiting-verification.
612
+ return "covered";
613
+ }
@@ -6,6 +6,7 @@ import { writeTaskConfig, writeTaskArtifactFile } from "../../infrastructure/har
6
6
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
7
7
  import { resolveLoopAgentProfile } from "../profile-mapping.js";
8
8
  import { mapRiskLevelToComplexity } from "../task-spec/complexity-mapping.js";
9
+ import { resolveWorkflow } from "../task-spec/workflow-routing.js";
9
10
  import { validateTaskSpec } from "../task-spec/validate.js";
10
11
  export async function materializeTaskSpec(options) {
11
12
  const now = options.now ?? new Date();
@@ -17,6 +18,7 @@ export async function materializeTaskSpec(options) {
17
18
  throw new Error(`TaskSpec validation failed: ${codes}`);
18
19
  }
19
20
  const harnessTaskId = buildHarnessTaskId(options.taskSpec, now);
21
+ const resolvedWorkflow = resolveWorkflow(options.taskSpec);
20
22
  const existingConfig = await loadExistingTaskConfig(options.repoRoot, harnessTaskId);
21
23
  if (!existingConfig) {
22
24
  const newTaskResult = await options.client.run(["new-task", harnessTaskId, options.taskSpec.title], {
@@ -36,6 +38,8 @@ export async function materializeTaskSpec(options) {
36
38
  ...baseConfig,
37
39
  taskId: harnessTaskId,
38
40
  title: options.taskSpec.title,
41
+ taskKind: resolvedWorkflow.taskKind,
42
+ featureId: options.taskSpec.feature_id,
39
43
  allowedPaths: options.taskSpec.constraints.allowed_paths,
40
44
  forbiddenPaths: options.taskSpec.constraints.forbidden_paths,
41
45
  hardConstraints: options.taskSpec.constraints.hard_constraints,
@@ -87,6 +91,7 @@ export async function materializeTaskSpec(options) {
87
91
  businessId: options.taskSpec.id,
88
92
  harnessTaskId,
89
93
  featureId: options.taskSpec.feature_id,
94
+ workflow: resolvedWorkflow.workflow,
90
95
  loopAgentProfile: profileMapping.loopAgentProfile,
91
96
  taskConfigPath: paths.taskConfigPath,
92
97
  source: {
@@ -159,6 +159,7 @@ export async function listTaskRunHistory(repoRoot, featureId, taskId, options =
159
159
  workerRunId: run.workerRunId,
160
160
  taskId: run.taskId,
161
161
  featureId: run.featureId ?? featureId,
162
+ workflow: enriched?.workflow ?? run.workflow,
162
163
  status: enriched?.status ?? mapRunRecordStatus(run.status),
163
164
  startedAt: enriched?.startedAt,
164
165
  finishedAt: enriched?.finishedAt ?? run.recordedAt,
@@ -335,6 +336,7 @@ function mergeLedgerRun(taskMap, run, warnings) {
335
336
  runRecordPath: run.runRecordPath,
336
337
  dagPath: run.dagPath,
337
338
  ...run.failureArtifacts,
339
+ ...(run.outcomePath ? { outcome: run.outcomePath } : {}),
338
340
  };
339
341
  taskMap.set(key, {
340
342
  ...(sameRun
@@ -347,6 +349,7 @@ function mergeLedgerRun(taskMap, run, warnings) {
347
349
  }),
348
350
  batchRunId: run.batchRunId,
349
351
  featureId,
352
+ workflow: run.workflow ?? existing.workflow,
350
353
  status: mapRunRecordStatus(run.status),
351
354
  workerRunId: run.workerRunId,
352
355
  harnessTaskId: run.harnessTaskId ?? existing.harnessTaskId,
@@ -491,6 +494,7 @@ function buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex = new M
491
494
  taskId: run.taskId,
492
495
  batchRunId: run.batchRunId,
493
496
  featureId: run.featureId,
497
+ workflow: run.workflow,
494
498
  status: mapRunRecordStatus(run.status),
495
499
  workerRunId: run.workerRunId,
496
500
  harnessTaskId: run.harnessTaskId,
@@ -501,6 +505,7 @@ function buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex = new M
501
505
  runRecordPath: run.runRecordPath,
502
506
  dagPath: run.dagPath,
503
507
  ...run.failureArtifacts,
508
+ ...(run.outcomePath ? { outcome: run.outcomePath } : {}),
504
509
  }),
505
510
  });
506
511
  }
@@ -1060,6 +1065,8 @@ async function loadLedgerRuns(repoRoot) {
1060
1065
  workerRunId,
1061
1066
  taskId,
1062
1067
  featureId: readString(parsed, "featureId"),
1068
+ workflow: readString(parsed, "workflow"),
1069
+ outcomePath: readString(parsed, "outcomePath"),
1063
1070
  status,
1064
1071
  harnessTaskId: readString(parsed, "harnessTaskId"),
1065
1072
  runRecordPath: readString(parsed, "runRecordPath"),
@@ -152,6 +152,7 @@ export async function renderTaskDetail(featureId, taskId) {
152
152
  metaGrid([
153
153
  ["Task ID", task.taskId],
154
154
  ["状态", badge(task.status)],
155
+ ["Workflow", task.workflow ?? "—"],
155
156
  [
156
157
  "Feature",
157
158
  task.featureId