@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
@@ -7,6 +7,9 @@ import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from
7
7
  import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
8
8
  import { preflightTargetRepo } from "../preflight.js";
9
9
  import { getTaskPoolRoot } from "../pool/run-store.js";
10
+ import { checkRequiredOutputs } from "../outcomes/gate.js";
11
+ import { projectOutcome } from "../outcomes/projector.js";
12
+ import { writeOutcome } from "../outcomes/store.js";
10
13
  export const DEFAULT_RUN_DAG_TIMEOUT_MS = 1_800_000;
11
14
  export const MAX_WORKER_TIMEOUT_MS = 7_200_000;
12
15
  export async function runTaskSpec(options) {
@@ -150,7 +153,7 @@ export async function runTaskSpec(options) {
150
153
  });
151
154
  return decision;
152
155
  }, { statusForResult: (decision) => decision.succeeded ? "succeeded" : "failed" });
153
- const status = reportDecision.succeeded ? "succeeded" : "failed";
156
+ let status = reportDecision.succeeded ? "succeeded" : "failed";
154
157
  progress.step(`report decision: ${status} (${reportDecision.reason}${reportDecision.runStatus ? `, status=${reportDecision.runStatus}` : ""})`);
155
158
  const failureArtifacts = reportDecision.succeeded
156
159
  ? undefined
@@ -161,7 +164,64 @@ export async function runTaskSpec(options) {
161
164
  taskArtifactsDir,
162
165
  eventCtx,
163
166
  });
