@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.
- package/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- 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 +3 -3
- package/src/health/checks.ts +126 -4
- package/src/health/types.ts +1 -1
- package/src/index.ts +128 -13
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +65 -0
- 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 +82 -67
- package/src/memory/database.ts +74 -12
- package/src/memory/decay.ts +11 -2
- package/src/memory/index.ts +17 -1
- 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/project-key.ts +6 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +52 -216
- package/src/memory/retrieval.ts +88 -170
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +69 -20
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +167 -0
- package/src/observability/forensic-schemas.ts +77 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +161 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +42 -219
- package/src/orchestrator/handlers/retrospective.ts +2 -2
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +36 -11
- package/src/orchestrator/orchestration-logger.ts +53 -24
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/progress.ts +63 -0
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +39 -11
- 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/doctor.ts +28 -4
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +38 -11
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +108 -90
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +46 -7
- package/src/tools/session-stats.ts +3 -2
- package/src/tools/summary.ts +43 -0
- package/src/utils/paths.ts +20 -1
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
package/src/tools/orchestrate.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
import
|
|
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 {
|
|
14
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
301
|
-
const
|
|
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
|
|
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
|
|
356
|
+
const candidateCount = counts[phase];
|
|
336
357
|
const limit = MAX_PHASE_DISPATCHES[phase] ?? 5;
|
|
337
|
-
if (
|
|
338
|
-
const msg = `Phase ${phase} exceeded max dispatches (${
|
|
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:
|
|
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 =
|
|
353
|
-
|
|
354
|
-
|
|
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(
|
|
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 (
|
|
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:
|
|
437
|
-
phase:
|
|
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 =
|
|
447
|
-
|
|
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[
|
|
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
|
|
465
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
532
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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,
|
|
108
|
-
: await readLatestSessionLog(
|
|
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.";
|
package/src/tools/quick.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/tools/review.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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,
|
|
143
|
-
: await readLatestSessionLog(
|
|
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
|
+
});
|