@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,20 +1,33 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { join } from "node:path";
3
2
  import { tool } from "@opencode-ai/plugin";
4
- import { parseResultEnvelope } from "../orchestrator/contracts/legacy-result-adapter";
5
- import type { ResultEnvelope } from "../orchestrator/contracts/result-envelope";
3
+ import { getLogger } from "../logging/domains";
4
+ import { parseTypedResultEnvelope } from "../orchestrator/contracts/legacy-result-adapter";
5
+ import type { PendingDispatch, ResultEnvelope } from "../orchestrator/contracts/result-envelope";
6
+ import { enrichErrorMessage } from "../orchestrator/error-context";
6
7
  import { PHASE_HANDLERS } from "../orchestrator/handlers/index";
7
8
  import type { DispatchResult, PhaseHandlerContext } from "../orchestrator/handlers/types";
8
9
  import { buildLessonContext } from "../orchestrator/lesson-injection";
9
10
  import { loadLessonMemory } from "../orchestrator/lesson-memory";
10
11
  import { logOrchestrationEvent } from "../orchestrator/orchestration-logger";
11
12
  import { completePhase, getNextPhase, PHASE_INDEX, TOTAL_PHASES } from "../orchestrator/phase";
13
+ import { getPhaseProgressString } from "../orchestrator/progress";
12
14
  import { loadAdaptiveSkillContext } from "../orchestrator/skill-injection";
13
- import { createInitialState, loadState, patchState, saveState } from "../orchestrator/state";
14
- import type { PendingDispatch, Phase, PipelineState } from "../orchestrator/types";
15
+ import {
16
+ createInitialState,
17
+ isStateConflictError,
18
+ loadState,
19
+ patchState,
20
+ saveState,
21
+ updatePersistedState,
22
+ } from "../orchestrator/state";
23
+ import type { Phase, PipelineState } from "../orchestrator/types";
15
24
  import { isEnoentError } from "../utils/fs-helpers";
16
25
  import { ensureGitignore } from "../utils/gitignore";
17
- import { getGlobalConfigDir, getProjectArtifactDir } from "../utils/paths";
26
+ import {
27
+ getGlobalConfigDir,
28
+ getProjectArtifactDir,
29
+ getProjectRootFromArtifactDir,
30
+ } from "../utils/paths";
18
31
  import { reviewCore } from "./review";
19
32
 
20
33
  interface OrchestrateArgs {
@@ -28,6 +41,8 @@ const ORCHESTRATE_ERROR_CODES = Object.freeze({
28
41
  PHASE_MISMATCH: "E_PHASE_MISMATCH",
29
42
  UNKNOWN_DISPATCH: "E_UNKNOWN_DISPATCH",
30
43
  DUPLICATE_RESULT: "E_DUPLICATE_RESULT",
44
+ PENDING_RESULT_REQUIRED: "E_PENDING_RESULT_REQUIRED",
45
+ RESULT_KIND_MISMATCH: "E_RESULT_KIND_MISMATCH",
31
46
  });
32
47
 
33
48
  function createDispatchId(): string {
@@ -56,6 +71,10 @@ function removePendingDispatch(state: Readonly<PipelineState>, dispatchId: strin
56
71
  });
57
72
  }
58
73
 
74
+ function expectedResultKindForPending(pending: Readonly<PendingDispatch>): string {
75
+ return pending.resultKind;
76
+ }
77
+
59
78
  function markResultProcessed(
60
79
  state: Readonly<PipelineState>,
61
80
  envelope: ResultEnvelope,
@@ -172,6 +191,11 @@ function applyResultEnvelope(
172
191
  `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: result phase ${envelope.phase} != pending ${pending.phase}`,
173
192
  );
174
193
  }
