@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kodrunhq/opencode-autopilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
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": [
|
|
@@ -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.
|
|
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 ${
|
|
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 (
|
|
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 &&
|
|
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 &&
|
|
176
|
-
if (hasCriticalFindings(
|
|
196
|
+
if (buildProgress.reviewPending && resultText) {
|
|
197
|
+
if (hasCriticalFindings(resultText)) {
|
|
177
198
|
// Re-dispatch implementer with fix instructions
|
|
178
|
-
const safeResult = sanitizeTemplateContent(
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
|
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:
|
|
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`,
|
|
@@ -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
|
* CHALLENGE phase handler — dispatches oc-challenger with RECON artifact references.
|
|
@@ -12,6 +12,7 @@ export async function handleChallenge(
|
|
|
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)
|
|
@@ -35,6 +36,7 @@ export async function handleChallenge(
|
|
|
35
36
|
return Object.freeze({
|
|
36
37
|
action: "dispatch" as const,
|
|
37
38
|
agent: AGENT_NAMES.CHALLENGE,
|
|
39
|
+
resultKind: "phase_output",
|
|
38
40
|
prompt: [
|
|
39
41
|
`Read ${reconRef} for research context.`,
|
|
40
42
|
`Original idea: ${safeIdea}`,
|
|
@@ -3,6 +3,7 @@ import type { DispatchResult, PhaseHandler } from "./types";
|
|
|
3
3
|
export const handleExplore: PhaseHandler = async (_state, _artifactDir, _result?) => {
|
|
4
4
|
return Object.freeze({
|
|
5
5
|
action: "complete",
|
|
6
|
+
resultKind: "phase_output",
|
|
6
7
|
phase: "EXPLORE",
|
|
7
8
|
progress: "EXPLORE skipped (not yet implemented)",
|
|
8
9
|
} satisfies DispatchResult);
|