@kodrunhq/opencode-autopilot 1.14.1 → 1.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodrunhq/opencode-autopilot",
3
- "version": "1.14.1",
3
+ "version": "1.15.1",
4
4
  "description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
5
5
  "main": "src/index.ts",
6
6
  "keywords": [
@@ -71,6 +71,22 @@ function registerAgents(
71
71
  }
72
72
  }
73
73
 
74
+ function suppressBuiltInVariants(
75
+ variants: readonly string[],
76
+ builtInKeys: ReadonlySet<string>,
77
+ config: Config,
78
+ ): void {
79
+ if (!config.agent) return;
80
+ for (const variant of variants) {
81
+ if (builtInKeys.has(variant) && config.agent[variant] !== undefined) {
82
+ config.agent[variant] = {
83
+ ...config.agent[variant],
84
+ disable: true,
85
+ };
86
+ }
87
+ }
88
+ }
89
+
74
90
  export async function configHook(config: Config, configPath?: string): Promise<void> {
75
91
  if (!config.agent) {
76
92
  config.agent = {};
@@ -90,24 +106,20 @@ export async function configHook(config: Config, configPath?: string): Promise<v
90
106
  const overrides: Readonly<Record<string, AgentOverride>> = pluginConfig?.overrides ?? {};
91
107
 
92
108
  // Snapshot built-in agent keys BEFORE we register ours — we only suppress
93
- // built-in Plan variants, not our own custom "planner" agent.
109
+ // built-in Plan/Build variants, never our custom planner/coder agents.
94
110
  const builtInKeys = new Set(Object.keys(config.agent));
95
111
 
96
112
  // Register standard agents and pipeline agents (v2 orchestrator subagents)
97
113
  registerAgents(agents, config, groups, overrides);
98
114
  registerAgents(pipelineAgents, config, groups, overrides);
99
115
 
100
- // Suppress built-in Plan agentour planner agent replaces it (D-17).
116
+ // Suppress built-in Plan/Build agents — planner/coder replace them.
101
117
  // Only disable keys that existed before our registration (built-ins).
102
118
  const planVariants = ["Plan", "plan", "Planner", "planner"] as const;
103
- for (const variant of planVariants) {
104
- if (builtInKeys.has(variant) && config.agent[variant] !== undefined) {
105
- config.agent[variant] = {
106
- ...config.agent[variant],
107
- disable: true,
108
- };
109
- }
110
- }
119
+ suppressBuiltInVariants(planVariants, builtInKeys, config);
120
+
121
+ const buildVariants = ["Build", "build", "Builder", "builder"] as const;
122
+ suppressBuiltInVariants(buildVariants, builtInKeys, config);
111
123
  }
112
124
 
113
125
  export { autopilotAgent } from "./autopilot";
@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk";
2
2
 
3
3
  export const researcherAgent: Readonly<AgentConfig> = Object.freeze({
4
4
  description: "Searches the web about a topic and produces a comprehensive report with sources",
5
- mode: "subagent",
5
+ mode: "all",
6
6
  prompt: `You are a research specialist. Your job is to thoroughly investigate a given topic and produce a clear, well-organized report.
7
7
 
8
8
  ## Instructions
@@ -26,7 +26,7 @@ export const PHASE_ARTIFACTS: Readonly<Record<string, readonly string[]>> = Obje
26
26
  CHALLENGE: Object.freeze(["brief.md"]),
27
27
  ARCHITECT: Object.freeze(["design.md"]),
28
28
  EXPLORE: Object.freeze([]),
29
- PLAN: Object.freeze(["tasks.md"]),
29
+ PLAN: Object.freeze(["tasks.json", "tasks.md"]),
30
30
  BUILD: Object.freeze([]),
31
31
  SHIP: Object.freeze(["walkthrough.md", "decisions.md", "changelog.md"]),
32
32
  RETROSPECTIVE: Object.freeze(["lessons.json"]),
@@ -0,0 +1,121 @@
1
+ import type { PipelineState } from "../types";
2
+
3
+ export interface InvariantViolation {
4
+ readonly code: string;
5
+ readonly message: string;
6
+ }
7
+
8
+ function hasSingleInProgressPhase(state: Readonly<PipelineState>): boolean {
9
+ const count = state.phases.filter((phase) => phase.status === "IN_PROGRESS").length;
10
+ if (state.currentPhase === null) {
11
+ return true;
12
+ }
13
+ return count === 1;
14
+ }
15
+
16
+ function hasMatchingCurrentPhase(state: Readonly<PipelineState>): boolean {
17
+ if (state.currentPhase === null) {
18
+ return true;
19
+ }
20
+ const phase = state.phases.find((item) => item.name === state.currentPhase);
21
+ return phase?.status === "IN_PROGRESS";
22
+ }
23
+
24
+ function isTerminalStatus(state: Readonly<PipelineState>): boolean {
25
+ return state.status === "COMPLETED" || state.status === "FAILED";
26
+ }
27
+
28
+ function buildTaskExists(state: Readonly<PipelineState>, taskId: number): boolean {
29
+ return state.tasks.some((task) => task.id === taskId);
30
+ }
31
+
32
+ function collectPendingDispatchKeys(state: Readonly<PipelineState>): readonly string[] {
33
+ return state.pendingDispatches.map((entry) => `${entry.dispatchId}|${entry.phase}`);
34
+ }
35
+
36
+ export function validateStateInvariants(
37
+ state: Readonly<PipelineState>,
38
+ ): readonly InvariantViolation[] {
39
+ const violations: InvariantViolation[] = [];
40
+
41
+ if (state.currentPhase === null && !isTerminalStatus(state)) {
42
+ violations.push({
43
+ code: "E_INVARIANT_PHASE_TERMINAL",
44
+ message: "currentPhase is null while status is non-terminal",
45
+ });
46
+ }
47
+
48
+ if (state.currentPhase !== null && isTerminalStatus(state)) {
49
+ if (state.status === "COMPLETED") {
50
+ violations.push({
51
+ code: "E_INVARIANT_PHASE_ACTIVE",
52
+ message: "currentPhase is set while status is terminal",
53
+ });
54
+ }
55
+ }
56
+
57
+ if (!hasSingleInProgressPhase(state)) {
58
+ violations.push({
59
+ code: "E_INVARIANT_IN_PROGRESS_COUNT",
60
+ message: "phase status must have exactly one IN_PROGRESS when pipeline is active",
61
+ });
62
+ }
63
+
64
+ if (!hasMatchingCurrentPhase(state)) {
65
+ violations.push({
66
+ code: "E_INVARIANT_CURRENT_PHASE_MISMATCH",
67
+ message: "currentPhase does not match an IN_PROGRESS phase entry",
68
+ });
69
+ }
70
+
71
+ if (
72
+ state.buildProgress.currentTask !== null &&
73
+ !buildTaskExists(state, state.buildProgress.currentTask)
74
+ ) {
75
+ violations.push({
76
+ code: "E_INVARIANT_BUILD_TASK",
77
+ message: "buildProgress.currentTask references unknown task",
78
+ });
79
+ }
80
+
81
+ if (state.buildProgress.reviewPending && state.currentPhase !== "BUILD") {
82
+ violations.push({
83
+ code: "E_INVARIANT_REVIEW_PHASE",
84
+ message: "buildProgress.reviewPending is true outside BUILD phase",
85
+ });
86
+ }
87
+
88
+ const pendingKeys = collectPendingDispatchKeys(state);
89
+ if (new Set(pendingKeys).size !== pendingKeys.length) {
90
+ violations.push({
91
+ code: "E_INVARIANT_PENDING_DISPATCH_DUP",
92
+ message: "pendingDispatches contains duplicate dispatch-phase keys",
93
+ });
94
+ }
95
+
96
+ const processedIds = state.processedResultIds;
97
+ if (new Set(processedIds).size !== processedIds.length) {
98
+ violations.push({
99
+ code: "E_INVARIANT_RESULT_ID_DUP",
100
+ message: "processedResultIds contains duplicate result IDs",
101
+ });
102
+ }
103
+
104
+ if (state.stateRevision < 0 || !Number.isInteger(state.stateRevision)) {
105
+ violations.push({
106
+ code: "E_INVARIANT_REVISION",
107
+ message: "stateRevision must be a non-negative integer",
108
+ });
109
+ }
110
+
111
+ return Object.freeze(violations);
112
+ }
113
+
114
+ export function assertStateInvariants(state: Readonly<PipelineState>): void {
115
+ const violations = validateStateInvariants(state);
116
+ if (violations.length === 0) {
117
+ return;
118
+ }
119
+ const top = violations[0];
120
+ throw new Error(`${top.code}: ${top.message}`);
121
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import type { Phase } from "../types";
3
+ import { type ResultEnvelope, resultEnvelopeSchema } from "./result-envelope";
4
+
5
+ export interface ParseResultEnvelopeResult {
6
+ readonly envelope: ResultEnvelope;
7
+ readonly legacy: boolean;
8
+ }
9
+
10
+ export function parseResultEnvelope(
11
+ raw: string,
12
+ ctx: {
13
+ readonly runId: string;
14
+ readonly phase: Phase;
15
+ readonly fallbackDispatchId: string;
16
+ readonly fallbackAgent?: string | null;
17
+ },
18
+ ): ParseResultEnvelopeResult {
19
+ const trimmed = raw.trim();
20
+ if (trimmed.length === 0) {
21
+ throw new Error("E_INVALID_RESULT: empty result payload");
22
+ }
23
+
24
+ try {
25
+ const parsed = JSON.parse(trimmed);
26
+ const envelope = resultEnvelopeSchema.parse(parsed);
27
+ return { envelope, legacy: false };
28
+ } catch (error: unknown) {
29
+ if (error instanceof z.ZodError) {
30
+ throw new Error(`E_INVALID_RESULT: ${error.issues[0]?.message ?? "invalid envelope"}`);
31
+ }
32
+ const legacyEnvelope = resultEnvelopeSchema.parse({
33
+ schemaVersion: 1,
34
+ resultId: `legacy-${ctx.fallbackDispatchId}`,
35
+ runId: ctx.runId,
36
+ phase: ctx.phase,
37
+ dispatchId: ctx.fallbackDispatchId,
38
+ agent: ctx.fallbackAgent ?? null,
39
+ kind: "phase_output",
40
+ taskId: null,
41
+ payload: {
42
+ text: raw,
43
+ },
44
+ });
45
+ return { envelope: legacyEnvelope, legacy: true };
46
+ }
47
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+
3
+ const taskIdPattern = /^W(\d+)-T(\d+)$/i;
4
+
5
+ export const planTaskArtifactItemSchema = z
6
+ .object({
7
+ taskId: z.string().regex(taskIdPattern),
8
+ title: z.string().min(1).max(2048),
9
+ wave: z.number().int().positive(),
10
+ depends_on: z.array(z.string().regex(taskIdPattern)).default([]),
11
+ })
12
+ .superRefine((task, ctx) => {
13
+ const parsed = taskIdPattern.exec(task.taskId);
14
+ if (!parsed) {
15
+ return;
16
+ }
17
+ const waveFromTaskId = Number.parseInt(parsed[1], 10);
18
+ if (waveFromTaskId !== task.wave) {
19
+ ctx.addIssue({
20
+ code: z.ZodIssueCode.custom,
21
+ message: `wave mismatch for ${task.taskId}: expected ${waveFromTaskId}, got ${task.wave}`,
22
+ path: ["wave"],
23
+ });
24
+ }
25
+ });
26
+
27
+ export const planTasksArtifactSchema = z
28
+ .object({
29
+ schemaVersion: z.literal(1).default(1),
30
+ tasks: z.array(planTaskArtifactItemSchema).min(1),
31
+ })
32
+ .superRefine((artifact, ctx) => {
33
+ const seen = new Set<string>();
34
+ for (let i = 0; i < artifact.tasks.length; i++) {
35
+ const id = artifact.tasks[i].taskId.toUpperCase();
36
+ if (seen.has(id)) {
37
+ ctx.addIssue({
38
+ code: z.ZodIssueCode.custom,
39
+ message: `duplicate taskId: ${artifact.tasks[i].taskId}`,
40
+ path: ["tasks", i, "taskId"],
41
+ });
42
+ continue;
43
+ }
44
+ seen.add(id);
45
+ }
46
+
47
+ for (let i = 0; i < artifact.tasks.length; i++) {
48
+ for (const dep of artifact.tasks[i].depends_on) {
49
+ if (!seen.has(dep.toUpperCase())) {
50
+ ctx.addIssue({
51
+ code: z.ZodIssueCode.custom,
52
+ message: `unknown dependency ${dep}`,
53
+ path: ["tasks", i, "depends_on"],
54
+ });
55
+ }
56
+ }
57
+ }
58
+ });
59
+
60
+ export interface NormalizedPlanTask {
61
+ readonly id: number;
62
+ readonly title: string;
63
+ readonly wave: number;
64
+ readonly dependsOnIndexes: readonly number[];
65
+ }
66
+
67
+ export function normalizePlanTasks(
68
+ artifact: z.infer<typeof planTasksArtifactSchema>,
69
+ ): readonly NormalizedPlanTask[] {
70
+ const ordered = artifact.tasks.map((task) => ({ ...task }));
71
+ const indexByTaskId = new Map<string, number>();
72
+ for (let i = 0; i < ordered.length; i++) {
73
+ indexByTaskId.set(ordered[i].taskId.toUpperCase(), i + 1);
74
+ }
75
+
76
+ return Object.freeze(
77
+ ordered.map((task, idx) => {
78
+ const deps = task.depends_on
79
+ .map((id) => indexByTaskId.get(id.toUpperCase()))
80
+ .filter((n): n is number => typeof n === "number")
81
+ .sort((a, b) => a - b);
82
+ return Object.freeze({
83
+ id: idx + 1,
84
+ title: task.title,
85
+ wave: task.wave,
86
+ dependsOnIndexes: Object.freeze(deps),
87
+ });
88
+ }),
89
+ );
90
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import { dispatchResultKindSchema, type pendingDispatchSchema, phaseSchema } from "../schemas";
3
+
4
+ export const resultKindSchema = dispatchResultKindSchema;
5
+
6
+ export const resultEnvelopeSchema = z.object({
7
+ schemaVersion: z.literal(1).default(1),
8
+ resultId: z.string().min(1).max(128),
9
+ runId: z.string().min(1).max(128),
10
+ phase: phaseSchema,
11
+ dispatchId: z.string().min(1).max(128),
12
+ agent: z.string().min(1).max(128).nullable().default(null),
13
+ kind: resultKindSchema,
14
+ taskId: z.number().int().positive().nullable().default(null),
15
+ payload: z
16
+ .object({
17
+ text: z.string().max(1_048_576).default(""),
18
+ })
19
+ .passthrough(),
20
+ });
21
+
22
+ export type PendingDispatch = z.infer<typeof pendingDispatchSchema>;
23
+ export type ResultEnvelope = z.infer<typeof resultEnvelopeSchema>;
@@ -6,7 +6,7 @@ import { getMemoryTunedDepth } from "../arena";
6
6
  import { ensurePhaseDir, getArtifactRef, getPhaseDir } from "../artifacts";
7
7
  import { filterByPhase } from "../confidence";
8
8
  import type { PipelineState } from "../types";
9
- import { AGENT_NAMES, type DispatchResult } from "./types";
9
+ import { AGENT_NAMES, type DispatchResult, type PhaseHandlerContext } from "./types";
10
10
 
11
11
  const CONSTRAINT_FRAMINGS: readonly string[] = Object.freeze([
12
12
  "Optimize for simplicity and minimal dependencies",
@@ -26,6 +26,7 @@ export async function handleArchitect(
26
26
  state: Readonly<PipelineState>,
27
27
  artifactDir: string,
28
28
  _result?: string,
29
+ _context?: PhaseHandlerContext,
29
30
  ): Promise<DispatchResult> {
30
31
  // _result is received from the orchestrator but completion is determined by
31
32
  // artifact existence (design.md/critique.md), not by result truthiness.
@@ -51,6 +52,7 @@ export async function handleArchitect(
51
52
  return Object.freeze({
52
53
  action: "dispatch" as const,
53
54
  agent: AGENT_NAMES.CRITIC,
55
+ resultKind: "phase_output",
54
56
  prompt: [
55
57
  `Review architecture proposals in ${proposalsDir}/`,
56
58
  `Read ${getArtifactRef(artifactDir, "RECON", "report.md")} and ${getArtifactRef(artifactDir, "CHALLENGE", "brief.md")} for context.`,
@@ -74,6 +76,7 @@ export async function handleArchitect(
74
76
  return Object.freeze({
75
77
  action: "dispatch" as const,
76
78
  agent: AGENT_NAMES.ARCHITECT,
79
+ resultKind: "phase_output",
77
80
  prompt: [
78
81
  `Read ${reconRef} and ${challengeRef} for context.`,
79
82
  `Design architecture for: ${safeIdea}`,
@@ -89,6 +92,7 @@ export async function handleArchitect(
89
92
  const label = String.fromCharCode(65 + i); // A, B, C
90
93
  return Object.freeze({
91
94
  agent: AGENT_NAMES.ARCHITECT,
95
+ resultKind: "phase_output" as const,
92
96
  prompt: [
93
97
  `Read ${reconRef} and ${challengeRef} for context.`,
94
98
  `Design architecture for: ${safeIdea}`,
@@ -1,9 +1,10 @@
1
1
  import { sanitizeTemplateContent } from "../../review/sanitize";
2
+ import { fileExists } from "../../utils/fs-helpers";
2
3
  import { getArtifactRef } from "../artifacts";
3
4
  import { groupByWave } from "../plan";
4
5
  import type { BuildProgress, Task } from "../types";
5
6
  import { assignWaves } from "../wave-assigner";
6
- import type { DispatchResult, PhaseHandler } from "./types";
7
+ import type { DispatchResult, PhaseHandler, PhaseHandlerContext } from "./types";
7
8
  import { AGENT_NAMES } from "./types";
8
9
 
9
10
  const MAX_STRIKES = 3;
@@ -55,12 +56,14 @@ function markTasksInProgress(tasks: readonly Task[], taskIds: readonly number[])
55
56
  /**
56
57
  * Build a prompt for a single task dispatch.
57
58
  */
58
- function buildTaskPrompt(task: Task, artifactDir: string): string {
59
- const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
59
+ async function buildTaskPrompt(task: Task, artifactDir: string): Promise<string> {
60
+ const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.json");
61
+ const planFallbackRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
60
62
  const designRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
63
+ const planPath = (await fileExists(planRef)) ? planRef : planFallbackRef;
61
64
  return [
62
65
  `Implement task ${task.id}: ${task.title}.`,
63
- `Reference the plan at ${planRef}`,
66
+ `Reference the plan at ${planPath}`,
64
67
  `and architecture at ${designRef}.`,
65
68
  `If a CLAUDE.md file exists in the project root, read it for project-specific conventions.`,
66
69
  `Check ~/.config/opencode/skills/coding-standards/SKILL.md for coding standards.`,
@@ -110,8 +113,14 @@ function hasCriticalFindings(resultStr: string): boolean {
110
113
  }
111
114
  }
112
115
 
113
- export const handleBuild: PhaseHandler = async (state, artifactDir, result?) => {
116
+ export const handleBuild: PhaseHandler = async (
117
+ state,
118
+ artifactDir,
119
+ result?,
120
+ context?: PhaseHandlerContext,
121
+ ) => {
114
122
  const { tasks, buildProgress } = state;
123
+ const resultText = context?.envelope.payload.text ?? result;
115
124
 
116
125
  // Edge case: no tasks
117
126
  if (tasks.length === 0) {
@@ -123,9 +132,10 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
123
132
  }
124
133
 
125
134
  // Edge case: strike count exceeded
126
- if (buildProgress.strikeCount > MAX_STRIKES && buildProgress.reviewPending && result) {
135
+ if (buildProgress.strikeCount > MAX_STRIKES && buildProgress.reviewPending && resultText) {
127
136
  return Object.freeze({
128
137
  action: "error",
138
+ code: "E_BUILD_MAX_STRIKES",
129
139
  phase: "BUILD",
130
140
  message: "Max retries exceeded — too many CRITICAL review findings",
131
141
  } satisfies DispatchResult);
@@ -171,15 +181,26 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
171
181
  });
172
182
  }
173
183
 
184
+ if (buildProgress.reviewPending && !resultText) {
185
+ return Object.freeze({
186
+ action: "dispatch",
187
+ agent: AGENT_NAMES.REVIEW,
188
+ prompt: "Review completed wave. Scope: branch. Report any CRITICAL findings.",
189
+ phase: "BUILD",
190
+ resultKind: "review_findings",
191
+ progress: "Review pending — dispatching reviewer",
192
+ } satisfies DispatchResult);
193
+ }
194
+
174
195
  // Case 1: Review pending + result provided -> process review outcome
175
- if (buildProgress.reviewPending && result) {
176
- if (hasCriticalFindings(result)) {
196
+ if (buildProgress.reviewPending && resultText) {
197
+ if (hasCriticalFindings(resultText)) {
177
198
  // Re-dispatch implementer with fix instructions
178
- const safeResult = sanitizeTemplateContent(result).slice(0, 4000);
199
+ const safeResult = sanitizeTemplateContent(resultText).slice(0, 4000);
179
200
  const prompt = [
180
201
  `CRITICAL review findings detected. Fix the following issues:`,
181
202
  safeResult,
182
- `Reference ${getArtifactRef(artifactDir, "PLAN", "tasks.md")} for context.`,
203
+ `Reference ${getArtifactRef(artifactDir, "PLAN", "tasks.json")} for context.`,
183
204
  ].join(" ");
184
205
 
185
206
  return Object.freeze({
@@ -187,6 +208,8 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
187
208
  agent: AGENT_NAMES.BUILD,
188
209
  prompt,
189
210
  phase: "BUILD",
211
+ resultKind: "task_completion",
212
+ taskId: buildProgress.currentTask,
190
213
  progress: "Fix dispatch — CRITICAL findings",
191
214
  _stateUpdates: {
192
215
  buildProgress: {
@@ -224,11 +247,14 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
224
247
 
225
248
  if (pendingTasks.length === 1) {
226
249
  const task = pendingTasks[0];
250
+ const prompt = await buildTaskPrompt(task, artifactDir);
227
251
  return Object.freeze({
228
252
  action: "dispatch",
229
253
  agent: AGENT_NAMES.BUILD,
230
- prompt: buildTaskPrompt(task, artifactDir),
254
+ prompt,
231
255
  phase: "BUILD",
256
+ resultKind: "task_completion",
257
+ taskId: task.id,
232
258
  progress: `Wave ${nextWave} — task ${task.id}`,
233
259
  _stateUpdates: {
234
260
  buildProgress: { ...updatedProgress, currentTask: task.id },
@@ -237,11 +263,19 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
237
263
  }
238
264
 
239
265
  const dispatchedIds = pendingTasks.map((t) => t.id);
266
+ const promptsByTaskId = new Map<number, string>();
267
+ await Promise.all(
268
+ pendingTasks.map(async (task) => {
269
+ promptsByTaskId.set(task.id, await buildTaskPrompt(task, artifactDir));
270
+ }),
271
+ );
240
272
  return Object.freeze({
241
273
  action: "dispatch_multi",
242
274
  agents: pendingTasks.map((task) => ({
243
275
  agent: AGENT_NAMES.BUILD,
244
- prompt: buildTaskPrompt(task, artifactDir),
276
+ prompt: promptsByTaskId.get(task.id) ?? "",
277
+ taskId: task.id,
278
+ resultKind: "task_completion" as const,
245
279
  })),
246
280
  phase: "BUILD",
247
281
  progress: `Wave ${nextWave} — ${pendingTasks.length} concurrent tasks`,
@@ -254,9 +288,50 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
254
288
 
255
289
  // Case 2: Result provided + not review pending -> mark task done
256
290
  // For dispatch_multi, currentTask may be null — find the first IN_PROGRESS task instead
257
- const taskToComplete =
258
- buildProgress.currentTask ?? effectiveTasks.find((t) => t.status === "IN_PROGRESS")?.id ?? null;
259
- if (result && !buildProgress.reviewPending && taskToComplete !== null) {
291
+ const hasTypedContext = context !== undefined && !context.legacy;
292
+ const isTaskCompletion = hasTypedContext && context.envelope.kind === "task_completion";
293
+ const isLegacyContext = context !== undefined && context.legacy;
294
+ const taskToComplete = isTaskCompletion
295
+ ? context.envelope.taskId
296
+ : hasTypedContext
297
+ ? buildProgress.currentTask
298
+ : (buildProgress.currentTask ??
299
+ effectiveTasks.find((t) => t.status === "IN_PROGRESS")?.id ??
300
+ null);
301
+
302
+ if (
303
+ resultText &&
304
+ !buildProgress.reviewPending &&
305
+ isLegacyContext &&
306
+ buildProgress.currentTask === null
307
+ ) {
308
+ return Object.freeze({
309
+ action: "error",
310
+ code: "E_BUILD_TASK_ID_REQUIRED",
311
+ phase: "BUILD",
312
+ message:
313
+ "Legacy BUILD result cannot be attributed when currentTask is null. Submit typed envelope with taskId.",
314
+ } satisfies DispatchResult);
315
+ }
316
+ if (resultText && !buildProgress.reviewPending && taskToComplete === null) {
317
+ return Object.freeze({
318
+ action: "error",
319
+ code: "E_BUILD_TASK_ID_REQUIRED",
320
+ phase: "BUILD",
321
+ message: "Cannot attribute BUILD result to a task. Provide taskId in result envelope.",
322
+ } satisfies DispatchResult);
323
+ }
324
+
325
+ if (resultText && !buildProgress.reviewPending && taskToComplete !== null) {
326
+ if (!effectiveTasks.some((t) => t.id === taskToComplete)) {
327
+ return Object.freeze({
328
+ action: "error",
329
+ code: "E_BUILD_UNKNOWN_TASK",
330
+ phase: "BUILD",
331
+ message: `Unknown taskId in BUILD result: ${taskToComplete}`,
332
+ } satisfies DispatchResult);
333
+ }
334
+
260
335
  const updatedTasks = markTaskDone(effectiveTasks, taskToComplete);
261
336
  const waveMap = groupByWave(updatedTasks);
262
337
  const currentWave = buildProgress.currentWave ?? 1;
@@ -268,6 +343,7 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
268
343
  agent: AGENT_NAMES.REVIEW,
269
344
  prompt: "Review completed wave. Scope: branch. Report any CRITICAL findings.",
270
345
  phase: "BUILD",
346
+ resultKind: "review_findings",
271
347
  progress: `Wave ${currentWave} complete — review pending`,
272
348
  _stateUpdates: {
273
349
  tasks: [...updatedTasks],
@@ -284,11 +360,14 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
284
360
  const pendingInWave = findPendingTasks(waveMap, currentWave);
285
361
  if (pendingInWave.length > 0) {
286
362
  const next = pendingInWave[0];
363
+ const prompt = await buildTaskPrompt(next, artifactDir);
287
364
  return Object.freeze({
288
365
  action: "dispatch",
289
366
  agent: AGENT_NAMES.BUILD,
290
- prompt: buildTaskPrompt(next, artifactDir),
367
+ prompt,
291
368
  phase: "BUILD",
369
+ resultKind: "task_completion",
370
+ taskId: next.id,
292
371
  progress: `Wave ${currentWave} — task ${next.id}`,
293
372
  _stateUpdates: {
294
373
  tasks: [...updatedTasks],
@@ -308,6 +387,7 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
308
387
  agent: AGENT_NAMES.BUILD,
309
388
  prompt: `Wave ${currentWave} has ${inProgressInWave.length} task(s) still in progress. Continue working on remaining tasks.`,
310
389
  phase: "BUILD",
390
+ resultKind: "phase_output",
311
391
  progress: `Wave ${currentWave} — waiting for ${inProgressInWave.length} in-progress task(s)`,
312
392
  _stateUpdates: {
313
393
  tasks: [...updatedTasks],
@@ -344,6 +424,7 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
344
424
  agent: AGENT_NAMES.BUILD,
345
425
  prompt: `Resume: wave ${currentWave} has ${inProgressTasks.length} task(s) still in progress. Wait for agent results and pass them back.`,
346
426
  phase: "BUILD",
427
+ resultKind: "phase_output",
347
428
  progress: `Wave ${currentWave} — waiting for ${inProgressTasks.length} in-progress task(s)`,
348
429
  _stateUpdates: {
349
430
  buildProgress: {
@@ -366,11 +447,14 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
366
447
 
367
448
  if (pendingTasks.length === 1) {
368
449
  const task = pendingTasks[0];
450
+ const prompt = await buildTaskPrompt(task, artifactDir);
369
451
  return Object.freeze({
370
452
  action: "dispatch",
371
453
  agent: AGENT_NAMES.BUILD,
372
- prompt: buildTaskPrompt(task, artifactDir),
454
+ prompt,
373
455
  phase: "BUILD",
456
+ resultKind: "task_completion",
457
+ taskId: task.id,
374
458
  progress: `Wave ${currentWave} — task ${task.id}`,
375
459
  _stateUpdates: {
376
460
  buildProgress: {
@@ -384,11 +468,19 @@ export const handleBuild: PhaseHandler = async (state, artifactDir, result?) =>
384
468
 
385
469
  // Multiple pending tasks in wave -> dispatch_multi
386
470
  const dispatchedIds = pendingTasks.map((t) => t.id);
471
+ const promptsByTaskId = new Map<number, string>();
472
+ await Promise.all(
473
+ pendingTasks.map(async (task) => {
474
+ promptsByTaskId.set(task.id, await buildTaskPrompt(task, artifactDir));
475
+ }),
476
+ );
387
477
  return Object.freeze({
388
478
  action: "dispatch_multi",
389
479
  agents: pendingTasks.map((task) => ({
390
480
  agent: AGENT_NAMES.BUILD,
391
- prompt: buildTaskPrompt(task, artifactDir),
481
+ prompt: promptsByTaskId.get(task.id) ?? "",
482
+ taskId: task.id,
483
+ resultKind: "task_completion" as const,
392
484
  })),
393
485
  phase: "BUILD",
394
486
  progress: `Wave ${currentWave} — ${pendingTasks.length} concurrent tasks`,