@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.
Files changed (93) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +3 -3
  9. package/src/health/checks.ts +126 -4
  10. package/src/health/types.ts +1 -1
  11. package/src/index.ts +128 -13
  12. package/src/inspect/formatters.ts +225 -0
  13. package/src/inspect/repository.ts +882 -0
  14. package/src/kernel/database.ts +45 -0
  15. package/src/kernel/migrations.ts +62 -0
  16. package/src/kernel/repository.ts +571 -0
  17. package/src/kernel/schema.ts +122 -0
  18. package/src/kernel/transaction.ts +48 -0
  19. package/src/kernel/types.ts +65 -0
  20. package/src/logging/domains.ts +39 -0
  21. package/src/logging/forensic-writer.ts +177 -0
  22. package/src/logging/index.ts +4 -0
  23. package/src/logging/logger.ts +44 -0
  24. package/src/logging/performance.ts +59 -0
  25. package/src/logging/rotation.ts +261 -0
  26. package/src/logging/types.ts +33 -0
  27. package/src/memory/capture-utils.ts +149 -0
  28. package/src/memory/capture.ts +82 -67
  29. package/src/memory/database.ts +74 -12
  30. package/src/memory/decay.ts +11 -2
  31. package/src/memory/index.ts +17 -1
  32. package/src/memory/injector.ts +4 -1
  33. package/src/memory/lessons.ts +85 -0
  34. package/src/memory/observations.ts +177 -0
  35. package/src/memory/preferences.ts +718 -0
  36. package/src/memory/project-key.ts +6 -0
  37. package/src/memory/projects.ts +83 -0
  38. package/src/memory/repository.ts +52 -216
  39. package/src/memory/retrieval.ts +88 -170
  40. package/src/memory/schemas.ts +39 -7
  41. package/src/memory/types.ts +4 -0
  42. package/src/observability/context-display.ts +8 -0
  43. package/src/observability/event-handlers.ts +69 -20
  44. package/src/observability/event-store.ts +29 -1
  45. package/src/observability/forensic-log.ts +167 -0
  46. package/src/observability/forensic-schemas.ts +77 -0
  47. package/src/observability/forensic-types.ts +10 -0
  48. package/src/observability/index.ts +21 -27
  49. package/src/observability/log-reader.ts +161 -111
  50. package/src/observability/log-writer.ts +41 -83
  51. package/src/observability/retention.ts +2 -2
  52. package/src/observability/session-logger.ts +36 -57
  53. package/src/observability/summary-generator.ts +31 -19
  54. package/src/observability/types.ts +12 -24
  55. package/src/orchestrator/contracts/invariants.ts +14 -0
  56. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  57. package/src/orchestrator/error-context.ts +24 -0
  58. package/src/orchestrator/fallback/event-handler.ts +47 -3
  59. package/src/orchestrator/handlers/architect.ts +2 -1
  60. package/src/orchestrator/handlers/build-utils.ts +118 -0
  61. package/src/orchestrator/handlers/build.ts +42 -219
  62. package/src/orchestrator/handlers/retrospective.ts +2 -2
  63. package/src/orchestrator/handlers/types.ts +0 -1
  64. package/src/orchestrator/lesson-memory.ts +36 -11
  65. package/src/orchestrator/orchestration-logger.ts +53 -24
  66. package/src/orchestrator/phase.ts +8 -4
  67. package/src/orchestrator/progress.ts +63 -0
  68. package/src/orchestrator/state.ts +79 -17
  69. package/src/projects/database.ts +47 -0
  70. package/src/projects/repository.ts +264 -0
  71. package/src/projects/resolve.ts +301 -0
  72. package/src/projects/schemas.ts +30 -0
  73. package/src/projects/types.ts +12 -0
  74. package/src/review/memory.ts +39 -11
  75. package/src/review/parse-findings.ts +116 -0
  76. package/src/review/pipeline.ts +3 -107
  77. package/src/review/selection.ts +38 -4
  78. package/src/scoring/time-provider.ts +23 -0
  79. package/src/tools/doctor.ts +28 -4
  80. package/src/tools/forensics.ts +7 -12
  81. package/src/tools/logs.ts +38 -11
  82. package/src/tools/memory-preferences.ts +157 -0
  83. package/src/tools/memory-status.ts +17 -96
  84. package/src/tools/orchestrate.ts +108 -90
  85. package/src/tools/pipeline-report.ts +3 -2
  86. package/src/tools/quick.ts +2 -2
  87. package/src/tools/replay.ts +42 -0
  88. package/src/tools/review.ts +46 -7
  89. package/src/tools/session-stats.ts +3 -2
  90. package/src/tools/summary.ts +43 -0
  91. package/src/utils/paths.ts +20 -1
  92. package/src/utils/random.ts +33 -0
  93. 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 updatedProgress: BuildProgress = {
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 === 1) {
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: "dispatch_multi",
274
- agents: pendingTasks.map((task) => ({
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
- progress: `Wave ${nextWave} ${pendingTasks.length} concurrent tasks`,
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
- // Case 2: Result provided + not review pending -> mark task done
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 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);
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: null,
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 Object.freeze({
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
- // Wave has only IN_PROGRESS tasks (e.g., resume after dispatch_multi).
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
- // Multiple pending tasks in wave -> dispatch_multi
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
- );
300
+ const task = pendingTasks[0];
301
+ const prompt = await buildTaskPrompt(task, artifactDir);
477
302
  return Object.freeze({
478
- action: "dispatch_multi",
479
- agents: pendingTasks.map((task) => ({
480
- agent: AGENT_NAMES.BUILD,
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
- progress: `Wave ${currentWave} — ${pendingTasks.length} concurrent tasks`,
307
+ resultKind: "task_completion",
308
+ taskId: task.id,
309
+ progress: `Wave ${currentWave} — task ${task.id}`,
487
310
  _stateUpdates: {
488
- tasks: [...markTasksInProgress(effectiveTasks, dispatchedIds)],
311
+ tasks: [...markTasksInProgress(effectiveTasks, [task.id])],
489
312
  buildProgress: {
490
313
  ...buildProgress,
491
- currentTask: null,
314
+ currentTask: task.id,
492
315
  currentWave,
493
316
  },
494
317
  },
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
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 = join(artifactDir, "..");
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];
@@ -40,7 +40,6 @@ export interface DispatchResult {
40
40
 
41
41
  export interface PhaseHandlerContext {
42
42
  readonly envelope: ResultEnvelope;
43
- readonly legacy: boolean;
44
43
  }
45
44
 
46
45
  export type PhaseHandler = (
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Per-project lesson memory persistence.
3
3
  *
4
- * Stores lessons extracted from pipeline runs at
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
- return lessonMemorySchema.parse(pruned);
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
- const dir = join(projectRoot, ".opencode-autopilot");
78
- await ensureDir(dir);
79
- const memoryPath = join(dir, LESSON_FILE);
80
- const tmpPath = `${memoryPath}.tmp.${randomBytes(8).toString("hex")}`;
81
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
82
- await rename(tmpPath, memoryPath);
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
- export function pruneLessons(memory: LessonMemory): LessonMemory {
94
- const now = Date.now();
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(