194
+ if (expectedResultKindForPending(pending) !== envelope.kind) {
195
+ throw new Error(
196
+ `${ORCHESTRATE_ERROR_CODES.RESULT_KIND_MISMATCH}: result kind ${envelope.kind} != pending ${pending.resultKind}`,
197
+ );
198
+ }
175
199
  if (pending.taskId !== null && envelope.taskId !== pending.taskId) {
176
200
  throw new Error(
177
201
  `${ORCHESTRATE_ERROR_CODES.PHASE_MISMATCH}: taskId ${String(envelope.taskId)} != pending ${pending.taskId}`,
@@ -207,9 +231,7 @@ async function applyStateUpdates(
207
231
  ): Promise<PipelineState> {
208
232
  const updates = handlerResult._stateUpdates;
209
233
  if (updates) {
210
- const updated = patchState(state, updates);
211
- await saveState(updated, artifactDir);
212
- return updated;
234
+ return updatePersistedState(artifactDir, state, (current) => patchState(current, updates));
213
235
  }
214
236
  return state;
215
237
  }
@@ -228,7 +250,7 @@ async function maybeInlineReview(
228
250
  handlerResult.agent === "oc-reviewer" &&
229
251
  handlerResult.prompt
230
252
  ) {
231
- const projectRoot = join(artifactDir, "..");
253
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
232
254
  const reviewResult = await reviewCore({ scope: "branch" }, projectRoot);
233
255
  return { inlined: true, reviewResult };
234
256
  }
@@ -245,7 +267,7 @@ async function injectLessonContext(
245
267
  artifactDir: string,
246
268
  ): Promise<string> {
247
269
  try {
248
- const projectRoot = join(artifactDir, "..");
270
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
249
271
  const memory = await loadLessonMemory(projectRoot);
250
272
  if (memory && memory.lessons.length > 0) {
251
273
  const ctx = buildLessonContext(memory.lessons, phase as Phase);
@@ -291,17 +313,16 @@ async function injectSkillContext(
291
313
  });
292
314
  if (ctx) return prompt + ctx;
293
315
  } catch (err) {
294
- console.warn("[opencode-autopilot] skill injection failed:", err);
316
+ getLogger("tool", "orchestrate").warn("skill injection failed", { err });
295
317
  }
296
318
  return prompt;
297
319
  }
298
320
 
299
321
  /** Build a human-readable progress string for user-facing display. */
300
- function buildUserProgress(phase: string, label?: string, attempt?: number): string {
301
- const idx = PHASE_INDEX[phase as Phase] ?? 0;
302
- const desc = label ?? "dispatching";
322
+ function buildUserProgress(state: PipelineState, label?: string, attempt?: number): string {
323
+ const baseProgress = getPhaseProgressString(state);
303
324
  const att = attempt != null ? ` (attempt ${attempt})` : "";
304
- return `Phase ${idx}/${TOTAL_PHASES}: ${phase} — ${desc}${att}`;
325
+ return `${baseProgress}${label ? ` — ${label}` : ""}${att}`;
305
326
  }
306
327
 
307
328
  /** Per-phase dispatch limits. BUILD is high because of multi-task waves. */
@@ -332,26 +353,33 @@ async function checkCircuitBreaker(
332
353
  }> {
333
354
  const counts = { ...(currentState.phaseDispatchCounts ?? {}) };
334
355
  counts[phase] = (counts[phase] ?? 0) + 1;
335
- const newCount = counts[phase];
356
+ const candidateCount = counts[phase];
336
357
  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.`;
358
+ if (candidateCount > limit) {
359
+ const msg = `Phase ${phase} exceeded max dispatches (${candidateCount}/${limit}) — possible infinite loop detected. Aborting.`;
339
360
  logOrchestrationEvent(artifactDir, {
340
361
  timestamp: new Date().toISOString(),
341
362
  phase,
342
363
  action: "loop_detected",
343
- attempt: newCount,
364
+ attempt: candidateCount,
344
365
  message: msg,
345
366
  });
346
367
  return {
347
368
  abortMsg: JSON.stringify({ action: "error", message: msg }),
348
- newCount,
369
+ newCount: candidateCount,
349
370
  nextState: currentState,
350
371
  };
351
372
  }
352
- const withCounts = patchState(currentState, { phaseDispatchCounts: counts });
353
- await saveState(withCounts, artifactDir);
354
- return { abortMsg: null, newCount, nextState: withCounts };
373
+ const withCounts = await updatePersistedState(artifactDir, currentState, (current) => {
374
+ const nextCounts = { ...(current.phaseDispatchCounts ?? {}) };
375
+ nextCounts[phase] = (nextCounts[phase] ?? 0) + 1;
376
+ return patchState(current, { phaseDispatchCounts: nextCounts });
377
+ });
378
+ return {
379
+ abortMsg: null,
380
+ newCount: withCounts.phaseDispatchCounts[phase] ?? candidateCount,
381
+ nextState: withCounts,
382
+ };
355
383
  }
356
384
 
357
385
  /**
@@ -400,11 +428,9 @@ async function processHandlerResult(
400
428
  resultKind: normalizedResult.expectedResultKind ?? "phase_output",
401
429
  taskId: normalizedResult.taskId ?? null,
402
430
  };
403
- const baseRevision = currentState.stateRevision;
404
- const withPendingState = withPendingDispatch(currentState, pendingEntry);
405
431
 
406
432
  // Log the dispatch event before any inline-review or context injection
407
- const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
433
+ const progress = buildUserProgress(currentState, normalizedResult.progress, attempt);
408
434
  logOrchestrationEvent(artifactDir, {
409
435
  timestamp: new Date().toISOString(),
410
436
  phase,
@@ -417,7 +443,7 @@ async function processHandlerResult(
417
443
  // Check if this is a review dispatch that should be inlined
418
444
  const { inlined, reviewResult } = await maybeInlineReview(normalizedResult, artifactDir);
419
445
  if (inlined && reviewResult) {
420
- if (withPendingState.currentPhase) {
446
+ if (currentState.currentPhase) {
421
447
  let reviewPayloadText = reviewResult;
422
448
  try {
423
449
  const parsedReview = JSON.parse(reviewResult) as {
@@ -433,8 +459,8 @@ async function processHandlerResult(
433
459
  const inlinedEnvelope: ResultEnvelope = {
434
460
  schemaVersion: 1,
435
461
  resultId: `inline-${createDispatchId()}`,
436
- runId: withPendingState.runId,
437
- phase: withPendingState.currentPhase,
462
+ runId: currentState.runId,
463
+ phase: currentState.currentPhase,
438
464
  dispatchId: pendingEntry.dispatchId,
439
465
  agent: normalizedResult.agent ?? null,
440
466
  kind: "review_findings",
@@ -443,13 +469,16 @@ async function processHandlerResult(
443
469
  text: reviewPayloadText,
444
470
  },
445
471
  };
446
- const withInlineResult = applyResultEnvelope(withPendingState, inlinedEnvelope);
447
- await saveState(withInlineResult, artifactDir, baseRevision);
472
+ const withInlineResult = await updatePersistedState(
473
+ artifactDir,
474
+ currentState,
475
+ (current) =>
476
+ applyResultEnvelope(withPendingDispatch(current, pendingEntry), inlinedEnvelope),
477
+ );
448
478
 
449
- const handler = PHASE_HANDLERS[withPendingState.currentPhase];
479
+ const handler = PHASE_HANDLERS[currentState.currentPhase];
450
480
  const nextResult = await handler(withInlineResult, artifactDir, reviewPayloadText, {
451
481
  envelope: inlinedEnvelope,
452
- legacy: false,
453
482
  });
454
483
  return processHandlerResult(nextResult, withInlineResult, artifactDir);
455
484
  }
@@ -461,8 +490,9 @@ async function processHandlerResult(
461
490
  });
462
491
  }
463
492
 
464
- await saveState(withPendingState, artifactDir, baseRevision);
465
- currentState = withPendingState;
493
+ currentState = await updatePersistedState(artifactDir, currentState, (current) =>
494
+ withPendingDispatch(current, pendingEntry),
495
+ );
466
496
 
467
497
  // Inject lesson + skill context into dispatch prompt (best-effort)
468
498
  if (normalizedResult.prompt && normalizedResult.phase) {
@@ -473,7 +503,7 @@ async function processHandlerResult(
473
503
  );
474
504
  const withSkills = await injectSkillContext(
475
505
  enrichedPrompt,
476
- join(artifactDir, ".."),
506
+ getProjectRootFromArtifactDir(artifactDir),
477
507
  normalizedResult.phase,
478
508
  );
479
509
  if (withSkills !== normalizedResult.prompt) {
@@ -514,13 +544,8 @@ async function processHandlerResult(
514
544
  resultKind: entry.resultKind ?? inferExpectedResultKindForAgent(entry.agent),
515
545
  taskId: entry.taskId ?? null,
516
546
  })) ?? [];
517
- const baseRevision = currentState.stateRevision;
518
- let withPendingState = currentState;
519
- for (const entry of pendingEntries) {
520
- withPendingState = withPendingDispatch(withPendingState, entry);
521
- }
522
547
 
523
- const progress = buildUserProgress(phase, normalizedResult.progress, attempt);
548
+ const progress = buildUserProgress(currentState, normalizedResult.progress, attempt);
524
549
  logOrchestrationEvent(artifactDir, {
525
550
  timestamp: new Date().toISOString(),
526
551
  phase,
@@ -528,8 +553,13 @@ async function processHandlerResult(
528
553
  agent: `${normalizedResult.agents?.length ?? 0} agents`,
529
554
  attempt,
530
555
  });
531
- await saveState(withPendingState, artifactDir, baseRevision);
532
- currentState = withPendingState;
556
+ currentState = await updatePersistedState(artifactDir, currentState, (current) => {
557
+ let nextState = current;
558
+ for (const entry of pendingEntries) {
559
+ nextState = withPendingDispatch(nextState, entry);
560
+ }
561
+ return nextState;
562
+ });
533
563
 
534
564
  // Inject lesson + skill context into each agent's prompt (best-effort)
535
565
  // Load lesson and skill context once and reuse for all agents in the batch
@@ -537,7 +567,7 @@ async function processHandlerResult(
537
567
  const lessonSuffix = await injectLessonContext("", normalizedResult.phase, artifactDir);
538
568
  const skillSuffix = await injectSkillContext(
539
569
  "",
540
- join(artifactDir, ".."),
570
+ getProjectRootFromArtifactDir(artifactDir),
541
571
  normalizedResult.phase,
542
572
  );
543
573
  const combinedSuffix = lessonSuffix + (skillSuffix || "");
@@ -575,12 +605,11 @@ async function processHandlerResult(
575
605
  action: "complete",
576
606
  });
577
607
  const nextPhase = getNextPhase(currentState.currentPhase);
578
- const advanced = completePhase(currentState);
608
+ const advanced = await updatePersistedState(artifactDir, currentState, (current) =>
609
+ completePhase(current),
610
+ );
579
611
 
580
612
  if (nextPhase === null) {
581
- // Terminal phase completed
582
- const finished = { ...advanced, status: "COMPLETED" as const };
583
- await saveState(finished, artifactDir);
584
613
  const idx = PHASE_INDEX[currentState.currentPhase] ?? TOTAL_PHASES;
585
614
  return JSON.stringify({
586
615
  action: "complete",
@@ -589,8 +618,6 @@ async function processHandlerResult(
589
618
  });
590
619
  }
591
620
 
592
- await saveState(advanced, artifactDir);
593
-
594
621
  // Invoke the next phase handler immediately
595
622
  const nextHandler = PHASE_HANDLERS[nextPhase];
596
623
  const nextResult = await nextHandler(advanced, artifactDir, undefined, undefined);
@@ -624,7 +651,7 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
624
651
 
625
652
  // Best-effort .gitignore update
626
653
  try {
627
- const projectRoot = join(artifactDir, "..");
654
+ const projectRoot = getProjectRootFromArtifactDir(artifactDir);
628
655
  await ensureGitignore(projectRoot);
629
656
  } catch {
630
657
  // Swallow gitignore errors -- non-critical
@@ -647,6 +674,18 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
647
674
  });
648
675
  }
649
676
 
677
+ if (args.result === undefined && state.pendingDispatches.length > 0) {
678
+ const pending = state.pendingDispatches.at(-1);
679
+ 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.`;
680
+ logDeterministicError(
681
+ artifactDir,
682
+ pending?.phase ?? state.currentPhase,
683
+ ORCHESTRATE_ERROR_CODES.PENDING_RESULT_REQUIRED,
684
+ msg,
685
+ );
686
+ return asErrorJson(ORCHESTRATE_ERROR_CODES.PENDING_RESULT_REQUIRED, msg);
687
+ }
688
+
650
689
  if (typeof args.result === "string") {
651
690
  const phaseHint = detectPhaseFromPending(state);
652
691
  if (phaseHint === null) {
@@ -661,7 +700,7 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
661
700
  }
662
701
 
663
702
  try {
664
- const parsed = parseResultEnvelope(args.result, {
703
+ const parsed = parseTypedResultEnvelope(args.result, {
665
704
  runId: state.runId,
666
705
  phase: phaseHint,
667
706
  fallbackDispatchId: detectDispatchFromPending(state),
@@ -679,39 +718,13 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
679
718
  return asErrorJson(ORCHESTRATE_ERROR_CODES.STALE_RESULT, msg);
680
719
  }
681
720
 
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);
721
+ const nextState = await updatePersistedState(artifactDir, state, (current) =>
722
+ applyResultEnvelope(current, parsed.envelope),
723
+ );
699
724
  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
725
 
712
726
  phaseHandlerContext = {
713
727
  envelope: parsed.envelope,
714
- legacy: parsed.legacy,
715
728
  };
716
729
  handlerInputResult = parsed.envelope.payload.text;
717
730
  } catch (error: unknown) {
@@ -747,12 +760,13 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
747
760
  } catch (error: unknown) {
748
761
  const message = error instanceof Error ? error.message : String(error);
749
762
  const parsedErr = parseErrorCode(error);
750
- const safeMessage = message.replace(/[/\\][^\s"']+/g, "[PATH]").slice(0, 4096);
763
+ let safeMessage = message.replace(/[/\\][^\s"']+/g, "[PATH]").slice(0, 4096);
751
764
 
752
765
  // Persist failure metadata for forensics (best-effort)
753
766
  try {
754
767
  const currentState = await loadState(artifactDir);
755
768
  if (currentState?.currentPhase) {
769
+ safeMessage = enrichErrorMessage(safeMessage, currentState);
756
770
  const lastDone = currentState.phases.filter((p) => p.status === "DONE").pop();
757
771
  const failureContext = {
758
772
  failedPhase: currentState.currentPhase,
@@ -761,13 +775,17 @@ export async function orchestrateCore(args: OrchestrateArgs, artifactDir: string
761
775
  timestamp: new Date().toISOString(),
762
776
  lastSuccessfulPhase: lastDone?.name ?? null,
763
777
  };
764
- const failed = patchState(currentState, {
765
- status: "FAILED" as const,
766
- failureContext,
767
- });
768
- await saveState(failed, artifactDir);
778
+ await updatePersistedState(artifactDir, currentState, (latest) =>
779
+ patchState(latest, {
780
+ status: "FAILED" as const,
781
+ failureContext,
782
+ }),
783
+ );
784
+ }
785
+ } catch (persistError: unknown) {
786
+ if (isStateConflictError(persistError)) {
787
+ // Swallow conflict after retry exhaustion -- original error takes priority
769
788
  }
770
- } catch {
771
789
  // Swallow save errors -- original error takes priority
772
790
  }
773
791
 
@@ -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
  }
@@ -0,0 +1,42 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import type { ReviewState } from "../review/types";
3
+
4
+ export const ocReplay = tool({
5
+ description:
6
+ "Verify determinism by replaying a known sequence of inputs to the review pipeline and ensuring identical state output.",
7
+ args: {
8
+ runId: tool.schema.string().describe("The pipeline runId to use as the random seed for replay"),
9
+ inputs: tool.schema
10
+ .array(tool.schema.string())
11
+ .describe("Array of raw JSON findings inputs to feed sequentially into the pipeline"),
12
+ },
13
+ async execute(args) {
14
+ const { advancePipeline } = await import("../review/pipeline");
15
+
16
+ let currentState: ReviewState = {
17
+ stage: 1,
18
+ scope: "replay-scope",
19
+ selectedAgentNames: ["logic-auditor", "security-auditor"],
20
+ accumulatedFindings: [],
21
+ startedAt: "2026-04-05T00:00:00.000Z",
22
+ };
23
+
24
+ for (const input of args.inputs) {
25
+ const res = advancePipeline(input, currentState, undefined, args.runId, args.runId);
26
+ if (res.state) {
27
+ currentState = res.state;
28
+ }
29
+ }
30
+
31
+ return JSON.stringify(
32
+ {
33
+ success: true,
34
+ message: `Replayed ${args.inputs.length} inputs deterministically.`,
35
+ replayedRunId: args.runId,
36
+ finalState: currentState,
37
+ },
38
+ null,
39
+ 2,
40
+ );
41
+ },
42
+ });
@@ -17,6 +17,13 @@ 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";
25
+ import { getLogger } from "../logging/domains";
26
+ import { loadState as loadPipelineState } from "../orchestrator/state";
20
27
  import { REVIEW_AGENTS, SPECIALIZED_AGENTS } from "../review/agents/index";
21
28
  import {
22
29
  createEmptyMemory,
@@ -42,6 +49,7 @@ interface ReviewArgs {
42
49
  const execFileAsync = promisify(execFile);
43
50
 
44
51
  const STATE_FILE = "current-review.json";
52
+ let legacyReviewStateMirrorWarned = false;
45
53
 
46
54
  /**
47
55
  * Get changed file paths for the given review scope.
@@ -83,11 +91,18 @@ async function getChangedFiles(
83
91
  * Load review state from disk. Returns null if no active review.
84
92
  */
85
93
  async function loadReviewState(artifactDir: string): Promise<ReviewState | null> {
94
+ const kernelState = loadActiveReviewStateFromKernel(artifactDir);
95
+ if (kernelState !== null) {
96
+ return kernelState;
97
+ }
98
+
86
99
  const statePath = join(artifactDir, STATE_FILE);
87
100
  try {
88
101
  const raw = await readFile(statePath, "utf-8");
89
102
  const parsed = JSON.parse(raw);
90
- return reviewStateSchema.parse(parsed) as ReviewState;
103
+ const validated = reviewStateSchema.parse(parsed) as ReviewState;
104
+ saveActiveReviewStateToKernel(artifactDir, validated);
105
+ return validated;
91
106
  } catch (error: unknown) {
92
107
  if (isEnoentError(error)) return null;
93
108
  // Treat parse/schema errors as recoverable — delete corrupt file
@@ -103,23 +118,43 @@ async function loadReviewState(artifactDir: string): Promise<ReviewState | null>
103
118
  }
104
119
  }
105
120
 
121
+ async function writeLegacyReviewStateMirror(
122
+ state: ReviewState,
123
+ artifactDir: string,
124
+ ): Promise<void> {
125
+ await ensureDir(artifactDir);
126
+ const statePath = join(artifactDir, STATE_FILE);
127
+ const tmpPath = `${statePath}.tmp.${randomBytes(8).toString("hex")}`;
128
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
129
+ await rename(tmpPath, statePath);
130
+ }
131
+
132
+ async function syncLegacyReviewStateMirror(state: ReviewState, artifactDir: string): Promise<void> {
133
+ try {
134
+ await writeLegacyReviewStateMirror(state, artifactDir);
135
+ } catch (error: unknown) {
136
+ if (!legacyReviewStateMirrorWarned) {
137
+ legacyReviewStateMirrorWarned = true;
138
+ getLogger("tool", "review").warn("current-review.json mirror write failed", { error });
139
+ }
140
+ }
141
+ }
142
+
106
143
  /**
107
144
  * Save review state atomically.
108
145
  */
109
146
  async function saveReviewState(state: ReviewState, artifactDir: string): Promise<void> {
110
- await ensureDir(artifactDir);
111
147
  // Validate before writing (bidirectional validation, same as orchestrator state)
112
148
  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);
149
+ saveActiveReviewStateToKernel(artifactDir, validated);
150
+ await syncLegacyReviewStateMirror(validated, artifactDir);
117
151
  }
118
152
 
119
153
  /**
120
154
  * Delete review state file (pipeline complete or error cleanup).
121
155
  */
122
156
  async function clearReviewState(artifactDir: string): Promise<void> {
157
+ clearActiveReviewStateInKernel(artifactDir);
123
158
  const statePath = join(artifactDir, STATE_FILE);
124
159
  try {
125
160
  await unlink(statePath);
@@ -157,7 +192,11 @@ async function startNewReview(
157
192
 
158
193
  // Select agents from all candidates (universal + specialized)
159
194
  const allCandidates = [...REVIEW_AGENTS, ...SPECIALIZED_AGENTS];
160
- const selection = selectAgents(detectedStacks, diffAnalysis, allCandidates);
195
+ const artifactDir = getProjectArtifactDir(projectRoot);
196
+ const pipelineState = await loadPipelineState(artifactDir);
197
+ const seed = pipelineState ? `${pipelineState.runId}-review-1` : undefined;
198
+
199
+ const selection = selectAgents(detectedStacks, diffAnalysis, allCandidates, { seed });
161
200
 
162
201
  const selectedNames = selection.selected.map((a) => a.name);
163
202
 
@@ -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.";
@@ -0,0 +1,43 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { z } from "zod";
3
+ import { readLatestSessionLog, readSessionLog } from "../observability/log-reader";
4
+ import { generateSessionSummary } from "../observability/summary-generator";
5
+
6
+ export async function summaryCore(sessionID?: string, logsDir?: string): Promise<string> {
7
+ const logsRoot = logsDir ?? process.cwd();
8
+ const log = sessionID
9
+ ? await readSessionLog(sessionID, logsRoot)
10
+ : await readLatestSessionLog(logsRoot);
11
+
12
+ if (!log) {
13
+ const target = sessionID ? `Session "${sessionID}" not found.` : "No session logs found.";
14
+ return JSON.stringify({
15
+ action: "error",
16
+ message: target,
17
+ });
18
+ }
19
+
20
+ const summary = generateSessionSummary(log);
21
+
22
+ return JSON.stringify({
23
+ action: "session_summary",
24
+ sessionId: log.sessionId,
25
+ summary,
26
+ displayText: summary,
27
+ });
28
+ }
29
+
30
+ export const ocSummary = tool({
31
+ description:
32
+ "Generate a markdown summary for the latest session or a specific session ID. Use this to review session outcomes, decisions, and errors.",
33
+ args: {
34
+ sessionID: z
35
+ .string()
36
+ .regex(/^[a-zA-Z0-9_-]{1,256}$/)
37
+ .optional()
38
+ .describe("Session ID to summarize (uses latest if omitted)"),
39
+ },
40
+ async execute({ sessionID }) {
41
+ return summaryCore(sessionID);
42
+ },
43
+ });