@kodrunhq/opencode-autopilot 1.15.2 → 1.16.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/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/health/checks.ts +29 -4
  6. package/src/index.ts +103 -11
  7. package/src/inspect/formatters.ts +225 -0
  8. package/src/inspect/repository.ts +882 -0
  9. package/src/kernel/database.ts +45 -0
  10. package/src/kernel/migrations.ts +62 -0
  11. package/src/kernel/repository.ts +571 -0
  12. package/src/kernel/schema.ts +122 -0
  13. package/src/kernel/types.ts +66 -0
  14. package/src/memory/capture.ts +221 -25
  15. package/src/memory/database.ts +74 -12
  16. package/src/memory/index.ts +17 -1
  17. package/src/memory/project-key.ts +6 -0
  18. package/src/memory/repository.ts +833 -42
  19. package/src/memory/retrieval.ts +83 -169
  20. package/src/memory/schemas.ts +39 -7
  21. package/src/memory/types.ts +4 -0
  22. package/src/observability/event-handlers.ts +28 -17
  23. package/src/observability/event-store.ts +29 -1
  24. package/src/observability/forensic-log.ts +159 -0
  25. package/src/observability/forensic-schemas.ts +69 -0
  26. package/src/observability/forensic-types.ts +10 -0
  27. package/src/observability/index.ts +21 -27
  28. package/src/observability/log-reader.ts +142 -111
  29. package/src/observability/log-writer.ts +41 -83
  30. package/src/observability/retention.ts +2 -2
  31. package/src/observability/session-logger.ts +36 -57
  32. package/src/observability/summary-generator.ts +31 -19
  33. package/src/observability/types.ts +12 -24
  34. package/src/orchestrator/contracts/invariants.ts +14 -0
  35. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  36. package/src/orchestrator/fallback/event-handler.ts +47 -3
  37. package/src/orchestrator/handlers/architect.ts +2 -1
  38. package/src/orchestrator/handlers/build.ts +55 -97
  39. package/src/orchestrator/handlers/retrospective.ts +2 -1
  40. package/src/orchestrator/handlers/types.ts +0 -1
  41. package/src/orchestrator/lesson-memory.ts +29 -9
  42. package/src/orchestrator/orchestration-logger.ts +37 -23
  43. package/src/orchestrator/phase.ts +8 -4
  44. package/src/orchestrator/state.ts +79 -17
  45. package/src/projects/database.ts +47 -0
  46. package/src/projects/repository.ts +264 -0
  47. package/src/projects/resolve.ts +301 -0
  48. package/src/projects/schemas.ts +30 -0
  49. package/src/projects/types.ts +12 -0
  50. package/src/review/memory.ts +29 -9
  51. package/src/tools/doctor.ts +26 -2
  52. package/src/tools/forensics.ts +7 -12
  53. package/src/tools/logs.ts +6 -5
  54. package/src/tools/memory-preferences.ts +157 -0
  55. package/src/tools/memory-status.ts +17 -96
  56. package/src/tools/orchestrate.ts +97 -81
  57. package/src/tools/pipeline-report.ts +3 -2
  58. package/src/tools/quick.ts +2 -2
  59. package/src/tools/review.ts +39 -6
  60. package/src/tools/session-stats.ts +3 -2
  61. package/src/utils/paths.ts +20 -1
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { tool } from "@opencode-ai/plugin";
4
- import { parseResultEnvelope } from "../orchestrator/contracts/legacy-result-adapter";
5
- import type { ResultEnvelope } from "../orchestrator/contracts/result-envelope";
4
+ import { parseTypedResultEnvelope } from "../orchestrator/contracts/legacy-result-adapter";
5
+ import type { PendingDispatch, ResultEnvelope } from "../orchestrator/contracts/result-envelope";
6
6
  import { PHASE_HANDLERS } from "../orchestrator/handlers/index";
7
7
  import type { DispatchResult, PhaseHandlerContext } from "../orchestrator/handlers/types";
8
8
  import { buildLessonContext } from "../orchestrator/lesson-injection";
@@ -10,11 +10,22 @@ import { loadLessonMemory } from "../orchestrator/lesson-memory";
10
10
  import { logOrchestrationEvent } from "../orchestrator/orchestration-logger";
11
11
  import { completePhase, getNextPhase, PHASE_INDEX, TOTAL_PHASES } from "../orchestrator/phase";
