@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
@@ -1,93 +1,51 @@
1
- /**
2
- * Session log persistence layer.
3
- *
4
- * Writes complete session logs as JSON files with atomic write pattern
5
- * (temp file + rename) to prevent corruption. Logs are stored in
6
- * ~/.config/opencode/logs/ (user-scoped, not project-scoped per D-08, D-09).
7
- */
1
+ import { getProjectArtifactDir, getProjectRootFromArtifactDir } from "../utils/paths";
2
+ import { appendForensicEvent } from "./forensic-log";
3
+ import type { ForensicEvent } from "./forensic-types";
8
4
 
9
- import { randomBytes } from "node:crypto";
10
- import { rename, writeFile } from "node:fs/promises";
11
- import { join } from "node:path";
12
- import { ensureDir } from "../utils/fs-helpers";
13
- import { getGlobalConfigDir } from "../utils/paths";
14
- import { sessionLogSchema } from "./schemas";
15
- import type { SessionEvent, SessionLog } from "./types";
16
-
17
- /**
18
- * Returns the global logs directory path.
19
- * Logs are user-scoped, not project-scoped (D-08, D-09).
20
- */
21
- export function getLogsDir(): string {
22
- return join(getGlobalConfigDir(), "logs");
5
+ export interface WriteSessionInput {
6
+ readonly projectRoot: string;
7
+ readonly sessionId: string;
8
+ readonly startedAt: string;
9
+ readonly endedAt?: string | null;
10
+ readonly events: readonly ForensicEvent[];
23
11
  }
24
12
 
25
- /**
26
- * Input shape for writeSessionLog -- the in-memory data to persist.
27
- */
28
- interface WriteSessionInput {
13
+ interface LegacyWriteSessionInput {
29
14
  readonly sessionId: string;
30
15
  readonly startedAt: string;
31
- readonly events: readonly SessionEvent[];
16
+ readonly endedAt?: string | null;
17
+ readonly events: readonly ForensicEvent[];
32
18
  }
33
19
 
34
- /**
35
- * Converts in-memory session events to the persisted SessionLog format.
36
- *
37
- * Extracts decisions from decision-type events.
38
- * Builds errorSummary by counting error events by errorType.
39
- */
40
- export function convertToSessionLog(input: WriteSessionInput): SessionLog {
41
- const decisions = input.events
42
- .filter((e): e is SessionEvent & { type: "decision" } => e.type === "decision")
43
- .map((e) => ({
44
- timestamp: e.timestamp,
45
- phase: e.phase,
46
- agent: e.agent,
47
- decision: e.decision,
48
- rationale: e.rationale,
49
- }));
50
-
51
- const errorSummary: Record<string, number> = {};
52
- for (const e of input.events) {
53
- if (e.type === "error") {
54
- errorSummary[e.errorType] = (errorSummary[e.errorType] ?? 0) + 1;
55
- }
56
- }
57
-
58
- return {
59
- schemaVersion: 1 as const,
60
- sessionId: input.sessionId,
61
- startedAt: input.startedAt,
62
- endedAt: new Date().toISOString(),
63
- events: [...input.events],
64
- decisions,
65
- errorSummary,
66
- };
20
+ export function getLogsDir(projectRoot: string): string {
21
+ return getProjectArtifactDir(projectRoot);
67
22
  }
68
23
 
69
- /**
70
- * Persists a session log to disk as a JSON file.
71
- *
72
- * Uses atomic write pattern: write to temp file, then rename.
73
- * Validates through sessionLogSchema before writing (defensive).
74
- * Creates the logs directory if it does not exist.
75
- *
76
- * @param input - Session data to persist
77
- * @param logsDir - Optional override for logs directory (for testing)
78
- */
79
- export async function writeSessionLog(input: WriteSessionInput, logsDir?: string): Promise<void> {
80
- const raw = convertToSessionLog(input);
81
-
82
- // Validate and sanitize — use parsed result (strips unknown keys, applies defaults)
83
- const log = sessionLogSchema.parse(raw);
84
-
85
- const dir = logsDir ?? getLogsDir();
86
- await ensureDir(dir);
87
-
88
- const finalPath = join(dir, `${log.sessionId}.json`);
89
- const tmpPath = `${finalPath}.tmp.${randomBytes(8).toString("hex")}`;
90
-
91
- await writeFile(tmpPath, JSON.stringify(log, null, 2), "utf-8");
92
- await rename(tmpPath, finalPath);
24
+ export async function writeSessionLog(
25
+ input: WriteSessionInput | LegacyWriteSessionInput,
26
+ projectRootOrArtifactDir?: string,
27
+ ): Promise<void> {
28
+ const projectRoot =
29
+ "projectRoot" in input
30
+ ? input.projectRoot
31
+ : getProjectRootFromArtifactDir(projectRootOrArtifactDir ?? process.cwd());
32
+
33
+ for (const event of input.events) {
34
+ appendForensicEvent(projectRoot, {
35
+ timestamp: event.timestamp,
36
+ projectRoot,
37
+ domain: event.domain,
38
+ runId: event.runId,
39
+ sessionId: event.sessionId,
40
+ parentSessionId: event.parentSessionId,
41
+ phase: event.phase,
42
+ dispatchId: event.dispatchId,
43
+ taskId: event.taskId,
44
+ agent: event.agent,
45
+ type: event.type,
46
+ code: event.code,
47
+ message: event.message,
48
+ payload: event.payload,
49
+ });
50
+ }
93
51
  }
@@ -1,7 +1,7 @@
1
1
  import { readdir, stat, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { isEnoentError } from "../utils/fs-helpers";
4
- import { getLogsDir } from "./session-logger";
4
+ import { getGlobalConfigDir } from "../utils/paths";
5
5
 
6
6
  const DEFAULT_RETENTION_DAYS = 30;
7
7
 
@@ -25,7 +25,7 @@ interface PruneResult {
25
25
  * @returns Count of pruned files
26
26
  */
27
27
  export async function pruneOldLogs(options?: PruneOptions): Promise<PruneResult> {
28
- const logsDir = options?.logsDir ?? getLogsDir();
28
+ const logsDir = options?.logsDir ?? join(getGlobalConfigDir(), "projects");
29
29
  const retentionDays = options?.retentionDays ?? DEFAULT_RETENTION_DAYS;
30
30
  const threshold = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
31
31
 
@@ -1,63 +1,42 @@
1
- import { appendFile, readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { ensureDir, isEnoentError } from "../utils/fs-helpers";
4
- import { getGlobalConfigDir } from "../utils/paths";
5
- import { sessionEventSchema } from "./schemas";
6
- import type { SessionEvent } from "./types";
1
+ import { getProjectRootFromArtifactDir } from "../utils/paths";
2
+ import { appendForensicEvent, readForensicEvents } from "./forensic-log";
3
+ import type { ForensicEvent } from "./forensic-types";
7
4
 
8
- /**
9
- * Returns the global logs directory path.
10
- * Logs are user-scoped, not project-scoped (D-08, D-09).
11
- */
12
- export function getLogsDir(): string {
13
- return join(getGlobalConfigDir(), "logs");
5
+ export function getLogsDir(projectRoot: string): string {
6
+ return projectRoot;
14
7
  }
15
8
 
16
- /**
17
- * Returns the JSONL log file path for a given session ID.
18
- */
19
- function getSessionLogPath(sessionId: string, logsDir?: string): string {
20
- return join(logsDir ?? getLogsDir(), `${sessionId}.jsonl`);
9
+ export async function logEvent(
10
+ event: ForensicEvent,
11
+ artifactDirOrProjectRoot?: string,
12
+ ): Promise<void> {
13
+ const projectRoot = artifactDirOrProjectRoot
14
+ ? getProjectRootFromArtifactDir(artifactDirOrProjectRoot)
15
+ : event.projectRoot;
16
+
17
+ appendForensicEvent(projectRoot, {
18
+ timestamp: event.timestamp,
19
+ projectRoot,
20
+ domain: event.domain,
21
+ runId: event.runId,
22
+ sessionId: event.sessionId,
23
+ parentSessionId: event.parentSessionId,
24
+ phase: event.phase,
25
+ dispatchId: event.dispatchId,
26
+ taskId: event.taskId,
27
+ agent: event.agent,
28
+ type: event.type,
29
+ code: event.code,
30
+ message: event.message,
31
+ payload: event.payload,
32
+ });
21
33
  }
22
34
 
23
- /**
24
- * Logs a structured event to the session's JSONL log file.
25
- * Validates the event against the Zod schema before writing.
26
- * Creates the logs directory if it does not exist.
27
- *
28
- * @param event - The session event to log (D-02, D-04-D-07)
29
- * @param logsDir - Optional override for the logs directory (for testing)
30
- */
31
- export async function logEvent(event: SessionEvent, logsDir?: string): Promise<void> {
32
- // Validate against schema -- throws on invalid events
33
- const validated = sessionEventSchema.parse(event);
34
-
35
- const dir = logsDir ?? getLogsDir();
36
- await ensureDir(dir);
37
-
38
- const logPath = getSessionLogPath(validated.sessionId, dir);
39
- const line = `${JSON.stringify(validated)}\n`;
40
-
41
- await appendFile(logPath, line, "utf-8");
42
- }
43
-
44
- /**
45
- * Reads and parses all events from a session's JSONL log file.
46
- * Returns an empty array if the session log does not exist.
47
- *
48
- * @param sessionId - The session ID to read logs for
49
- * @param logsDir - Optional override for the logs directory (for testing)
50
- */
51
- export async function getSessionLog(sessionId: string, logsDir?: string): Promise<SessionEvent[]> {
52
- const logPath = getSessionLogPath(sessionId, logsDir);
53
-
54
- try {
55
- const content = await readFile(logPath, "utf-8");
56
- const lines = content.trim().split("\n").filter(Boolean);
57
-
58
- return lines.map((line) => sessionEventSchema.parse(JSON.parse(line)));
59
- } catch (error: unknown) {
60
- if (isEnoentError(error)) return [];
61
- throw error;
62
- }
35
+ export async function getSessionLog(
36
+ sessionId: string,
37
+ artifactDirOrProjectRoot: string,
38
+ ): Promise<readonly ForensicEvent[]> {
39
+ const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
40
+ const events = await readForensicEvents(projectRoot);
41
+ return Object.freeze(events.filter((event) => event.sessionId === sessionId));
63
42
  }
@@ -1,14 +1,8 @@
1
1
  /**
2
- * Session summary generator.
3
- *
4
- * Transforms structured SessionLog data into human-readable markdown summaries
5
- * for post-session analysis. All functions are pure (no I/O, no side effects).
6
- *
7
- * Summaries include: metadata, decisions, errors, fallbacks, model switches,
8
- * and a strategic one-paragraph analysis (D-03, D-11, D-42, D-43).
2
+ * Session summary generator over the unified forensic event stream.
9
3
  */
10
4
 
11
- import type { SessionEvent, SessionLog } from "./types";
5
+ import type { ForensicEvent, SessionLog } from "./types";
12
6
 
13
7
  /**
14
8
  * Computes session duration in milliseconds from startedAt/endedAt.
@@ -61,7 +55,7 @@ export function generateSessionSummary(log: SessionLog): string {
61
55
 
62
56
  // Errors section (conditional)
63
57
  const errorEvents = log.events.filter(
64
- (e): e is SessionEvent & { type: "error" } => e.type === "error",
58
+ (e): e is ForensicEvent & { type: "error" } => e.type === "error",
65
59
  );
66
60
  if (errorEvents.length > 0) {
67
61
  sections.push(renderErrors(errorEvents, log.errorSummary));
@@ -69,7 +63,7 @@ export function generateSessionSummary(log: SessionLog): string {
69
63
 
70
64
  // Fallbacks section (conditional)
71
65
  const fallbackEvents = log.events.filter(
72
- (e): e is SessionEvent & { type: "fallback" } => e.type === "fallback",
66
+ (e): e is ForensicEvent & { type: "fallback" } => e.type === "fallback",
73
67
  );
74
68
  if (fallbackEvents.length > 0) {
75
69
  sections.push(renderFallbacks(fallbackEvents));
@@ -77,7 +71,7 @@ export function generateSessionSummary(log: SessionLog): string {
77
71
 
78
72
  // Model switches section (conditional)
79
73
  const switchEvents = log.events.filter(
80
- (e): e is SessionEvent & { type: "model_switch" } => e.type === "model_switch",
74
+ (e): e is ForensicEvent & { type: "model_switch" } => e.type === "model_switch",
81
75
  );
82
76
  if (switchEvents.length > 0) {
83
77
  sections.push(renderModelSwitches(switchEvents));
@@ -121,7 +115,7 @@ function renderDecisions(log: SessionLog): string {
121
115
  }
122
116
 
123
117
  function renderErrors(
124
- events: readonly (SessionEvent & { type: "error" })[],
118
+ events: readonly (ForensicEvent & { type: "error" })[],
125
119
  errorSummary: Record<string, number>,
126
120
  ): string {
127
121
  const lines = [
@@ -133,8 +127,13 @@ function renderErrors(
133
127
 
134
128
  for (const e of events) {
135
129
  const safeMsg = (e.message ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
136
- const safeModel = (e.model ?? "").replace(/\|/g, "\\|");
137
- lines.push(`| ${e.timestamp} | ${e.errorType} | ${safeModel} | ${safeMsg} |`);
130
+ const safeModel = (typeof e.payload.model === "string" ? e.payload.model : "").replace(
131
+ /\|/g,
132
+ "\\|",
133
+ );
134
+ const errorCode =
135
+ e.code ?? (typeof e.payload.errorType === "string" ? e.payload.errorType : "unknown");
136
+ lines.push(`| ${e.timestamp} | ${errorCode} | ${safeModel} | ${safeMsg} |`);
138
137
  }
139
138
 
140
139
  // Error summary counts
@@ -150,7 +149,7 @@ function renderErrors(
150
149
  return lines.join("\n");
151
150
  }
152
151
 
153
- function renderFallbacks(events: readonly (SessionEvent & { type: "fallback" })[]): string {
152
+ function renderFallbacks(events: readonly (ForensicEvent & { type: "fallback" })[]): string {
154
153
  const lines = [
155
154
  "## Fallbacks",
156
155
  "",
@@ -159,15 +158,23 @@ function renderFallbacks(events: readonly (SessionEvent & { type: "fallback" })[
159
158
  ];
160
159
 
161
160
  for (const e of events) {
161
+ const failedModel =
162
+ typeof e.payload.failedModel === "string" ? e.payload.failedModel : "unknown";
163
+ const nextModel = typeof e.payload.nextModel === "string" ? e.payload.nextModel : "unknown";
164
+ const reason =
165
+ typeof e.payload.reason === "string" ? e.payload.reason : (e.message ?? "unknown");
166
+ const success = e.payload.success === true;
162
167
  lines.push(
163
- `| ${e.timestamp} | ${e.failedModel} | ${e.nextModel} | ${e.reason} | ${e.success ? "Yes" : "No"} |`,
168
+ `| ${e.timestamp} | ${failedModel} | ${nextModel} | ${reason} | ${success ? "Yes" : "No"} |`,
164
169
  );
165
170
  }
166
171
 
167
172
  return lines.join("\n");
168
173
  }
169
174
 
170
- function renderModelSwitches(events: readonly (SessionEvent & { type: "model_switch" })[]): string {
175
+ function renderModelSwitches(
176
+ events: readonly (ForensicEvent & { type: "model_switch" })[],
177
+ ): string {
171
178
  const lines = [
172
179
  "## Model Switches",
173
180
  "",
@@ -176,7 +183,10 @@ function renderModelSwitches(events: readonly (SessionEvent & { type: "model_swi
176
183
  ];
177
184
 
178
185
  for (const e of events) {
179
- lines.push(`| ${e.timestamp} | ${e.fromModel} | ${e.toModel} | ${e.trigger} |`);
186
+ const fromModel = typeof e.payload.fromModel === "string" ? e.payload.fromModel : "unknown";
187
+ const toModel = typeof e.payload.toModel === "string" ? e.payload.toModel : "unknown";
188
+ const trigger = typeof e.payload.trigger === "string" ? e.payload.trigger : "unknown";
189
+ lines.push(`| ${e.timestamp} | ${fromModel} | ${toModel} | ${trigger} |`);
180
190
  }
181
191
 
182
192
  return lines.join("\n");
@@ -194,7 +204,9 @@ function renderStrategicSummary(log: SessionLog): string {
194
204
  );
195
205
 
196
206
  if (errorCount > 0) {
197
- const successfulFallbacks = log.events.filter((e) => e.type === "fallback" && e.success).length;
207
+ const successfulFallbacks = log.events.filter(
208
+ (e) => e.type === "fallback" && e.payload.success === true,
209
+ ).length;
198
210
  parts.push(
199
211
  `Encountered ${errorCount} errors; ${fallbackCount} fallback attempts (${successfulFallbacks} successful).`,
200
212
  );
@@ -1,24 +1,12 @@
1
- import type { z } from "zod";
2
- import type {
3
- baseEventSchema,
4
- decisionEventSchema,
5
- errorEventSchema,
6
- fallbackEventSchema,
7
- loggingConfigSchema,
8
- modelSwitchEventSchema,
9
- sessionDecisionSchema,
10
- sessionEventSchema,
11
- sessionLogSchema,
12
- } from "./schemas";
13
-
14
- export type SessionEventType = "fallback" | "error" | "decision" | "model_switch";
15
-
16
- export type BaseEvent = z.infer<typeof baseEventSchema>;
17
- export type FallbackEvent = z.infer<typeof fallbackEventSchema>;
18
- export type ErrorEvent = z.infer<typeof errorEventSchema>;
19
- export type DecisionEvent = z.infer<typeof decisionEventSchema>;
20
- export type ModelSwitchEvent = z.infer<typeof modelSwitchEventSchema>;
21
- export type SessionEvent = z.infer<typeof sessionEventSchema>;
22
- export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
23
- export type SessionDecision = z.infer<typeof sessionDecisionSchema>;
24
- export type SessionLog = z.infer<typeof sessionLogSchema>;
1
+ export type {
2
+ ForensicEvent,
3
+ ForensicEvent as SessionEvent,
4
+ ForensicEventDomain,
5
+ ForensicEventType,
6
+ } from "./forensic-types";
7
+ export type {
8
+ EventSearchFilters,
9
+ SessionDecision,
10
+ SessionLog,
11
+ SessionLogEntry,
12
+ } from "./log-reader";
@@ -33,6 +33,13 @@ function collectPendingDispatchKeys(state: Readonly<PipelineState>): readonly st
33
33
  return state.pendingDispatches.map((entry) => `${entry.dispatchId}|${entry.phase}`);
34
34
  }
35
35
 
36
+ function hasPendingDispatchOutsideCurrentPhase(state: Readonly<PipelineState>): boolean {
37
+ if (state.currentPhase === null) {
38
+ return state.pendingDispatches.length > 0;
39
+ }
40
+ return state.pendingDispatches.some((entry) => entry.phase !== state.currentPhase);
41
+ }
42
+
36
43
  export function validateStateInvariants(
37
44
  state: Readonly<PipelineState>,
38
45
  ): readonly InvariantViolation[] {
@@ -93,6 +100,13 @@ export function validateStateInvariants(
93
100
  });
94
101
  }
95
102
 
103
+ if (hasPendingDispatchOutsideCurrentPhase(state)) {
104
+ violations.push({
105
+ code: "E_INVARIANT_PENDING_PHASE",
106
+ message: "pendingDispatches must belong to the current active phase only",
107
+ });
108
+ }
109
+
96
110
  const processedIds = state.processedResultIds;
97
111
  if (new Set(processedIds).size !== processedIds.length) {
98
112
  violations.push({
@@ -2,20 +2,19 @@ import { z } from "zod";
2
2
  import type { Phase } from "../types";
3
3
  import { type ResultEnvelope, resultEnvelopeSchema } from "./result-envelope";
4
4
 
5
- export interface ParseResultEnvelopeResult {
5
+ export interface ParseTypedResultEnvelopeResult {
6
6
  readonly envelope: ResultEnvelope;
7
- readonly legacy: boolean;
8
7
  }
9
8
 
10
- export function parseResultEnvelope(
9
+ export function parseTypedResultEnvelope(
11
10
  raw: string,
12
- ctx: {
11
+ _ctx: {
13
12
  readonly runId: string;
14
13
  readonly phase: Phase;
15
14
  readonly fallbackDispatchId: string;
16
15
  readonly fallbackAgent?: string | null;
17
16
  },
18
- ): ParseResultEnvelopeResult {
17
+ ): ParseTypedResultEnvelopeResult {
19
18
  const trimmed = raw.trim();
20
19
  if (trimmed.length === 0) {
21
20
  throw new Error("E_INVALID_RESULT: empty result payload");
@@ -24,24 +23,13 @@ export function parseResultEnvelope(
24
23
  try {
25
24
  const parsed = JSON.parse(trimmed);
26
25
  const envelope = resultEnvelopeSchema.parse(parsed);
27
- return { envelope, legacy: false };
26
+ return { envelope };
28
27
  } catch (error: unknown) {
29
28
  if (error instanceof z.ZodError) {
30
29
  throw new Error(`E_INVALID_RESULT: ${error.issues[0]?.message ?? "invalid envelope"}`);
31
30
  }
32
- const legacyEnvelope = resultEnvelopeSchema.parse({
33
- schemaVersion: 1,
34
- resultId: `legacy-${ctx.fallbackDispatchId}`,
35
- runId: ctx.runId,
36
- phase: ctx.phase,
37
- dispatchId: ctx.fallbackDispatchId,
38
- agent: ctx.fallbackAgent ?? null,
39
- kind: "phase_output",
40
- taskId: null,
41
- payload: {
42
- text: raw,
43
- },
44
- });
45
- return { envelope: legacyEnvelope, legacy: true };
31
+ throw new Error(
32
+ "E_INVALID_RESULT: Result payload must be a typed result envelope JSON object.",
33
+ );
46
34
  }
47
35
  }
@@ -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
+ }
@@ -26,6 +26,17 @@ export interface EventHandlerDeps {
26
26
  readonly manager: FallbackManager;
27
27
  readonly sdk: SdkOperations;
28
28
  readonly config: FallbackConfig;
29
+ readonly onFallbackEvent?: (event: {
30
+ readonly type: "fallback" | "model_switch";
31
+ readonly sessionId: string;
32
+ readonly failedModel?: string;
33
+ readonly nextModel?: string;
34
+ readonly reason?: string;
35
+ readonly success?: boolean;
36
+ readonly fromModel?: string;
37
+ readonly toModel?: string;
38
+ readonly trigger?: "fallback" | "config" | "user";
39
+ }) => void;
29
40
  }
30
41
 
31
42
  /**
@@ -64,7 +75,7 @@ function extractSessionID(properties: Record<string, unknown>): string | undefin
64
75
  * The returned handler routes OpenCode events to the FallbackManager.
65
76
  */
66
77
  export function createEventHandler(deps: EventHandlerDeps) {
67
- const { manager, sdk, config } = deps;
78
+ const { manager, sdk, config, onFallbackEvent } = deps;
68
79
 
69
80
  return async (input: {
70
81
  readonly event: { readonly type: string; readonly [key: string]: unknown };
@@ -133,7 +144,15 @@ export function createEventHandler(deps: EventHandlerDeps) {
133
144
  const error = properties.error;
134
145
  const modelStr = typeof properties.model === "string" ? properties.model : undefined;
135
146
 
136
- await handleFallbackError(manager, sdk, config, sessionID, error, modelStr);
147
+ await handleFallbackError(
148
+ manager,
149
+ sdk,
150
+ config,
151
+ sessionID,
152
+ error,
153
+ modelStr,
154
+ onFallbackEvent,
155
+ );
137
156
  return;
138
157
  }
139
158
 
@@ -144,7 +163,15 @@ export function createEventHandler(deps: EventHandlerDeps) {
144
163
  if (!info?.sessionID || !info.error) return;
145
164
 
146
165
  const modelStr = typeof info.model === "string" ? info.model : undefined;
147
- await handleFallbackError(manager, sdk, config, info.sessionID, info.error, modelStr);
166
+ await handleFallbackError(
167
+ manager,
168
+ sdk,
169
+ config,
170
+ info.sessionID,
171
+ info.error,
172
+ modelStr,
173
+ onFallbackEvent,
174
+ );
148
175
  return;
149
176
  }
150
177
 
@@ -165,6 +192,7 @@ async function handleFallbackError(
165
192
  sessionID: string,
166
193
  error: unknown,
167
194
  modelStr?: string,
195
+ onFallbackEvent?: EventHandlerDeps["onFallbackEvent"],
168
196
  ): Promise<void> {
169
197
  // All guards (self-abort, stale, retryable, lock) are inside manager.handleError
170
198
  const plan = manager.handleError(sessionID, error, modelStr);
@@ -220,6 +248,22 @@ async function handleFallbackError(
220
248
  });
221
249
  }
222
250
 
251
+ onFallbackEvent?.({
252
+ type: "fallback",
253
+ sessionId: sessionID,
254
+ failedModel: plan.failedModel,
255
+ nextModel: plan.newModel,
256
+ reason: plan.reason,
257
+ success: true,
258
+ });
259
+ onFallbackEvent?.({
260
+ type: "model_switch",
261
+ sessionId: sessionID,
262
+ fromModel: plan.failedModel,
263
+ toModel: plan.newModel,
264
+ trigger: "fallback",
265
+ });
266
+
223
267
  // Dispatch replay with fallback model
224
268
  await sdk.promptAsync(sessionID, parsedModel, replayedParts);
225
269
  // Mark awaiting result inside dispatch block — only when prompt was sent
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { sanitizeTemplateContent } from "../../review/sanitize";
4
4
  import { fileExists } from "../../utils/fs-helpers";
5
+ import { getProjectRootFromArtifactDir } from "../../utils/paths";
5
6
  import { getMemoryTunedDepth } from "../arena";
6
7
  import { ensurePhaseDir, getArtifactRef, getPhaseDir } from "../artifacts";
7
8
  import { filterByPhase } from "../confidence";
@@ -67,7 +68,7 @@ export async function handleArchitect(
67
68
  // Step 1: Dispatch architect(s) based on confidence depth
68
69
  await ensurePhaseDir(artifactDir, "ARCHITECT");
69
70
  const reconEntries = filterByPhase(state.confidence, "RECON");
70
- const depth = getMemoryTunedDepth(reconEntries, join(artifactDir, ".."));
71
+ const depth = getMemoryTunedDepth(reconEntries, getProjectRootFromArtifactDir(artifactDir));
71
72
  const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
72
73
  const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
73
74
  const safeIdea = sanitizeTemplateContent(state.idea).replace(/[\r\n]+/g, " ");