@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.
- package/assets/commands/oc-doctor.md +17 -0
- package/bin/configure-tui.ts +1 -1
- package/bin/inspect.ts +2 -2
- package/package.json +1 -1
- 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 +108 -24
- package/src/health/checks.ts +165 -0
- package/src/health/runner.ts +8 -2
- package/src/health/types.ts +1 -1
- package/src/index.ts +25 -2
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +1 -2
- 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 +16 -197
- package/src/memory/decay.ts +11 -2
- 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/projects.ts +83 -0
- package/src/memory/repository.ts +46 -1001
- package/src/memory/retrieval.ts +5 -1
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +44 -6
- package/src/observability/forensic-log.ts +10 -2
- package/src/observability/forensic-schemas.ts +9 -1
- package/src/observability/log-reader.ts +20 -1
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +13 -148
- package/src/orchestrator/handlers/retrospective.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +7 -2
- package/src/orchestrator/orchestration-logger.ts +46 -31
- package/src/orchestrator/progress.ts +63 -0
- package/src/review/memory.ts +11 -3
- 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/configure.ts +1 -1
- package/src/tools/doctor.ts +2 -2
- package/src/tools/logs.ts +32 -6
- package/src/tools/orchestrate.ts +11 -9
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +8 -2
- package/src/tools/summary.ts +43 -0
- package/src/types/background.ts +51 -0
- package/src/types/mcp.ts +27 -0
- package/src/types/recovery.ts +39 -0
- package/src/types/routing.ts +39 -0
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
package/src/memory/retrieval.ts
CHANGED
|
@@ -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
|
-
|
|
183
|
+
getContextUtilizationString(info.tokens.input, 200000),
|
|
180
184
|
"warning",
|
|
181
185
|
).catch((err) => {
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
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
|
|
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({
|
|
@@ -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
|
-
|
|
114
|
-
|
|
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(
|