12
12
  import { loadAdaptiveSkillContext } from "../orchestrator/skill-injection";
13
- import { createInitialState, loadState, patchState, saveState } from "../orchestrator/state";
14
- import type { PendingDispatch, Phase, PipelineState } from "../orchestrator/types";
13
+ import {
14
+ createInitialState,
15
+ isStateConflictError,
16
+ loadState,
17
+ patchState,
18
+ saveState,
19
+ updatePersistedState,
20
+ } from "../orchestrator/state";
21
+ import type { Phase, PipelineState } from "../orchestrator/types";
15
22
  import { isEnoentError } from "../utils/fs-helpers";
16
23
  import { ensureGitignore } from "../utils/gitignore";
17
- import { getGlobalConfigDir, getProjectArtifactDir } from "../utils/paths";
24
+ import {
25
+ getGlobalConfigDir,
26
+ getProjectArtifactDir,
27
+ getProjectRootFromArtifactDir,
28
+ } from "../utils/paths";
18
29
  import { reviewCore } from "./review";
19
30
 
20
31
  interface OrchestrateArgs {
@@ -28,6 +39,8 @@ const ORCHESTRATE_ERROR_CODES = Object.freeze({
28
39
  PHASE_MISMATCH: "E_PHASE_MISMATCH",
29
40
  UNKNOWN_DISPATCH: "E_UNKNOWN_DISPATCH",
30
41
  DUPLICATE_RESULT: "E_DUPLICATE_RESULT",
42
+ PENDING_RESULT_REQUIRED: "E_PENDING_RESULT_REQUIRED",
43
+ RESULT_KIND_MISMATCH: "E_RESULT_KIND_MISMATCH",
31
44
  });
32
45
 
33
46
  function createDispatchId(): string {
@@ -56,6 +69,10 @@ function removePendingDispatch(state: Readonly<PipelineState>, dispatchId: strin
56
69
  });
57
70
  }
58
71
 
72
+ function expectedResultKindForPending(pending: Readonly<PendingDispatch>): string {
73
+ return pending.resultKind;
74
+ }
75
+
59
76
  function markResultProcessed(
60
77
  state: Readonly<PipelineState>,
61
78
  envelope: ResultEnvelope,
@@ -172,6 +189,11 @@ function applyResultEnvelope(
172
189
  `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: result phase ${envelope.phase} != pending ${pending.phase}`,
173
190
  );
174
191
  }
