@tea-agent/loop-agent 0.15.0 → 0.16.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +7 -11
  2. package/dist/executors/dag-pi-executor.js +44 -4
  3. package/dist/worker/cli.js +6 -3
  4. package/dist/worker/delivery/final-verification.js +96 -8
  5. package/dist/worker/delivery/package.js +23 -4
  6. package/dist/worker/delivery/verification-bundle.js +510 -0
  7. package/dist/worker/feature/fullstack-validate.js +337 -0
  8. package/dist/worker/feature/profile-schema.js +44 -0
  9. package/dist/worker/feature/ready-plan-projection.js +1 -0
  10. package/dist/worker/feature/reducer.js +2 -0
  11. package/dist/worker/feature/review.js +105 -11
  12. package/dist/worker/materialize/harness-task-materializer.js +5 -0
  13. package/dist/worker/observability/read-model.js +7 -0
  14. package/dist/worker/observe/static/views/task.js +1 -0
  15. package/dist/worker/outcomes/adapters.js +141 -0
  16. package/dist/worker/outcomes/gate.js +41 -0
  17. package/dist/worker/outcomes/projector.js +176 -0
  18. package/dist/worker/outcomes/registry.js +1 -0
  19. package/dist/worker/outcomes/store.js +131 -0
  20. package/dist/worker/outcomes/types.js +76 -0
  21. package/dist/worker/report/morning-report.js +4 -3
  22. package/dist/worker/run-task/run-task.js +66 -2
  23. package/dist/worker/runner/run-ready.js +32 -1
  24. package/dist/worker/task-graph/acceptance-schema.js +12 -0
  25. package/dist/worker/task-graph/ready-planner.js +125 -0
  26. package/dist/worker/task-graph/task-graph-schema.js +29 -0
  27. package/dist/worker/task-graph/validate.js +44 -4
  28. package/dist/worker/task-spec/schema.js +9 -0
  29. package/dist/worker/task-spec/validate.js +39 -0
  30. package/dist/worker/task-spec/workflow-routing.js +149 -0
  31. package/dist/workflows/dag/init-hybrid.js +3 -2
  32. package/dist/workflows/dag/types.js +1 -0
  33. package/docs/templates/agent-dag.schema.json +5 -0
  34. package/harness.json +1 -1
  35. package/package.json +1 -1
  36. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  37. package/skills/loop-agent/references/hybrid-dag.md +1 -1
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Shared artifact extraction. Every successful worker run produces a canonical
4
+ * `run_record` (the worker-run-record.json) and a `dag` (the generated DAG
5
+ * JSON). These are the deterministic `kind` tokens the required-output gate
6
+ * matches literally. Workflow adapters extend this with workflow-specific
7
+ * artifacts surfaced from the DAG report (e.g. structured JSON artifact gates).
8
+ *
9
+ * Adapters never invent tokens outside the canonical facts: if a report node
10
+ * did not emit a structured artifact, no artifact is added for it.
11
+ */
12
+ function canonicalArtifacts(input) {
13
+ const artifacts = [];
14
+ if (input.runRecordPath) {
15
+ artifacts.push({
16
+ path: toRepoRelativePath(input.repoRoot, input.runRecordPath),
17
+ sha256: "",
18
+ kind: "run_record",
19
+ });
20
+ }
21
+ if (input.dagPath) {
22
+ artifacts.push({
23
+ path: toRepoRelativePath(input.repoRoot, input.dagPath),
24
+ sha256: "",
25
+ kind: "dag_json",
26
+ });
27
+ }
28
+ return artifacts;
29
+ }
30
+ function toRepoRelativePath(repoRoot, candidate) {
31
+ return path.relative(repoRoot, candidate) || candidate;
32
+ }
33
+ /**
34
+ * Pull structured JSON artifacts emitted by DAG nodes (jsonArtifactGate
35
+ * outputs). The adapter only carries path+sha256 when both are present in the
36
+ * report; the projector re-validates the sha256 against the file on disk.
37
+ */
38
+ function structuredArtifactsFromReport(run) {
39
+ if (!run?.nodes)
40
+ return [];
41
+ const artifacts = [];
42
+ for (const node of run.nodes) {
43
+ if (node.structuredArtifactPath &&
44
+ node.structuredArtifactSha256 &&
45
+ node.structuredArtifactSchemaId) {
46
+ artifacts.push({
47
+ path: node.structuredArtifactPath,
48
+ sha256: node.structuredArtifactSha256,
49
+ kind: node.structuredArtifactSchemaId,
50
+ });
51
+ }
52
+ }
53
+ return artifacts;
54
+ }
55
+ /**
56
+ * `agent-dag` — the default standard agent DAG. No dedicated test/integration
57
+ * harness; integration status is `mock:false, real:false` (neither a mock nor a
58
+ * declared real integration). The canonical artifacts are run_record + dag.
59
+ */
60
+ class AgentDagAdapter {
61
+ workflow = "agent-dag";
62
+ project(input) {
63
+ return {
64
+ artifacts: [
65
+ ...canonicalArtifacts(input),
66
+ ...structuredArtifactsFromReport(input.dagReportRun),
67
+ ],
68
+ acceptanceCoverage: [...input.acceptanceRefs],
69
+ integrationStatus: { mock: false, real: false },
70
+ };
71
+ }
72
+ }
73
+ /**
74
+ * `frontend-implementation` — produces a frontend implementation contract
75
+ * structured artifact. No automated real integration by default.
76
+ */
77
+ class FrontendImplementationAdapter {
78
+ workflow = "frontend-implementation";
79
+ project(input) {
80
+ return {
81
+ artifacts: [
82
+ ...canonicalArtifacts(input),
83
+ ...structuredArtifactsFromReport(input.dagReportRun),
84
+ ],
85
+ acceptanceCoverage: [...input.acceptanceRefs],
86
+ integrationStatus: { mock: false, real: false },
87
+ };
88
+ }
89
+ }
90
+ /**
91
+ * `backend-test` — generates + executes backend tests. Integration status is
92
+ * `real: true` only when the report completed (a real test execution); mock is
93
+ * always distinct and never merged into the real flag.
94
+ */
95
+ class BackendTestAdapter {
96
+ workflow = "backend-test";
97
+ project(input) {
98
+ const real = input.reportDecision.succeeded;
99
+ return {
100
+ artifacts: [
101
+ ...canonicalArtifacts(input),
102
+ ...structuredArtifactsFromReport(input.dagReportRun),
103
+ ],
104
+ acceptanceCoverage: [...input.acceptanceRefs],
105
+ integrationStatus: { mock: false, real },
106
+ };
107
+ }
108
+ }
109
+ /**
110
+ * `frontend-test` — browser/UI tests. Distinct from backend-test: `real` is
111
+ * true only on a successful report; mock is never merged with real.
112
+ */
113
+ class FrontendTestAdapter {
114
+ workflow = "frontend-test";
115
+ project(input) {
116
+ const real = input.reportDecision.succeeded;
117
+ return {
118
+ artifacts: [
119
+ ...canonicalArtifacts(input),
120
+ ...structuredArtifactsFromReport(input.dagReportRun),
121
+ ],
122
+ acceptanceCoverage: [...input.acceptanceRefs],
123
+ integrationStatus: { mock: false, real },
124
+ };
125
+ }
126
+ }
127
+ const ADAPTERS = new Map([
128
+ ["agent-dag", new AgentDagAdapter()],
129
+ ["frontend-implementation", new FrontendImplementationAdapter()],
130
+ ["backend-test", new BackendTestAdapter()],
131
+ ["frontend-test", new FrontendTestAdapter()],
132
+ ]);
133
+ export const OUTCOME_ADAPTER_WORKFLOWS = [...ADAPTERS.keys()];
134
+ /**
135
+ * Resolve the outcome adapter for a workflow. Unknown workflows return
136
+ * `undefined` so the projector can fail closed deterministically — there is no
137
+ * silent fallback.
138
+ */
139
+ export function getOutcomeAdapter(workflow) {
140
+ return ADAPTERS.get(workflow);
141
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Deterministic required-output gate.
3
+ *
4
+ * The gate runs *after* a successful DAG report. It performs a **literal
5
+ * string match** of each `outputs.required` token against the `kind` of every
6
+ * artifact the adapter produced (and the run-record/dag canonical kinds). A
7
+ * required token with no matching produced artifact fails closed — the run is
8
+ * downgraded from `succeeded` to `failed` with `ContractMismatch`.
9
+ *
10
+ * - No regex / substring matching: a token matches only when an artifact
11
+ * `kind` equals it exactly.
12
+ * - Unknown tokens (no adapter mapping) are simply unmatched → missing. The
13
+ * gate never invents a mapping and never passes a run missing a known
14
+ * required output.
15
+ * - An empty `required` list always passes (nothing to enforce).
16
+ *
17
+ * This gate is the success-criteria guardrail: "DAG report succeeded but a
18
+ * known required output is missing ⇒ the Task must not be marked Done."
19
+ */
20
+ export function checkRequiredOutputs(envelope, requiredOutputs) {
21
+ if (requiredOutputs.length === 0) {
22
+ return { passed: true, missing: [] };
23
+ }
24
+ const producedKinds = new Set(envelope.artifacts
25
+ .map((artifact) => artifact.kind)
26
+ .filter((value) => Boolean(value)));
27
+ const missing = [];
28
+ for (const token of requiredOutputs) {
29
+ if (token === "shell_verification") {
30
+ if (!envelope.shellVerification?.exitZero)
31
+ missing.push(token);
32
+ continue;
33
+ }
34
+ if (KNOWN_ARTIFACT_OUTPUTS.has(token) && !producedKinds.has(token)) {
35
+ missing.push(token);
36
+ }
37
+ }
38
+ return { passed: missing.length === 0, missing };
39
+ }
40
+ /** M2 deliberately resolves only stable, deterministic legacy tokens. */
41
+ const KNOWN_ARTIFACT_OUTPUTS = new Set(["run_record", "dag_json"]);
@@ -0,0 +1,176 @@
1
+ import path from "node:path";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { sha256File } from "../pool/run-store.js";
4
+ import { taskOutcomeEnvelopeV1Schema } from "./types.js";
5
+ import { getOutcomeAdapter } from "./adapters.js";
6
+ /**
7
+ * Deterministic path-containment guard. Mirrors run-task's `isPathInside`: a
8
+ * path must resolve *inside* the repo root and must not escape it. Absolute
9
+ * paths outside the repo are rejected so an adapter can never point at an
10
+ * arbitrary filesystem location. Completed DAG run facts live under
11
+ * `.harness/dag-runs/`; outcome artifacts must not be read from outside the
12
+ * repo root.
13
+ */
14
+ export function isRepoRelativePath(repoRoot, candidate) {
15
+ if (!candidate)
16
+ return false;
17
+ const resolved = path.resolve(repoRoot, candidate);
18
+ const relative = path.relative(path.resolve(repoRoot), resolved);
19
+ return (relative !== "" &&
20
+ !relative.startsWith("..") &&
21
+ !path.isAbsolute(relative));
22
+ }
23
+ function contractError(reason, missingOutputs) {
24
+ return {
25
+ ok: false,
26
+ category: "ContractMismatch",
27
+ reason,
28
+ ...(missingOutputs && missingOutputs.length > 0 ? { missingOutputs } : {}),
29
+ };
30
+ }
31
+ function envFailure(reason) {
32
+ return { ok: false, category: "EnvFailure", reason };
33
+ }
34
+ /**
35
+ * Project a completed worker run into a {@link TaskOutcomeEnvelopeV1}.
36
+ *
37
+ * The projector is the single deterministic gate between canonical facts and a
38
+ * successful outcome envelope:
39
+ * 1. Identity: `taskSpec.feature_id === runRecord.featureId`, `taskId` match,
40
+ * and `workerRunId` ownership (the run record must own the worker run id).
41
+ * 2. Workflow: an adapter must exist for the resolved workflow — no fallback.
42
+ * 3. Artifacts: every artifact path must be repo-relative; sha256 declared by
43
+ * the adapter is re-computed from the file on disk and compared. A missing
44
+ * file or hash mismatch is `EnvFailure` / `ContractMismatch` respectively.
45
+ * 4. Schema: the assembled envelope must parse against the strict schema.
46
+ *
47
+ * A projection failure is never turned into a successful Task Pool fact.
48
+ */
49
+ export async function projectOutcome(input) {
50
+ // 1. Identity.
51
+ if (input.taskSpec.feature_id !== input.runRecord.featureId) {
52
+ return contractError(`feature_id mismatch: taskSpec=${input.taskSpec.feature_id} runRecord=${input.runRecord.featureId}`);
53
+ }
54
+ if (input.taskSpec.id !== input.runRecord.taskId) {
55
+ return contractError(`taskId mismatch: taskSpec=${input.taskSpec.id} runRecord=${input.runRecord.taskId}`);
56
+ }
57
+ if (input.workerRunId !== input.runRecord.workerRunId) {
58
+ return contractError(`workerRunId ownership mismatch: input=${input.workerRunId} runRecord=${input.runRecord.workerRunId}`);
59
+ }
60
+ // 2. Workflow adapter (no silent fallback).
61
+ const workflow = (input.runRecord.workflow ?? resolveLegacyWorkflow(input));
62
+ if (!workflow) {
63
+ return contractError(`outcome projection requires a resolved workflow; run record has none`);
64
+ }
65
+ const adapter = getOutcomeAdapter(workflow);
66
+ if (!adapter) {
67
+ return contractError(`no outcome adapter for workflow "${workflow}"`);
68
+ }
69
+ // 3. Adapter projection (pure; no FS).
70
+ const adapterProjection = adapter.project(input);
71
+ // 4. Artifact path + hash validation.
72
+ const validatedArtifacts = [];
73
+ for (const artifact of adapterProjection.artifacts) {
74
+ if (!isRepoRelativePath(input.repoRoot, artifact.path)) {
75
+ return contractError(`artifact path is not repo-relative: ${artifact.path}`);
76
+ }
77
+ const resolved = path.resolve(input.repoRoot, artifact.path);
78
+ let exists;
79
+ try {
80
+ await stat(resolved);
81
+ exists = true;
82
+ }
83
+ catch {
84
+ exists = false;
85
+ }
86
+ if (!exists) {
87
+ return envFailure(`artifact file is missing: ${artifact.path}`);
88
+ }
89
+ const recomputed = await safeSha256File(resolved);
90
+ if (recomputed === undefined) {
91
+ return envFailure(`artifact file is unreadable: ${artifact.path}`);
92
+ }
93
+ if (artifact.sha256 && artifact.sha256 !== recomputed) {
94
+ return contractError(`artifact sha256 mismatch for ${artifact.path}: declared=${artifact.sha256} actual=${recomputed}`);
95
+ }
96
+ validatedArtifacts.push({
97
+ path: artifact.path,
98
+ sha256: recomputed,
99
+ ...(artifact.kind ? { kind: artifact.kind } : {}),
100
+ });
101
+ }
102
+ // 5. Shell verification from report decision (exit-zero = report succeeded).
103
+ const shellVerification = {
104
+ exitZero: input.reportDecision.succeeded,
105
+ ...(input.reportDecision.reason
106
+ ? { evidenceRef: input.reportDecision.reason }
107
+ : {}),
108
+ };
109
+ const controllerIdentity = input.runRecord.controllerIdentity
110
+ ? {
111
+ packageName: input.runRecord.controllerIdentity.packageName,
112
+ packageVersion: input.runRecord.controllerIdentity.packageVersion,
113
+ }
114
+ : undefined;
115
+ const sourceBinding = input.sourceBinding ?? deriveSourceBinding(input);
116
+ const envelope = {
117
+ schemaVersion: 1,
118
+ identity: {
119
+ featureId: input.runRecord.featureId,
120
+ taskId: input.runRecord.taskId,
121
+ workflow,
122
+ workerRunId: input.runRecord.workerRunId,
123
+ ...(input.runRecord.harnessTaskId
124
+ ? { harnessTaskId: input.runRecord.harnessTaskId }
125
+ : {}),
126
+ },
127
+ sourceBinding,
128
+ acceptanceCoverage: adapterProjection.acceptanceCoverage,
129
+ artifacts: validatedArtifacts,
130
+ shellVerification,
131
+ integrationStatus: adapterProjection.integrationStatus,
132
+ ...(controllerIdentity ? { controllerIdentity } : {}),
133
+ createdAt: input.now.toISOString(),
134
+ outcomeStatus: input.reportDecision.succeeded ? "succeeded" : "failed",
135
+ };
136
+ // 6. Strict schema parse.
137
+ const parsed = taskOutcomeEnvelopeV1Schema.safeParse(envelope);
138
+ if (!parsed.success) {
139
+ return contractError(`outcome envelope failed schema validation: ${parsed.error.message}`);
140
+ }
141
+ return { ok: true, envelope: parsed.data };
142
+ }
143
+ function deriveSourceBinding(input) {
144
+ // TaskSpec paths may point into the Feature Packet (and need not be copied
145
+ // into an isolated test repo); the materializer owns their immutable copy.
146
+ // Preserve the declared binding here without treating availability as a
147
+ // successful artifact claim.
148
+ return { sourceFiles: [input.taskSpecPath] };
149
+ }
150
+ /**
151
+ * Legacy fallback for run records written before the additive `workflow` field.
152
+ * Resolves deterministically from the TaskSpec execution.workflow so M2 never
153
+ * changes Ready Planner routing.
154
+ */
155
+ function resolveLegacyWorkflow(input) {
156
+ return input.taskSpec.execution?.workflow;
157
+ }
158
+ async function safeSha256File(filePath) {
159
+ try {
160
+ return await sha256File(filePath);
161
+ }
162
+ catch {
163
+ return undefined;
164
+ }
165
+ }
166
+ // read-side helper kept for symmetry with store.ts callers that need raw bytes.
167
+ export async function readRepoFile(repoRoot, relativePath) {
168
+ if (!isRepoRelativePath(repoRoot, relativePath))
169
+ return undefined;
170
+ try {
171
+ return await readFile(path.resolve(repoRoot, relativePath), "utf-8");
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { assertSafeTaskPoolId, getTaskPoolRoot, sha256Text, } from "../pool/run-store.js";
5
+ import { TASK_POOL_STATE_ERROR_CODES, TaskPoolStateError, } from "../pool/types.js";
6
+ import { taskOutcomeEnvelopeV1Schema } from "./types.js";
7
+ /** Canonical M2 projection root, shared with other Task Pool artifacts. */
8
+ export const OUTCOMES_RELATIVE_ROOT = "artifacts";
9
+ export function getOutcomesRoot(repoRoot) {
10
+ return path.join(getTaskPoolRoot(repoRoot), OUTCOMES_RELATIVE_ROOT);
11
+ }
12
+ export function getOutcomePath(repoRoot, workerRunId) {
13
+ assertSafeTaskPoolId(workerRunId, "outcome workerRunId");
14
+ const outcomePath = path.join(getOutcomesRoot(repoRoot), workerRunId, "task-outcome.json");
15
+ assertWithinOutcomesRoot(repoRoot, outcomePath);
16
+ return outcomePath;
17
+ }
18
+ /**
19
+ * Path-containment guard mirroring run-store's `assertWithinStatesRoot`. An
20
+ * outcome path must resolve strictly under `.harness/task-pool/outcomes/` so a
21
+ * crafted workerRunId can never escape to arbitrary repo paths or, crucially,
22
+ * into `.harness/dag-runs/**` completed facts.
23
+ */
24
+ export function assertWithinOutcomesRoot(repoRoot, candidatePath) {
25
+ const outcomesRoot = path.resolve(getOutcomesRoot(repoRoot));
26
+ const resolved = path.resolve(candidatePath);
27
+ const relative = path.relative(outcomesRoot, resolved);
28
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
29
+ throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `Task Pool outcome path escapes outcomes root: ${candidatePath}`);
30
+ }
31
+ }
32
+ /**
33
+ * Persist a {@link TaskOutcomeEnvelopeV1} atomically under the worker-run
34
+ * isolated outcomes dir. Returns the absolute path and the sha256 of the
35
+ * written bytes.
36
+ *
37
+ * - Re-validates the envelope against the strict schema before writing (a
38
+ * projection failure must never become a stored successful fact).
39
+ * - Atomic write via mkdtemp-style temp + rename + unlink-tmp, mirroring
40
+ * `writeTaskPoolState`.
41
+ * - Never writes under `.harness/dag-runs/**`; the containment guard rejects
42
+ * any escape from the outcomes root.
43
+ */
44
+ export async function writeOutcome(repoRoot, envelope) {
45
+ const parsed = taskOutcomeEnvelopeV1Schema.safeParse(envelope);
46
+ if (!parsed.success) {
47
+ throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `refusing to write invalid outcome envelope: ${parsed.error.message}`);
48
+ }
49
+ const safe = parsed.data;
50
+ assertSafeTaskPoolId(safe.identity.workerRunId, "outcome workerRunId");
51
+ const outcomePath = getOutcomePath(repoRoot, safe.identity.workerRunId);
52
+ await mkdir(path.dirname(outcomePath), { recursive: true });
53
+ const payload = `${JSON.stringify(safe, null, 2)}\n`;
54
+ const tempPath = path.join(path.dirname(outcomePath), `.${path.basename(outcomePath)}.${randomUUID()}.tmp`);
55
+ try {
56
+ await writeFile(tempPath, payload, "utf-8");
57
+ await rename(tempPath, outcomePath);
58
+ }
59
+ finally {
60
+ await unlink(tempPath).catch(() => { });
61
+ }
62
+ const outcomeSha256 = await sha256Text(payload);
63
+ return { outcomePath, outcomeSha256 };
64
+ }
65
+ /**
66
+ * Read an outcome envelope by workerRunId. Returns `undefined` when absent or
67
+ * semantically invalid (read-models degrade gracefully rather than throwing).
68
+ */
69
+ export async function readOutcome(repoRoot, workerRunId) {
70
+ const outcomePath = getOutcomePath(repoRoot, workerRunId);
71
+ let raw;
72
+ try {
73
+ raw = await readFile(outcomePath, "utf-8");
74
+ }
75
+ catch (error) {
76
+ if (isNotFound(error))
77
+ return undefined;
78
+ throw error;
79
+ }
80
+ const parsed = taskOutcomeEnvelopeV1Schema.safeParse(JSON.parse(raw));
81
+ return parsed.success ? parsed.data : undefined;
82
+ }
83
+ /**
84
+ * Read an outcome only when the Task Pool run's path and byte hash still bind
85
+ * it to the expected worker run. Callers receive `undefined` on any mismatch
86
+ * and therefore naturally fail the M3 artifact gate closed.
87
+ */
88
+ export async function readVerifiedOutcome(input) {
89
+ if (!input.outcomePath || !input.outcomeSha256)
90
+ return undefined;
91
+ const outcomePath = input.outcomePath;
92
+ const expectedPath = getOutcomePath(input.repoRoot, input.workerRunId);
93
+ // Compare canonical (realpath-resolved) locations so a repo root reached
94
+ // via a symlink alias (e.g. macOS /var -> /private/var) cannot break the
95
+ // hash-bound outcome binding. Both paths must still resolve to the same
96
+ // canonical file inside the outcomes root.
97
+ let canonicalExpected = expectedPath;
98
+ let canonicalInput = outcomePath;
99
+ try {
100
+ const [resolvedExpected, resolvedInput] = await Promise.all([
101
+ realpath(expectedPath).catch(() => expectedPath),
102
+ realpath(outcomePath).catch(() => outcomePath),
103
+ ]);
104
+ canonicalExpected = resolvedExpected;
105
+ canonicalInput = resolvedInput;
106
+ }
107
+ catch {
108
+ // Fall back to lexical comparison when realpath is unavailable.
109
+ }
110
+ if (path.resolve(canonicalInput) !== path.resolve(canonicalExpected))
111
+ return undefined;
112
+ let raw;
113
+ try {
114
+ raw = await readFile(expectedPath, "utf-8");
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ if (await sha256Text(raw) !== input.outcomeSha256)
120
+ return undefined;
121
+ const parsed = taskOutcomeEnvelopeV1Schema.safeParse(JSON.parse(raw));
122
+ if (!parsed.success || parsed.data.identity.workerRunId !== input.workerRunId)
123
+ return undefined;
124
+ return parsed.data;
125
+ }
126
+ function isNotFound(error) {
127
+ return Boolean(error &&
128
+ typeof error === "object" &&
129
+ "code" in error &&
130
+ error.code === "ENOENT");
131
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { workflowSchema } from "../task-spec/workflow-routing.js";
3
+ /** Worker-owned, serialized source binding; never imports the DAG runtime. */
4
+ export const outcomeSourceBindingSchema = z.object({
5
+ sourceFiles: z.array(z.string().min(1)).optional(),
6
+ sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
7
+ }).passthrough();
8
+ /**
9
+ * Task Outcome Envelope v1 — derived projection of a completed DAG run.
10
+ *
11
+ * M2 adds this envelope as a *read-only* view over canonical completed DAG
12
+ * facts + TaskSpec + worker run record. It never mutates completed DAG facts,
13
+ * the DAG runtime, executors, Delivery, Follow-up, Final Verification, or
14
+ * Ready Planner dependency behavior. Projection failures fail closed and are
15
+ * classified into existing {@link OutcomeFailureCategory} values only.
16
+ */
17
+ export const OUTCOME_SCHEMA_VERSION = 1;
18
+ /** Failure categories reuse the existing ProductLineFailureCategory taxonomy. */
19
+ export const outcomeFailureCategorySchema = z.enum([
20
+ "ContractMismatch",
21
+ "EnvFailure",
22
+ ]);
23
+ /** Workflow-bound outcome adapter identity. */
24
+ export const OUTCOME_WORKFLOWS = [
25
+ "agent-dag",
26
+ "frontend-implementation",
27
+ "backend-test",
28
+ "frontend-test",
29
+ ];
30
+ export const outcomeArtifactSchema = z.object({
31
+ path: z.string().min(1),
32
+ sha256: z.string().regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex"),
33
+ kind: z.string().min(1).optional(),
34
+ });
35
+ export const shellVerificationSchema = z.object({
36
+ exitZero: z.boolean(),
37
+ command: z.string().optional(),
38
+ evidenceRef: z.string().optional(),
39
+ });
40
+ /**
41
+ * Integration status is strictly split: `mock` and `real` are independent so a
42
+ * mock adapter can never be mistaken for a real integration and vice versa.
43
+ */
44
+ export const integrationStatusSchema = z.object({
45
+ mock: z.boolean(),
46
+ real: z.boolean(),
47
+ });
48
+ export const outcomeFailureSchema = z.object({
49
+ category: outcomeFailureCategorySchema,
50
+ reason: z.string().min(1),
51
+ missingOutputs: z.array(z.string().min(1)).optional(),
52
+ });
53
+ export const taskOutcomeEnvelopeV1Schema = z.object({
54
+ schemaVersion: z.literal(OUTCOME_SCHEMA_VERSION),
55
+ identity: z.object({
56
+ featureId: z.string().min(1),
57
+ taskId: z.string().min(1),
58
+ workflow: workflowSchema,
59
+ workerRunId: z.string().min(1),
60
+ harnessTaskId: z.string().min(1).optional(),
61
+ }),
62
+ sourceBinding: outcomeSourceBindingSchema.optional(),
63
+ acceptanceCoverage: z.array(z.string().min(1)).default([]),
64
+ artifacts: z.array(outcomeArtifactSchema).default([]),
65
+ shellVerification: shellVerificationSchema.optional(),
66
+ integrationStatus: integrationStatusSchema,
67
+ controllerIdentity: z
68
+ .object({
69
+ packageName: z.string().min(1),
70
+ packageVersion: z.string().min(1),
71
+ })
72
+ .optional(),
73
+ createdAt: z.string().datetime(),
74
+ outcomeStatus: z.enum(["succeeded", "failed"]),
75
+ outcomeFailure: outcomeFailureSchema.optional(),
76
+ });
@@ -44,13 +44,13 @@ export async function renderMorningReport(options) {
44
44
  "",
45
45
  "## Results",
46
46
  "",
47
- "| Task | Status | Run | Failure | Next | Artifacts |",
48
- "|---|---|---|---|---|---|",
47
+ "| Task | Workflow | Status | Run | Failure | Next | Artifacts |",
48
+ "|---|---|---|---|---|---|---|",
49
49
  ];
50
50
  for (const run of runs) {
51
51
  const followUp = await followUpSummary(run, options.repoRoot);
52
52
  const identity = `${run.featureId}/${run.taskId}`;
53
- lines.push(`| ${identity} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
53
+ lines.push(`| ${identity} | ${run.workflow ?? "-"} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
54
54
  }
55
55
  if (followUps > 0) {
56
56
  lines.push("", "## Human Actions", "");
@@ -94,6 +94,7 @@ async function artifactSummary(run) {
94
94
  ["report", run.failureArtifacts?.reportMarkdownArtifactPath],
95
95
  ["doctor", run.failureArtifacts?.doctorMarkdownArtifactPath],
96
96
  ["closeout", run.failureArtifacts?.closeoutDraftPath],
97
+ ["outcome", run.outcomePath],
97
98
  ].filter((entry) => Boolean(entry[1]));
98
99
  if (artifacts.length === 0)
99
100
  return "-";