@kodrunhq/opencode-autopilot 1.15.2 → 1.17.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/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +3 -3
- package/src/health/checks.ts +126 -4
- package/src/health/types.ts +1 -1
- package/src/index.ts +128 -13
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +65 -0
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +82 -67
- package/src/memory/database.ts +74 -12
- package/src/memory/decay.ts +11 -2
- package/src/memory/index.ts +17 -1
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/project-key.ts +6 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +52 -216
- package/src/memory/retrieval.ts +88 -170
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +69 -20
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +167 -0
- package/src/observability/forensic-schemas.ts +77 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +161 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +42 -219
- package/src/orchestrator/handlers/retrospective.ts +2 -2
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +36 -11
- package/src/orchestrator/orchestration-logger.ts +53 -24
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/progress.ts +63 -0
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +39 -11
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/doctor.ts +28 -4
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +38 -11
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +108 -90
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +46 -7
- package/src/tools/session-stats.ts +3 -2
- package/src/tools/summary.ts +43 -0
- package/src/utils/paths.ts +20 -1
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { fileExists } from "../../utils/fs-helpers";
|
|
2
|
+
import { getArtifactRef } from "../artifacts";
|
|
3
|
+
import type { BuildProgress, Task } from "../types";
|
|
4
|
+
import type { DispatchResult } from "./types";
|
|
5
|
+
|
|
6
|
+
const MAX_STRIKES = 3;
|
|
7
|
+
|
|
8
|
+
export function findCurrentWave(waveMap: ReadonlyMap<number, readonly Task[]>): number | null {
|
|
9
|
+
const sortedWaves = [...waveMap.keys()].sort((a, b) => a - b);
|
|
10
|
+
for (const wave of sortedWaves) {
|
|
11
|
+
const tasks = waveMap.get(wave) ?? [];
|
|
12
|
+
if (tasks.some((t) => t.status === "PENDING" || t.status === "IN_PROGRESS")) {
|
|
13
|
+
return wave;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findPendingTasks(
|
|
20
|
+
waveMap: ReadonlyMap<number, readonly Task[]>,
|
|
21
|
+
wave: number,
|
|
22
|
+
): readonly Task[] {
|
|
23
|
+
const tasks = waveMap.get(wave) ?? [];
|
|
24
|
+
return tasks.filter((t) => t.status === "PENDING");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findInProgressTasks(
|
|
28
|
+
waveMap: ReadonlyMap<number, readonly Task[]>,
|
|
29
|
+
wave: number,
|
|
30
|
+
): readonly Task[] {
|
|
31
|
+
const tasks = waveMap.get(wave) ?? [];
|
|
32
|
+
return tasks.filter((t) => t.status === "IN_PROGRESS");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildPendingResultError(
|
|
36
|
+
wave: number,
|
|
37
|
+
inProgressTasks: readonly Task[],
|
|
38
|
+
buildProgress: Readonly<BuildProgress>,
|
|
39
|
+
updatedTasks?: readonly Task[],
|
|
40
|
+
): DispatchResult {
|
|
41
|
+
const taskIds = inProgressTasks.map((task) => task.id);
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
action: "error",
|
|
44
|
+
code: "E_BUILD_RESULT_PENDING",
|
|
45
|
+
phase: "BUILD",
|
|
46
|
+
message: `Wave ${wave} still has in-progress task result(s) pending for taskIds [${taskIds.join(", ")}]. Wait for the typed result envelope and pass it back to oc_orchestrate.`,
|
|
47
|
+
progress: `Wave ${wave} — waiting for typed result(s) for taskIds [${taskIds.join(", ")}]`,
|
|
48
|
+
_stateUpdates: {
|
|
49
|
+
...(updatedTasks ? { tasks: [...updatedTasks] } : {}),
|
|
50
|
+
buildProgress: {
|
|
51
|
+
...buildProgress,
|
|
52
|
+
currentWave: wave,
|
|
53
|
+
currentTask: buildProgress.currentTask ?? inProgressTasks[0]?.id ?? null,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} satisfies DispatchResult);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function markTasksInProgress(
|
|
60
|
+
tasks: readonly Task[],
|
|
61
|
+
taskIds: readonly number[],
|
|
62
|
+
): readonly Task[] {
|
|
63
|
+
const idSet = new Set(taskIds);
|
|
64
|
+
return tasks.map((t) => (idSet.has(t.id) ? { ...t, status: "IN_PROGRESS" as const } : t));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function buildTaskPrompt(task: Task, artifactDir: string): Promise<string> {
|
|
68
|
+
const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.json");
|
|
69
|
+
const planFallbackRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
|
|
70
|
+
const designRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
|
|
71
|
+
const planPath = (await fileExists(planRef)) ? planRef : planFallbackRef;
|
|
72
|
+
return [
|
|
73
|
+
`Implement task ${task.id}: ${task.title}.`,
|
|
74
|
+
`Reference the plan at ${planPath}`,
|
|
75
|
+
`and architecture at ${designRef}.`,
|
|
76
|
+
`If a CLAUDE.md file exists in the project root, read it for project-specific conventions.`,
|
|
77
|
+
`Check ~/.config/opencode/skills/coding-standards/SKILL.md for coding standards.`,
|
|
78
|
+
`Report completion when done.`,
|
|
79
|
+
].join(" ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function markTaskDone(tasks: readonly Task[], taskId: number): readonly Task[] {
|
|
83
|
+
return tasks.map((t) => (t.id === taskId ? { ...t, status: "DONE" as const } : t));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isWaveComplete(
|
|
87
|
+
waveMap: ReadonlyMap<number, readonly Task[]>,
|
|
88
|
+
wave: number,
|
|
89
|
+
): boolean {
|
|
90
|
+
const tasks = waveMap.get(wave) ?? [];
|
|
91
|
+
return tasks.every((t) => t.status === "DONE");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hasCriticalFindings(resultStr: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(resultStr);
|
|
97
|
+
if (parsed.severity === "CRITICAL") return true;
|
|
98
|
+
const hasCritical = (arr: unknown[]): boolean =>
|
|
99
|
+
arr.some(
|
|
100
|
+
(f: unknown) =>
|
|
101
|
+
typeof f === "object" &&
|
|
102
|
+
f !== null &&
|
|
103
|
+
"severity" in f &&
|
|
104
|
+
(f as { severity: string }).severity === "CRITICAL",
|
|
105
|
+
);
|
|
106
|
+
if (Array.isArray(parsed.findings)) {
|
|
107
|
+
return hasCritical(parsed.findings);
|
|
108
|
+
}
|
|
109
|
+
if (parsed.report?.findings && Array.isArray(parsed.report.findings)) {
|
|
110
|
+
return hasCritical(parsed.report.findings);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { MAX_STRIKES };
|
|
@@ -1,118 +1,22 @@
|
|
|
1
1
|
import { sanitizeTemplateContent } from "../../review/sanitize";
|
|
2
|
-
import { fileExists } from "../../utils/fs-helpers";
|
|
3
2
|
import { getArtifactRef } from "../artifacts";
|
|
4
3
|
import { groupByWave } from "../plan";
|
|
5
|
-
import type { BuildProgress, Task } from "../types";
|
|
6
4
|
import { assignWaves } from "../wave-assigner";
|
|
5
|
+
import {
|
|
6
|
+
buildPendingResultError,
|
|
7
|
+
buildTaskPrompt,
|
|
8
|
+
findCurrentWave,
|
|
9
|
+
findInProgressTasks,
|
|
10
|
+
findPendingTasks,
|
|
11
|
+
hasCriticalFindings,
|
|
12
|
+
isWaveComplete,
|
|
13
|
+
MAX_STRIKES,
|
|
14
|
+
markTaskDone,
|
|
15
|
+
markTasksInProgress,
|
|
16
|
+
} from "./build-utils";
|
|
7
17
|
import type { DispatchResult, PhaseHandler, PhaseHandlerContext } from "./types";
|
|
8
18
|
import { AGENT_NAMES } from "./types";
|
|
9
19
|
|
|
10
|
-
const MAX_STRIKES = 3;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Find the first wave number that has PENDING tasks.
|
|
14
|
-
*/
|
|
15
|
-
function findCurrentWave(waveMap: ReadonlyMap<number, readonly Task[]>): number | null {
|
|
16
|
-
const sortedWaves = [...waveMap.keys()].sort((a, b) => a - b);
|
|
17
|
-
for (const wave of sortedWaves) {
|
|
18
|
-
const tasks = waveMap.get(wave) ?? [];
|
|
19
|
-
if (tasks.some((t) => t.status === "PENDING" || t.status === "IN_PROGRESS")) {
|
|
20
|
-
return wave;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Get pending tasks for a specific wave.
|
|
28
|
-
*/
|
|
29
|
-
function findPendingTasks(
|
|
30
|
-
waveMap: ReadonlyMap<number, readonly Task[]>,
|
|
31
|
-
wave: number,
|
|
32
|
-
): readonly Task[] {
|
|
33
|
-
const tasks = waveMap.get(wave) ?? [];
|
|
34
|
-
return tasks.filter((t) => t.status === "PENDING");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Get in-progress tasks for a specific wave.
|
|
39
|
-
*/
|
|
40
|
-
function findInProgressTasks(
|
|
41
|
-
waveMap: ReadonlyMap<number, readonly Task[]>,
|
|
42
|
-
wave: number,
|
|
43
|
-
): readonly Task[] {
|
|
44
|
-
const tasks = waveMap.get(wave) ?? [];
|
|
45
|
-
return tasks.filter((t) => t.status === "IN_PROGRESS");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Mark multiple tasks as IN_PROGRESS immutably.
|
|
50
|
-
*/
|
|
51
|
-
function markTasksInProgress(tasks: readonly Task[], taskIds: readonly number[]): readonly Task[] {
|
|
52
|
-
const idSet = new Set(taskIds);
|
|
53
|
-
return tasks.map((t) => (idSet.has(t.id) ? { ...t, status: "IN_PROGRESS" as const } : t));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Build a prompt for a single task dispatch.
|
|
58
|
-
*/
|
|
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");
|
|
62
|
-
const designRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
|
|
63
|
-
const planPath = (await fileExists(planRef)) ? planRef : planFallbackRef;
|
|
64
|
-
return [
|
|
65
|
-
`Implement task ${task.id}: ${task.title}.`,
|
|
66
|
-
`Reference the plan at ${planPath}`,
|
|
67
|
-
`and architecture at ${designRef}.`,
|
|
68
|
-
`If a CLAUDE.md file exists in the project root, read it for project-specific conventions.`,
|
|
69
|
-
`Check ~/.config/opencode/skills/coding-standards/SKILL.md for coding standards.`,
|
|
70
|
-
`Report completion when done.`,
|
|
71
|
-
].join(" ");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Mark a task as DONE immutably and return the updated tasks array.
|
|
76
|
-
*/
|
|
77
|
-
function markTaskDone(tasks: readonly Task[], taskId: number): readonly Task[] {
|
|
78
|
-
return tasks.map((t) => (t.id === taskId ? { ...t, status: "DONE" as const } : t));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Check whether all tasks in a given wave are DONE.
|
|
83
|
-
*/
|
|
84
|
-
function isWaveComplete(waveMap: ReadonlyMap<number, readonly Task[]>, wave: number): boolean {
|
|
85
|
-
const tasks = waveMap.get(wave) ?? [];
|
|
86
|
-
return tasks.every((t) => t.status === "DONE");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Parse review result to check for CRITICAL findings.
|
|
91
|
-
*/
|
|
92
|
-
function hasCriticalFindings(resultStr: string): boolean {
|
|
93
|
-
try {
|
|
94
|
-
const parsed = JSON.parse(resultStr);
|
|
95
|
-
if (parsed.severity === "CRITICAL") return true;
|
|
96
|
-
const hasCritical = (arr: unknown[]): boolean =>
|
|
97
|
-
arr.some(
|
|
98
|
-
(f: unknown) =>
|
|
99
|
-
typeof f === "object" &&
|
|
100
|
-
f !== null &&
|
|
101
|
-
"severity" in f &&
|
|
102
|
-
(f as { severity: string }).severity === "CRITICAL",
|
|
103
|
-
);
|
|
104
|
-
if (Array.isArray(parsed.findings)) {
|
|
105
|
-
return hasCritical(parsed.findings);
|
|
106
|
-
}
|
|
107
|
-
if (parsed.report?.findings && Array.isArray(parsed.report.findings)) {
|
|
108
|
-
return hasCritical(parsed.report.findings);
|
|
109
|
-
}
|
|
110
|
-
return false;
|
|
111
|
-
} catch {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
20
|
export const handleBuild: PhaseHandler = async (
|
|
117
21
|
state,
|
|
118
22
|
artifactDir,
|
|
@@ -122,7 +26,6 @@ export const handleBuild: PhaseHandler = async (
|
|
|
122
26
|
const { tasks, buildProgress } = state;
|
|
123
27
|
const resultText = context?.envelope.payload.text ?? result;
|
|
124
28
|
|
|
125
|
-
// Edge case: no tasks
|
|
126
29
|
if (tasks.length === 0) {
|
|
127
30
|
return Object.freeze({
|
|
128
31
|
action: "error",
|
|
@@ -131,7 +34,6 @@ export const handleBuild: PhaseHandler = async (
|
|
|
131
34
|
} satisfies DispatchResult);
|
|
132
35
|
}
|
|
133
36
|
|
|
134
|
-
// Edge case: strike count exceeded
|
|
135
37
|
if (buildProgress.strikeCount > MAX_STRIKES && buildProgress.reviewPending && resultText) {
|
|
136
38
|
return Object.freeze({
|
|
137
39
|
action: "error",
|
|
@@ -141,7 +43,6 @@ export const handleBuild: PhaseHandler = async (
|
|
|
141
43
|
} satisfies DispatchResult);
|
|
142
44
|
}
|
|
143
45
|
|
|
144
|
-
// Auto-assign waves from depends_on declarations (D-15)
|
|
145
46
|
let effectiveTasks = tasks;
|
|
146
47
|
const hasDependencies = tasks.some((t) => t.depends_on && t.depends_on.length > 0);
|
|
147
48
|
if (hasDependencies) {
|
|
@@ -163,7 +64,6 @@ export const handleBuild: PhaseHandler = async (
|
|
|
163
64
|
}
|
|
164
65
|
}
|
|
165
66
|
|
|
166
|
-
// Check if all remaining tasks are BLOCKED (cycles or MAX_TASKS cap)
|
|
167
67
|
const nonDoneTasks = effectiveTasks.filter((t) => t.status !== "DONE" && t.status !== "SKIPPED");
|
|
168
68
|
if (nonDoneTasks.length > 0 && nonDoneTasks.every((t) => t.status === "BLOCKED")) {
|
|
169
69
|
const blockedIds = nonDoneTasks.map((t) => t.id).join(", ");
|
|
@@ -192,10 +92,8 @@ export const handleBuild: PhaseHandler = async (
|
|
|
192
92
|
} satisfies DispatchResult);
|
|
193
93
|
}
|
|
194
94
|
|
|
195
|
-
// Case 1: Review pending + result provided -> process review outcome
|
|
196
95
|
if (buildProgress.reviewPending && resultText) {
|
|
197
96
|
if (hasCriticalFindings(resultText)) {
|
|
198
|
-
// Re-dispatch implementer with fix instructions
|
|
199
97
|
const safeResult = sanitizeTemplateContent(resultText).slice(0, 4000);
|
|
200
98
|
const prompt = [
|
|
201
99
|
`CRITICAL review findings detected. Fix the following issues:`,
|
|
@@ -214,13 +112,13 @@ export const handleBuild: PhaseHandler = async (
|
|
|
214
112
|
_stateUpdates: {
|
|
215
113
|
buildProgress: {
|
|
216
114
|
...buildProgress,
|
|
115
|
+
reviewPending: false,
|
|
217
116
|
strikeCount: buildProgress.strikeCount + 1,
|
|
218
117
|
},
|
|
219
118
|
},
|
|
220
119
|
} satisfies DispatchResult);
|
|
221
120
|
}
|
|
222
121
|
|
|
223
|
-
// No critical -> advance to next wave
|
|
224
122
|
const waveMap = groupByWave(effectiveTasks);
|
|
225
123
|
const nextWave = findCurrentWave(waveMap);
|
|
226
124
|
|
|
@@ -232,6 +130,7 @@ export const handleBuild: PhaseHandler = async (
|
|
|
232
130
|
_stateUpdates: {
|
|
233
131
|
buildProgress: {
|
|
234
132
|
...buildProgress,
|
|
133
|
+
currentTask: null,
|
|
235
134
|
reviewPending: false,
|
|
236
135
|
},
|
|
237
136
|
},
|
|
@@ -239,13 +138,18 @@ export const handleBuild: PhaseHandler = async (
|
|
|
239
138
|
}
|
|
240
139
|
|
|
241
140
|
const pendingTasks = findPendingTasks(waveMap, nextWave);
|
|
242
|
-
const
|
|
141
|
+
const inProgressTasks = findInProgressTasks(waveMap, nextWave);
|
|
142
|
+
const updatedProgress = {
|
|
243
143
|
...buildProgress,
|
|
244
144
|
reviewPending: false,
|
|
245
145
|
currentWave: nextWave,
|
|
246
146
|
};
|
|
247
147
|
|
|
248
|
-
if (pendingTasks.length ===
|
|
148
|
+
if (pendingTasks.length === 0 && inProgressTasks.length > 0) {
|
|
149
|
+
return buildPendingResultError(nextWave, inProgressTasks, updatedProgress);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (pendingTasks.length > 0) {
|
|
249
153
|
const task = pendingTasks[0];
|
|
250
154
|
const prompt = await buildTaskPrompt(task, artifactDir);
|
|
251
155
|
return Object.freeze({
|
|
@@ -257,62 +161,24 @@ export const handleBuild: PhaseHandler = async (
|
|
|
257
161
|
taskId: task.id,
|
|
258
162
|
progress: `Wave ${nextWave} — task ${task.id}`,
|
|
259
163
|
_stateUpdates: {
|
|
164
|
+
tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
|
|
260
165
|
buildProgress: { ...updatedProgress, currentTask: task.id },
|
|
261
166
|
},
|
|
262
167
|
} satisfies DispatchResult);
|
|
263
168
|
}
|
|
264
169
|
|
|
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
|
-
);
|
|
272
170
|
return Object.freeze({
|
|
273
|
-
action: "
|
|
274
|
-
|
|
275
|
-
agent: AGENT_NAMES.BUILD,
|
|
276
|
-
prompt: promptsByTaskId.get(task.id) ?? "",
|
|
277
|
-
taskId: task.id,
|
|
278
|
-
resultKind: "task_completion" as const,
|
|
279
|
-
})),
|
|
171
|
+
action: "error",
|
|
172
|
+
code: "E_BUILD_NO_DISPATCHABLE_TASK",
|
|
280
173
|
phase: "BUILD",
|
|
281
|
-
|
|
282
|
-
_stateUpdates: {
|
|
283
|
-
tasks: [...markTasksInProgress(effectiveTasks, dispatchedIds)],
|
|
284
|
-
buildProgress: { ...updatedProgress, currentTask: null },
|
|
285
|
-
},
|
|
174
|
+
message: `Wave ${nextWave} has no dispatchable pending tasks.`,
|
|
286
175
|
} satisfies DispatchResult);
|
|
287
176
|
}
|
|
288
177
|
|
|
289
|
-
|
|
290
|
-
// For dispatch_multi, currentTask may be null — find the first IN_PROGRESS task instead
|
|
291
|
-
const hasTypedContext = context !== undefined && !context.legacy;
|
|
178
|
+
const hasTypedContext = context !== undefined;
|
|
292
179
|
const isTaskCompletion = hasTypedContext && context.envelope.kind === "task_completion";
|
|
293
|
-
const
|
|
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);
|
|
180
|
+
const taskToComplete = isTaskCompletion ? context.envelope.taskId : buildProgress.currentTask;
|
|
301
181
|
|
|
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
182
|
if (resultText && !buildProgress.reviewPending && taskToComplete === null) {
|
|
317
183
|
return Object.freeze({
|
|
318
184
|
action: "error",
|
|
@@ -337,7 +203,6 @@ export const handleBuild: PhaseHandler = async (
|
|
|
337
203
|
const currentWave = buildProgress.currentWave ?? 1;
|
|
338
204
|
|
|
339
205
|
if (isWaveComplete(waveMap, currentWave)) {
|
|
340
|
-
// Wave complete -> trigger review (same for final wave or intermediate)
|
|
341
206
|
return Object.freeze({
|
|
342
207
|
action: "dispatch",
|
|
343
208
|
agent: AGENT_NAMES.REVIEW,
|
|
@@ -349,14 +214,13 @@ export const handleBuild: PhaseHandler = async (
|
|
|
349
214
|
tasks: [...updatedTasks],
|
|
350
215
|
buildProgress: {
|
|
351
216
|
...buildProgress,
|
|
352
|
-
currentTask:
|
|
217
|
+
currentTask: taskToComplete,
|
|
353
218
|
reviewPending: true,
|
|
354
219
|
},
|
|
355
220
|
},
|
|
356
221
|
} satisfies DispatchResult);
|
|
357
222
|
}
|
|
358
223
|
|
|
359
|
-
// Wave not complete -> dispatch next pending task or wait for in-progress
|
|
360
224
|
const pendingInWave = findPendingTasks(waveMap, currentWave);
|
|
361
225
|
if (pendingInWave.length > 0) {
|
|
362
226
|
const next = pendingInWave[0];
|
|
@@ -370,7 +234,7 @@ export const handleBuild: PhaseHandler = async (
|
|
|
370
234
|
taskId: next.id,
|
|
371
235
|
progress: `Wave ${currentWave} — task ${next.id}`,
|
|
372
236
|
_stateUpdates: {
|
|
373
|
-
tasks: [...updatedTasks],
|
|
237
|
+
tasks: [...markTasksInProgress(updatedTasks, [next.id])],
|
|
374
238
|
buildProgress: {
|
|
375
239
|
...buildProgress,
|
|
376
240
|
currentTask: next.id,
|
|
@@ -379,33 +243,16 @@ export const handleBuild: PhaseHandler = async (
|
|
|
379
243
|
} satisfies DispatchResult);
|
|
380
244
|
}
|
|
381
245
|
|
|
382
|
-
// No pending tasks but wave not complete — other tasks are still IN_PROGRESS
|
|
383
246
|
const inProgressInWave = findInProgressTasks(waveMap, currentWave);
|
|
384
247
|
if (inProgressInWave.length > 0) {
|
|
385
|
-
return
|
|
386
|
-
action: "dispatch",
|
|
387
|
-
agent: AGENT_NAMES.BUILD,
|
|
388
|
-
prompt: `Wave ${currentWave} has ${inProgressInWave.length} task(s) still in progress. Continue working on remaining tasks.`,
|
|
389
|
-
phase: "BUILD",
|
|
390
|
-
resultKind: "phase_output",
|
|
391
|
-
progress: `Wave ${currentWave} — waiting for ${inProgressInWave.length} in-progress task(s)`,
|
|
392
|
-
_stateUpdates: {
|
|
393
|
-
tasks: [...updatedTasks],
|
|
394
|
-
buildProgress: {
|
|
395
|
-
...buildProgress,
|
|
396
|
-
currentTask: null,
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
} satisfies DispatchResult);
|
|
248
|
+
return buildPendingResultError(currentWave, inProgressInWave, buildProgress, updatedTasks);
|
|
400
249
|
}
|
|
401
250
|
}
|
|
402
251
|
|
|
403
|
-
// Case 3: No result (first call or resume) -> find first pending wave
|
|
404
252
|
const waveMap = groupByWave(effectiveTasks);
|
|
405
253
|
const currentWave = findCurrentWave(waveMap);
|
|
406
254
|
|
|
407
255
|
if (currentWave === null) {
|
|
408
|
-
// All tasks already DONE
|
|
409
256
|
return Object.freeze({
|
|
410
257
|
action: "complete",
|
|
411
258
|
phase: "BUILD",
|
|
@@ -417,27 +264,10 @@ export const handleBuild: PhaseHandler = async (
|
|
|
417
264
|
const inProgressTasks = findInProgressTasks(waveMap, currentWave);
|
|
418
265
|
|
|
419
266
|
if (pendingTasks.length === 0 && inProgressTasks.length > 0) {
|
|
420
|
-
|
|
421
|
-
// Return a dispatch instruction so the orchestrator knows work is underway.
|
|
422
|
-
return Object.freeze({
|
|
423
|
-
action: "dispatch",
|
|
424
|
-
agent: AGENT_NAMES.BUILD,
|
|
425
|
-
prompt: `Resume: wave ${currentWave} has ${inProgressTasks.length} task(s) still in progress. Wait for agent results and pass them back.`,
|
|
426
|
-
phase: "BUILD",
|
|
427
|
-
resultKind: "phase_output",
|
|
428
|
-
progress: `Wave ${currentWave} — waiting for ${inProgressTasks.length} in-progress task(s)`,
|
|
429
|
-
_stateUpdates: {
|
|
430
|
-
buildProgress: {
|
|
431
|
-
...buildProgress,
|
|
432
|
-
currentWave,
|
|
433
|
-
currentTask: null,
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
} satisfies DispatchResult);
|
|
267
|
+
return buildPendingResultError(currentWave, inProgressTasks, buildProgress);
|
|
437
268
|
}
|
|
438
269
|
|
|
439
270
|
if (pendingTasks.length === 0) {
|
|
440
|
-
// All tasks in all waves DONE (findCurrentWave already checked PENDING + IN_PROGRESS)
|
|
441
271
|
return Object.freeze({
|
|
442
272
|
action: "complete",
|
|
443
273
|
phase: "BUILD",
|
|
@@ -457,6 +287,7 @@ export const handleBuild: PhaseHandler = async (
|
|
|
457
287
|
taskId: task.id,
|
|
458
288
|
progress: `Wave ${currentWave} — task ${task.id}`,
|
|
459
289
|
_stateUpdates: {
|
|
290
|
+
tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
|
|
460
291
|
buildProgress: {
|
|
461
292
|
...buildProgress,
|
|
462
293
|
currentTask: task.id,
|
|
@@ -466,29 +297,21 @@ export const handleBuild: PhaseHandler = async (
|
|
|
466
297
|
} satisfies DispatchResult);
|
|
467
298
|
}
|
|
468
299
|
|
|
469
|
-
|
|
470
|
-
const
|
|
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
|
-
);
|
|
300
|
+
const task = pendingTasks[0];
|
|
301
|
+
const prompt = await buildTaskPrompt(task, artifactDir);
|
|
477
302
|
return Object.freeze({
|
|
478
|
-
action: "
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
prompt: promptsByTaskId.get(task.id) ?? "",
|
|
482
|
-
taskId: task.id,
|
|
483
|
-
resultKind: "task_completion" as const,
|
|
484
|
-
})),
|
|
303
|
+
action: "dispatch",
|
|
304
|
+
agent: AGENT_NAMES.BUILD,
|
|
305
|
+
prompt,
|
|
485
306
|
phase: "BUILD",
|
|
486
|
-
|
|
307
|
+
resultKind: "task_completion",
|
|
308
|
+
taskId: task.id,
|
|
309
|
+
progress: `Wave ${currentWave} — task ${task.id}`,
|
|
487
310
|
_stateUpdates: {
|
|
488
|
-
tasks: [...markTasksInProgress(effectiveTasks,
|
|
311
|
+
tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
|
|
489
312
|
buildProgress: {
|
|
490
313
|
...buildProgress,
|
|
491
|
-
currentTask:
|
|
314
|
+
currentTask: task.id,
|
|
492
315
|
currentWave,
|
|
493
316
|
},
|
|
494
317
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getProjectRootFromArtifactDir } from "../../utils/paths";
|
|
2
2
|
import { getArtifactRef, PHASE_ARTIFACTS } from "../artifacts";
|
|
3
3
|
import {
|
|
4
4
|
createEmptyLessonMemory,
|
|
@@ -81,7 +81,7 @@ export const handleRetrospective: PhaseHandler = async (_state, artifactDir, res
|
|
|
81
81
|
|
|
82
82
|
// Persist lessons to memory (best-effort: failure should not mark pipeline as FAILED)
|
|
83
83
|
try {
|
|
84
|
-
const projectRoot =
|
|
84
|
+
const projectRoot = getProjectRootFromArtifactDir(artifactDir);
|
|
85
85
|
const existing = await loadLessonMemory(projectRoot);
|
|
86
86
|
const memory = existing ?? createEmptyLessonMemory();
|
|
87
87
|
const merged = [...memory.lessons, ...valid];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-project lesson memory persistence.
|
|
3
3
|
*
|
|
4
|
-
* Stores lessons extracted from pipeline runs
|
|
5
|
-
* {projectRoot}/.opencode-autopilot/lesson-memory.json
|
|
4
|
+
* Stores lessons extracted from pipeline runs in the project kernel,
|
|
5
|
+
* with {projectRoot}/.opencode-autopilot/lesson-memory.json kept as a
|
|
6
|
+
* compatibility mirror/export during the Phase 4 migration window.
|
|
6
7
|
*
|
|
7
8
|
* Memory is pruned on load to remove stale entries (>90 days)
|
|
8
9
|
* and cap at 50 lessons. All writes are atomic (tmp file + rename)
|
|
@@ -12,7 +13,9 @@
|
|
|
12
13
|
import { randomBytes } from "node:crypto";
|
|
13
14
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
14
15
|
import { join } from "node:path";
|
|
16
|
+
import { loadLessonMemoryFromKernel, saveLessonMemoryToKernel } from "../kernel/repository";
|
|
15
17
|
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
18
|
+
import { getProjectArtifactDir } from "../utils/paths";
|
|
16
19
|
import { lessonMemorySchema } from "./lesson-schemas";
|
|
17
20
|
import type { LessonMemory } from "./lesson-types";
|
|
18
21
|
|
|
@@ -21,6 +24,7 @@ export type { LessonMemory };
|
|
|
21
24
|
const LESSON_FILE = "lesson-memory.json";
|
|
22
25
|
const MAX_LESSONS = 50;
|
|
23
26
|
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
|
|
27
|
+
let legacyLessonMemoryMirrorWarned = false;
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* Create a valid empty lesson memory object.
|
|
@@ -41,6 +45,11 @@ export function createEmptyLessonMemory(): LessonMemory {
|
|
|
41
45
|
* Prunes on load to remove stale entries and cap storage.
|
|
42
46
|
*/
|
|
43
47
|
export async function loadLessonMemory(projectRoot: string): Promise<LessonMemory | null> {
|
|
48
|
+
const kernelMemory = loadLessonMemoryFromKernel(getProjectArtifactDir(projectRoot));
|
|
49
|
+
if (kernelMemory !== null) {
|
|
50
|
+
return pruneLessons(kernelMemory);
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
const memoryPath = join(projectRoot, ".opencode-autopilot", LESSON_FILE);
|
|
45
54
|
try {
|
|
46
55
|
const raw = await readFile(memoryPath, "utf-8");
|
|
@@ -51,7 +60,9 @@ export async function loadLessonMemory(projectRoot: string): Promise<LessonMemor
|
|
|
51
60
|
...parsed,
|
|
52
61
|
lessons: Array.isArray(parsed.lessons) ? parsed.lessons : [],
|
|
53
62
|
});
|
|
54
|
-
|
|
63
|
+
const validated = lessonMemorySchema.parse(pruned);
|
|
64
|
+
saveLessonMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
|
|
65
|
+
return validated;
|
|
55
66
|
} catch (error: unknown) {
|
|
56
67
|
if (isEnoentError(error)) {
|
|
57
68
|
return null;
|
|
@@ -74,12 +85,21 @@ export async function loadLessonMemory(projectRoot: string): Promise<LessonMemor
|
|
|
74
85
|
*/
|
|
75
86
|
export async function saveLessonMemory(memory: LessonMemory, projectRoot: string): Promise<void> {
|
|
76
87
|
const validated = lessonMemorySchema.parse(memory);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
saveLessonMemoryToKernel(getProjectArtifactDir(projectRoot), validated);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const dir = join(projectRoot, ".opencode-autopilot");
|
|
92
|
+
await ensureDir(dir);
|
|
93
|
+
const memoryPath = join(dir, LESSON_FILE);
|
|
94
|
+
const tmpPath = `${memoryPath}.tmp.${randomBytes(8).toString("hex")}`;
|
|
95
|
+
await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
|
|
96
|
+
await rename(tmpPath, memoryPath);
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
if (!legacyLessonMemoryMirrorWarned) {
|
|
99
|
+
legacyLessonMemoryMirrorWarned = true;
|
|
100
|
+
console.warn("[opencode-autopilot] lesson-memory.json mirror write failed:", error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
/**
|
|
@@ -90,8 +110,13 @@ export async function saveLessonMemory(memory: LessonMemory, projectRoot: string
|
|
|
90
110
|
* - Sort remaining by extractedAt descending (newest first)
|
|
91
111
|
* - Cap at 50 lessons
|
|
92
112
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
import { systemTimeProvider, type TimeProvider } from "../scoring/time-provider";
|
|
114
|
+
|
|
115
|
+
export function pruneLessons(
|
|
116
|
+
memory: LessonMemory,
|
|
117
|
+
timeProvider: TimeProvider = systemTimeProvider,
|
|
118
|
+
): LessonMemory {
|
|
119
|
+
const now = timeProvider.now();
|
|
95
120
|
|
|
96
121
|
// Filter out stale lessons (>90 days)
|
|
97
122
|
const fresh = memory.lessons.filter(
|