@kodrunhq/opencode-autopilot 1.16.0 → 1.18.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 (61) hide show
  1. package/assets/commands/oc-doctor.md +17 -0
  2. package/bin/configure-tui.ts +1 -1
  3. package/bin/inspect.ts +2 -2
  4. package/package.json +1 -1
  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 +108 -24
  9. package/src/health/checks.ts +165 -0
  10. package/src/health/runner.ts +8 -2
  11. package/src/health/types.ts +1 -1
  12. package/src/index.ts +25 -2
  13. package/src/kernel/transaction.ts +48 -0
  14. package/src/kernel/types.ts +1 -2
  15. package/src/logging/domains.ts +39 -0
  16. package/src/logging/forensic-writer.ts +177 -0
  17. package/src/logging/index.ts +4 -0
  18. package/src/logging/logger.ts +44 -0
  19. package/src/logging/performance.ts +59 -0
  20. package/src/logging/rotation.ts +261 -0
  21. package/src/logging/types.ts +33 -0
  22. package/src/memory/capture-utils.ts +149 -0
  23. package/src/memory/capture.ts +16 -197
  24. package/src/memory/decay.ts +11 -2
  25. package/src/memory/injector.ts +4 -1
  26. package/src/memory/lessons.ts +85 -0
  27. package/src/memory/observations.ts +177 -0
  28. package/src/memory/preferences.ts +718 -0
  29. package/src/memory/projects.ts +83 -0
  30. package/src/memory/repository.ts +46 -1001
  31. package/src/memory/retrieval.ts +5 -1
  32. package/src/observability/context-display.ts +8 -0
  33. package/src/observability/event-handlers.ts +44 -6
  34. package/src/observability/forensic-log.ts +10 -2
  35. package/src/observability/forensic-schemas.ts +9 -1
  36. package/src/observability/log-reader.ts +20 -1
  37. package/src/orchestrator/error-context.ts +24 -0
  38. package/src/orchestrator/handlers/build-utils.ts +118 -0
  39. package/src/orchestrator/handlers/build.ts +13 -148
  40. package/src/orchestrator/handlers/retrospective.ts +0 -1
  41. package/src/orchestrator/lesson-memory.ts +7 -2
  42. package/src/orchestrator/orchestration-logger.ts +46 -31
  43. package/src/orchestrator/progress.ts +63 -0
  44. package/src/review/memory.ts +11 -3
  45. package/src/review/parse-findings.ts +116 -0
  46. package/src/review/pipeline.ts +3 -107
  47. package/src/review/selection.ts +38 -4
  48. package/src/scoring/time-provider.ts +23 -0
  49. package/src/tools/configure.ts +1 -1
  50. package/src/tools/doctor.ts +2 -2
  51. package/src/tools/logs.ts +32 -6
  52. package/src/tools/orchestrate.ts +11 -9
  53. package/src/tools/replay.ts +42 -0
  54. package/src/tools/review.ts +8 -2
  55. package/src/tools/summary.ts +43 -0
  56. package/src/types/background.ts +51 -0
  57. package/src/types/mcp.ts +27 -0
  58. package/src/types/recovery.ts +39 -0
  59. package/src/types/routing.ts +39 -0
  60. package/src/utils/random.ts +33 -0
  61. package/src/ux/session-summary.ts +56 -0
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { Database } from "bun:sqlite";
16
+ import { getLogger } from "../logging/domains";
16
17
  import { CHARS_PER_TOKEN, DEFAULT_INJECTION_BUDGET } from "./constants";
17
18
  import { getMemoryDb } from "./database";
18
19
  import { computeRelevanceScore } from "./decay";
@@ -25,6 +26,8 @@ import {
25
26
  } from "./repository";
26
27
  import type { Observation, Preference } from "./types";
27
28
 
29
+ const logger = getLogger("memory", "retrieval");
30
+
28
31
  /**
29
32
  * An observation with its computed relevance score.
30
33
  */
@@ -194,8 +197,9 @@ export function retrieveMemoryContext(
194
197
  updateAccessCount(id, db);
195
198
  }
196
199
  resolvedDb.run("COMMIT");
