@tea-agent/loop-agent 0.14.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 (117) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +98 -11
  3. package/README.md +24 -5
  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/init-hybrid.js +27 -11
  64. package/dist/workflows/dag/knowledge-curator.js +3 -0
  65. package/dist/workflows/dag/node-execution.js +11 -4
  66. package/dist/workflows/dag/prompt.js +1 -1
  67. package/dist/workflows/dag/runner.js +43 -16
  68. package/dist/workflows/dag/skill-snapshot.js +11 -7
  69. package/dist/workflows/dag/types.js +18 -0
  70. package/docs/README.md +1 -0
  71. package/docs/init-surface.manifest.json +7 -7
  72. package/docs/templates/branch-merge-report.md +0 -1
  73. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  74. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  75. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  76. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  77. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  78. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  79. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  80. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  81. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  82. package/docs/templates/product-line/AGENTS.md +1 -0
  83. package/docs/templates/product-line/README.md +17 -0
  84. package/docs/templates/product-line/acceptance.yaml +9 -0
  85. package/docs/templates/product-line/feature.yaml +11 -0
  86. package/docs/templates/product-line/task-graph.yaml +8 -0
  87. package/docs/templates/product-line/task.yaml +4 -0
  88. package/package.json +6 -16
  89. package/skills/browser-tools/SKILL.md +2 -2
  90. package/skills/frontend-design-review/references/review-checklist.md +27 -45
  91. package/skills/frontend-implementation/references/node-contracts.md +4 -4
  92. package/skills/frontend-review/SKILL.md +3 -1
  93. package/skills/frontend-review/references/review-findings.md +2 -1
  94. package/skills/frontend-verification/SKILL.md +3 -1
  95. package/skills/frontend-verification/references/verification-checklist.md +13 -22
  96. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  97. package/docs/agent-dag-recovery-playbook.md +0 -195
  98. package/docs/agent-dag-runner.md +0 -67
  99. package/docs/cursor-prompt-sidecar.md +0 -36
  100. package/docs/decisions/README.md +0 -18
  101. package/docs/design/README.md +0 -167
  102. package/docs/development-principles.md +0 -73
  103. package/docs/exec-plans/README.md +0 -6
  104. package/docs/exec-plans/active/README.md +0 -13
  105. package/docs/exec-plans/completed/README.md +0 -108
  106. package/docs/feature-workflow.md +0 -414
  107. package/docs/loop-agent-harness.md +0 -142
  108. package/docs/production-readiness.md +0 -96
  109. package/docs/progress/README.md +0 -81
  110. package/docs/reports/README.md +0 -163
  111. package/docs/verification-matrix.md +0 -70
  112. package/scripts/check-product-line-docs.sh +0 -29
  113. package/scripts/check-task-pool-root.sh +0 -32
  114. package/scripts/kb-graph-incremental-prepare.sh +0 -5
  115. package/scripts/kb-graph-materialize.sh +0 -4
  116. package/scripts/kb-graph-promote.sh +0 -4
  117. package/scripts/kb-query.sh +0 -5
