@dieulc/workflow 0.7.0 → 0.8.1
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/README.md +111 -28
- package/commit-policy.ts +43 -0
- package/index.ts +3017 -1119
- package/model-default.ts +535 -0
- package/package.json +8 -2
- package/picker.ts +246 -0
- package/profiles.ts +73 -0
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +267 -76
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +444 -69
package/review-runtime.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
planHash,
|
|
33
33
|
readProviderErrorEvent,
|
|
34
34
|
reviewNeedsVerification,
|
|
35
|
+
reviewRunLabel,
|
|
35
36
|
truncateErrorText,
|
|
36
37
|
type CreateReviewSessionOptions,
|
|
37
38
|
type CreateReviewSessionResult,
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
} from "./review.ts";
|
|
51
52
|
import { appendProviderAuthHint } from "./review-providers.ts";
|
|
52
53
|
import { createReviewTools } from "./review-tools.ts";
|
|
53
|
-
import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
|
|
54
|
+
import { buildPlanDiff, renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
|
|
54
55
|
|
|
55
56
|
/** Event `type` from a child-session event, or "" for junk. Never throws. */
|
|
56
57
|
function readEventType(event: unknown): string {
|
|
@@ -91,6 +92,18 @@ export interface ReviewRuntimeDeps {
|
|
|
91
92
|
readPlan: () => string | undefined;
|
|
92
93
|
/** Pruned parent context entries (from ctx.sessionManager.getBranch()). */
|
|
93
94
|
contextEntries: () => unknown[];
|
|
95
|
+
/**
|
|
96
|
+
* Whether the last Planner manifest carried recon summaries (step 8).
|
|
97
|
+
* When true the reviewer verifies instead of re-exploring: its explore
|
|
98
|
+
* budget is 0. Optional — absent means "no recon".
|
|
99
|
+
*/
|
|
100
|
+
hasRecon?: () => boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Planner-context manifest (file inventory + recon) for the pass-1 prompt
|
|
103
|
+
* (step 8). Optional: when absent the reviewer works from the transcript
|
|
104
|
+
* block alone, as before.
|
|
105
|
+
*/
|
|
106
|
+
plannerManifest?: () => string;
|
|
94
107
|
/**
|
|
95
108
|
* Resolved reviewer guidance (review-prompt.md, else the built-in default).
|
|
96
109
|
* A function, not a string: the user can edit the file between rounds.
|
|
@@ -129,6 +142,18 @@ export interface RunRoundInput {
|
|
|
129
142
|
round: number;
|
|
130
143
|
/** Findings from the previous round, so pass 2 can assert they were fixed. */
|
|
131
144
|
previousFindings?: ReviewFinding[];
|
|
145
|
+
/**
|
|
146
|
+
* Host abort signal (ESC). When it fires the round stops promptly and the
|
|
147
|
+
* author's plan is kept — the same outcome as the internal abortRound().
|
|
148
|
+
* Linked to the round's own controller; detached when the round ends.
|
|
149
|
+
*/
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
/**
|
|
152
|
+
* `/review-resume` is continuing a paused round on the warm session. Only the
|
|
153
|
+
* transcript label needs this: warmth alone cannot express intent, because a
|
|
154
|
+
* next round reuses the same session too.
|
|
155
|
+
*/
|
|
156
|
+
resumed?: boolean;
|
|
132
157
|
}
|
|
133
158
|
|
|
134
159
|
export interface ReviewRuntime {
|
|
@@ -162,6 +187,8 @@ export function buildPass1Prompt(input: {
|
|
|
162
187
|
planText: string;
|
|
163
188
|
reviewPrompt: string;
|
|
164
189
|
contextBlock: string;
|
|
190
|
+
/** Planner-context manifest (step 8): inventory + recon, trusted by default. */
|
|
191
|
+
manifest?: string;
|
|
165
192
|
previousFindings?: ReviewFinding[];
|
|
166
193
|
round: number;
|
|
167
194
|
totalRounds: number;
|
|
@@ -178,13 +205,18 @@ export function buildPass1Prompt(input: {
|
|
|
178
205
|
input.reviewPrompt,
|
|
179
206
|
];
|
|
180
207
|
|
|
208
|
+
if (input.manifest && input.manifest.trim()) {
|
|
209
|
+
parts.push("", input.manifest.trim());
|
|
210
|
+
}
|
|
211
|
+
|
|
181
212
|
if (input.contextBlock.trim()) {
|
|
182
213
|
parts.push(
|
|
183
214
|
"",
|
|
184
|
-
"##
|
|
215
|
+
"## Plan Mode transcript (abridged)",
|
|
185
216
|
"",
|
|
186
|
-
"
|
|
187
|
-
"
|
|
217
|
+
"The author's own words and tool spew, truncated for size. Prefer the",
|
|
218
|
+
"shared-context inventory above; re-read anything you rely on with",
|
|
219
|
+
"read/grep.",
|
|
188
220
|
"",
|
|
189
221
|
input.contextBlock.trim(),
|
|
190
222
|
);
|
|
@@ -209,12 +241,18 @@ export function buildPass1Prompt(input: {
|
|
|
209
241
|
"The author has revised the plan in response. Confirm whether each was",
|
|
210
242
|
"actually addressed, and say so in your findings rather than re-raising it.",
|
|
211
243
|
"",
|
|
212
|
-
...input.previousFindings.map(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
244
|
+
...input.previousFindings.map((f) => {
|
|
245
|
+
const loc = f.file
|
|
246
|
+
? ` (${f.file}${f.lineRange ? `:${f.lineRange}` : ""})`
|
|
247
|
+
: "";
|
|
248
|
+
// Step 8: carry the rationale so re-review rounds verify instead of
|
|
249
|
+
// re-discovering (clipped — the full text lives in the changelog).
|
|
250
|
+
const why = String(f.rationale ?? "")
|
|
251
|
+
.replace(/\s+/g, " ")
|
|
252
|
+
.trim();
|
|
253
|
+
const clipped = why.length > 200 ? `${why.slice(0, 200)}…` : why;
|
|
254
|
+
return `- [${f.disposition}] ${f.summary}${loc}${clipped ? ` — ${clipped}` : ""}`;
|
|
255
|
+
}),
|
|
218
256
|
);
|
|
219
257
|
}
|
|
220
258
|
|
|
@@ -241,22 +279,18 @@ export function buildPass2Prompt(input: {
|
|
|
241
279
|
rewrittenPlan: string;
|
|
242
280
|
findings: ReviewFinding[];
|
|
243
281
|
}): string {
|
|
282
|
+
// Step 9: pass 2 checks the rewrite against a diff, not two full plans.
|
|
283
|
+
const diff = buildPlanDiff(input.originalPlan, input.rewrittenPlan);
|
|
244
284
|
return [
|
|
245
285
|
"[REVIEW MODE — PASS 2 of 2: VERIFY YOUR OWN REWRITE]",
|
|
246
286
|
"",
|
|
247
287
|
"You just rewrote the plan and submitted it. Now check your own work. This pass",
|
|
248
288
|
"exists because a rewrite can silently drop a requirement the author cared about.",
|
|
249
289
|
"",
|
|
250
|
-
"##
|
|
290
|
+
"## What changed (unified diff of your rewrite)",
|
|
251
291
|
"",
|
|
252
|
-
"```
|
|
253
|
-
|
|
254
|
-
"```",
|
|
255
|
-
"",
|
|
256
|
-
"## Your rewrite (currently on disk)",
|
|
257
|
-
"",
|
|
258
|
-
"```markdown",
|
|
259
|
-
input.rewrittenPlan,
|
|
292
|
+
"```diff",
|
|
293
|
+
diff,
|
|
260
294
|
"```",
|
|
261
295
|
"",
|
|
262
296
|
"## Findings you reported",
|
|
@@ -289,8 +323,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
289
323
|
let state: ReviewState | undefined;
|
|
290
324
|
let transcript: Transcript | undefined;
|
|
291
325
|
let gate: ReviewRoundGate = createReviewRoundGate();
|
|
292
|
-
let starting: Promise<
|
|
326
|
+
let starting: Promise<EnsureSessionResult> | undefined;
|
|
293
327
|
let abortController: AbortController | undefined;
|
|
328
|
+
/** `provider/id/thinking` the live session was created with (step 10). */
|
|
329
|
+
let sessionModelKey: string | undefined;
|
|
294
330
|
/** True while `runRound` is in flight (gates prompt routing). */
|
|
295
331
|
let roundActive = false;
|
|
296
332
|
/**
|
|
@@ -339,6 +375,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
339
375
|
return transcript;
|
|
340
376
|
};
|
|
341
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Emit a runtime-authored status line to the local transcript buffer and the
|
|
380
|
+
* parent stream. Used for run-start narration, which the child's
|
|
381
|
+
* `agent_start` cannot describe truthfully (see `reviewRunLabel`).
|
|
382
|
+
* Display-only: a broken sink must never break a review.
|
|
383
|
+
*/
|
|
384
|
+
const pushStatus = (text: string): void => {
|
|
385
|
+
const op: ReviewTranscriptOp = { op: "new", kind: "status", text };
|
|
386
|
+
try {
|
|
387
|
+
ensureTranscript().apply(op);
|
|
388
|
+
} catch {
|
|
389
|
+
/* ignore */
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
deps.onTranscriptOps?.([op]);
|
|
393
|
+
} catch {
|
|
394
|
+
/* ignore */
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
342
398
|
/**
|
|
343
399
|
* Completed reviewer runs (`agent_settled`). A pass is over when either its
|
|
344
400
|
* `review_pass_done` or the end of its run arrives — see `runPass()`.
|
|
@@ -476,6 +532,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
476
532
|
const remaining = (): number | undefined =>
|
|
477
533
|
deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
|
|
478
534
|
let sendError: unknown;
|
|
535
|
+
// The losing waiters in the pass race must be cancelled when the winner
|
|
536
|
+
// settles. Otherwise their timers (up to `timeoutMs`, 5 minutes by
|
|
537
|
+
// default) stay referenced and keep the process alive after the round.
|
|
538
|
+
const waitAbort = new AbortController();
|
|
479
539
|
|
|
480
540
|
const stop = await new Promise<PassStop>((resolve) => {
|
|
481
541
|
let done = false;
|
|
@@ -486,6 +546,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
486
546
|
done = true;
|
|
487
547
|
if (timer !== undefined) clearTimeout(timer);
|
|
488
548
|
rc.signal.removeEventListener("abort", onAbort);
|
|
549
|
+
waitAbort.abort();
|
|
489
550
|
resolve(s);
|
|
490
551
|
};
|
|
491
552
|
if (rc.signal.aborted) {
|
|
@@ -495,10 +556,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
495
556
|
const cap = remaining();
|
|
496
557
|
if (cap !== undefined) timer = setTimeout(() => finish("timeout"), cap);
|
|
497
558
|
rc.signal.addEventListener("abort", onAbort, { once: true });
|
|
498
|
-
void gate.waitForPassDone(remaining(),
|
|
559
|
+
void gate.waitForPassDone(remaining(), waitAbort.signal).then((result) => {
|
|
499
560
|
if (result) finish("passDone");
|
|
500
561
|
});
|
|
501
|
-
void waitForSettle(baseline, remaining(),
|
|
562
|
+
void waitForSettle(baseline, remaining(), waitAbort.signal).then((did) => {
|
|
502
563
|
if (did) finish("settled");
|
|
503
564
|
});
|
|
504
565
|
void send().catch((e: unknown) => {
|
|
@@ -512,7 +573,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
512
573
|
if (stop === "timeout") {
|
|
513
574
|
const capMs = Math.max(1000, config().timeoutMs);
|
|
514
575
|
deps.log(
|
|
515
|
-
`
|
|
576
|
+
`pass ${pass} hit its ${Math.round(
|
|
516
577
|
capMs / 1000,
|
|
517
578
|
)}s limit — continuing with ${
|
|
518
579
|
submitted ? "the submitted plan" : "the author's plan"
|
|
@@ -528,18 +589,70 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
528
589
|
};
|
|
529
590
|
}
|
|
530
591
|
|
|
531
|
-
|
|
532
|
-
|
|
592
|
+
/** Release the child session and its round state (step 10). Never throws. */
|
|
593
|
+
async function disposeSession(): Promise<void> {
|
|
594
|
+
try {
|
|
595
|
+
abortController?.abort();
|
|
596
|
+
await session?.abort();
|
|
597
|
+
} catch {
|
|
598
|
+
/* ignore */
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
session?.dispose();
|
|
602
|
+
} catch {
|
|
603
|
+
/* ignore */
|
|
604
|
+
}
|
|
605
|
+
session = undefined;
|
|
606
|
+
state = undefined;
|
|
607
|
+
transcript = undefined;
|
|
608
|
+
sessionModelKey = undefined;
|
|
609
|
+
gate = createReviewRoundGate();
|
|
610
|
+
abortController = undefined;
|
|
611
|
+
try {
|
|
612
|
+
deps.onState?.(undefined);
|
|
613
|
+
} catch {
|
|
614
|
+
/* ignore */
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Outcome of `ensureSession`: `created` is true only for a fresh boot. */
|
|
619
|
+
interface EnsureSessionResult {
|
|
620
|
+
ok: boolean;
|
|
621
|
+
created: boolean;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Idempotent session boot. `created` tells the caller whether THIS call paid
|
|
626
|
+
* for a new child session, which is what makes the transcript's run label
|
|
627
|
+
* truthful (fresh boot vs warm resume/next round).
|
|
628
|
+
*/
|
|
629
|
+
async function ensureSession(): Promise<EnsureSessionResult> {
|
|
630
|
+
const modelRef = deps.reviewerModel();
|
|
631
|
+
const modelKey = modelRef
|
|
632
|
+
? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
|
|
633
|
+
: "";
|
|
634
|
+
if (session) {
|
|
635
|
+
// Step 10: the session is bound to the model it was created with. A
|
|
636
|
+
// reviewer switch since then reboots it so the new model audits.
|
|
637
|
+
if (modelKey && sessionModelKey && modelKey !== sessionModelKey) {
|
|
638
|
+
deps.log(
|
|
639
|
+
"reviewer model changed — restarting the reviewer session.",
|
|
640
|
+
"info",
|
|
641
|
+
);
|
|
642
|
+
await disposeSession();
|
|
643
|
+
} else {
|
|
644
|
+
return { ok: true, created: false };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
533
647
|
if (starting) return starting;
|
|
534
|
-
starting = (async () => {
|
|
648
|
+
starting = (async (): Promise<EnsureSessionResult> => {
|
|
535
649
|
const cfg = config();
|
|
536
|
-
const modelRef = deps.reviewerModel();
|
|
537
650
|
if (!modelRef) {
|
|
538
651
|
deps.log(
|
|
539
|
-
"
|
|
652
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
540
653
|
"warning",
|
|
541
654
|
);
|
|
542
|
-
return false;
|
|
655
|
+
return { ok: false, created: false };
|
|
543
656
|
}
|
|
544
657
|
try {
|
|
545
658
|
// The SDK must be loaded BEFORE building the tools: review_bash is
|
|
@@ -553,7 +666,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
553
666
|
cwd: deps.cwd,
|
|
554
667
|
log: deps.log,
|
|
555
668
|
explore: {
|
|
556
|
-
|
|
669
|
+
// Step 8: Planner recon rides along in the prompt — when it is
|
|
670
|
+
// present the reviewer verifies instead of re-exploring.
|
|
671
|
+
budget: () =>
|
|
672
|
+
(deps.hasRecon?.() ?? false) ? 0 : config().exploreBudget,
|
|
557
673
|
run: (task, signal) => deps.runExplore(task, signal),
|
|
558
674
|
onUsed: (used) => {
|
|
559
675
|
if (state) state.exploreUsed = used;
|
|
@@ -642,22 +758,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
642
758
|
|
|
643
759
|
if (!created.ok || !created.session) {
|
|
644
760
|
deps.log(
|
|
645
|
-
`
|
|
761
|
+
`reviewer unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
|
|
646
762
|
cfg.fallbackOnError === "block" ? "error" : "warning",
|
|
647
763
|
);
|
|
648
|
-
return false;
|
|
764
|
+
return { ok: false, created: false };
|
|
649
765
|
}
|
|
650
766
|
|
|
651
767
|
// One line per session, so "the reviewer has no tools" is visible
|
|
652
768
|
// immediately instead of only after a failed pass (see /review-status).
|
|
653
769
|
if (created.activeTools) {
|
|
654
770
|
deps.log(
|
|
655
|
-
`
|
|
771
|
+
`reviewer tools active — ${created.activeTools.join(", ")}`,
|
|
656
772
|
"info",
|
|
657
773
|
);
|
|
658
774
|
}
|
|
659
775
|
|
|
660
776
|
session = created.session;
|
|
777
|
+
// Step 10: remember which model this session audits with.
|
|
778
|
+
sessionModelKey = modelKey;
|
|
779
|
+
// Config, not round state: report the missing cap once per live
|
|
780
|
+
// session, so a resume cannot replay it as if the reviewer had just
|
|
781
|
+
// started. Also avoids the old `Review Mode: Review Mode:` doubling.
|
|
782
|
+
if (cfg.timeoutMs <= 0) {
|
|
783
|
+
deps.log(
|
|
784
|
+
"no time limit configured — each pass ends when the reviewer submits or its run settles.",
|
|
785
|
+
"info",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
661
788
|
ensureTranscript();
|
|
662
789
|
const child = created.session;
|
|
663
790
|
session.subscribe((event) => {
|
|
@@ -710,13 +837,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
710
837
|
}, 0);
|
|
711
838
|
}
|
|
712
839
|
});
|
|
713
|
-
return true;
|
|
840
|
+
return { ok: true, created: true };
|
|
714
841
|
} catch (e: unknown) {
|
|
715
842
|
deps.log(
|
|
716
|
-
`
|
|
843
|
+
`failed to start: ${e instanceof Error ? e.message : String(e)}`,
|
|
717
844
|
"warning",
|
|
718
845
|
);
|
|
719
|
-
return false;
|
|
846
|
+
return { ok: false, created: false };
|
|
720
847
|
} finally {
|
|
721
848
|
starting = undefined;
|
|
722
849
|
}
|
|
@@ -724,11 +851,20 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
724
851
|
return starting;
|
|
725
852
|
}
|
|
726
853
|
|
|
854
|
+
/** Public shape: idempotent, creates the reviewer session if it is not alive. */
|
|
855
|
+
async function ensureStarted(): Promise<boolean> {
|
|
856
|
+
return (await ensureSession()).ok;
|
|
857
|
+
}
|
|
858
|
+
|
|
727
859
|
async function runRound(input: RunRoundInput): Promise<ReviewRoundResult> {
|
|
728
860
|
const cfg = config();
|
|
729
861
|
const hash = planHash(input.planText);
|
|
730
862
|
const started = Date.now();
|
|
731
863
|
const originalPlan = input.planText;
|
|
864
|
+
// Declared outside the try: a `let` inside the try block would not be
|
|
865
|
+
// visible in `finally` (block scoping), and the external listener must
|
|
866
|
+
// always be detached when the round ends.
|
|
867
|
+
let detachExternal: (() => void) | undefined;
|
|
732
868
|
|
|
733
869
|
const fail = (
|
|
734
870
|
reason: string,
|
|
@@ -768,10 +904,52 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
768
904
|
return fail("no plan path — the reviewer has nowhere to submit");
|
|
769
905
|
}
|
|
770
906
|
|
|
771
|
-
|
|
772
|
-
|
|
907
|
+
// A signal that is already aborted means the host turn this round would
|
|
908
|
+
// belong to is gone (e.g. the planner turn was interrupted). Do not boot
|
|
909
|
+
// a session, send a prompt or spend a pass on it: report a fast abort.
|
|
910
|
+
// The caller decides what that means — the automatic handoff skips the
|
|
911
|
+
// review and hands off unreviewed, while an explicit `/review-resume`
|
|
912
|
+
// passes no host signal at all (its stop paths are ESC / `/review-abort`).
|
|
913
|
+
// Crucially, a pre-aborted signal must never become a pause: doing so
|
|
914
|
+
// re-created `pausedReview` on every resume and parked the handoff
|
|
915
|
+
// forever (2026-10-25 stall).
|
|
916
|
+
if (input.signal?.aborted === true) {
|
|
917
|
+
if (!state || state.planHash !== hash) {
|
|
918
|
+
state = createReviewState({
|
|
919
|
+
planHash: hash,
|
|
920
|
+
planPath: input.planPath,
|
|
921
|
+
modelLabel: session?.modelLabel ?? "reviewer",
|
|
922
|
+
round: input.round,
|
|
923
|
+
now: started,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
state.round = input.round;
|
|
927
|
+
state.planPath = input.planPath;
|
|
928
|
+
state.phase = "aborted";
|
|
929
|
+
state.error =
|
|
930
|
+
"aborted before start — the host turn was already aborted";
|
|
931
|
+
state.finishedAt = Date.now();
|
|
932
|
+
try {
|
|
933
|
+
deps.onState?.(state);
|
|
934
|
+
} catch {
|
|
935
|
+
/* status updates are best-effort */
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
ok: false,
|
|
939
|
+
fallback: true,
|
|
940
|
+
planText: originalPlan,
|
|
941
|
+
planHash: hash,
|
|
942
|
+
state,
|
|
943
|
+
reason: "aborted-before-start",
|
|
944
|
+
abortedBeforeStart: true,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const start = await ensureSession();
|
|
949
|
+
if (!start.ok || !session) {
|
|
773
950
|
return fail("reviewer session unavailable");
|
|
774
951
|
}
|
|
952
|
+
const created = start.created;
|
|
775
953
|
const child = session;
|
|
776
954
|
|
|
777
955
|
if (!state || state.planHash !== hash || state.phase === "done") {
|
|
@@ -789,16 +967,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
789
967
|
|
|
790
968
|
abortController = new AbortController();
|
|
791
969
|
const rc = abortController;
|
|
970
|
+
// ESC cancels: link the host signal (when the caller threads one
|
|
971
|
+
// through) to the round's controller, so ESC aborts the pass race,
|
|
972
|
+
// the settle wait and the send alike. The existing `aborted` branch
|
|
973
|
+
// below then keeps the session warm and hands back the author's plan.
|
|
974
|
+
const externalSignal = input.signal;
|
|
975
|
+
if (externalSignal) {
|
|
976
|
+
if (externalSignal.aborted) {
|
|
977
|
+
rc.abort();
|
|
978
|
+
} else {
|
|
979
|
+
const onExternalAbort = () => {
|
|
980
|
+
try {
|
|
981
|
+
rc.abort();
|
|
982
|
+
} catch {
|
|
983
|
+
/* abort is best-effort */
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
externalSignal.addEventListener("abort", onExternalAbort, {
|
|
987
|
+
once: true,
|
|
988
|
+
});
|
|
989
|
+
detachExternal = () =>
|
|
990
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
792
993
|
// 0 (default) = no cap. A configured cap is the outer safety net; the
|
|
793
994
|
// settle race below still ends a silent pass quickly either way.
|
|
794
995
|
const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
|
|
795
996
|
const deadline = limitMs !== undefined ? started + limitMs : undefined;
|
|
796
|
-
if (limitMs === undefined) {
|
|
797
|
-
deps.log(
|
|
798
|
-
"Review Mode: no time limit configured — each pass ends when the reviewer submits or its run settles.",
|
|
799
|
-
"info",
|
|
800
|
-
);
|
|
801
|
-
}
|
|
802
997
|
|
|
803
998
|
// ── Pass 1 ──────────────────────────────────────────────────────
|
|
804
999
|
await settleInFlight(deadline, rc);
|
|
@@ -808,16 +1003,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
808
1003
|
planPath: input.planPath,
|
|
809
1004
|
planText: originalPlan,
|
|
810
1005
|
reviewPrompt: deps.promptText(),
|
|
811
|
-
contextBlock: renderReviewContextBlock(deps.contextEntries()
|
|
1006
|
+
contextBlock: renderReviewContextBlock(deps.contextEntries(), {
|
|
1007
|
+
// Step 7 — diet default (settings-overridable).
|
|
1008
|
+
maxChars: config().contextChars,
|
|
1009
|
+
}),
|
|
1010
|
+
// Step 8: inventory + recon so the reviewer verifies, not re-explores.
|
|
1011
|
+
manifest: deps.plannerManifest?.() ?? "",
|
|
812
1012
|
previousFindings: input.previousFindings,
|
|
813
1013
|
round: input.round,
|
|
814
1014
|
totalRounds: cfg.rounds,
|
|
815
1015
|
verify: cfg.verify && cfg.passes > 1,
|
|
816
1016
|
});
|
|
817
1017
|
|
|
818
|
-
const p1 = await runPass(1, deadline, rc, () =>
|
|
819
|
-
|
|
820
|
-
|
|
1018
|
+
const p1 = await runPass(1, deadline, rc, () => {
|
|
1019
|
+
// Pushed inside `send`: an already-aborted pass never sends, so it must
|
|
1020
|
+
// not claim a start either.
|
|
1021
|
+
pushStatus(
|
|
1022
|
+
reviewRunLabel({ created, resumed: input.resumed === true, pass: 1 }),
|
|
1023
|
+
);
|
|
1024
|
+
return child.prompt(pass1Prompt);
|
|
1025
|
+
});
|
|
821
1026
|
|
|
822
1027
|
if (p1.stop === "error") {
|
|
823
1028
|
await abortChild();
|
|
@@ -872,7 +1077,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
872
1077
|
// The protocol completed, the run did not: stop paying for it, but keep
|
|
873
1078
|
// the submission — never discard real work over a slow model.
|
|
874
1079
|
deps.log(
|
|
875
|
-
"
|
|
1080
|
+
"pass 1 exceeded its time limit after submitting — using the submitted plan.",
|
|
876
1081
|
"warning",
|
|
877
1082
|
);
|
|
878
1083
|
await abortChild();
|
|
@@ -897,9 +1102,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
897
1102
|
rewrittenPlan: p1.submitted.planBody,
|
|
898
1103
|
findings: state?.findings ?? [],
|
|
899
1104
|
});
|
|
900
|
-
const p2 = await runPass(2, deadline, rc, () =>
|
|
901
|
-
|
|
902
|
-
|
|
1105
|
+
const p2 = await runPass(2, deadline, rc, () => {
|
|
1106
|
+
pushStatus(reviewRunLabel({ created, pass: 2 }));
|
|
1107
|
+
return child.followUp(pass2Prompt);
|
|
1108
|
+
});
|
|
903
1109
|
// Name the provider when the verification pass died on one; pass 2
|
|
904
1110
|
// stays best-effort either way — pass 1's plan is never lost to it.
|
|
905
1111
|
const p2Provider = lastProviderError
|
|
@@ -909,8 +1115,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
909
1115
|
// Verification is an enhancement: never lose pass 1's plan over it.
|
|
910
1116
|
deps.log(
|
|
911
1117
|
p2Provider
|
|
912
|
-
? `
|
|
913
|
-
: `
|
|
1118
|
+
? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
|
|
1119
|
+
: `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
|
|
914
1120
|
"warning",
|
|
915
1121
|
);
|
|
916
1122
|
} else if (p2.submitted) {
|
|
@@ -919,7 +1125,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
919
1125
|
// The run settled without a submission AND a provider error was
|
|
920
1126
|
// seen this pass: say why, then keep going with pass 1's rewrite.
|
|
921
1127
|
deps.log(
|
|
922
|
-
`
|
|
1128
|
+
`verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
|
|
923
1129
|
"warning",
|
|
924
1130
|
);
|
|
925
1131
|
}
|
|
@@ -949,6 +1155,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
949
1155
|
`review round failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
950
1156
|
);
|
|
951
1157
|
} finally {
|
|
1158
|
+
try {
|
|
1159
|
+
detachExternal?.();
|
|
1160
|
+
} catch {
|
|
1161
|
+
/* listener cleanup is best-effort */
|
|
1162
|
+
}
|
|
952
1163
|
abortController = undefined;
|
|
953
1164
|
roundActive = false;
|
|
954
1165
|
}
|
|
@@ -1008,27 +1219,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1008
1219
|
}
|
|
1009
1220
|
},
|
|
1010
1221
|
async teardown() {
|
|
1011
|
-
|
|
1012
|
-
abortController?.abort();
|
|
1013
|
-
await session?.abort();
|
|
1014
|
-
} catch {
|
|
1015
|
-
/* ignore */
|
|
1016
|
-
}
|
|
1017
|
-
try {
|
|
1018
|
-
session?.dispose();
|
|
1019
|
-
} catch {
|
|
1020
|
-
/* ignore */
|
|
1021
|
-
}
|
|
1022
|
-
session = undefined;
|
|
1023
|
-
state = undefined;
|
|
1024
|
-
transcript = undefined;
|
|
1025
|
-
gate = createReviewRoundGate();
|
|
1026
|
-
abortController = undefined;
|
|
1027
|
-
try {
|
|
1028
|
-
deps.onState?.(undefined);
|
|
1029
|
-
} catch {
|
|
1030
|
-
/* ignore */
|
|
1031
|
-
}
|
|
1222
|
+
await disposeSession();
|
|
1032
1223
|
},
|
|
1033
1224
|
};
|
|
1034
1225
|
}
|
package/review-ui.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface ReviewModeBandData {
|
|
|
50
50
|
round?: number;
|
|
51
51
|
verdict?: string;
|
|
52
52
|
reason?: string;
|
|
53
|
+
/**
|
|
54
|
+
* A round that continues a paused one (`/review-resume`). The band renders
|
|
55
|
+
* `resumed` instead of `sequential audit` so the two flows never look alike.
|
|
56
|
+
*/
|
|
57
|
+
resumed?: boolean;
|
|
58
|
+
/** The round stopped for a pause; renders `REVIEW PAUSED`, not `REVIEW DONE`. */
|
|
59
|
+
paused?: boolean;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/** Payload of a `workflow-review-transcript` chunk entry. */
|
|
@@ -103,6 +110,10 @@ export function reviewBandKey(data: ReviewModeBandData | undefined): string {
|
|
|
103
110
|
data?.modelLabel ?? "",
|
|
104
111
|
data?.verdict ?? "",
|
|
105
112
|
data?.reason ?? "",
|
|
113
|
+
// A resumed (or paused) band is never the same event as the fresh band it
|
|
114
|
+
// continues, even when mode/round/model/verdict/reason happen to match.
|
|
115
|
+
data?.resumed === true ? "resumed" : "",
|
|
116
|
+
data?.paused === true ? "paused" : "",
|
|
106
117
|
].join("|");
|
|
107
118
|
}
|
|
108
119
|
|
|
@@ -295,19 +306,28 @@ export function formatReviewModeBand(
|
|
|
295
306
|
}
|
|
296
307
|
if (mode === "review") {
|
|
297
308
|
const bits: string[] = [];
|
|
298
|
-
|
|
309
|
+
const resumed = data?.resumed === true;
|
|
310
|
+
if (resumed) {
|
|
311
|
+
bits.push("resumed");
|
|
312
|
+
// Always name the round on a resume: "resumed · round 1" reads as a
|
|
313
|
+
// continuation, while a bare "resumed" hides which round continues.
|
|
314
|
+
if (typeof data?.round === "number") bits.push(`round ${data.round}`);
|
|
315
|
+
} else if (typeof data?.round === "number" && data.round > 1) {
|
|
299
316
|
bits.push(`round ${data.round}`);
|
|
300
317
|
}
|
|
301
318
|
if (typeof data?.modelLabel === "string" && data.modelLabel) {
|
|
302
319
|
bits.push(data.modelLabel);
|
|
303
320
|
}
|
|
304
|
-
|
|
321
|
+
// "sequential audit" describes a fresh flow; a resumed round continues the
|
|
322
|
+
// flow it was paused in.
|
|
323
|
+
if (!resumed) bits.push("sequential audit");
|
|
305
324
|
const title = theme.fg("accent", theme.bold("▌ REVIEW MODE"));
|
|
306
325
|
return [title + theme.fg("muted", ` ${bits.join(" · ")}`)];
|
|
307
326
|
}
|
|
308
327
|
if (mode === "review-done") {
|
|
328
|
+
const paused = data?.paused === true;
|
|
309
329
|
const clean = data?.verdict === "approve" && !data?.reason;
|
|
310
|
-
const color: ThemeColor = clean ? "
|
|
330
|
+
const color: ThemeColor = paused || !clean ? "warning" : "success";
|
|
311
331
|
let detail = "";
|
|
312
332
|
if (data?.reason) {
|
|
313
333
|
detail = ` ${data.reason}`;
|
|
@@ -315,7 +335,10 @@ export function formatReviewModeBand(
|
|
|
315
335
|
detail = ` verdict: ${data.verdict}`;
|
|
316
336
|
}
|
|
317
337
|
return [
|
|
318
|
-
theme.fg(
|
|
338
|
+
theme.fg(
|
|
339
|
+
color,
|
|
340
|
+
theme.bold(paused ? "▌ REVIEW PAUSED" : "▌ REVIEW DONE"),
|
|
341
|
+
) + theme.fg("muted", detail),
|
|
319
342
|
];
|
|
320
343
|
}
|
|
321
344
|
return [theme.fg("muted", "▌ review")];
|