@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +98 -11
- package/README.md +24 -5
- package/dist/application/evaluation/alias.js +184 -0
- package/dist/application/evaluation/budget.js +192 -0
- package/dist/application/evaluation/campaign-hash.js +47 -0
- package/dist/application/evaluation/campaign-matrix.js +372 -0
- package/dist/application/evaluation/campaign-scorecard.js +135 -0
- package/dist/application/evaluation/campaign.js +370 -0
- package/dist/application/evaluation/candidate.js +23 -6
- package/dist/application/evaluation/corpus-hash.js +38 -0
- package/dist/application/evaluation/corpus.js +56 -0
- package/dist/application/evaluation/experiment.js +294 -0
- package/dist/application/evaluation/ignition.js +198 -0
- package/dist/application/evaluation/integrity-audit.js +162 -0
- package/dist/application/evaluation/outer-loop.js +132 -0
- package/dist/application/evaluation/pi-cell-executor.js +39 -0
- package/dist/application/evaluation/private-verifier.js +46 -0
- package/dist/application/evaluation/promotion-policy.js +151 -0
- package/dist/application/evaluation/proposer.js +98 -0
- package/dist/application/evaluation/types.js +522 -0
- package/dist/cli/command-definitions.js +19 -3
- package/dist/commands/eval.js +1176 -13
- package/dist/commands/init.js +4 -1
- package/dist/infrastructure/evaluation/alias-store.js +199 -0
- package/dist/infrastructure/evaluation/campaign-store.js +154 -0
- package/dist/infrastructure/evaluation/corpus-store.js +181 -0
- package/dist/infrastructure/evaluation/experiment-store.js +124 -0
- package/dist/infrastructure/evaluation/ignition-store.js +82 -0
- package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
- package/dist/infrastructure/evaluation/proposer-store.js +78 -0
- package/dist/worker/cli.js +6 -3
- package/dist/worker/delivery/final-verification.js +96 -8
- package/dist/worker/delivery/package.js +23 -4
- package/dist/worker/delivery/verification-bundle.js +521 -0
- package/dist/worker/feature/fullstack-validate.js +337 -0
- package/dist/worker/feature/profile-schema.js +44 -0
- package/dist/worker/feature/ready-plan-projection.js +1 -0
- package/dist/worker/feature/reducer.js +2 -0
- package/dist/worker/feature/review.js +106 -11
- package/dist/worker/materialize/harness-task-materializer.js +5 -0
- package/dist/worker/observability/read-model.js +7 -0
- package/dist/worker/observe/static/views/task.js +1 -0
- package/dist/worker/outcomes/adapters.js +144 -0
- package/dist/worker/outcomes/evidence-tokens.js +29 -0
- package/dist/worker/outcomes/gate.js +40 -0
- package/dist/worker/outcomes/projector.js +185 -0
- package/dist/worker/outcomes/registry.js +1 -0
- package/dist/worker/outcomes/store.js +131 -0
- package/dist/worker/outcomes/types.js +79 -0
- package/dist/worker/report/morning-report.js +4 -3
- package/dist/worker/run-task/run-task.js +85 -2
- package/dist/worker/runner/run-ready.js +32 -1
- package/dist/worker/task-graph/acceptance-schema.js +12 -0
- package/dist/worker/task-graph/ready-planner.js +131 -0
- package/dist/worker/task-graph/task-graph-schema.js +31 -0
- package/dist/worker/task-graph/validate.js +44 -4
- package/dist/worker/task-spec/schema.js +9 -0
- package/dist/worker/task-spec/validate.js +39 -0
- package/dist/worker/task-spec/workflow-routing.js +149 -0
- package/dist/workflows/dag/budget-enforcement.js +67 -0
- package/dist/workflows/dag/context-policy.js +137 -0
- package/dist/workflows/dag/init-hybrid.js +27 -11
- package/dist/workflows/dag/knowledge-curator.js +3 -0
- package/dist/workflows/dag/node-execution.js +11 -4
- package/dist/workflows/dag/prompt.js +1 -1
- package/dist/workflows/dag/runner.js +43 -16
- package/dist/workflows/dag/skill-snapshot.js +11 -7
- package/dist/workflows/dag/types.js +18 -0
- package/docs/README.md +1 -0
- package/docs/init-surface.manifest.json +7 -7
- package/docs/templates/branch-merge-report.md +0 -1
- package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
- package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
- package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
- package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
- package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
- package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
- package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
- package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
- package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
- package/docs/templates/product-line/AGENTS.md +1 -0
- package/docs/templates/product-line/README.md +17 -0
- package/docs/templates/product-line/acceptance.yaml +9 -0
- package/docs/templates/product-line/feature.yaml +11 -0
- package/docs/templates/product-line/task-graph.yaml +8 -0
- package/docs/templates/product-line/task.yaml +4 -0
- package/package.json +6 -16
- package/skills/browser-tools/SKILL.md +2 -2
- package/skills/frontend-design-review/references/review-checklist.md +27 -45
- package/skills/frontend-implementation/references/node-contracts.md +4 -4
- package/skills/frontend-review/SKILL.md +3 -1
- package/skills/frontend-review/references/review-findings.md +2 -1
- package/skills/frontend-verification/SKILL.md +3 -1
- package/skills/frontend-verification/references/verification-checklist.md +13 -22
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +0 -195
- package/docs/agent-dag-runner.md +0 -67
- package/docs/cursor-prompt-sidecar.md +0 -36
- package/docs/decisions/README.md +0 -18
- package/docs/design/README.md +0 -167
- package/docs/development-principles.md +0 -73
- package/docs/exec-plans/README.md +0 -6
- package/docs/exec-plans/active/README.md +0 -13
- package/docs/exec-plans/completed/README.md +0 -108
- package/docs/feature-workflow.md +0 -414
- package/docs/loop-agent-harness.md +0 -142
- package/docs/production-readiness.md +0 -96
- package/docs/progress/README.md +0 -81
- package/docs/reports/README.md +0 -163
- package/docs/verification-matrix.md +0 -70
- package/scripts/check-product-line-docs.sh +0 -29
- package/scripts/check-task-pool-root.sh +0 -32
- package/scripts/kb-graph-incremental-prepare.sh +0 -5
- package/scripts/kb-graph-materialize.sh +0 -4
- package/scripts/kb-graph-promote.sh +0 -4
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|