@@ -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,83 @@ 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
+ let envelope = projection.envelope;
215
+ const gate = checkRequiredOutputs(envelope, options.taskSpec.outputs.required);
216
+ if (reportDecision.succeeded && !gate.passed) {
217
+ status = "failed";
218
+ outcomeFailure = {
219
+ category: "ContractMismatch",
220
+ reason: `missing required outputs: ${gate.missing.join(", ")}`,
221
+ };
222
+ envelope = {
223
+ ...envelope,
224
+ outcomeStatus: "failed",
225
+ outcomeFailure: {
226
+ category: "ContractMismatch",
227
+ reason: `missing required outputs: ${gate.missing.join(", ")}`,
228
+ missingOutputs: [...gate.missing],
229
+ },
230
+ };
231
+ progress.step(`required output gate failed: ${gate.missing.join(", ")}`);
232
+ }
233
+ else if (envelope.outcomeFailure) {
234
+ outcomeFailure = {
235
+ category: envelope.outcomeFailure.category,
236
+ reason: envelope.outcomeFailure.reason,
237
+ };
238
+ }
239
+ // Persist succeeded and failed envelopes so read models can inspect
240
+ // outcomeFailure without re-parsing run-record / report decision.
241
+ outcome = await writeOutcome(options.repoRoot, envelope);
242
+ }
243
+ if (status === "succeeded" && !options.skipSuccessFinalization) {
165
244
  await runObservedStep(eventCtx, "promote-run", async () => {
166
245
  progress.step("promote run");
167
246
  await runRequiredCommand(options.repoRoot, client, "promote-run", ["promote-run", materializeManifest.harnessTaskId, "--run-id", workerRunId], true, undefined, eventCtx);
@@ -178,6 +257,7 @@ export async function runTaskSpec(options) {
178
257
  businessId: options.taskSpec.id,
179
258
  harnessTaskId: materializeManifest.harnessTaskId,
180
259
  featureId: options.taskSpec.feature_id,
260
+ workflow: materializeManifest.workflow,
181
261
  loopAgentProfile: materializeManifest.loopAgentProfile,
182
262
  dagPath,
183
263
  runRecordPath,
@@ -211,9 +291,12 @@ export async function runTaskSpec(options) {
211
291
  workerRunId,
212
292
  businessId: options.taskSpec.id,
213
293
  harnessTaskId: materializeManifest.harnessTaskId,
294
+ workflow: materializeManifest.workflow,
214
295
  runRecordPath,
215
296
  dagPath,
216
297
  reportDecision,
298
+ ...(outcome ? { outcome } : {}),
299
+ ...(outcomeFailure ? { outcomeFailure } : {}),
217
300
  ...(failureArtifacts ? { failureArtifacts } : {}),
218
301
  };
219
302
  }
@@ -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,111 @@ 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
+ * Envelope-level binding only: Feature / producer task / kind / optional
159
+ * schemaId / optional sourceBinding must match the injected Outcome. The
160
+ * planner is pure and synchronous — it does **not** re-read artifact files or
161
+ * recompute sha256. Callers must inject envelopes via `readVerifiedOutcome`
162
+ * (path/hash of the outcome JSON). File-level artifact sha256 is enforced at
163
+ * `projectOutcome` and again at Feature Verification Bundle / verify-final /
164
+ * Delivery / Closeout (`verifyArtifactRefs`).
165
+ *
166
+ * When `outcomes` is omitted, this gate is skipped (legacy callers unchanged).
167
+ */
168
+ function evaluateArtifactGate(consumes, featureId, states, outcomes, producerKinds) {
169
+ if (!outcomes || !consumes || consumes.length === 0)
170
+ return undefined;
171
+ for (const ref of consumes) {
172
+ // Graph-level precheck (gap B): the producer must exist in the graph and
173
+ // must declare the consumed `kind`. This runs *before* envelope lookup so
174
+ // a stray matching envelope can never bypass a missing/misdeclared wire.
175
+ const declaredKinds = producerKinds.get(ref.producerTaskId);
176
+ if (!declaredKinds) {
177
+ return {
178
+ blockedBy: [ref.producerTaskId],
179
+ projection: {
180
+ producerTaskId: ref.producerTaskId,
181
+ kind: ref.kind,
182
+ missingKind: "producer-kind-undeclared",
183
+ detail: `producer node ${ref.producerTaskId} not in graph`,
184
+ },
185
+ };
186
+ }
187
+ if (!declaredKinds.has(ref.kind)) {
188
+ return {
189
+ blockedBy: [ref.producerTaskId],
190
+ projection: {
191
+ producerTaskId: ref.producerTaskId,
192
+ kind: ref.kind,
193
+ missingKind: "producer-kind-undeclared",
194
+ detail: `producer ${ref.producerTaskId} does not declare kind ${ref.kind}`,
195
+ },
196
+ };
197
+ }
198
+ const failure = evaluateArtifactRef(ref, featureId, states, outcomes);
199
+ if (failure)
200
+ return failure;
201
+ }
202
+ return undefined;
203
+ }
204
+ function evaluateArtifactRef(ref, featureId, states, outcomes) {
205
+ const projection = (missingKind, detail) => ({
206
+ blockedBy: [ref.producerTaskId],
207
+ projection: { producerTaskId: ref.producerTaskId, kind: ref.kind, missingKind, detail },
208
+ });
209
+ const depState = states[ref.producerTaskId];
210
+ // Non-Done producers are handled by the existing dependency gate; do not
211
+ // duplicate that classification here.
212
+ if (depState?.status !== undefined && depState.status !== "Done")
213
+ return undefined;
214
+ const workerRunId = depState?.workerRunId;
215
+ if (!workerRunId)
216
+ return projection("outcome-absent", "producer state has no workerRunId");
217
+ const envelope = outcomes.get(workerRunId);
218
+ if (!envelope)
219
+ return projection("outcome-absent", "no outcome envelope injected for workerRunId");
220
+ // Fail-closed (gap A): a producer run that ended in `failed` carries no
221
+ // trustworthy artifact even if its `kind` happens to match. Such an envelope
222
+ // must never make a downstream Ready.
223
+ if (envelope.outcomeStatus !== "succeeded") {
224
+ return projection("outcome-absent", `producer outcome status ${envelope.outcomeStatus}`);
225
+ }
226
+ if (envelope.identity.featureId !== featureId) {
227
+ return projection("feature-cross-wire", `envelope featureId ${envelope.identity.featureId} != ${featureId}`);
228
+ }
229
+ if (envelope.identity.taskId !== ref.producerTaskId) {
230
+ return projection("producer-task-mismatch", `envelope taskId ${envelope.identity.taskId} != ${ref.producerTaskId}`);
231
+ }
232
+ const artifact = envelope.artifacts.find((item) => item.kind !== undefined && item.kind === ref.kind);
233
+ if (!artifact) {
234
+ return projection("artifact-kind-absent", `no artifact with kind ${ref.kind}`);
235
+ }
236
+ if (ref.schemaId !== undefined) {
237
+ const artifactSchemaId = artifact.schemaId ?? artifact.kind;
238
+ if (artifactSchemaId !== ref.schemaId) {
239
+ return projection("schema-mismatch", `artifact schemaId ${String(artifactSchemaId)} != ${ref.schemaId}`);
240
+ }
241
+ }
242
+ if (ref.sourceBinding) {
243
+ const envBinding = envelope.sourceBinding;
244
+ if (!envBinding) {
245
+ return projection("source-binding-mismatch", "envelope has no sourceBinding");
246
+ }
247
+ if (ref.sourceBinding.sourceFiles !== undefined) {
248
+ const envFile = envBinding.sourceFiles?.[0];
249
+ if (envFile !== ref.sourceBinding.sourceFiles[0]) {
250
+ return projection("source-binding-mismatch", `sourceFiles[0] ${String(envFile)} != ${ref.sourceBinding.sourceFiles[0]}`);
251
+ }
252
+ }
253
+ if (ref.sourceBinding.sha256 !== undefined && envBinding.sha256 !== ref.sourceBinding.sha256) {
254
+ return projection("source-binding-mismatch", "sourceBinding sha256 mismatch");
255
+ }
256
+ }
257
+ return undefined;
258
+ }
128
259
  function priority(value) {
129
260
  return { P0: 0, P1: 1, P2: 2, P3: 3 }[value];
130
261
  }
@@ -1,11 +1,42 @@
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. Ready Planner validates envelope-level
13
+ * Feature / producer / kind / optional schemaId / optional sourceBinding.
14
+ * File-level path+sha256 of artifact bytes is enforced at Outcome projection
15
+ * and Feature Verification Bundle / Delivery / Closeout — not inside the
16
+ * synchronous Ready Planner.
17
+ */
18
+ export const artifactRefSchema = z
19
+ .object({
20
+ kind: z.string().min(1),
21
+ schemaId: z.string().min(1).optional(),
22
+ producerTaskId: z.string().min(1),
23
+ sourceBinding: z
24
+ .object({
25
+ sourceFiles: z.array(z.string().min(1)).optional(),
26
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
27
+ })
28
+ .strict()
29
+ .optional(),
30
+ })
31
+ .strict();
3
32
  export const taskGraphNodeSchema = z
4
33
  .object({
5
34
  id: z.string().min(1),
6
35
  task: z.string().min(1),
7
36
  type: taskSpecTypeSchema,
8
37
  depends_on: z.array(z.string().min(1)).optional().default([]),
38
+ produces: z.array(artifactDeclSchema).optional(),
39
+ consumes: z.array(artifactRefSchema).optional(),
9
40
  })
10
41
  .strict();
11
42
  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
  }