@kodrunhq/opencode-autopilot 1.14.1 → 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,6 +1,9 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, writeFile } from "node:fs/promises";
2
2
  import { isEnoentError } from "../../utils/fs-helpers";
3
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";
4
7
  import { taskSchema } from "../schemas";
5
8
  import type { Task } from "../types";
6
9
  import type { DispatchResult, PhaseHandler } from "./types";
@@ -30,8 +33,7 @@ function isSeparatorRow(columns: readonly string[]): boolean {
30
33
 
31
34
  /**
32
35
  * Parse tasks from markdown table in tasks.md.
33
- * Expected format: | Task ID | Title | Description | Files | Wave | Criteria |
34
- * Returns array of Task objects.
36
+ * Legacy fallback only -- canonical source is tasks.json.
35
37
  */
36
38
  async function loadTasksFromMarkdown(tasksPath: string): Promise<Task[]> {
37
39
  const content = await readFile(tasksPath, "utf-8");
@@ -85,17 +87,90 @@ async function loadTasksFromMarkdown(tasksPath: string): Promise<Task[]> {
85
87
  return tasks;
86
88
  }
87
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
+
88
125
  export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) => {
89
126
  // When result is provided, the planner has completed writing tasks
90
- // Load them from tasks.md and populate state.tasks
127
+ // Load them from tasks.json (canonical) and populate state.tasks.
128
+ // Fall back to tasks.md for compatibility with legacy planners.
91
129
  if (result) {
130
+ const tasksJsonPath = getArtifactRef(artifactDir, "PLAN", "tasks.json");
92
131
  const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.md");
93
132
  try {
94
- const loadedTasks = await loadTasksFromMarkdown(tasksPath);
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
+
95
167
  return Object.freeze({
96
168
  action: "complete",
97
169
  phase: "PLAN",
98
- progress: `Planning complete — loaded ${loadedTasks.length} task(s)`,
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`,
99
174
  _stateUpdates: {
100
175
  tasks: loadedTasks,
101
176
  },
@@ -109,6 +184,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
109
184
 
110
185
  return Object.freeze({
111
186
  action: "error",
187
+ code: "E_PLAN_TASK_LOAD",
112
188
  phase: "PLAN",
113
189
  message: `Failed to load PLAN tasks: ${reason}`,
114
190
  progress: "Planning failed — task extraction error",
@@ -118,7 +194,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
118
194
 
119
195
  const architectRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
120
196
  const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
121
- const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.md");
197
+ const tasksPath = getArtifactRef(artifactDir, "PLAN", "tasks.json");
122
198
 
123
199
  const prompt = [
124
200
  "Read the architecture design at",
@@ -126,7 +202,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
126
202
  "and the challenge brief at",
127
203
  challengeRef,
128
204
  "then produce a task plan.",
129
- `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":[]}]}.`,
130
206
  "Each task should have a 300-line diff max.",
131
207
  "Assign wave numbers for parallel execution.",
132
208
  ].join(" ");
@@ -134,6 +210,7 @@ export const handlePlan: PhaseHandler = async (_state, artifactDir, result?) =>
134
210
  return Object.freeze({
135
211
  action: "dispatch",
136
212
  agent: AGENT_NAMES.PLAN,
213
+ resultKind: "phase_output",
137
214
  prompt,
138
215
  phase: "PLAN",
139
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),
@@ -4,6 +4,7 @@ import type {
4
4
  falsePositiveSchema,
5
5
  reviewConfigSchema,
6
6
  reviewFindingSchema,
7
+ reviewFindingsEnvelopeSchema,
7
8
  reviewMemorySchema,
8
9
  reviewReportSchema,
9
10
  reviewStateSchema,
@@ -14,6 +15,7 @@ import type {
14
15
  export type Severity = z.infer<typeof severitySchema>;
15
16
  export type Verdict = z.infer<typeof verdictSchema>;
16
17
  export type ReviewFinding = z.infer<typeof reviewFindingSchema>;
18
+ export type ReviewFindingsEnvelope = z.infer<typeof reviewFindingsEnvelopeSchema>;
17
19
  export type AgentResult = z.infer<typeof agentResultSchema>;
18
20
  export type ReviewReport = z.infer<typeof reviewReportSchema>;
19
21
  export type ReviewConfig = z.infer<typeof reviewConfigSchema>;