@dieulc/workflow 0.6.1 → 0.8.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/README.md +126 -40
- package/commit-policy.ts +43 -0
- package/edit-policy.ts +193 -14
- package/index.ts +3090 -1148
- package/model-default.ts +535 -0
- package/package.json +10 -3
- package/picker.ts +246 -0
- package/profiles.ts +98 -1
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- 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 +372 -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()`.
|
|
@@ -512,7 +568,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
512
568
|
if (stop === "timeout") {
|
|
513
569
|
const capMs = Math.max(1000, config().timeoutMs);
|
|
514
570
|
deps.log(
|
|
515
|
-
`
|
|
571
|
+
`pass ${pass} hit its ${Math.round(
|
|
516
572
|
capMs / 1000,
|
|
517
573
|
)}s limit — continuing with ${
|
|
518
574
|
submitted ? "the submitted plan" : "the author's plan"
|
|
@@ -528,18 +584,70 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
528
584
|
};
|
|
529
585
|
}
|
|
530
586
|
|
|
531
|
-
|
|
532
|
-
|
|
587
|
+
/** Release the child session and its round state (step 10). Never throws. */
|
|
588
|
+
async function disposeSession(): Promise<void> {
|
|
589
|
+
try {
|
|
590
|
+
abortController?.abort();
|
|
591
|
+
await session?.abort();
|
|
592
|
+
} catch {
|
|
593
|
+
/* ignore */
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
session?.dispose();
|
|
597
|
+
} catch {
|
|
598
|
+
/* ignore */
|
|
599
|
+
}
|
|
600
|
+
session = undefined;
|
|
601
|
+
state = undefined;
|
|
602
|
+
transcript = undefined;
|
|
603
|
+
sessionModelKey = undefined;
|
|
604
|
+
gate = createReviewRoundGate();
|
|
605
|
+
abortController = undefined;
|
|
606
|
+
try {
|
|
607
|
+
deps.onState?.(undefined);
|
|
608
|
+
} catch {
|
|
609
|
+
/* ignore */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Outcome of `ensureSession`: `created` is true only for a fresh boot. */
|
|
614
|
+
interface EnsureSessionResult {
|
|
615
|
+
ok: boolean;
|
|
616
|
+
created: boolean;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Idempotent session boot. `created` tells the caller whether THIS call paid
|
|
621
|
+
* for a new child session, which is what makes the transcript's run label
|
|
622
|
+
* truthful (fresh boot vs warm resume/next round).
|
|
623
|
+
*/
|
|
624
|
+
async function ensureSession(): Promise<EnsureSessionResult> {
|
|
625
|
+
const modelRef = deps.reviewerModel();
|
|
626
|
+
const modelKey = modelRef
|
|
627
|
+
? `${modelRef.provider}/${modelRef.id}/${modelRef.thinking}`
|
|
628
|
+
: "";
|
|
629
|
+
if (session) {
|
|
630
|
+
// Step 10: the session is bound to the model it was created with. A
|
|
631
|
+
// reviewer switch since then reboots it so the new model audits.
|
|
632
|
+
if (modelKey && sessionModelKey && modelKey !== sessionModelKey) {
|
|
633
|
+
deps.log(
|
|
634
|
+
"reviewer model changed — restarting the reviewer session.",
|
|
635
|
+
"info",
|
|
636
|
+
);
|
|
637
|
+
await disposeSession();
|
|
638
|
+
} else {
|
|
639
|
+
return { ok: true, created: false };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
533
642
|
if (starting) return starting;
|
|
534
|
-
starting = (async () => {
|
|
643
|
+
starting = (async (): Promise<EnsureSessionResult> => {
|
|
535
644
|
const cfg = config();
|
|
536
|
-
const modelRef = deps.reviewerModel();
|
|
537
645
|
if (!modelRef) {
|
|
538
646
|
deps.log(
|
|
539
|
-
"
|
|
647
|
+
"no reviewer model configured — run /role set reviewer <provider/model>",
|
|
540
648
|
"warning",
|
|
541
649
|
);
|
|
542
|
-
return false;
|
|
650
|
+
return { ok: false, created: false };
|
|
543
651
|
}
|
|
544
652
|
try {
|
|
545
653
|
// The SDK must be loaded BEFORE building the tools: review_bash is
|
|
@@ -553,7 +661,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
553
661
|
cwd: deps.cwd,
|
|
554
662
|
log: deps.log,
|
|
555
663
|
explore: {
|
|
556
|
-
|
|
664
|
+
// Step 8: Planner recon rides along in the prompt — when it is
|
|
665
|
+
// present the reviewer verifies instead of re-exploring.
|
|
666
|
+
budget: () =>
|
|
667
|
+
(deps.hasRecon?.() ?? false) ? 0 : config().exploreBudget,
|
|
557
668
|
run: (task, signal) => deps.runExplore(task, signal),
|
|
558
669
|
onUsed: (used) => {
|
|
559
670
|
if (state) state.exploreUsed = used;
|
|
@@ -642,22 +753,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
642
753
|
|
|
643
754
|
if (!created.ok || !created.session) {
|
|
644
755
|
deps.log(
|
|
645
|
-
`
|
|
756
|
+
`reviewer unavailable: ${created.error ?? "unknown error"} — the plan will be handed over unreviewed.`,
|
|
646
757
|
cfg.fallbackOnError === "block" ? "error" : "warning",
|
|
647
758
|
);
|
|
648
|
-
return false;
|
|
759
|
+
return { ok: false, created: false };
|
|
649
760
|
}
|
|
650
761
|
|
|
651
762
|
// One line per session, so "the reviewer has no tools" is visible
|
|
652
763
|
// immediately instead of only after a failed pass (see /review-status).
|
|
653
764
|
if (created.activeTools) {
|
|
654
765
|
deps.log(
|
|
655
|
-
`
|
|
766
|
+
`reviewer tools active — ${created.activeTools.join(", ")}`,
|
|
656
767
|
"info",
|
|
657
768
|
);
|
|
658
769
|
}
|
|
659
770
|
|
|
660
771
|
session = created.session;
|
|
772
|
+
// Step 10: remember which model this session audits with.
|
|
773
|
+
sessionModelKey = modelKey;
|
|
774
|
+
// Config, not round state: report the missing cap once per live
|
|
775
|
+
// session, so a resume cannot replay it as if the reviewer had just
|
|
776
|
+
// started. Also avoids the old `Review Mode: Review Mode:` doubling.
|
|
777
|
+
if (cfg.timeoutMs <= 0) {
|
|
778
|
+
deps.log(
|
|
779
|
+
"no time limit configured — each pass ends when the reviewer submits or its run settles.",
|
|
780
|
+
"info",
|
|
781
|
+
);
|
|
782
|
+
}
|
|
661
783
|
ensureTranscript();
|
|
662
784
|
const child = created.session;
|
|
663
785
|
session.subscribe((event) => {
|
|
@@ -710,13 +832,13 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
710
832
|
}, 0);
|
|
711
833
|
}
|
|
712
834
|
});
|
|
713
|
-
return true;
|
|
835
|
+
return { ok: true, created: true };
|
|
714
836
|
} catch (e: unknown) {
|
|
715
837
|
deps.log(
|
|
716
|
-
`
|
|
838
|
+
`failed to start: ${e instanceof Error ? e.message : String(e)}`,
|
|
717
839
|
"warning",
|
|
718
840
|
);
|
|
719
|
-
return false;
|
|
841
|
+
return { ok: false, created: false };
|
|
720
842
|
} finally {
|
|
721
843
|
starting = undefined;
|
|
722
844
|
}
|
|
@@ -724,11 +846,20 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
724
846
|
return starting;
|
|
725
847
|
}
|
|
726
848
|
|
|
849
|
+
/** Public shape: idempotent, creates the reviewer session if it is not alive. */
|
|
850
|
+
async function ensureStarted(): Promise<boolean> {
|
|
851
|
+
return (await ensureSession()).ok;
|
|
852
|
+
}
|
|
853
|
+
|
|
727
854
|
async function runRound(input: RunRoundInput): Promise<ReviewRoundResult> {
|
|
728
855
|
const cfg = config();
|
|
729
856
|
const hash = planHash(input.planText);
|
|
730
857
|
const started = Date.now();
|
|
731
858
|
const originalPlan = input.planText;
|
|
859
|
+
// Declared outside the try: a `let` inside the try block would not be
|
|
860
|
+
// visible in `finally` (block scoping), and the external listener must
|
|
861
|
+
// always be detached when the round ends.
|
|
862
|
+
let detachExternal: (() => void) | undefined;
|
|
732
863
|
|
|
733
864
|
const fail = (
|
|
734
865
|
reason: string,
|
|
@@ -768,10 +899,52 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
768
899
|
return fail("no plan path — the reviewer has nowhere to submit");
|
|
769
900
|
}
|
|
770
901
|
|
|
771
|
-
|
|
772
|
-
|
|
902
|
+
// A signal that is already aborted means the host turn this round would
|
|
903
|
+
// belong to is gone (e.g. the planner turn was interrupted). Do not boot
|
|
904
|
+
// a session, send a prompt or spend a pass on it: report a fast abort.
|
|
905
|
+
// The caller decides what that means — the automatic handoff skips the
|
|
906
|
+
// review and hands off unreviewed, while an explicit `/review-resume`
|
|
907
|
+
// passes no host signal at all (its stop paths are ESC / `/review-abort`).
|
|
908
|
+
// Crucially, a pre-aborted signal must never become a pause: doing so
|
|
909
|
+
// re-created `pausedReview` on every resume and parked the handoff
|
|
910
|
+
// forever (2026-10-25 stall).
|
|
911
|
+
if (input.signal?.aborted === true) {
|
|
912
|
+
if (!state || state.planHash !== hash) {
|
|
913
|
+
state = createReviewState({
|
|
914
|
+
planHash: hash,
|
|
915
|
+
planPath: input.planPath,
|
|
916
|
+
modelLabel: session?.modelLabel ?? "reviewer",
|
|
917
|
+
round: input.round,
|
|
918
|
+
now: started,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
state.round = input.round;
|
|
922
|
+
state.planPath = input.planPath;
|
|
923
|
+
state.phase = "aborted";
|
|
924
|
+
state.error =
|
|
925
|
+
"aborted before start — the host turn was already aborted";
|
|
926
|
+
state.finishedAt = Date.now();
|
|
927
|
+
try {
|
|
928
|
+
deps.onState?.(state);
|
|
929
|
+
} catch {
|
|
930
|
+
/* status updates are best-effort */
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
fallback: true,
|
|
935
|
+
planText: originalPlan,
|
|
936
|
+
planHash: hash,
|
|
937
|
+
state,
|
|
938
|
+
reason: "aborted-before-start",
|
|
939
|
+
abortedBeforeStart: true,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const start = await ensureSession();
|
|
944
|
+
if (!start.ok || !session) {
|
|
773
945
|
return fail("reviewer session unavailable");
|
|
774
946
|
}
|
|
947
|
+
const created = start.created;
|
|
775
948
|
const child = session;
|
|
776
949
|
|
|
777
950
|
if (!state || state.planHash !== hash || state.phase === "done") {
|
|
@@ -789,16 +962,33 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
789
962
|
|
|
790
963
|
abortController = new AbortController();
|
|
791
964
|
const rc = abortController;
|
|
965
|
+
// ESC cancels: link the host signal (when the caller threads one
|
|
966
|
+
// through) to the round's controller, so ESC aborts the pass race,
|
|
967
|
+
// the settle wait and the send alike. The existing `aborted` branch
|
|
968
|
+
// below then keeps the session warm and hands back the author's plan.
|
|
969
|
+
const externalSignal = input.signal;
|
|
970
|
+
if (externalSignal) {
|
|
971
|
+
if (externalSignal.aborted) {
|
|
972
|
+
rc.abort();
|
|
973
|
+
} else {
|
|
974
|
+
const onExternalAbort = () => {
|
|
975
|
+
try {
|
|
976
|
+
rc.abort();
|
|
977
|
+
} catch {
|
|
978
|
+
/* abort is best-effort */
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
externalSignal.addEventListener("abort", onExternalAbort, {
|
|
982
|
+
once: true,
|
|
983
|
+
});
|
|
984
|
+
detachExternal = () =>
|
|
985
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
792
988
|
// 0 (default) = no cap. A configured cap is the outer safety net; the
|
|
793
989
|
// settle race below still ends a silent pass quickly either way.
|
|
794
990
|
const limitMs = cfg.timeoutMs > 0 ? cfg.timeoutMs : undefined;
|
|
795
991
|
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
992
|
|
|
803
993
|
// ── Pass 1 ──────────────────────────────────────────────────────
|
|
804
994
|
await settleInFlight(deadline, rc);
|
|
@@ -808,16 +998,26 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
808
998
|
planPath: input.planPath,
|
|
809
999
|
planText: originalPlan,
|
|
810
1000
|
reviewPrompt: deps.promptText(),
|
|
811
|
-
contextBlock: renderReviewContextBlock(deps.contextEntries()
|
|
1001
|
+
contextBlock: renderReviewContextBlock(deps.contextEntries(), {
|
|
1002
|
+
// Step 7 — diet default (settings-overridable).
|
|
1003
|
+
maxChars: config().contextChars,
|
|
1004
|
+
}),
|
|
1005
|
+
// Step 8: inventory + recon so the reviewer verifies, not re-explores.
|
|
1006
|
+
manifest: deps.plannerManifest?.() ?? "",
|
|
812
1007
|
previousFindings: input.previousFindings,
|
|
813
1008
|
round: input.round,
|
|
814
1009
|
totalRounds: cfg.rounds,
|
|
815
1010
|
verify: cfg.verify && cfg.passes > 1,
|
|
816
1011
|
});
|
|
817
1012
|
|
|
818
|
-
const p1 = await runPass(1, deadline, rc, () =>
|
|
819
|
-
|
|
820
|
-
|
|
1013
|
+
const p1 = await runPass(1, deadline, rc, () => {
|
|
1014
|
+
// Pushed inside `send`: an already-aborted pass never sends, so it must
|
|
1015
|
+
// not claim a start either.
|
|
1016
|
+
pushStatus(
|
|
1017
|
+
reviewRunLabel({ created, resumed: input.resumed === true, pass: 1 }),
|
|
1018
|
+
);
|
|
1019
|
+
return child.prompt(pass1Prompt);
|
|
1020
|
+
});
|
|
821
1021
|
|
|
822
1022
|
if (p1.stop === "error") {
|
|
823
1023
|
await abortChild();
|
|
@@ -872,7 +1072,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
872
1072
|
// The protocol completed, the run did not: stop paying for it, but keep
|
|
873
1073
|
// the submission — never discard real work over a slow model.
|
|
874
1074
|
deps.log(
|
|
875
|
-
"
|
|
1075
|
+
"pass 1 exceeded its time limit after submitting — using the submitted plan.",
|
|
876
1076
|
"warning",
|
|
877
1077
|
);
|
|
878
1078
|
await abortChild();
|
|
@@ -897,9 +1097,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
897
1097
|
rewrittenPlan: p1.submitted.planBody,
|
|
898
1098
|
findings: state?.findings ?? [],
|
|
899
1099
|
});
|
|
900
|
-
const p2 = await runPass(2, deadline, rc, () =>
|
|
901
|
-
|
|
902
|
-
|
|
1100
|
+
const p2 = await runPass(2, deadline, rc, () => {
|
|
1101
|
+
pushStatus(reviewRunLabel({ created, pass: 2 }));
|
|
1102
|
+
return child.followUp(pass2Prompt);
|
|
1103
|
+
});
|
|
903
1104
|
// Name the provider when the verification pass died on one; pass 2
|
|
904
1105
|
// stays best-effort either way — pass 1's plan is never lost to it.
|
|
905
1106
|
const p2Provider = lastProviderError
|
|
@@ -909,8 +1110,8 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
909
1110
|
// Verification is an enhancement: never lose pass 1's plan over it.
|
|
910
1111
|
deps.log(
|
|
911
1112
|
p2Provider
|
|
912
|
-
? `
|
|
913
|
-
: `
|
|
1113
|
+
? `verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
|
|
1114
|
+
: `verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
|
|
914
1115
|
"warning",
|
|
915
1116
|
);
|
|
916
1117
|
} else if (p2.submitted) {
|
|
@@ -919,7 +1120,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
919
1120
|
// The run settled without a submission AND a provider error was
|
|
920
1121
|
// seen this pass: say why, then keep going with pass 1's rewrite.
|
|
921
1122
|
deps.log(
|
|
922
|
-
`
|
|
1123
|
+
`verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
|
|
923
1124
|
"warning",
|
|
924
1125
|
);
|
|
925
1126
|
}
|
|
@@ -949,6 +1150,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
949
1150
|
`review round failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
950
1151
|
);
|
|
951
1152
|
} finally {
|
|
1153
|
+
try {
|
|
1154
|
+
detachExternal?.();
|
|
1155
|
+
} catch {
|
|
1156
|
+
/* listener cleanup is best-effort */
|
|
1157
|
+
}
|
|
952
1158
|
abortController = undefined;
|
|
953
1159
|
roundActive = false;
|
|
954
1160
|
}
|
|
@@ -1008,27 +1214,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
1008
1214
|
}
|
|
1009
1215
|
},
|
|
1010
1216
|
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
|
-
}
|
|
1217
|
+
await disposeSession();
|
|
1032
1218
|
},
|
|
1033
1219
|
};
|
|
1034
1220
|
}
|
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")];
|