@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.
- package/package.json +1 -1
- package/src/orchestrator/artifacts.ts +1 -1
- package/src/orchestrator/contracts/invariants.ts +121 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +47 -0
- package/src/orchestrator/contracts/phase-artifacts.ts +90 -0
- package/src/orchestrator/contracts/result-envelope.ts +23 -0
- package/src/orchestrator/handlers/architect.ts +5 -1
- package/src/orchestrator/handlers/build.ts +110 -18
- package/src/orchestrator/handlers/challenge.ts +3 -1
- package/src/orchestrator/handlers/explore.ts +1 -0
- package/src/orchestrator/handlers/plan.ts +85 -8
- package/src/orchestrator/handlers/recon.ts +3 -1
- package/src/orchestrator/handlers/retrospective.ts +8 -0
- package/src/orchestrator/handlers/ship.ts +6 -1
- package/src/orchestrator/handlers/types.ts +21 -2
- package/src/orchestrator/renderers/tasks-markdown.ts +22 -0
- package/src/orchestrator/replay.ts +14 -0
- package/src/orchestrator/schemas.ts +19 -0
- package/src/orchestrator/state.ts +48 -7
- package/src/orchestrator/types.ts +4 -0
- package/src/review/pipeline.ts +41 -6
- package/src/review/schemas.ts +6 -0
- package/src/review/types.ts +2 -0
- package/src/tools/doctor.ts +34 -0
- package/src/tools/forensics.ts +34 -0
- package/src/tools/orchestrate.ts +418 -54
- package/src/tools/quick.ts +4 -0
- package/src/tools/review.ts +27 -2
- package/src/types/inquirer-shims.d.ts +42 -0
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
86
|
+
stateRevision: current.stateRevision + 1,
|
|
87
|
+
lastUpdatedAt: now,
|
|
62
88
|
};
|
|
63
|
-
|
|
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
|
-
|
|
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>;
|
package/src/review/pipeline.ts
CHANGED
|
@@ -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
|
-
|
|
149
|
-
const
|
|
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({
|
|
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({
|
|
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:
|
package/src/review/schemas.ts
CHANGED
|
@@ -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),
|
package/src/review/types.ts
CHANGED
|
@@ -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>;
|