192
+ if (expectedResultKindForPending(pending) !== envelope.kind) {
193
+ throw new Error(
194
+ `${ORCHESTRATE_ERROR_CODES.RESULT_KIND_MISMATCH}: result kind ${envelope.kind} != pending ${pending.resultKind}`,
195
+ );
196
+ }
175
197
  if (pending.taskId !== null && envelope.taskId !== pending.taskId) {
176
198
  throw new Error(
177
199
  `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: taskId ${String(envelope.taskId)} != pending ${pending.taskId}`,
@@ -207,9 +229,7 @@ async function applyStateUpdates(
207
229
  ): Promise<PipelineState> {
208
230
  const updates = handlerResult._stateUpdates;
209
231
  if (updates) {
210
- const updated = patchState(state, updates);
211
- await saveState(updated, artifactDir);
212
- return updated;
232
+ return updatePersistedState(artifactDir, state, (current) => patchState(current, updates));
213
233
  }
214
234
  return state;
215
235
  }
@@ -228,7 +248,7 @@ async function maybeInlineReview(
228
248
  handlerResult.agent === "oc-reviewer" &&
229
249
  handlerResult.prompt
230
250
  ) {
231
- const projectRoot = join(artifactDir, "..");
251
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
232
252
  const reviewResult = await reviewCore({ scope: "branch" }, projectRoot);
233
253
  return { inlined: true, reviewResult };
234
254
  }
@@ -245,7 +265,7 @@ async function injectLessonContext(
245
265
  artifactDir: string,
246
266
  ): Promise<string> {
247
267
  try {
248
- const projectRoot = join(artifactDir, "..");
268
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
249
269
  const memory = await loadLessonMemory(projectRoot);
250
270
  if (memory && memory.lessons.length > 0) {
251
271
  const ctx = buildLessonContext(memory.lessons, phase as Phase);
@@ -332,26 +352,33 @@ async function checkCircuitBreaker(
332
352
  }> {
333
353
  const counts = { ...(currentState.phaseDispatchCounts ?? {}) };
334
354
  counts[phase] = (counts[phase] ?? 0) + 1;
335
- const newCount = counts[phase];
355
+ const candidateCount = counts[phase];
336
356
  const limit = MAX_PHASE_DISPATCHES[phase] ?? 5;
337
- if (newCount > limit) {
338
- const msg = `Phase ${phase} exceeded max dispatches (${newCount}/${limit}) — possible infinite loop detected. Aborting.`;
357
+ if (candidateCount > limit) {
358
+ const msg = `Phase ${phase} exceeded max dispatches (${candidateCount}/${limit}) — possible infinite loop detected. Aborting.`;
339
359
  logOrchestrationEvent(artifactDir, {
340
360
  timestamp: new Date().toISOString(),
341
361
  phase,
342
362
  action: "loop_detected",
343
- attempt: newCount,
363
+ attempt: candidateCount,
344
364
  message: msg,
345
365
  });
346
366
  return {
347
367
  abortMsg: JSON.stringify({ action: "error", message: msg }),
348
- newCount,
368
+ newCount: candidateCount,
349
369
  nextState: currentState,
350
370
  };
351
371
  }
352
- const withCounts = patchState(currentState, { phaseDispatchCounts: counts });
353
- await saveState(withCounts, artifactDir);
354
- return { abortMsg: null, newCount, nextState: withCounts };
372
+ const withCounts = await updatePersistedState(artifactDir, currentState, (current) => {
373
+ const nextCounts = { ...(current.phaseDispatchCounts ?? {}) };
374
+ nextCounts[phase] = (nextCounts[phase] ?? 0) + 1;
375
+ return patchState(current, { phaseDispatchCounts: nextCounts });
376
+ });
377
+ return {
378
+ abortMsg: null,
379
+ newCount: withCounts.phaseDispatchCounts[phase] ?? candidateCount,
380
+ nextState: withCounts,
381
+ };
355
382
  }
356
383
 
357
384
  /**
@@ -400,8 +427,6 @@ async function processHandlerResult(
400
427
  resultKind: normalizedResult.expectedResultKind ?? "phase_output",
401
428
  taskId: normalizedResult.taskId ?? null,
402
429
  };
403
- const baseRevision = currentState.stateRevision;
404
- const withPendingState = withPendingDispatch(currentState, pendingEntry);
405
430
 
406
431
  // Log the dispatch event before any inline-review or context injection
407
432
  const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
@@ -417,7 +442,7 @@ async function processHandlerResult(
417
442
  // Check if this is a review dispatch that should be inlined
418
443
  const { inlined, reviewResult } = await maybeInlineReview(normalizedResult, artifactDir);
419
444
  if (inlined && reviewResult) {
420
- if (withPendingState.currentPhase) {
445
+ if (currentState.currentPhase) {
421
446
  let reviewPayloadText = reviewResult;
422
447
  try {
423
448
  const parsedReview = JSON.parse(reviewResult) as {
@@ -433,8 +458,8 @@ async function processHandlerResult(
433
458
  const inlinedEnvelope: ResultEnvelope = {
434
459
  schemaVersion: 1,
435
460
  resultId: `inline-${createDispatchId()}`,
436
- runId: withPendingState.runId,
437
- phase: withPendingState.currentPhase,
461
+ runId: currentState.runId,
462
+ phase: currentState.currentPhase,
438
463
  dispatchId: pendingEntry.dispatchId,
439
464
  agent: normalizedResult.agent ?? null,
440
465
  kind: "review_findings",
@@ -443,13 +468,16 @@ async function processHandlerResult(
443
468
  text: reviewPayloadText,
444
469
  },
445
470
  };
446
- const withInlineResult = applyResultEnvelope(withPendingState, inlinedEnvelope);
447
- await saveState(withInlineResult, artifactDir, baseRevision);
471
+ const withInlineResult = await updatePersistedState(
472
+ artifactDir,
473
+ currentState,
474
+ (current) =>
475
+ applyResultEnvelope(withPendingDispatch(current, pendingEntry), inlinedEnvelope),
476
+ );
448
477
 
449
- const handler = PHASE_HANDLERS[withPendingState.currentPhase];
478
+ const handler = PHASE_HANDLERS[currentState.currentPhase];
450
479
  const nextResult = await handler(withInlineResult, artifactDir, reviewPayloadText, {
451
480
  envelope: inlinedEnvelope,
452
- legacy: false,
453
481
  });
454
482
  return processHandlerResult(nextResult, withInlineResult, artifactDir);
455
483
  }
@@ -461,8 +489,9 @@ async function processHandlerResult(
461
489
  });
462
490
  }
463
491
 
464
- await saveState(withPendingState, artifactDir, baseRevision);
465
- currentState = withPendingState;
492
+ currentState = await updatePersistedState(artifactDir, currentState, (current) =>
493
+ withPendingDispatch(current, pendingEntry),
494
+ );
466
495
 
467
496
  // Inject lesson + skill context into dispatch prompt (best-effort)
468
497
  if (normalizedResult.prompt && normalizedResult.phase) {
@@ -473,7 +502,7 @@ async function processHandlerResult(
473
502
  );
474
503
  const withSkills = await injectSkillContext(
475
504
  enrichedPrompt,
476
- join(artifactDir, ".."),
505
+ getProjectRootFromArtifactDir(artifactDir),
477
506
  normalizedResult.phase,
478
507
  );
479
508
  if (withSkills !== normalizedResult.prompt) {
@@ -514,11 +543,6 @@ async function processHandlerResult(
514
543
  resultKind: entry.resultKind ?? inferExpectedResultKindForAgent(entry.agent),
515
544
  taskId: entry.taskId ?? null,
516
545
  })) ?? [];
517
- const baseRevision = currentState.stateRevision;
518
- let withPendingState = currentState;
519
- for (const entry of pendingEntries) {
520
- withPendingState = withPendingDispatch(withPendingState, entry);
521
- }
522
546
 
523
547
  const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
524
548
  logOrchestrationEvent(artifactDir, {
@@ -528,8 +552,13 @@ async function processHandlerResult(
528
552
  agent: `${normalizedResult.agents?.length ?? 0} agents`,
529
553
  attempt,
530
554
  });
531
- await saveState(withPendingState, artifactDir, baseRevision);
532
- currentState = withPendingState;
555
+ currentState = await updatePersistedState(artifactDir, currentState, (current) => {
556
+ let nextState = current;
557
+ for (const entry of pendingEntries) {
558
+ nextState = withPendingDispatch(nextState, entry);
559
+ }
560
+ return nextState;
561
+ });
533
562
 
534
563
  // Inject lesson + skill context into each agent's prompt (best-effort)
535
564
  // Load lesson and skill context once and reuse for all agents in the batch
@@ -537,7 +566,7 @@ async function processHandlerResult(
537
566
  const lessonSuffix = await injectLessonContext("", normalizedResult.phase, artifactDir);
538
567
  const skillSuffix = await injectSkillContext(
539
568
  "",
540
- join(artifactDir, ".."),
569
+ getProjectRootFromArtifactDir(artifactDir),
541
570
  normalizedResult.phase,
542
571
  );
543
572
  const combinedSuffix = lessonSuffix + (skillSuffix || "");
@@ -575,12 +604,11 @@ async function processHandlerResult(
575
604
  action: "complete",
576
605
  });
577
606
  const nextPhase = getNextPhase(currentState.currentPhase);
578
- const advanced = completePhase(currentState);
607
+ const advanced = await updatePersistedState(artifactDir, currentState, (current) =>
608
+ completePhase(current),
609
+ );
579
610
 
580
611
  if (nextPhase === null) {
581
- // Terminal phase completed
582
- const finished = { ...advanced, status: "COMPLETED" as const };
583
- await saveState(finished, artifactDir);
584
612
  const idx = PHASE_INDEX[currentState.currentPhase] ?? TOTAL_PHASES;
585
613
  return JSON.stringify({
586
614
  action: "complete",
@@ -589,8 +617,6 @@ async function processHandlerResult(
589
617
  });
590
618
  }
591
619
 
592
- await saveState(advanced, artifactDir);
593
-
594
620
  // Invoke the next phase handler immediately
595
621
  const nextHandler = PHASE_HANDLERS[nextPhase];
596
622
  const nextResult = await nextHandler(advanced, artifactDir, undefined, undefined);
@@ -624,7 +650,7 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
624
650
 
625
651
  // Best-effort .gitignore update
626
652
  try {
627
- const projectRoot = join(artifactDir, "..");
653
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
628
654
  await ensureGitignore(projectRoot);
629
655
  } catch {
630
656
  // Swallow gitignore errors -- non-critical
@@ -647,6 +673,18 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
647
673
  });
648
674
  }
649
675
 
676
+ if (args.result === undefined && state.pendingDispatches.length > 0) {
677
+ const pending = state.pendingDispatches.at(-1);
678
+ const msg = `Pending result required for dispatch ${pending?.dispatchId ?? "unknown"} (${pending?.agent ?? "unknown"} / ${pending?.phase ?? state.currentPhase}). Submit a typed result envelope before calling oc_orchestrate again.`;
679
+ logDeterministicError(
680
+ artifactDir,
681
+ pending?.phase ?? state.currentPhase,
682
+ ORCHESTRATE_ERROR_CODES.PENDING_RESULT_REQUIRED,
683
+ msg,
684
+ );
685
+ return asErrorJson(ORCHESTRATE_ERROR_CODES.PENDING_RESULT_REQUIRED, msg);
686
+ }
687
+
650
688
  if (typeof args.result === "string") {
651
689
  const phaseHint = detectPhaseFromPending(state);
652
690
  if (phaseHint === null) {
@@ -661,7 +699,7 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
661
699
  }
662
700
 
663
701
  try {
664
- const parsed = parseResultEnvelope(args.result, {
702
+ const parsed = parseTypedResultEnvelope(args.result, {
665
703
  runId: state.runId,
666
704
  phase: phaseHint,
667
705
  fallbackDispatchId: detectDispatchFromPending(state),
@@ -679,39 +717,13 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
679
717
  return asErrorJson(ORCHESTRATE_ERROR_CODES.STALE_RESULT, msg);
680
718
  }
681
719
 
682
- if (parsed.legacy && state.pendingDispatches.length > 1) {
683
- const msg =
684
- "Legacy result payload cannot be attributed with multiple pending dispatches. Provide typed envelope with dispatchId/taskId.";
685
- logDeterministicError(
686
- artifactDir,
687
- state.currentPhase ?? phaseHint,
688
- ORCHESTRATE_ERROR_CODES.INVALID_RESULT,
689
- msg,
690
- );
691
- return asErrorJson(ORCHESTRATE_ERROR_CODES.INVALID_RESULT, msg);
692
- }
693
-
694
- const allowMissingPending = parsed.legacy && state.pendingDispatches.length === 0;
695
- const nextState = applyResultEnvelope(state, parsed.envelope, {
696
- allowMissingPending,
697
- });
698
- await saveState(nextState, artifactDir);
720
+ const nextState = await updatePersistedState(artifactDir, state, (current) =>
721
+ applyResultEnvelope(current, parsed.envelope),
722
+ );
699
723
  state = nextState;
700
- if (!allowMissingPending && parsed.legacy) {
701
- const legacyMsg =
702
- "Legacy result parser path used. Submit typed envelopes for deterministic replay guarantees.";
703
- logOrchestrationEvent(artifactDir, {
704
- timestamp: new Date().toISOString(),
705
- phase: state.currentPhase ?? phaseHint,
706
- action: "error",
707
- message: legacyMsg,
708
- });
709
- console.warn(`[opencode-autopilot] ${legacyMsg}`);
710
- }
711
724
 
712
725
  phaseHandlerContext = {
713
726
  envelope: parsed.envelope,
714
- legacy: parsed.legacy,
715
727
  };
716
728
  handlerInputResult = parsed.envelope.payload.text;
717
729
  } catch (error: unknown) {
@@ -761,13 +773,17 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
761
773
  timestamp: new Date().toISOString(),
762
774
  lastSuccessfulPhase: lastDone?.name ?? null,
763
775
  };
764
- const failed = patchState(currentState, {
765
- status: "FAILED" as const,
766
- failureContext,
767
- });
768
- await saveState(failed, artifactDir);
776
+ await updatePersistedState(artifactDir, currentState, (latest) =>
777
+ patchState(latest, {
778
+ status: "FAILED" as const,
779
+ failureContext,
780
+ }),
781
+ );
782
+ }
783
+ } catch (persistError: unknown) {
784
+ if (isStateConflictError(persistError)) {
785
+ // Swallow conflict after retry exhaustion -- original error takes priority
769
786
  }
770
- } catch {
771
787
  // Swallow save errors -- original error takes priority
772
788
  }
773
789
 
@@ -103,9 +103,10 @@ function buildDisplayText(
103
103
  * @param logsDir - Optional override for logs directory (for testing)
104
104
  */
105
105
  export async function pipelineReportCore(sessionID?: string, logsDir?: string): Promise<string> {
106
+ const logsRoot = logsDir ?? process.cwd();
106
107
  const log = sessionID
107
- ? await readSessionLog(sessionID, logsDir)
108
- : await readLatestSessionLog(logsDir);
108
+ ? await readSessionLog(sessionID, logsRoot)
109
+ : await readLatestSessionLog(logsRoot);
109
110
 
110
111
  if (!log) {
111
112
  const target = sessionID ? `Session "${sessionID}" not found.` : "No session logs found.";
@@ -5,7 +5,7 @@ import { ensurePhaseDir } from "../orchestrator/artifacts";
5
5
  import { PHASES, pipelineStateSchema } from "../orchestrator/schemas";
6
6
  import { loadState, saveState } from "../orchestrator/state";
7
7
  import { ensureGitignore } from "../utils/gitignore";
8
- import { getProjectArtifactDir } from "../utils/paths";
8
+ import { getProjectArtifactDir, getProjectRootFromArtifactDir } from "../utils/paths";
9
9
  import { orchestrateCore } from "./orchestrate";
10
10
 
11
11
  /** Phases skipped in quick mode (per D-17: skip RECON, CHALLENGE, ARCHITECT, EXPLORE). */
@@ -109,7 +109,7 @@ export async function quickCore(args: QuickArgs, artifactDir: string): Promise<s
109
109
 
110
110
  // 6. Best-effort .gitignore update (same pattern as orchestrateCore)
111
111
  try {
112
- await ensureGitignore(join(artifactDir, ".."));
112
+ await ensureGitignore(getProjectRootFromArtifactDir(artifactDir));
113
113
  } catch {
114
114
  // Non-critical -- swallow gitignore errors
115
115
  }
@@ -17,6 +17,11 @@ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { promisify } from "node:util";
19
19
  import { tool } from "@opencode-ai/plugin";
20
+ import {
21
+ clearActiveReviewStateInKernel,
22
+ loadActiveReviewStateFromKernel,
23
+ saveActiveReviewStateToKernel,
24
+ } from "../kernel/repository";
20
25
  import { REVIEW_AGENTS, SPECIALIZED_AGENTS } from "../review/agents/index";
21
26
  import {
22
27
  createEmptyMemory,
@@ -42,6 +47,7 @@ interface ReviewArgs {
42
47
  const execFileAsync = promisify(execFile);
43
48
 
44
49
  const STATE_FILE = "current-review.json";
50
+ let legacyReviewStateMirrorWarned = false;
45
51
 
46
52
  /**
47
53
  * Get changed file paths for the given review scope.
@@ -83,11 +89,18 @@ async function getChangedFiles(
83
89
  * Load review state from disk. Returns null if no active review.
84
90
  */
85
91
  async function loadReviewState(artifactDir: string): Promise<ReviewState | null> {
92
+ const kernelState = loadActiveReviewStateFromKernel(artifactDir);
93
+ if (kernelState !== null) {
94
+ return kernelState;
95
+ }
96
+
86
97
  const statePath = join(artifactDir, STATE_FILE);
87
98
  try {
88
99
  const raw = await readFile(statePath, "utf-8");
89
100
  const parsed = JSON.parse(raw);
90
- return reviewStateSchema.parse(parsed) as ReviewState;
101
+ const validated = reviewStateSchema.parse(parsed) as ReviewState;
102
+ saveActiveReviewStateToKernel(artifactDir, validated);
103
+ return validated;
91
104
  } catch (error: unknown) {
92
105
  if (isEnoentError(error)) return null;
93
106
  // Treat parse/schema errors as recoverable — delete corrupt file
@@ -103,23 +116,43 @@ async function loadReviewState(artifactDir: string): Promise<ReviewState | null>
103
116
  }
104
117
  }
105
118
 
119
+ async function writeLegacyReviewStateMirror(
120
+ state: ReviewState,
121
+ artifactDir: string,
122
+ ): Promise<void> {
123
+ await ensureDir(artifactDir);
124
+ const statePath = join(artifactDir, STATE_FILE);
125
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
126
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
127
+ await rename(tmpPath, statePath);
128
+ }
129
+
130
+ async function syncLegacyReviewStateMirror(state: ReviewState, artifactDir: string): Promise<void> {
131
+ try {
132
+ await writeLegacyReviewStateMirror(state, artifactDir);
133
+ } catch (error: unknown) {
134
+ if (!legacyReviewStateMirrorWarned) {
135
+ legacyReviewStateMirrorWarned = true;
136
+ console.warn("[opencode-autopilot] current-review.json mirror write failed:", error);
137
+ }
138
+ }
139
+ }
140
+
106
141
  /**
107
142
  * Save review state atomically.
108
143
  */
109
144
  async function saveReviewState(state: ReviewState, artifactDir: string): Promise<void> {
110
- await ensureDir(artifactDir);
111
145
  // Validate before writing (bidirectional validation, same as orchestrator state)
112
146
  const validated = reviewStateSchema.parse(state);
113
- const statePath = join(artifactDir, STATE_FILE);
114
- const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
115
- await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
116
- await rename(tmpPath, statePath);
147
+ saveActiveReviewStateToKernel(artifactDir, validated);
148
+ await syncLegacyReviewStateMirror(validated, artifactDir);
117
149
  }
118
150
 
119
151
  /**
120
152
  * Delete review state file (pipeline complete or error cleanup).
121
153
  */
122
154
  async function clearReviewState(artifactDir: string): Promise<void> {
155
+ clearActiveReviewStateInKernel(artifactDir);
123
156
  const statePath = join(artifactDir, STATE_FILE);
124
157
  try {
125
158
  await unlink(statePath);
@@ -138,9 +138,10 @@ function buildDisplayText(
138
138
  * @param logsDir - Optional override for logs directory (for testing)
139
139
  */
140
140
  export async function sessionStatsCore(sessionID?: string, logsDir?: string): Promise<string> {
141
+ const logsRoot = logsDir ?? process.cwd();
141
142
  const log = sessionID
142
- ? await readSessionLog(sessionID, logsDir)
143
- : await readLatestSessionLog(logsDir);
143
+ ? await readSessionLog(sessionID, logsRoot)
144
+ : await readLatestSessionLog(logsRoot);
144
145
 
145
146
  if (!log) {
146
147
  const target = sessionID ? `Session "${sessionID}" not found.` : "No session logs found.";
@@ -1,13 +1,24 @@
1
1
  import { homedir } from "node:os";
2
- import { dirname, join } from "node:path";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  const __dirname = import.meta.dir ?? dirname(fileURLToPath(import.meta.url));
6
+ const AUTOPILOT_DB_FILE = "autopilot.db";
7
+ const LEGACY_MEMORY_DIR = "memory";
8
+ const LEGACY_MEMORY_DB_FILE = "memory.db";
6
9
 
7
10
  export function getGlobalConfigDir(): string {
8
11
  return join(homedir(), ".config", "opencode");
9
12
  }
10
13
 
14
+ export function getAutopilotDbPath(baseDir?: string): string {
15
+ return join(baseDir ?? getGlobalConfigDir(), AUTOPILOT_DB_FILE);
16
+ }
17
+
18
+ export function getLegacyMemoryDbPath(baseDir?: string): string {
19
+ return join(baseDir ?? getGlobalConfigDir(), LEGACY_MEMORY_DIR, LEGACY_MEMORY_DB_FILE);
20
+ }
21
+
11
22
  export function getAssetsDir(): string {
12
23
  return join(__dirname, "..", "..", "assets");
13
24
  }
@@ -15,3 +26,11 @@ export function getAssetsDir(): string {
15
26
  export function getProjectArtifactDir(projectRoot: string): string {
16
27
  return join(projectRoot, ".opencode-autopilot");
17
28
  }
29
+
30
+ export function isProjectArtifactDir(path: string): boolean {
31
+ return basename(path) === ".opencode-autopilot";
32
+ }
33
+
34
+ export function getProjectRootFromArtifactDir(artifactDir: string): string {
35
+ return isProjectArtifactDir(artifactDir) ? dirname(artifactDir) : artifactDir;
36
+ }