164
- if (reportDecision.succeeded && !options.skipSuccessFinalization) {
167
+ // The outcome adapter reads a canonical worker record. Persist that fact
168
+ // before gating, but do not promote or close out until the projection passes.
169
+ const provisionalRecord = {
170
+ schemaVersion: 1,
171
+ status,
172
+ workerRunId,
173
+ businessId: options.taskSpec.id,
174
+ harnessTaskId: materializeManifest.harnessTaskId,
175
+ featureId: options.taskSpec.feature_id,
176
+ workflow: materializeManifest.workflow,
177
+ loopAgentProfile: materializeManifest.loopAgentProfile,
178
+ dagPath,
179
+ runRecordPath,
180
+ materializeManifest,
181
+ reportDecision,
182
+ commands,
183
+ ...(failureArtifacts ? { failureArtifacts } : {}),
184
+ ...(controllerIdentity ? { controllerIdentity } : {}),
185
+ };
186
+ await writeFile(runRecordPath, `${JSON.stringify(provisionalRecord, null, 2)}\n`, "utf-8");
187
+ let outcome;
188
+ let outcomeFailure;
189
+ const projection = await projectOutcome({
190
+ repoRoot: options.repoRoot,
191
+ workerRunId,
192
+ taskSpec: options.taskSpec,
193
+ taskSpecPath: options.taskSpecPath,
194
+ runRecord: {
195
+ featureId: options.taskSpec.feature_id,
196
+ taskId: options.taskSpec.id,
197
+ workerRunId,
198
+ harnessTaskId: materializeManifest.harnessTaskId,
199
+ workflow: materializeManifest.workflow,
200
+ ...(controllerIdentity ? { controllerIdentity } : {}),
201
+ },
202
+ reportDecision,
203
+ runRecordPath,
204
+ dagPath,
205
+ acceptanceRefs: options.taskSpec.acceptance_refs,
206
+ now,
207
+ });
208
+ if (!projection.ok) {
209
+ status = "failed";
210
+ outcomeFailure = { category: projection.category, reason: projection.reason };
211
+ progress.step(`outcome projection failed: ${projection.category} (${projection.reason})`);
212
+ }
213
+ else {
214
+ const gate = checkRequiredOutputs(projection.envelope, options.taskSpec.outputs.required);
215
+ if (reportDecision.succeeded && !gate.passed) {
216
+ status = "failed";
217
+ outcomeFailure = { category: "ContractMismatch", reason: `missing required outputs: ${gate.missing.join(", ")}` };
218
+ progress.step(`required output gate failed: ${gate.missing.join(", ")}`);
219
+ }
220
+ else {
221
+ outcome = await writeOutcome(options.repoRoot, projection.envelope);
222
+ }
223
+ }
224
+ if (status === "succeeded" && !options.skipSuccessFinalization) {
165
225
  await runObservedStep(eventCtx, "promote-run", async () => {
166
226
  progress.step("promote run");
167
227
  await runRequiredCommand(options.repoRoot, client, "promote-run", ["promote-run", materializeManifest.harnessTaskId, "--run-id", workerRunId], true, undefined, eventCtx);
@@ -178,6 +238,7 @@ export async function runTaskSpec(options) {
178
238
  businessId: options.taskSpec.id,
179
239
  harnessTaskId: materializeManifest.harnessTaskId,
180
240
  featureId: options.taskSpec.feature_id,
241
+ workflow: materializeManifest.workflow,
181
242
  loopAgentProfile: materializeManifest.loopAgentProfile,
182
243
  dagPath,
183
244
  runRecordPath,
@@ -211,9 +272,12 @@ export async function runTaskSpec(options) {
211
272
  workerRunId,
212
273
  businessId: options.taskSpec.id,
213
274
  harnessTaskId: materializeManifest.harnessTaskId,
275
+ workflow: materializeManifest.workflow,
214
276
  runRecordPath,
215
277
  dagPath,
216
278
  reportDecision,
279
+ ...(outcome ? { outcome } : {}),
280
+ ...(outcomeFailure ? { outcomeFailure } : {}),
217
281
  ...(failureArtifacts ? { failureArtifacts } : {}),
218
282
  };
219
283
  }
@@ -5,6 +5,7 @@ import YAML from "yaml";
5
5
  import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
6
6
  import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
7
7
  import { findRunByWorkerRunId, getTaskPoolRoot, readFeatureTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
8
+ import { readVerifiedOutcome } from "../outcomes/store.js";
8
9
  import { runTaskSpec, } from "../run-task/run-task.js";
9
10
  import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
10
11
  import { planReadyTasks } from "../task-graph/ready-planner.js";
@@ -36,12 +37,14 @@ export async function runReadyTasks(options) {
36
37
  const graph = await loadTaskGraph(options.featureDir);
37
38
  const featureId = graph.feature_id;
38
39
  const states = await readFeatureTaskPoolStates(options.repoRoot, featureId);
40
+ const outcomes = await loadVerifiedOutcomes(options.repoRoot, states);
39
41
  const taskSpecs = await loadFeatureTaskSpecs(options.featureDir, graph);
40
42
  const plan = planReadyTasks({
41
43
  featureId,
42
44
  graph,
43
45
  taskSpecs,
44
46
  states,
47
+ outcomes,
45
48
  selectionLimit: options.limit ?? graph.nodes.length,
46
49
  });
47
50
  const limitedTaskIds = plan.selected.map((candidate) => candidate.taskId);
@@ -211,7 +214,14 @@ export async function runReadyTasks(options) {
211
214
  });
212
215
  continue;
213
216
  }
214
- const failure = deriveFailureRoute(result);
217
+ const failure = result.outcomeFailure
218
+ ? {
219
+ category: result.outcomeFailure.category,
220
+ recommendedFollowUpKind: "manual-review",
221
+ derivedFollowUpTaskId: `${taskId}-outcome-contract-review`,
222
+ source: "report-decision",
223
+ }
224
+ : deriveFailureRoute(result);
215
225
  emit(progress, {
216
226
  type: "task.finished",
217
227
  source: "worker",
@@ -256,6 +266,8 @@ export async function runReadyTasks(options) {
256
266
  ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
257
267
  ...(result.failureArtifacts ? { failureArtifacts: result.failureArtifacts } : {}),
258
268
  ...(controllerIdentity ? { controllerIdentity } : {}),
269
+ ...(result.workflow ? { workflow: result.workflow } : {}),
270
+ ...(result.outcome ? { outcomePath: result.outcome.outcomePath, outcomeSha256: result.outcome.outcomeSha256 } : {}),
259
271
  };
260
272
  let recorded = false;
261
273
  try {
@@ -314,6 +326,25 @@ export async function runReadyTasks(options) {
314
326
  });
315
327
  return output;
316
328
  }
329
+ async function loadVerifiedOutcomes(repoRoot, states) {
330
+ const outcomes = new Map();
331
+ for (const state of Object.values(states)) {
332
+ if (!state.workerRunId)
333
+ continue;
334
+ const run = await findRunByWorkerRunId(repoRoot, state.workerRunId);
335
+ if (!run)
336
+ continue;
337
+ const outcome = await readVerifiedOutcome({
338
+ repoRoot,
339
+ workerRunId: state.workerRunId,
340
+ outcomePath: run.outcomePath,
341
+ outcomeSha256: run.outcomeSha256,
342
+ });
343
+ if (outcome)
344
+ outcomes.set(state.workerRunId, outcome);
345
+ }
346
+ return outcomes;
347
+ }
317
348
  function emit(progress, input) {
318
349
  const structured = progress;
319
350
  if (typeof structured.event !== "function")
@@ -12,6 +12,18 @@ export const acceptanceItemSchema = z
12
12
  .object({
13
13
  expected_task_refs: z.array(z.string().min(1)).min(1),
14
14
  suggested_tests: z.array(z.string().min(1)).optional().default([]),
15
+ /**
16
+ * fullstack-v1 dual-coverage declarations (M4). All optional so legacy
17
+ * packets and inline fixtures using only `expected_task_refs` keep
18
+ * parsing unchanged. Kept strict: no passthrough, otherwise typed
19
+ * validators/read models could not read these fields.
20
+ */
21
+ implementation_task_refs: z.array(z.string().min(1)).optional(),
22
+ verification_task_refs: z.array(z.string().min(1)).optional(),
23
+ required_evidence: z.array(z.string().min(1)).optional(),
24
+ integration: z
25
+ .enum(["not-applicable", "mock-allowed", "real-required"])
26
+ .optional(),
15
27
  })
16
28
  .strict(),
17
29
  })
@@ -31,6 +31,19 @@ export function planReadyTasks(input) {
31
31
  }
32
32
  const eligible = [];
33
33
  const blocked = [];
34
+ // Build a producer->declared-kinds index once (loop-free) so the artifact
35
+ // gate can verify graph-level consumes/produces consistency without reading
36
+ // `produces` per-node. The gate is only consulted when `outcomes` is
37
+ // injected, so this index is unused otherwise (no behavior change).
38
+ const producerKinds = new Map();
39
+ for (const node of input.graph.nodes) {
40
+ if (node.produces && node.produces.length > 0) {
41
+ const set = producerKinds.get(node.id) ?? new Set();
42
+ for (const decl of node.produces)
43
+ set.add(decl.kind);
44
+ producerKinds.set(node.id, set);
45
+ }
46
+ }
34
47
  for (const [graphIndex, node] of input.graph.nodes.entries()) {
35
48
  const spec = input.taskSpecs.get(node.id);
36
49
  if (!spec) {
@@ -85,6 +98,19 @@ export function planReadyTasks(input) {
85
98
  });
86
99
  continue;
87
100
  }
101
+ const gateFailure = evaluateArtifactGate(node.consumes ?? [], input.featureId, input.states, input.outcomes, producerKinds);
102
+ if (gateFailure) {
103
+ blocked.push({
104
+ featureId: input.featureId,
105
+ taskId: node.id,
106
+ priority: spec.priority,
107
+ reasonCode: "required-artifact-missing",
108
+ reason: "required artifact is missing or invalid",
109
+ blockedBy: gateFailure.blockedBy,
110
+ artifactGate: gateFailure.projection,
111
+ });
112
+ continue;
113
+ }
88
114
  eligible.push({
89
115
  featureId: input.featureId,
90
116
  taskId: node.id,
@@ -125,6 +151,105 @@ export function planReadyTasks(input) {
125
151
  },
126
152
  };
127
153
  }
154
+ /**
155
+ * Evaluate artifact-gate eligibility for a node whose dependencies are all
156
+ * `Done`. Returns the first blocking projection, or `undefined` when eligible.
157
+ *
158
+ * Fail-closed: any unverified producer path/hash/schema/feature/producer/source
159
+ * binding blocks the consumer as `required-artifact-missing` and never becomes
160
+ * Ready. When `outcomes` is not provided, eligibility is skipped (M3 keeps
161
+ * callers that have not wired disk injection unchanged). Hash byte-level
162
+ * verification is the caller's responsibility before injecting an envelope;
163
+ * the planner trusts `envelope.artifacts[].sha256` as a literal.
164
+ */
165
+ function evaluateArtifactGate(consumes, featureId, states, outcomes, producerKinds) {
166
+ if (!outcomes || !consumes || consumes.length === 0)
167
+ return undefined;
168
+ for (const ref of consumes) {
169
+ // Graph-level precheck (gap B): the producer must exist in the graph and
170
+ // must declare the consumed `kind`. This runs *before* envelope lookup so
171
+ // a stray matching envelope can never bypass a missing/misdeclared wire.
172
+ const declaredKinds = producerKinds.get(ref.producerTaskId);
173
+ if (!declaredKinds) {
174
+ return {
175
+ blockedBy: [ref.producerTaskId],
176
+ projection: {
177
+ producerTaskId: ref.producerTaskId,
178
+ kind: ref.kind,
179
+ missingKind: "producer-kind-undeclared",
180
+ detail: `producer node ${ref.producerTaskId} not in graph`,
181
+ },
182
+ };
183
+ }
184
+ if (!declaredKinds.has(ref.kind)) {
185
+ return {
186
+ blockedBy: [ref.producerTaskId],
187
+ projection: {
188
+ producerTaskId: ref.producerTaskId,
189
+ kind: ref.kind,
190
+ missingKind: "producer-kind-undeclared",
191
+ detail: `producer ${ref.producerTaskId} does not declare kind ${ref.kind}`,
192
+ },
193
+ };
194
+ }
195
+ const failure = evaluateArtifactRef(ref, featureId, states, outcomes);
196
+ if (failure)
197
+ return failure;
198
+ }
199
+ return undefined;
200
+ }
201
+ function evaluateArtifactRef(ref, featureId, states, outcomes) {
202
+ const projection = (missingKind, detail) => ({
203
+ blockedBy: [ref.producerTaskId],
204
+ projection: { producerTaskId: ref.producerTaskId, kind: ref.kind, missingKind, detail },
205
+ });
206
+ const depState = states[ref.producerTaskId];
207
+ // Non-Done producers are handled by the existing dependency gate; do not
208
+ // duplicate that classification here.
209
+ if (depState?.status !== undefined && depState.status !== "Done")
210
+ return undefined;
211
+ const workerRunId = depState?.workerRunId;
212
+ if (!workerRunId)
213
+ return projection("outcome-absent", "producer state has no workerRunId");
214
+ const envelope = outcomes.get(workerRunId);
215
+ if (!envelope)
216
+ return projection("outcome-absent", "no outcome envelope injected for workerRunId");
217
+ // Fail-closed (gap A): a producer run that ended in `failed` carries no
218
+ // trustworthy artifact even if its `kind` happens to match. Such an envelope
219
+ // must never make a downstream Ready.
220
+ if (envelope.outcomeStatus !== "succeeded") {
221
+ return projection("outcome-absent", `producer outcome status ${envelope.outcomeStatus}`);
222
+ }
223
+ if (envelope.identity.featureId !== featureId) {
224
+ return projection("feature-cross-wire", `envelope featureId ${envelope.identity.featureId} != ${featureId}`);
225
+ }
226
+ if (envelope.identity.taskId !== ref.producerTaskId) {
227
+ return projection("producer-task-mismatch", `envelope taskId ${envelope.identity.taskId} != ${ref.producerTaskId}`);
228
+ }
229
+ const artifact = envelope.artifacts.find((item) => item.kind !== undefined && item.kind === ref.kind);
230
+ if (!artifact) {
231
+ return projection("artifact-kind-absent", `no artifact with kind ${ref.kind}`);
232
+ }
233
+ if (ref.schemaId !== undefined && artifact.kind !== ref.schemaId) {
234
+ return projection("schema-mismatch", `artifact kind ${String(artifact.kind)} != schemaId ${ref.schemaId}`);
235
+ }
236
+ if (ref.sourceBinding) {
237
+ const envBinding = envelope.sourceBinding;
238
+ if (!envBinding) {
239
+ return projection("source-binding-mismatch", "envelope has no sourceBinding");
240
+ }
241
+ if (ref.sourceBinding.sourceFiles !== undefined) {
242
+ const envFile = envBinding.sourceFiles?.[0];
243
+ if (envFile !== ref.sourceBinding.sourceFiles[0]) {
244
+ return projection("source-binding-mismatch", `sourceFiles[0] ${String(envFile)} != ${ref.sourceBinding.sourceFiles[0]}`);
245
+ }
246
+ }
247
+ if (ref.sourceBinding.sha256 !== undefined && envBinding.sha256 !== ref.sourceBinding.sha256) {
248
+ return projection("source-binding-mismatch", "sourceBinding sha256 mismatch");
249
+ }
250
+ }
251
+ return undefined;
252
+ }
128
253
  function priority(value) {
129
254
  return { P0: 0, P1: 1, P2: 2, P3: 3 }[value];
130
255
  }
@@ -1,11 +1,40 @@
1
1
  import { z } from "zod";
2
2
  import { taskSpecTypeSchema } from "../task-spec/schema.js";
3
+ /** Producer-side artifact declaration on a graph node. */
4
+ export const artifactDeclSchema = z
5
+ .object({
6
+ kind: z.string().min(1),
7
+ path: z.string().min(1).optional(),
8
+ schemaId: z.string().min(1).optional(),
9
+ })
10
+ .strict();
11
+ /**
12
+ * Consumer-side artifact reference. A node may only trust an upstream Task
13
+ * Outcome artifact after deterministic path, hash, schema, feature,
14
+ * producer-task and source-binding validation (see ready-planner gate).
15
+ */
16
+ export const artifactRefSchema = z
17
+ .object({
18
+ kind: z.string().min(1),
19
+ schemaId: z.string().min(1).optional(),
20
+ producerTaskId: z.string().min(1),
21
+ sourceBinding: z
22
+ .object({
23
+ sourceFiles: z.array(z.string().min(1)).optional(),
24
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
25
+ })
26
+ .strict()
27
+ .optional(),
28
+ })
29
+ .strict();
3
30
  export const taskGraphNodeSchema = z
4
31
  .object({
5
32
  id: z.string().min(1),
6
33
  task: z.string().min(1),
7
34
  type: taskSpecTypeSchema,
8
35
  depends_on: z.array(z.string().min(1)).optional().default([]),
36
+ produces: z.array(artifactDeclSchema).optional(),
37
+ consumes: z.array(artifactRefSchema).optional(),
9
38
  })
10
39
  .strict();
11
40
  export const taskGraphSpecSchema = z
@@ -6,21 +6,27 @@ import { validateTaskSpec } from "../task-spec/validate.js";
6
6
  import { acceptanceSpecSchema } from "./acceptance-schema.js";
7
7
  import { computeReadyQueue } from "./ready-queue.js";
8
8
  import { taskGraphSpecSchema } from "./task-graph-schema.js";
9
+ import { FEATURE_PROFILE_FILENAME, featureProfileSchema, isFullstackProfile, } from "../feature/profile-schema.js";
10
+ import { validateFullstackStructure } from "../feature/fullstack-validate.js";
9
11
  export async function validateFeatureTaskGraph(featureDir, options = {}) {
10
12
  const errors = [];
11
13
  const acceptance = await loadAcceptanceSpec(featureDir, options, errors);
12
14
  const graph = await loadTaskGraphSpec(featureDir, options, errors);
13
15
  if (!acceptance || !graph) {
14
- return result("", acceptance, graph, errors);
16
+ return result("", acceptance, graph, errors, "generic");
15
17
  }
16
18
  checkDuplicateNodeIds(graph, errors);
17
19
  checkDuplicateAcceptanceIds(acceptance, errors);
18
20
  checkAcceptanceTaskRefs(acceptance, graph, errors);
19
21
  checkUnknownDependencies(graph, errors);
20
22
  checkCycles(graph, errors);
21
- await checkTaskFiles(featureDir, graph, acceptance, errors);
23
+ const taskSpecs = await checkTaskFiles(featureDir, graph, acceptance, errors);
22
24
  await checkQaCloseoutOrder(featureDir, options, errors);
23
- return result(graph.feature_id, acceptance, graph, errors);
25
+ const profile = await loadFeatureProfile(featureDir, options, acceptance.feature_id, errors);
26
+ if (acceptance && graph && profile && isFullstackProfile(profile) && taskSpecs) {
27
+ errors.push(...validateFullstackStructure({ profile, acceptance, graph, taskSpecs }));
28
+ }
29
+ return result(graph.feature_id, acceptance, graph, errors, profile?.profile ?? "generic");
24
30
  }
25
31
  async function checkQaCloseoutOrder(featureDir, options, errors) {
26
32
  const closeoutPath = path.join(featureDir, "closeout.yaml");
@@ -163,6 +169,7 @@ function checkCycles(graph, errors) {
163
169
  }
164
170
  async function checkTaskFiles(featureDir, graph, acceptance, errors) {
165
171
  const acceptanceIds = new Set(acceptance.acceptance.map((item) => item.id));
172
+ const taskSpecs = new Map();
166
173
  for (const node of graph.nodes) {
167
174
  const taskPath = path.join(featureDir, "tasks", node.task);
168
175
  if (!(await exists(taskPath))) {
@@ -184,6 +191,7 @@ async function checkTaskFiles(featureDir, graph, acceptance, errors) {
184
191
  continue;
185
192
  }
186
193
  const taskSpec = parsedTask.data;
194
+ taskSpecs.set(node.id, taskSpec);
187
195
  const detailed = await validateTaskSpec(rawTask, { taskSpecPath: taskPath });
188
196
  for (const issue of detailed.errors) {
189
197
  errors.push({
@@ -223,8 +231,9 @@ async function checkTaskFiles(featureDir, graph, acceptance, errors) {
223
231
  });
224
232
  }
225
233
  }
234
+ return taskSpecs;
226
235
  }
227
- function result(featureId, acceptance, graph, errors) {
236
+ function result(featureId, acceptance, graph, errors, profile) {
228
237
  return {
229
238
  ok: errors.length === 0,
230
239
  featureId,
@@ -232,6 +241,7 @@ function result(featureId, acceptance, graph, errors) {
232
241
  acceptanceCount: acceptance?.acceptance.length ?? 0,
233
242
  nodeCount: graph?.nodes.length ?? 0,
234
243
  readyWithoutState: graph ? computeReadyQueue(graph, {}) : [],
244
+ profile,
235
245
  },
236
246
  errors,
237
247
  };
@@ -251,3 +261,33 @@ function sameStringSet(left, right) {
251
261
  const rightSet = new Set(right);
252
262
  return left.every((value) => rightSet.has(value));
253
263
  }
264
+ async function loadFeatureProfile(featureDir, options, expectedFeatureId, errors) {
265
+ const profilePath = path.join(featureDir, FEATURE_PROFILE_FILENAME);
266
+ let raw = options.profileOverride;
267
+ if (raw === undefined) {
268
+ if (!(await exists(profilePath))) {
269
+ // No feature.yaml → generic packet. Legacy/generic validation behavior is
270
+ // preserved; fullstack gates do not fire.
271
+ return undefined;
272
+ }
273
+ raw = YAML.parse(await readFile(profilePath, "utf-8"));
274
+ }
275
+ const parsed = featureProfileSchema.safeParse(raw);
276
+ if (!parsed.success) {
277
+ errors.push({
278
+ code: "feature-profile-schema-invalid",
279
+ message: parsed.error.issues.map((issue) => issue.message).join("; "),
280
+ path: FEATURE_PROFILE_FILENAME,
281
+ });
282
+ return undefined;
283
+ }
284
+ if (parsed.data.feature_id !== expectedFeatureId) {
285
+ errors.push({
286
+ code: "feature-profile-id-mismatch",
287
+ message: `feature.yaml feature_id ${parsed.data.feature_id} differs from acceptance/graph ${expectedFeatureId}`,
288
+ path: `${FEATURE_PROFILE_FILENAME}:feature_id`,
289
+ });
290
+ return undefined;
291
+ }
292
+ return parsed.data;
293
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { taskCapabilitySchema, verifyModeSchema, verifyPresetSchema, verifyQuotaSchema, } from "../../task/config-types.js";
3
+ import { workflowSchema } from "./workflow-routing.js";
3
4
  export const taskSpecTypeSchema = z.enum([
4
5
  "architecture",
5
6
  "backend-feature",
@@ -92,6 +93,14 @@ export const taskSpecSchema = z
92
93
  create_worktree: z.boolean().optional().default(false),
93
94
  })
94
95
  .strict(),
96
+ /**
97
+ * Optional explicit runtime workflow that routes the TaskSpec to a DAG.
98
+ * Orthogonal to the business {@link type} and the governance loop_agent
99
+ * profile. When absent, legacy backend/frontend feature types route
100
+ * deterministically; legacy QA types retain their current default behavior
101
+ * with migration guidance until they declare it explicitly.
102
+ */
103
+ execution: z.object({ workflow: workflowSchema }).strict().optional(),
95
104
  loop_agent: z
96
105
  .object({
97
106
  profile_policy: z.literal("mapped"),
@@ -4,6 +4,7 @@ import YAML from "yaml";
4
4
  import { resolveLoopAgentProfile } from "../profile-mapping.js";
5
5
  import { mapRiskLevelToComplexity } from "./complexity-mapping.js";
6
6
  import { taskSpecSchema } from "./schema.js";
7
+ import { resolveWorkflow, validateWorkflowCompatibility, } from "./workflow-routing.js";
7
8
  const CODE_WRITING_TYPES = new Set([
8
9
  "backend-feature",
9
10
  "frontend-feature",
@@ -42,6 +43,7 @@ export async function validateTaskSpec(input, options = {}) {
42
43
  pathBoundary: failureFor("schema-invalid", "Skipped because schema validation failed"),
43
44
  verifyCommands: failureFor("schema-invalid", "Skipped because schema validation failed"),
44
45
  profileMapping: failureFor("schema-invalid", "Skipped because schema validation failed"),
46
+ workflow: failureFor("schema-invalid", "Skipped because schema validation failed"),
45
47
  },
46
48
  errors,
47
49
  warnings: [],
@@ -56,12 +58,15 @@ export async function validateTaskSpec(input, options = {}) {
56
58
  const pathBoundaryCheck = checkPathBoundaries(taskSpec, errors, warnings);
57
59
  const verifyCommandsCheck = checkVerifyCommands(taskSpec, errors);
58
60
  const profileMapping = resolveLoopAgentProfile(taskSpec);
61
+ const workflowCheck = checkWorkflow(taskSpec, errors, warnings);
62
+ const resolved = workflowCheck === "ok" ? resolveWorkflow(taskSpec) : undefined;
59
63
  return buildResult({
60
64
  taskId: taskSpec.id,
61
65
  featureId: taskSpec.feature_id,
62
66
  businessProfile: taskSpec.type,
63
67
  riskLevel: taskSpec.risk_level,
64
68
  loopAgentProfilePreview: profileMapping.loopAgentProfile,
69
+ ...(resolved ? { workflow: resolved.workflow, taskKind: resolved.taskKind } : {}),
65
70
  checks: {
66
71
  schema: "ok",
67
72
  sourceDocs: sourceDocsCheck,
@@ -70,6 +75,7 @@ export async function validateTaskSpec(input, options = {}) {
70
75
  pathBoundary: pathBoundaryCheck,
71
76
  verifyCommands: verifyCommandsCheck,
72
77
  profileMapping: "ok",
78
+ workflow: workflowCheck,
73
79
  },
74
80
  errors,
75
81
  warnings,
@@ -90,6 +96,8 @@ function buildResult(input) {
90
96
  riskLevel: input.riskLevel,
91
97
  complexityPreview: mapRiskLevelToComplexity(riskLevel),
92
98
  loopAgentProfilePreview: input.loopAgentProfilePreview,
99
+ ...(input.workflow ? { workflow: input.workflow } : {}),
100
+ ...(input.taskKind ? { taskKind: input.taskKind } : {}),
93
101
  checks: input.checks,
94
102
  errors: input.errors,
95
103
  warnings: input.warnings,
@@ -253,6 +261,37 @@ function checkVerifyCommands(taskSpec, errors) {
253
261
  }
254
262
  return "ok";
255
263
  }
264
+ /**
265
+ * Deterministic workflow compatibility check. Surfaces migration guidance for
266
+ * legacy QA types without an explicit `execution.workflow`, and rejects
267
+ * incompatible explicit workflow/type combinations before materialize.
268
+ */
269
+ function checkWorkflow(taskSpec, errors, warnings) {
270
+ const result = validateWorkflowCompatibility(taskSpec);
271
+ if (result.migrationGuidance) {
272
+ warnings.push({
273
+ code: "qa-workflow-migration-required",
274
+ message: result.migrationGuidance,
275
+ path: "execution.workflow",
276
+ });
277
+ }
278
+ if (result.issues.length === 0)
279
+ return "ok";
280
+ for (const issue of result.issues) {
281
+ errors.push({
282
+ layer: "schema",
283
+ code: issue.code,
284
+ message: issue.message,
285
+ path: issue.path,
286
+ });
287
+ }
288
+ return toFailure({
289
+ layer: "schema",
290
+ code: result.issues[0].code,
291
+ message: result.issues[0].message,
292
+ path: result.issues[0].path,
293
+ });
294
+ }
256
295
  function getTaskSpecDir(options) {
257
296
  return options.taskSpecPath ? path.dirname(options.taskSpecPath) : undefined;
258
297
  }