@kodrunhq/opencode-autopilot 1.14.0 → 1.15.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.
@@ -1,19 +1,200 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isEnoentError } from "../../utils/fs-helpers";
1
3
  import { getArtifactRef } from "../artifacts";
4
+ import { normalizePlanTasks, planTasksArtifactSchema } from "../contracts/phase-artifacts";
5
+ import { logOrchestrationEvent } from "../orchestration-logger";
6
+ import { renderTasksMarkdown } from "../renderers/tasks-markdown";
7
+ import { taskSchema } from "../schemas";
8
+ import type { Task } from "../types";
2
9
  import type { DispatchResult, PhaseHandler } from "./types";
3
10
  import { AGENT_NAMES } from "./types";
4
11
 
12
+ const EXPECTED_COLUMN_COUNT = 6;
13
+ const taskIdPattern = /^W(\d+)-T(\d+)$/i;
14
+ const separatorCellPattern = /^:?-{3,}:?$/;
15
+
16
+ function parseTableColumns(line: string): readonly string[] | null {
17
+ const trimmed = line.trim();
18
+ if (!trimmed.includes("|")) {
19
+ return null;
20
+ }
21
+
22
+ const withoutLeadingBoundary = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
23
+ const normalized = withoutLeadingBoundary.endsWith("|")
24
+ ? withoutLeadingBoundary.slice(0, -1)
25
+ : withoutLeadingBoundary;
26
+
27
+ return normalized.split("|").map((col) => col.trim());
28
+ }
29
+
30
+ function isSeparatorRow(columns: readonly string[]): boolean {
31
+ return columns.length > 0 && columns.every((col) => separatorCellPattern.test(col));
32
+ }
33
+
34
+ /**
35
+ * Parse tasks from markdown table in tasks.md.
36
+ * Legacy fallback only -- canonical source is tasks.json.
37
+ */
38
+ async function loadTasksFromMarkdown(tasksPath: string): Promise<Task[]> {
39
+ const content = await readFile(tasksPath, "utf-8");
40
+ const lines = content.split("\n");
41
+
42
+ const tasks: Task[] = [];
43
+ for (const line of lines) {
44
+ const columns = parseTableColumns(line);
45
+ if (columns === null || columns.length < EXPECTED_COLUMN_COUNT || isSeparatorRow(columns)) {
46
+ continue;
47
+ }
48
+
49
+ if (columns[0].toLowerCase() === "task id") {
50
+ continue;
51
+ }
52
+
53
+ const idMatch = taskIdPattern.exec(columns[0]);
54
+ if (idMatch === null) {
55
+ continue;
56
+ }
57
+
58
+ const waveFromId = Number.parseInt(idMatch[1], 10);
59
+ const title = columns[1];
60
+ const waveFromColumn = Number.parseInt(columns[4], 10);
61
+
62
+ if (!title || Number.isNaN(waveFromId) || Number.isNaN(waveFromColumn)) {
63
+ continue;
64
+ }
65
+
66
+ if (waveFromId !== waveFromColumn) {
67
+ continue;
68
+ }
69
+
70
+ tasks.push(
71
+ taskSchema.parse({
72
+ id: tasks.length + 1,
73
+ title,
74
+ status: "PENDING",
75
+ wave: waveFromColumn,
76
+ depends_on: [],
77
+ attempt: 0,
78
+ strike: 0,
79
+ }),
80
+ );
81
+ }
82
+
83
+ if (tasks.length === 0) {
84
+ throw new Error("No valid task rows found in PLAN tasks.md");
85
+ }
86
+
87
+ return tasks;
88
+ }
89
+
90
+ async function loadTasksFromJson(tasksPath: string): Promise<Task[]> {
91
+ const raw = await readFile(tasksPath, "utf-8");
92
+ const parsed = JSON.parse(raw);
93
+ const artifact = planTasksArtifactSchema.parse(parsed);
94
+ const normalized = normalizePlanTasks(artifact);
95
+ return normalized.map((task) =>
96
+ taskSchema.parse({
97
+ id: task.id,
98
+ title: task.title,
99
+ status: "PENDING",
100
+ wave: task.wave,
101
+ depends_on: task.dependsOnIndexes,
102
+ attempt: 0,
103
+ strike: 0,
104
+ }),
105
+ );
106
+ }
107
+
108
+ function buildTasksArtifactFromLegacyTasks(tasks: readonly Task[]) {
109
+ const countersByWave = new Map<number, number>();
110
+ return planTasksArtifactSchema.parse({
111
+ schemaVersion: 1,
112
+ tasks: tasks.map((task) => {
113
+ const nextIndex = (countersByWave.get(task.wave) ?? 0) + 1;
114
+ countersByWave.set(task.wave, nextIndex);
115
+ return {
116
+ taskId: `W${task.wave}-T${String(nextIndex).padStart(2, "0")}`,
117
+ title: task.title,
118
+ wave: task.wave,
119
+ depends_on: [] as string[],
120
+ };
121
+ }),
122
+ });
123
+ }
124
+
5
125
  export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) => {
126
+ // When result is provided, the planner has completed writing tasks
127
+ // Load them from tasks.json (canonical) and populate state.tasks.
128
+ // Fall back to tasks.md for compatibility with legacy planners.
6
129
  if (result) {
7
- return Object.freeze({
8
- action: "complete",
9
- phase: "PLAN",
10
- progress: "Planning complete — tasks written",
11
- } satisfies DispatchResult);
130
+ const tasksJsonPath = getArtifactRef(artifactDir, "PLAN", "tasks.json");
131
+ const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.md");
132
+ try {
133
+ let loadedTasks: Task[];
134
+ let usedLegacyMarkdown = false;
135
+
136
+ try {
137
+ loadedTasks = await loadTasksFromJson(tasksJsonPath);
138
+ } catch (jsonError: unknown) {
139
+ if (!isEnoentError(jsonError)) {
140
+ throw jsonError;
141
+ }
142
+ loadedTasks = await loadTasksFromMarkdown(tasksPath);
143
+ usedLegacyMarkdown = true;
144
+ }
145
+
146
+ if (usedLegacyMarkdown) {
147
+ const msg =
148
+ "PLAN fallback: parsed legacy tasks.md (tasks.json missing). Migrate planner output to tasks.json.";
149
+ console.warn(`[opencode-autopilot] ${msg}`);
150
+ logOrchestrationEvent(artifactDir, {
151
+ timestamp: new Date().toISOString(),
152
+ phase: "PLAN",
153
+ action: "error",
154
+ message: msg,
155
+ });
156
+
157
+ const artifact = buildTasksArtifactFromLegacyTasks(loadedTasks);
158
+ await writeFile(tasksJsonPath, JSON.stringify(artifact, null, 2), "utf-8");
159
+ } else {
160
+ const artifact = planTasksArtifactSchema.parse(
161
+ JSON.parse(await readFile(tasksJsonPath, "utf-8")),
162
+ );
163
+ const markdown = renderTasksMarkdown(artifact);
164
+ await writeFile(tasksPath, markdown, "utf-8");
165
+ }
166
+
167
+ return Object.freeze({
168
+ action: "complete",
169
+ phase: "PLAN",
170
+ resultKind: "phase_output",
171
+ progress: usedLegacyMarkdown
172
+ ? `Planning complete — loaded ${loadedTasks.length} task(s) via legacy markdown fallback`
173
+ : `Planning complete — loaded ${loadedTasks.length} task(s) from tasks.json`,
174
+ _stateUpdates: {
175
+ tasks: loadedTasks,
176
+ },
177
+ } satisfies DispatchResult);
178
+ } catch (error: unknown) {
179
+ const reason = isEnoentError(error)
180
+ ? "tasks.md not found after planner completion"
181
+ : error instanceof Error
182
+ ? error.message
183
+ : "Unknown parsing error";
184
+
185
+ return Object.freeze({
186
+ action: "error",
187
+ code: "E_PLAN_TASK_LOAD",
188
+ phase: "PLAN",
189
+ message: `Failed to load PLAN tasks: ${reason}`,
190
+ progress: "Planning failed — task extraction error",
191
+ } satisfies DispatchResult);
192
+ }
12
193
  }
13
194
 
14
195
  const architectRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
15
196
  const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
16
- const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.md");
197
+ const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.json");
17
198
 
18
199
  const prompt = [
19
200
  "Read the architecture design at",
@@ -21,7 +202,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
21
202
  "and the challenge brief at",
22
203
  challengeRef,
23
204
  "then produce a task plan.",
24
- `Write tasks to ${tasksPath}.`,
205
+ `Write tasks to ${tasksPath} as strict JSON with shape {"schemaVersion":1,"tasks":[{"taskId":"W1-T01","title":"...","wave":1,"depends_on":[]}]}.`,
25
206
  "Each task should have a 300-line diff max.",
26
207
  "Assign wave numbers for parallel execution.",
27
208
  ].join(" ");
@@ -29,6 +210,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
29
210
  return Object.freeze({
30
211
  action: "dispatch",
31
212
  agent: AGENT_NAMES.PLAN,
213
+ resultKind: "phase_output",
32
214
  prompt,
33
215
  phase: "PLAN",
34
216
  progress: "Dispatching planner",
@@ -2,7 +2,7 @@ import { sanitizeTemplateContent } from "../../review/sanitize";
2
2
  import { fileExists } from "../../utils/fs-helpers";
3
3
  import { ensurePhaseDir, getArtifactRef } from "../artifacts";
4
4
  import type { PipelineState } from "../types";
5
- import { AGENT_NAMES, type DispatchResult } from "./types";
5
+ import { AGENT_NAMES, type DispatchResult, type PhaseHandlerContext } from "./types";
6
6
 
7
7
  /**
8
8
  * RECON phase handler — dispatches oc-researcher with idea and artifact path.
@@ -12,6 +12,7 @@ export async function handleRecon(
12
12
  state: Readonly<PipelineState>,
13
13
  artifactDir: string,
14
14
  result?: string,
15
+ _context?: PhaseHandlerContext,
15
16
  ): Promise<DispatchResult> {
16
17
  if (result) {
17
18
  // Warn if artifact wasn't written (best-effort — still complete the phase)
@@ -34,6 +35,7 @@ export async function handleRecon(
34
35
  return Object.freeze({
35
36
  action: "dispatch" as const,
36
37
  agent: AGENT_NAMES.RECON,
38
+ resultKind: "phase_output",
37
39
  prompt: [
38
40
  `Research the following idea and write findings to ${outputPath}`,
39
41
  `Idea: ${safeIdea}`,
@@ -12,6 +12,8 @@ import type { Phase } from "../types";
12
12
  import type { DispatchResult, PhaseHandler } from "./types";
13
13
  import { AGENT_NAMES } from "./types";
14
14
 
15
+ export const LESSONS_PARSE_ERROR_CODE = "E_RETRO_PARSE";
16
+
15
17
  /**
16
18
  * Parse and validate lessons from the agent's JSON output.
17
19
  * Returns only valid lessons; invalid entries are silently skipped (graceful degradation).
@@ -61,6 +63,8 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
61
63
  if (parseError) {
62
64
  return Object.freeze({
63
65
  action: "complete",
66
+ code: LESSONS_PARSE_ERROR_CODE,
67
+ resultKind: "phase_output",
64
68
  phase: "RETROSPECTIVE",
65
69
  progress: "Retrospective complete -- no lessons extracted (parse error)",
66
70
  } satisfies DispatchResult);
@@ -69,6 +73,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
69
73
  if (valid.length === 0) {
70
74
  return Object.freeze({
71
75
  action: "complete",
76
+ resultKind: "phase_output",
72
77
  phase: "RETROSPECTIVE",
73
78
  progress: "Retrospective complete -- 0 lessons extracted",
74
79
  } satisfies DispatchResult);
@@ -91,6 +96,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
91
96
  const msg = raw.replace(/[/\\][^\s"']+/g, "[PATH]").slice(0, 256);
92
97
  return Object.freeze({
93
98
  action: "complete",
99
+ resultKind: "phase_output",
94
100
  phase: "RETROSPECTIVE",
95
101
  progress: `Retrospective complete — ${valid.length} lessons extracted (persistence failed: ${msg})`,
96
102
  } satisfies DispatchResult);
@@ -98,6 +104,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
98
104
 
99
105
  return Object.freeze({
100
106
  action: "complete",
107
+ resultKind: "phase_output",
101
108
  phase: "RETROSPECTIVE",
102
109
  progress: `Retrospective complete -- ${valid.length} lessons extracted`,
103
110
  } satisfies DispatchResult);
@@ -118,6 +125,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
118
125
  return Object.freeze({
119
126
  action: "dispatch",
120
127
  agent: AGENT_NAMES.RETROSPECTIVE,
128
+ resultKind: "phase_output",
121
129
  prompt,
122
130
  phase: "RETROSPECTIVE",
123
131
  progress: "Dispatching retrospector",
@@ -1,3 +1,4 @@
1
+ import { fileExists } from "../../utils/fs-helpers";
1
2
  import { getArtifactRef, getPhaseDir } from "../artifacts";
2
3
  import type { DispatchResult, PhaseHandler } from "./types";
3
4
  import { AGENT_NAMES } from "./types";
@@ -6,6 +7,7 @@ export const handleShip: PhaseHandler = async (_state, artifactDir, result?) =>
6
7
  if (result) {
7
8
  return Object.freeze({
8
9
  action: "complete",
10
+ resultKind: "phase_output",
9
11
  phase: "SHIP",
10
12
  progress: "Shipping complete — documentation written",
11
13
  } satisfies DispatchResult);
@@ -14,7 +16,9 @@ export const handleShip: PhaseHandler = async (_state, artifactDir, result?) =>
14
16
  const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
15
17
  const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
16
18
  const architectRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
17
- const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
19
+ const tasksJsonRef = getArtifactRef(artifactDir, "PLAN", "tasks.json");
20
+ const tasksMarkdownRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
21
+ const planRef = (await fileExists(tasksJsonRef)) ? tasksJsonRef : tasksMarkdownRef;
18
22
  const shipDir = getPhaseDir(artifactDir, "SHIP");
19
23
 
20
24
  const prompt = [
@@ -32,6 +36,7 @@ export const handleShip: PhaseHandler = async (_state, artifactDir, result?) =>
32
36
  return Object.freeze({
33
37
  action: "dispatch",
34
38
  agent: AGENT_NAMES.SHIP,
39
+ resultKind: "phase_output",
35
40
  prompt,
36
41
  phase: "SHIP",
37
42
  progress: "Dispatching shipper",
@@ -1,4 +1,5 @@
1
- import type { PipelineState } from "../types";
1
+ import type { ResultEnvelope } from "../contracts/result-envelope";
2
+ import type { DispatchResultKind, PipelineState } from "../types";
2
3
 
3
4
  export const AGENT_NAMES = Object.freeze({
4
5
  RECON: "oc-researcher",
@@ -15,18 +16,36 @@ export const AGENT_NAMES = Object.freeze({
15
16
 
16
17
  export interface DispatchResult {
17
18
  readonly action: "dispatch" | "dispatch_multi" | "complete" | "error";
19
+ readonly code?: string;
18
20
  readonly agent?: string;
19
- readonly agents?: readonly { readonly agent: string; readonly prompt: string }[];
21
+ readonly agents?: readonly {
22
+ readonly agent: string;
23
+ readonly prompt: string;
24
+ readonly dispatchId?: string;
25
+ readonly taskId?: number | null;
26
+ readonly resultKind?: DispatchResultKind;
27
+ }[];
20
28
  readonly prompt?: string;
21
29
  readonly phase?: string;
22
30
  readonly progress?: string;
23
31
  readonly message?: string;
32
+ readonly resultKind?: DispatchResultKind;
33
+ readonly taskId?: number | null;
34
+ readonly dispatchId?: string;
35
+ readonly runId?: string;
36
+ readonly expectedResultKind?: DispatchResultKind;
24
37
  readonly _stateUpdates?: Partial<PipelineState>;
25
38
  readonly _userProgress?: string;
26
39
  }
27
40
 
41
+ export interface PhaseHandlerContext {
42
+ readonly envelope: ResultEnvelope;
43
+ readonly legacy: boolean;
44
+ }
45
+
28
46
  export type PhaseHandler = (
29
47
  state: Readonly<PipelineState>,
30
48
  artifactDir: string,
31
49
  result?: string,
50
+ context?: PhaseHandlerContext,
32
51
  ) => Promise<DispatchResult>;
@@ -0,0 +1,22 @@
1
+ import type { z } from "zod";
2
+ import type { planTasksArtifactSchema } from "../contracts/phase-artifacts";
3
+
4
+ type PlanTasksArtifact = z.infer<typeof planTasksArtifactSchema>;
5
+
6
+ export function renderTasksMarkdown(artifact: PlanTasksArtifact): string {
7
+ const header = [
8
+ "# Implementation Task Plan",
9
+ "",
10
+ "## Task Table",
11
+ "",
12
+ "| Task ID | Title | Description | Files to Modify | Wave Number | Acceptance Criteria |",
13
+ "|---|---|---|---|---:|---|",
14
+ ];
15
+
16
+ const rows = artifact.tasks.map((task) => {
17
+ const deps = task.depends_on.length > 0 ? `Depends on: ${task.depends_on.join(", ")}` : "None";
18
+ return `| ${task.taskId} | ${task.title.replace(/\|/g, "\\|")} | ${deps} | TBD | ${task.wave} | TBD |`;
19
+ });
20
+
21
+ return [...header, ...rows, ""].join("\n");
22
+ }
@@ -0,0 +1,14 @@
1
+ import { orchestrateCore } from "../tools/orchestrate";
2
+ import type { ResultEnvelope } from "./contracts/result-envelope";
3
+
4
+ export async function replayEnvelopes(
5
+ artifactDir: string,
6
+ envelopes: readonly ResultEnvelope[],
7
+ ): Promise<readonly string[]> {
8
+ const outputs: string[] = [];
9
+ for (const envelope of envelopes) {
10
+ const result = await orchestrateCore({ result: JSON.stringify(envelope) }, artifactDir);
11
+ outputs.push(result);
12
+ }
13
+ return Object.freeze(outputs);
14
+ }
@@ -55,6 +55,21 @@ export const buildProgressSchema = z.object({
55
55
  reviewPending: z.boolean().default(false),
56
56
  });
57
57
 
58
+ export const dispatchResultKindSchema = z.enum([
59
+ "phase_output",
60
+ "task_completion",
61
+ "review_findings",
62
+ ]);
63
+
64
+ export const pendingDispatchSchema = z.object({
65
+ dispatchId: z.string().min(1).max(128),
66
+ phase: phaseSchema,
67
+ agent: z.string().min(1).max(128),
68
+ issuedAt: z.string().max(128),
69
+ resultKind: dispatchResultKindSchema.default("phase_output"),
70
+ taskId: z.number().int().positive().nullable().default(null),
71
+ });
72
+
58
73
  export const failureContextSchema = z.object({
59
74
  failedPhase: phaseSchema,
60
75
  failedAgent: z.string().max(128).nullable(),
@@ -66,6 +81,8 @@ export const failureContextSchema = z.object({
66
81
  export const pipelineStateSchema = z.object({
67
82
  schemaVersion: z.literal(2),
68
83
  status: z.enum(["NOT_STARTED", "IN_PROGRESS", "COMPLETED", "FAILED"]),
84
+ runId: z.string().max(128).default("legacy-run"),
85
+ stateRevision: z.number().int().min(0).default(0),
69
86
  idea: z.string().max(4096),
70
87
  currentPhase: phaseSchema.nullable(),
71
88
  startedAt: z.string().max(128),
@@ -83,6 +100,8 @@ export const pipelineStateSchema = z.object({
83
100
  strikeCount: 0,
84
101
  reviewPending: false,
85
102
  }),
103
+ pendingDispatches: z.array(pendingDispatchSchema).max(2000).default([]),
104
+ processedResultIds: z.array(z.string().max(128)).max(10_000).default([]),
86
105
  failureContext: failureContextSchema.nullable().default(null),
87
106
  phaseDispatchCounts: z.record(z.string().max(32), z.number().int().min(0).max(1000)).default({}),
88
107
  });
@@ -2,16 +2,23 @@ import { randomBytes } from "node:crypto";
2
2
  import { readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { ensureDir, isEnoentError } from "../utils/fs-helpers";
5
+ import { assertStateInvariants } from "./contracts/invariants";
5
6
  import { PHASES, pipelineStateSchema } from "./schemas";
6
7
  import type { PipelineState } from "./types";
7
8
 
8
9
  const STATE_FILE = "state.json";
9
10
 
11
+ function generateRunId(): string {
12
+ return `run_${randomBytes(8).toString("hex")}`;
13
+ }
14
+
10
15
  export function createInitialState(idea: string): PipelineState {
11
16
  const now = new Date().toISOString();
12
17
  return pipelineStateSchema.parse({
13
18
  schemaVersion: 2,
14
19
  status: "IN_PROGRESS",
20
+ runId: generateRunId(),
21
+ stateRevision: 0,
15
22
  idea,
16
23
  currentPhase: "RECON",
17
24
  startedAt: now,
@@ -25,6 +32,8 @@ export function createInitialState(idea: string): PipelineState {
25
32
  tasks: [],
26
33
  arenaConfidence: null,
27
34
  exploreTriggered: false,
35
+ pendingDispatches: [],
36
+ processedResultIds: [],
28
37
  });
29
38
  }
30
39
 
@@ -42,8 +51,23 @@ export async function loadState(artifactDir: string): Promise<PipelineState | nu
42
51
  }
43
52
  }
44
53
 
45
- export async function saveState(state: PipelineState, artifactDir: string): Promise<void> {
54
+ export async function saveState(
55
+ state: PipelineState,
56
+ artifactDir: string,
57
+ expectedRevision?: number,
58
+ ): Promise<void> {
59
+ if (typeof expectedRevision === "number") {
60
+ const current = await loadState(artifactDir);
61
+ const currentRevision = current?.stateRevision ?? -1;
62
+ if (currentRevision !== expectedRevision) {
63
+ throw new Error(
64
+ `E_STATE_CONFLICT: expected stateRevision ${expectedRevision}, found ${currentRevision}`,
65
+ );
66
+ }
67
+ }
68
+
46
69
  const validated = pipelineStateSchema.parse(state);
70
+ assertStateInvariants(validated);
47
71
  await ensureDir(artifactDir);
48
72
  const statePath = join(artifactDir, STATE_FILE);
49
73
  const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
@@ -55,20 +79,38 @@ export function patchState(
55
79
  current: Readonly<PipelineState>,
56
80
  updates: Partial<PipelineState>,
57
81
  ): PipelineState {
82
+ const now = new Date().toISOString();
58
83
  const merged = {
59
84
  ...current,
60
85
  ...updates,
61
- lastUpdatedAt: new Date().toISOString(),
86
+ stateRevision: current.stateRevision + 1,
87
+ lastUpdatedAt: now,
62
88
  };
63
- return pipelineStateSchema.parse(merged);
89
+
90
+ if (merged.status === "COMPLETED") {
91
+ merged.currentPhase = null;
92
+ merged.phases = merged.phases.map((phase) => {
93
+ if (phase.status === "IN_PROGRESS") {
94
+ return {
95
+ ...phase,
96
+ status: "DONE" as const,
97
+ completedAt: phase.completedAt ?? now,
98
+ };
99
+ }
100
+ return phase;
101
+ });
102
+ }
103
+
104
+ const validated = pipelineStateSchema.parse(merged);
105
+ assertStateInvariants(validated);
106
+ return validated;
64
107
  }
65
108
 
66
109
  export function appendDecision(
67
110
  current: Readonly<PipelineState>,
68
111
  decision: { phase: string; agent: string; decision: string; rationale: string },
69
112
  ): PipelineState {
70
- return {
71
- ...current,
113
+ return patchState(current, {
72
114
  decisions: [
73
115
  ...current.decisions,
74
116
  {
@@ -76,6 +118,5 @@ export function appendDecision(
76
118
  timestamp: new Date().toISOString(),
77
119
  },
78
120
  ],
79
- lastUpdatedAt: new Date().toISOString(),
80
- };
121
+ });
81
122
  }
@@ -3,7 +3,9 @@ import type {
3
3
  buildProgressSchema,
4
4
  confidenceEntrySchema,
5
5
  decisionEntrySchema,
6
+ dispatchResultKindSchema,
6
7
  failureContextSchema,
8
+ pendingDispatchSchema,
7
9
  phaseSchema,
8
10
  phaseStatusSchema,
9
11
  pipelineStateSchema,
@@ -18,3 +20,5 @@ export type ConfidenceEntry = z.infer<typeof confidenceEntrySchema>;
18
20
  export type Task = z.infer<typeof taskSchema>;
19
21
  export type BuildProgress = z.infer<typeof buildProgressSchema>;
20
22
  export type FailureContext = z.infer<typeof failureContextSchema>;
23
+ export type PendingDispatch = z.infer<typeof pendingDispatchSchema>;
24
+ export type DispatchResultKind = z.infer<typeof dispatchResultKindSchema>;
@@ -19,8 +19,8 @@ const STAGE3_NAMES: ReadonlySet<string> = new Set(STAGE3_AGENTS.map((a) => a.nam
19
19
 
20
20
  import { buildFixInstructions, determineFixableFindings } from "./fix-cycle";
21
21
  import { buildReport } from "./report";
22
- import { reviewFindingSchema } from "./schemas";
23
- import type { ReviewFinding, ReviewReport, ReviewState } from "./types";
22
+ import { reviewFindingSchema, reviewFindingsEnvelopeSchema } from "./schemas";
23
+ import type { ReviewFinding, ReviewFindingsEnvelope, ReviewReport, ReviewState } from "./types";
24
24
 
25
25
  export type { ReviewState };
26
26
 
@@ -32,8 +32,19 @@ export interface ReviewStageResult {
32
32
  readonly stage?: number;
33
33
  readonly agents?: readonly { readonly name: string; readonly prompt: string }[];
34
34
  readonly report?: ReviewReport;
35
+ readonly findingsEnvelope?: ReviewFindingsEnvelope;
35
36
  readonly message?: string;
36
37
  readonly state?: ReviewState;
38
+ readonly parseMode?: "typed" | "legacy";
39
+ }
40
+
41
+ function parseTypedFindingsEnvelope(raw: string): ReviewFindingsEnvelope | null {
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ return reviewFindingsEnvelopeSchema.parse(parsed);
45
+ } catch {
46
+ return null;
47
+ }
37
48
  }
38
49
 
39
50
  /**
@@ -145,8 +156,11 @@ export function advancePipeline(
145
156
  currentState: ReviewState,
146
157
  agentName = "unknown",
147
158
  ): ReviewStageResult {
148
- // Parse new findings
149
- const newFindings = parseAgentFindings(findingsJson, agentName);
159
+ const typedEnvelope = parseTypedFindingsEnvelope(findingsJson);
160
+ const parseMode = typedEnvelope ? "typed" : "legacy";
161
+ const newFindings = typedEnvelope
162
+ ? typedEnvelope.findings
163
+ : parseAgentFindings(findingsJson, agentName);
150
164
  const accumulated = [...currentState.accumulatedFindings, ...newFindings];
151
165
 
152
166
  const nextStage = currentState.stage + 1;
@@ -169,6 +183,7 @@ export function advancePipeline(
169
183
  action: "dispatch" as const,
170
184
  stage: nextStage,
171
185
  agents: prompts,
186
+ parseMode,
172
187
  state: newState,
173
188
  });
174
189
  }
@@ -193,6 +208,7 @@ export function advancePipeline(
193
208
  action: "dispatch" as const,
194
209
  stage: nextStage,
195
210
  agents: Object.freeze(stage3Prompts.map((p) => Object.freeze(p))),
211
+ parseMode,
196
212
  state: newState,
197
213
  });
198
214
  }
@@ -217,18 +233,37 @@ export function advancePipeline(
217
233
  stage: nextStage,
218
234
  message: "Fix cycle: CRITICAL findings with actionable suggestions detected.",
219
235
  agents: Object.freeze(fixAgents),
236
+ parseMode,
220
237
  state: newState,
221
238
  });
222
239
  }
223
240
  // No fix cycle needed -- complete
224
241
  const report = buildReport(accumulated, currentState.scope, currentState.selectedAgentNames);
225
- return Object.freeze({ action: "complete" as const, report });
242
+ return Object.freeze({
243
+ action: "complete" as const,
244
+ report,
245
+ findingsEnvelope: Object.freeze({
246
+ schemaVersion: 1 as const,
247
+ kind: "review_findings" as const,
248
+ findings: accumulated,
249
+ }),
250
+ parseMode,
251
+ });
226
252
  }
227
253
 
228
254
  case 4: {
229
255
  // Stage 4 -> complete: Build final report with all findings
230
256
  const report = buildReport(accumulated, currentState.scope, currentState.selectedAgentNames);
231
- return Object.freeze({ action: "complete" as const, report });
257
+ return Object.freeze({
258
+ action: "complete" as const,
259
+ report,
260
+ findingsEnvelope: Object.freeze({
261
+ schemaVersion: 1 as const,
262
+ kind: "review_findings" as const,
263
+ findings: accumulated,
264
+ }),
265
+ parseMode,
266
+ });
232
267
  }
233
268
 
234
269
  default:
@@ -65,6 +65,12 @@ export const reviewStateSchema = z.object({
65
65
  startedAt: z.string().max(128),
66
66
  });
67
67
 
68
+ export const reviewFindingsEnvelopeSchema = z.object({
69
+ schemaVersion: z.literal(1).default(1),
70
+ kind: z.literal("review_findings"),
71
+ findings: z.array(reviewFindingSchema).max(500).default([]),
72
+ });
73
+
68
74
  export const reviewConfigSchema = z.object({
69
75
  parallel: z.boolean().default(true),
70
76
  maxFixAttempts: z.number().int().min(0).max(10).default(3),