197
- } catch {
200
+ } catch (err) {
198
201
  // best-effort — access count update is non-critical
202
+ logger.warn("access count update failed", { error: String(err) });
199
203
  }
200
204
  }
201
205
 
@@ -0,0 +1,8 @@
1
+ export function getContextUtilizationString(usedTokens: number, maxTokens: number): string {
2
+ const safeMaxTokens = Math.max(0, maxTokens);
3
+ const safeUsedTokens = Math.max(0, usedTokens);
4
+ const utilization =
5
+ safeMaxTokens > 0 ? Math.min(100, Math.round((safeUsedTokens / safeMaxTokens) * 100)) : 0;
6
+
7
+ return `[${utilization}% used] ${safeUsedTokens} / ${safeMaxTokens} tokens`;
8
+ }
@@ -12,12 +12,17 @@
12
12
  * @module
13
13
  */
14
14
 
15
+ import { getLogger } from "../logging/domains";
15
16
  import { classifyErrorType, getErrorMessage } from "../orchestrator/fallback/error-classifier";
17
+ import { generateSessionSummary } from "../ux/session-summary";
18
+ import { getContextUtilizationString } from "./context-display";
16
19
  import type { ContextMonitor } from "./context-monitor";
17
20
  import { emitErrorEvent, emitToolCompleteEvent } from "./event-emitter";
18
21
  import type { ObservabilityEvent, SessionEventStore, SessionEvents } from "./event-store";
19
22
  import { accumulateTokensFromMessage, createEmptyTokenAggregate } from "./token-tracker";
20
23
 
24
+ const logger = getLogger("session", "event-handlers");
25
+
21
26
  /**
22
27
  * Dependencies for the observability event handler.
23
28
  */
@@ -161,7 +166,6 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
161
166
  // Check context utilization
162
167
  const utilResult = contextMonitor.processMessage(sessionId, info.tokens.input);
163
168
  if (utilResult.shouldWarn) {
164
- const pct = Math.round(utilResult.utilization * 100);
165
169
  // Append context_warning event
166
170
  const warningEvent: ObservabilityEvent = Object.freeze({
167
171
  type: "context_warning" as const,
@@ -176,10 +180,14 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
176
180
  // Fire toast (per D-35)
177
181
  showToast(
178
182
  "Context Warning",
179
- `Context at ${pct}% -- consider compacting`,
183
+ getContextUtilizationString(info.tokens.input, 200000),
180
184
  "warning",
181
185
  ).catch((err) => {
182
- console.error("[opencode-autopilot]", err);
186
+ logger.error("showToast failed for context warning", {
187
+ operation: "context_warning",
188
+ sessionId,
189
+ error: String(err),
190
+ });
183
191
  });
184
192
  }
185
193
  }
@@ -194,7 +202,11 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
194
202
  const sessionData = eventStore.getUnpersistedSession(sessionId);
195
203
  if (sessionData && sessionData.events.length > 0) {
196
204
  writeSessionLog(sessionData).catch((err) => {
197
- console.error("[opencode-autopilot]", err);
205
+ logger.error("writeSessionLog failed on session.idle", {
206
+ operation: "session_end",
207
+ sessionId,
208
+ error: String(err),
209
+ });
198
210
  });
199
211
  }
200
212
  return;
@@ -212,11 +224,33 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
212
224
  totalCost: 0,
213
225
  });
214
226
 
227
+ const summary = generateSessionSummary(eventStore.getSession(sessionId), null);
228
+ logger.info(`Session ended summary:\n${summary}`, {
229
+ operation: "session_end",
230
+ sessionId,
231
+ });
232
+
233
+ void showToast(
234
+ "Session ended",
235
+ "Run /oc_summary to view the session summary.",
236
+ "info",
237
+ ).catch((err) => {
238
+ logger.error("showToast failed for session end", {
239
+ operation: "session_end",
240
+ sessionId,
241
+ error: String(err),
242
+ });
243
+ });
244
+
215
245
  // Final flush — session is done, remove from store
216
246
  const sessionData = eventStore.flush(sessionId);
217
247
  if (sessionData && sessionData.events.length > 0) {
218
248
  writeSessionLog(sessionData).catch((err) => {
219
- console.error("[opencode-autopilot]", err);
249
+ logger.error("writeSessionLog failed on session.deleted", {
250
+ operation: "session_end",
251
+ sessionId,
252
+ error: String(err),
253
+ });
220
254
  });
221
255
  }
222
256
 
@@ -242,7 +276,11 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
242
276
  const sessionData = eventStore.getUnpersistedSession(sessionId);
243
277
  if (sessionData && sessionData.events.length > 0) {
244
278
  writeSessionLog(sessionData).catch((err) => {
245
- console.error("[opencode-autopilot]", err);
279
+ logger.error("writeSessionLog failed on session.compacted", {
280
+ operation: "compacted",
281
+ sessionId,
282
+ error: String(err),
283
+ });
246
284
  });
247
285
  }
248
286
  return;
@@ -40,10 +40,18 @@ function toProjectRootFromArtifactDir(artifactDir: string): string {
40
40
  }
41
41
 
42
42
  function appendValidatedForensicEvent(artifactDir: string, event: ForensicEvent): void {
43
- appendForensicEventsToKernel(artifactDir, [event]);
43
+ mkdirSync(artifactDir, { recursive: true });
44
+
45
+ try {
46
+ appendForensicEventsToKernel(artifactDir, [event]);
47
+ } catch (kernelError) {
48
+ if (!forensicWriteWarned) {
49
+ forensicWriteWarned = true;
50
+ console.warn("[opencode-autopilot] forensic log write failed:", kernelError);
51
+ }
52
+ }
44
53
 
45
54
  try {
46
- mkdirSync(artifactDir, { recursive: true });
47
55
  const logPath = join(artifactDir, FORENSIC_LOG_FILE);
48
56
  appendFileSync(logPath, `${JSON.stringify(event)}\n`, "utf-8");
49
57
  } catch (mirrorError) {
@@ -19,9 +19,17 @@ export const forensicEventTypeSchema = z.enum([
19
19
  "context_warning",
20
20
  "tool_complete",
21
21
  "compacted",
22
+ "info",
23
+ "debug",
22
24
  ]);
23
25
 
24
- export const forensicEventDomainSchema = z.enum(["session", "orchestrator", "contract"]);
26
+ export const forensicEventDomainSchema = z.enum([
27
+ "session",
28
+ "orchestrator",
29
+ "contract",
30
+ "system",
31
+ "review",
32
+ ]);
25
33
 
26
34
  export type JsonValue =
27
35
  | null
@@ -39,9 +39,13 @@ export interface EventSearchFilters {
39
39
  readonly after?: string;
40
40
  readonly before?: string;
41
41
  readonly domain?: string;
42
+ readonly subsystem?: string;
43
+ /** Matches against event.type for semantic severity (e.g. "error", "warning"),
44
+ * or against event.payload.severity / event.payload.level for explicit severity fields. */
45
+ readonly severity?: string;
42
46
  }
43
47
 
44
- function isSessionForProject(event: Readonly<ForensicEvent>, sessionId: string): boolean {
48
+ function _isSessionForProject(event: Readonly<ForensicEvent>, sessionId: string): boolean {
45
49
  return event.domain === "session" && event.sessionId === sessionId;
46
50
  }
47
51
 
@@ -164,6 +168,21 @@ export function searchEvents(
164
168
  if (filters.domain && event.domain !== filters.domain) return false;
165
169
  if (filters.after && event.timestamp <= filters.after) return false;
166
170
  if (filters.before && event.timestamp >= filters.before) return false;
171
+ if (filters.subsystem) {
172
+ const subsystem = event.payload.subsystem;
173
+ if (typeof subsystem !== "string" || subsystem !== filters.subsystem) return false;
174
+ }
175
+ if (filters.severity) {
176
+ const payloadSeverity =
177
+ typeof event.payload.severity === "string"
178
+ ? event.payload.severity
179
+ : typeof event.payload.level === "string"
180
+ ? event.payload.level
181
+ : null;
182
+ const matchesSemantic = event.type === filters.severity;
183
+ const matchesPayload = payloadSeverity === filters.severity;
184
+ if (!matchesSemantic && !matchesPayload) return false;
185
+ }
167
186
  return true;
168
187
  });
169
188
  }
@@ -0,0 +1,24 @@
1
+ import type { PipelineState } from "./types";
2
+
3
+ export function enrichErrorMessage(error: string, state: PipelineState): string {
4
+ const phase = state.currentPhase ?? "UNKNOWN";
5
+ const details: string[] = [];
6
+
7
+ if (
8
+ state.currentPhase === "BUILD" &&
9
+ state.buildProgress?.currentWave !== null &&
10
+ state.buildProgress?.currentWave !== undefined
11
+ ) {
12
+ details.push(`wave ${state.buildProgress.currentWave}`);
13
+ }
14
+
15
+ if (state.buildProgress?.currentTask !== null && state.buildProgress?.currentTask !== undefined) {
16
+ const task = state.tasks.find((entry) => entry.id === state.buildProgress.currentTask);
17
+ details.push(
18
+ task ? `task ${task.id}: ${task.title}` : `task ${state.buildProgress.currentTask}`,
19
+ );
20
+ }
21
+
22
+ const context = details.length > 0 ? ` (${details.join(", ")})` : "";
23
+ return `Error in phase ${phase}${context}: ${error}`;
24
+ }
@@ -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,142 +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
- function buildPendingResultError(
49
- wave: number,
50
- inProgressTasks: readonly Task[],
51
- buildProgress: Readonly<BuildProgress>,
52
- updatedTasks?: readonly Task[],
53
- ): DispatchResult {
54
- const taskIds = inProgressTasks.map((task) => task.id);
55
- return Object.freeze({
56
- action: "error",
57
- code: "E_BUILD_RESULT_PENDING",
58
- phase: "BUILD",
59
- 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.`,
60
- progress: `Wave ${wave} — waiting for typed result(s) for taskIds [${taskIds.join(", ")}]`,
61
- _stateUpdates: {
62
- ...(updatedTasks ? { tasks: [...updatedTasks] } : {}),
63
- buildProgress: {
64
- ...buildProgress,
65
- currentWave: wave,
66
- currentTask: buildProgress.currentTask ?? inProgressTasks[0]?.id ?? null,
67
- },
68
- },
69
- } satisfies DispatchResult);
70
- }
71
-
72
- /**
73
- * Mark multiple tasks as IN_PROGRESS immutably.
74
- */
75
- function markTasksInProgress(tasks: readonly Task[], taskIds: readonly number[]): readonly Task[] {
76
- const idSet = new Set(taskIds);
77
- return tasks.map((t) => (idSet.has(t.id) ? { ...t, status: "IN_PROGRESS" as const } : t));
78
- }
79
-
80
- /**
81
- * Build a prompt for a single task dispatch.
82
- */
83
- async function buildTaskPrompt(task: Task, artifactDir: string): Promise<string> {
84
- const planRef = getArtifactRef(artifactDir, "PLAN", "tasks.json");
85
- const planFallbackRef = getArtifactRef(artifactDir, "PLAN", "tasks.md");
86
- const designRef = getArtifactRef(artifactDir, "ARCHITECT", "design.md");
87
- const planPath = (await fileExists(planRef)) ? planRef : planFallbackRef;
88
- return [
89
- `Implement task ${task.id}: ${task.title}.`,
90
- `Reference the plan at ${planPath}`,
91
- `and architecture at ${designRef}.`,
92
- `If a CLAUDE.md file exists in the project root, read it for project-specific conventions.`,
93
- `Check ~/.config/opencode/skills/coding-standards/SKILL.md for coding standards.`,
94
- `Report completion when done.`,
95
- ].join(" ");
96
- }
97
-
98
- /**
99
- * Mark a task as DONE immutably and return the updated tasks array.
100
- */
101
- function markTaskDone(tasks: readonly Task[], taskId: number): readonly Task[] {
102
- return tasks.map((t) => (t.id === taskId ? { ...t, status: "DONE" as const } : t));
103
- }
104
-
105
- /**
106
- * Check whether all tasks in a given wave are DONE.
107
- */
108
- function isWaveComplete(waveMap: ReadonlyMap<number, readonly Task[]>, wave: number): boolean {
109
- const tasks = waveMap.get(wave) ?? [];
110
- return tasks.every((t) => t.status === "DONE");
111
- }
112
-
113
- /**
114
- * Parse review result to check for CRITICAL findings.
115
- */
116
- function hasCriticalFindings(resultStr: string): boolean {
117
- try {
118
- const parsed = JSON.parse(resultStr);
119
- if (parsed.severity === "CRITICAL") return true;
120
- const hasCritical = (arr: unknown[]): boolean =>
121
- arr.some(
122
- (f: unknown) =>
123
- typeof f === "object" &&
124
- f !== null &&
125
- "severity" in f &&
126
- (f as { severity: string }).severity === "CRITICAL",
127
- );
128
- if (Array.isArray(parsed.findings)) {
129
- return hasCritical(parsed.findings);
130
- }
131
- if (parsed.report?.findings && Array.isArray(parsed.report.findings)) {
132
- return hasCritical(parsed.report.findings);
133
- }
134
- return false;
135
- } catch {
136
- return false;
137
- }
138
- }
139
-
140
20
  export const handleBuild: PhaseHandler = async (
141
21
  state,
142
22
  artifactDir,
@@ -146,7 +26,6 @@ export const handleBuild: PhaseHandler = async (
146
26
  const { tasks, buildProgress } = state;
147
27
  const resultText = context?.envelope.payload.text ?? result;
148
28
 
149
- // Edge case: no tasks
150
29
  if (tasks.length === 0) {
151
30
  return Object.freeze({
152
31
  action: "error",
@@ -155,7 +34,6 @@ export const handleBuild: PhaseHandler = async (
155
34
  } satisfies DispatchResult);
156
35
  }
157
36
 
158
- // Edge case: strike count exceeded
159
37
  if (buildProgress.strikeCount > MAX_STRIKES && buildProgress.reviewPending && resultText) {
160
38
  return Object.freeze({
161
39
  action: "error",
@@ -165,7 +43,6 @@ export const handleBuild: PhaseHandler = async (
165
43
  } satisfies DispatchResult);
166
44
  }
167
45
 
168
- // Auto-assign waves from depends_on declarations (D-15)
169
46
  let effectiveTasks = tasks;
170
47
  const hasDependencies = tasks.some((t) => t.depends_on && t.depends_on.length > 0);
171
48
  if (hasDependencies) {
@@ -187,7 +64,6 @@ export const handleBuild: PhaseHandler = async (
187
64
  }
188
65
  }
189
66
 
190
- // Check if all remaining tasks are BLOCKED (cycles or MAX_TASKS cap)
191
67
  const nonDoneTasks = effectiveTasks.filter((t) => t.status !== "DONE" && t.status !== "SKIPPED");
192
68
  if (nonDoneTasks.length > 0 && nonDoneTasks.every((t) => t.status === "BLOCKED")) {
193
69
  const blockedIds = nonDoneTasks.map((t) => t.id).join(", ");
@@ -216,10 +92,8 @@ export const handleBuild: PhaseHandler = async (
216
92
  } satisfies DispatchResult);
217
93
  }
218
94
 
219
- // Case 1: Review pending + result provided -> process review outcome
220
95
  if (buildProgress.reviewPending && resultText) {
221
96
  if (hasCriticalFindings(resultText)) {
222
- // Re-dispatch implementer with fix instructions
223
97
  const safeResult = sanitizeTemplateContent(resultText).slice(0, 4000);
224
98
  const prompt = [
225
99
  `CRITICAL review findings detected. Fix the following issues:`,
@@ -245,7 +119,6 @@ export const handleBuild: PhaseHandler = async (
245
119
  } satisfies DispatchResult);
246
120
  }
247
121
 
248
- // No critical -> advance to next wave
249
122
  const waveMap = groupByWave(effectiveTasks);
250
123
  const nextWave = findCurrentWave(waveMap);
251
124
 
@@ -266,7 +139,7 @@ export const handleBuild: PhaseHandler = async (
266
139
 
267
140
  const pendingTasks = findPendingTasks(waveMap, nextWave);
268
141
  const inProgressTasks = findInProgressTasks(waveMap, nextWave);
269
- const updatedProgress: BuildProgress = {
142
+ const updatedProgress = {
270
143
  ...buildProgress,
271
144
  reviewPending: false,
272
145
  currentWave: nextWave,
@@ -302,7 +175,6 @@ export const handleBuild: PhaseHandler = async (
302
175
  } satisfies DispatchResult);
303
176
  }
304
177
 
305
- // Case 2: Result provided + not review pending -> mark task done
306
178
  const hasTypedContext = context !== undefined;
307
179
  const isTaskCompletion = hasTypedContext && context.envelope.kind === "task_completion";
308
180
  const taskToComplete = isTaskCompletion ? context.envelope.taskId : buildProgress.currentTask;
@@ -331,7 +203,6 @@ export const handleBuild: PhaseHandler = async (
331
203
  const currentWave = buildProgress.currentWave ?? 1;
332
204
 
333
205
  if (isWaveComplete(waveMap, currentWave)) {
334
- // Wave complete -> trigger review (same for final wave or intermediate)
335
206
  return Object.freeze({
336
207
  action: "dispatch",
337
208
  agent: AGENT_NAMES.REVIEW,
@@ -350,7 +221,6 @@ export const handleBuild: PhaseHandler = async (
350
221
  } satisfies DispatchResult);
351
222
  }
352
223
 
353
- // Wave not complete -> dispatch next pending task or wait for in-progress
354
224
  const pendingInWave = findPendingTasks(waveMap, currentWave);
355
225
  if (pendingInWave.length > 0) {
356
226
  const next = pendingInWave[0];
@@ -373,19 +243,16 @@ export const handleBuild: PhaseHandler = async (
373
243
  } satisfies DispatchResult);
374
244
  }
375
245
 
376
- // No pending tasks but wave not complete — other tasks are still IN_PROGRESS
377
246
  const inProgressInWave = findInProgressTasks(waveMap, currentWave);
378
247
  if (inProgressInWave.length > 0) {
379
248
  return buildPendingResultError(currentWave, inProgressInWave, buildProgress, updatedTasks);
380
249
  }
381
250
  }
382
251
 
383
- // Case 3: No result (first call or resume) -> find first pending wave
384
252
  const waveMap = groupByWave(effectiveTasks);
385
253
  const currentWave = findCurrentWave(waveMap);
386
254
 
387
255
  if (currentWave === null) {
388
- // All tasks already DONE
389
256
  return Object.freeze({
390
257
  action: "complete",
391
258
  phase: "BUILD",
@@ -401,7 +268,6 @@ export const handleBuild: PhaseHandler = async (
401
268
  }
402
269
 
403
270
  if (pendingTasks.length === 0) {
404
- // All tasks in all waves DONE (findCurrentWave already checked PENDING + IN_PROGRESS)
405
271
  return Object.freeze({
406
272
  action: "complete",
407
273
  phase: "BUILD",
@@ -431,7 +297,6 @@ export const handleBuild: PhaseHandler = async (
431
297
  } satisfies DispatchResult);
432
298
  }
433
299
 
434
- // Multiple pending tasks in wave -> dispatch only the next task sequentially.
435
300
  const task = pendingTasks[0];
436
301
  const prompt = await buildTaskPrompt(task, artifactDir);
437
302
  return Object.freeze({
@@ -1,4 +1,3 @@
1
- import { join } from "node:path";
2
1
  import { getProjectRootFromArtifactDir } from "../../utils/paths";
3
2
  import { getArtifactRef, PHASE_ARTIFACTS } from "../artifacts";
4
3
  import {
@@ -110,8 +110,13 @@ export async function saveLessonMemory(memory: LessonMemory, projectRoot: string
110
110
  * - Sort remaining by extractedAt descending (newest first)
111
111
  * - Cap at 50 lessons
112
112
  */
113
- export function pruneLessons(memory: LessonMemory): LessonMemory {
114
- 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();
115
120
 
116
121
  // Filter out stale lessons (>90 days)
117
122
  const fresh = memory.lessons.filter(