@dieulc/workflow 0.7.0 → 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 +110 -28
- package/commit-policy.ts +43 -0
- package/index.ts +2725 -1100
- 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 +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.ts
CHANGED
|
@@ -78,10 +78,21 @@ export interface ReviewModeConfig {
|
|
|
78
78
|
fallbackOnError: ReviewFallbackOnError;
|
|
79
79
|
/** Max `review_explore` subagents per review round (cost ceiling). */
|
|
80
80
|
exploreBudget: number;
|
|
81
|
+
/**
|
|
82
|
+
* Max chars of shared Plan Mode context in the pass-1 prompt (step 7).
|
|
83
|
+
* Diet default keeps the review fast; the truncation marker tells the
|
|
84
|
+
* reviewer to re-read via read/grep anything it relies on.
|
|
85
|
+
*/
|
|
86
|
+
contextChars: number;
|
|
87
|
+
/** Max pruned entries handed to the context renderer. */
|
|
88
|
+
contextEntries: number;
|
|
89
|
+
/** Max chars kept per tool-result string when pruning entries. */
|
|
90
|
+
contextToolChars: number;
|
|
81
91
|
/**
|
|
82
92
|
* Optional wall-clock cap for a single review round, in milliseconds.
|
|
83
|
-
* 0 = unlimited (
|
|
84
|
-
*
|
|
93
|
+
* 0 = unlimited (explicit opt-out). Default 300_000 (5 minutes, step 10):
|
|
94
|
+
* a hung reviewer must not hold the handoff forever; the settle race still
|
|
95
|
+
* ends a silent pass in seconds either way.
|
|
85
96
|
*/
|
|
86
97
|
timeoutMs: number;
|
|
87
98
|
}
|
|
@@ -92,8 +103,11 @@ export const DEFAULT_REVIEW_MODE_CONFIG: Readonly<ReviewModeConfig> = {
|
|
|
92
103
|
passes: 2,
|
|
93
104
|
verify: true,
|
|
94
105
|
fallbackOnError: "skip",
|
|
95
|
-
exploreBudget:
|
|
96
|
-
timeoutMs:
|
|
106
|
+
exploreBudget: 1,
|
|
107
|
+
timeoutMs: 300_000,
|
|
108
|
+
contextChars: 15_000,
|
|
109
|
+
contextEntries: 30,
|
|
110
|
+
contextToolChars: 1_000,
|
|
97
111
|
};
|
|
98
112
|
|
|
99
113
|
const ROUNDS_MIN = 1;
|
|
@@ -104,6 +118,12 @@ const EXPLORE_MIN = 0;
|
|
|
104
118
|
const EXPLORE_MAX = 8;
|
|
105
119
|
const TIMEOUT_MIN = 0;
|
|
106
120
|
const TIMEOUT_MAX = 3_600_000;
|
|
121
|
+
const CONTEXT_CHARS_MIN = 1_000;
|
|
122
|
+
const CONTEXT_CHARS_MAX = 60_000;
|
|
123
|
+
const CONTEXT_ENTRIES_MIN = 1;
|
|
124
|
+
const CONTEXT_ENTRIES_MAX = 120;
|
|
125
|
+
const CONTEXT_TOOL_CHARS_MIN = 200;
|
|
126
|
+
const CONTEXT_TOOL_CHARS_MAX = 4_000;
|
|
107
127
|
|
|
108
128
|
/** A JSON object parsed from an untrusted source (settings file, fragment). */
|
|
109
129
|
export type JsonObject = Record<string, unknown>;
|
|
@@ -161,6 +181,24 @@ export function coerceReviewModeConfig(
|
|
|
161
181
|
base.exploreBudget,
|
|
162
182
|
),
|
|
163
183
|
timeoutMs: clampInt(r.timeoutMs, TIMEOUT_MIN, TIMEOUT_MAX, base.timeoutMs),
|
|
184
|
+
contextChars: clampInt(
|
|
185
|
+
r.contextChars,
|
|
186
|
+
CONTEXT_CHARS_MIN,
|
|
187
|
+
CONTEXT_CHARS_MAX,
|
|
188
|
+
base.contextChars,
|
|
189
|
+
),
|
|
190
|
+
contextEntries: clampInt(
|
|
191
|
+
r.contextEntries,
|
|
192
|
+
CONTEXT_ENTRIES_MIN,
|
|
193
|
+
CONTEXT_ENTRIES_MAX,
|
|
194
|
+
base.contextEntries,
|
|
195
|
+
),
|
|
196
|
+
contextToolChars: clampInt(
|
|
197
|
+
r.contextToolChars,
|
|
198
|
+
CONTEXT_TOOL_CHARS_MIN,
|
|
199
|
+
CONTEXT_TOOL_CHARS_MAX,
|
|
200
|
+
base.contextToolChars,
|
|
201
|
+
),
|
|
164
202
|
};
|
|
165
203
|
}
|
|
166
204
|
|
|
@@ -280,7 +318,11 @@ export function readReviewModeConfig(
|
|
|
280
318
|
}
|
|
281
319
|
|
|
282
320
|
/**
|
|
283
|
-
* The
|
|
321
|
+
* The automatic-review gate: true only when the persisted
|
|
322
|
+
* `workflow.reviewMode.enabled` flag is on. This gates AUTOMATIC review
|
|
323
|
+
* only (background handoff kickoff, reviewer prefetch, planner prompt
|
|
324
|
+
* note). Manual one-shot commands (`/review`, `/review say|pause|resume|
|
|
325
|
+
* abort|status`) intentionally bypass this gate — see `canRunManualReview`.
|
|
284
326
|
* Never throws.
|
|
285
327
|
*/
|
|
286
328
|
export function isReviewEnabled(cwd: string, agentDir: string): boolean {
|
|
@@ -291,6 +333,31 @@ export function isReviewEnabled(cwd: string, agentDir: string): boolean {
|
|
|
291
333
|
}
|
|
292
334
|
}
|
|
293
335
|
|
|
336
|
+
/**
|
|
337
|
+
* What caused a review round: `"auto"` is the background handoff audit
|
|
338
|
+
* (requires the persisted `enabled` flag); `"manual"` is an explicit
|
|
339
|
+
* user command (`/review`, `/review-resume` fresh-plan branch) and bypasses
|
|
340
|
+
* only the `enabled` check — limits, model and prompt still come from saved
|
|
341
|
+
* config. Never persisted; the manual path never flips `enabled`.
|
|
342
|
+
*/
|
|
343
|
+
export type ReviewTrigger = "auto" | "manual";
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The manual-review gate: true whenever review settings are readable,
|
|
347
|
+
* regardless of the persisted `enabled` flag. A one-shot `/review` uses the
|
|
348
|
+
* saved rounds/passes/verify/budget/timeout/model as-is and never flips
|
|
349
|
+
* `enabled`. Returns false only when settings cannot be read at all.
|
|
350
|
+
* Never throws.
|
|
351
|
+
*/
|
|
352
|
+
export function canRunManualReview(cwd: string, agentDir: string): boolean {
|
|
353
|
+
try {
|
|
354
|
+
readReviewModeConfig(cwd, agentDir);
|
|
355
|
+
return true;
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
294
361
|
/**
|
|
295
362
|
* Read-modify-write `workflow.reviewMode` into a settings file, preserving
|
|
296
363
|
* every other key in that file (Pi owns settings.json — we must not clobber it).
|
|
@@ -707,9 +774,18 @@ export function shouldRouteInputToReviewer(input: {
|
|
|
707
774
|
reviewEnabled: boolean;
|
|
708
775
|
reviewActive: boolean;
|
|
709
776
|
hasReviewerSession: boolean;
|
|
777
|
+
/**
|
|
778
|
+
* Live ad-hoc session override: true while a manual one-shot review
|
|
779
|
+
* owns a live reviewer session (`reviewSessionLive()` in index.ts).
|
|
780
|
+
* Lets `/review say` and plain-chat steering reach the reviewer even
|
|
781
|
+
* when the persisted `enabled` flag is off. Never persisted.
|
|
782
|
+
*/
|
|
783
|
+
manualSessionActive?: boolean;
|
|
710
784
|
}): boolean {
|
|
785
|
+
const allowed =
|
|
786
|
+
input.reviewEnabled === true || input.manualSessionActive === true;
|
|
711
787
|
return (
|
|
712
|
-
|
|
788
|
+
allowed &&
|
|
713
789
|
input.reviewActive === true &&
|
|
714
790
|
input.hasReviewerSession === true
|
|
715
791
|
);
|
|
@@ -747,6 +823,166 @@ export interface ReviewRoundResult {
|
|
|
747
823
|
planHash: string;
|
|
748
824
|
state: ReviewState;
|
|
749
825
|
reason?: string;
|
|
826
|
+
/**
|
|
827
|
+
* The round never started because the host signal was already aborted.
|
|
828
|
+
* A pre-aborted signal is an abort, never a pause (see
|
|
829
|
+
* `classifyReviewExecution`).
|
|
830
|
+
*/
|
|
831
|
+
abortedBeforeStart?: boolean;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* How one executed review round ended. Used for user notices and for deciding
|
|
836
|
+
* whether a paused gated handoff settles. `"paused"` is the only outcome that
|
|
837
|
+
* keeps the handoff open; everything else is terminal.
|
|
838
|
+
*/
|
|
839
|
+
export type ReviewExecutionOutcome =
|
|
840
|
+
| "rewritten"
|
|
841
|
+
| "unchanged"
|
|
842
|
+
| "paused"
|
|
843
|
+
| "aborted"
|
|
844
|
+
| "skipped"
|
|
845
|
+
| "fallback";
|
|
846
|
+
|
|
847
|
+
/** Result of one `/review`, `/review-resume` or gated handoff execution. */
|
|
848
|
+
export interface ReviewExecutionResult {
|
|
849
|
+
/** Plan to hand off: the reviewer's rewrite or the author's text. */
|
|
850
|
+
planText: string;
|
|
851
|
+
outcome: ReviewExecutionOutcome;
|
|
852
|
+
/** Human-facing reason for paused / aborted / skipped / fallback. */
|
|
853
|
+
reason?: string;
|
|
854
|
+
/** True when `pausedReview` holds this revision and the gate stays open. */
|
|
855
|
+
paused: boolean;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Classify one round outcome from the pieces the orchestrator already knows.
|
|
860
|
+
*
|
|
861
|
+
* Invariant: a pre-aborted/stale host signal (`abortedBeforeStart`) is an
|
|
862
|
+
* abort, never a pause. Only an explicit pause snapshot may keep the gate
|
|
863
|
+
* open, so `paused` is decided by the caller from `pausedReview.planHash`.
|
|
864
|
+
*/
|
|
865
|
+
export function classifyReviewExecution(input: {
|
|
866
|
+
ok: boolean;
|
|
867
|
+
aborted: boolean;
|
|
868
|
+
abortedBeforeStart?: boolean;
|
|
869
|
+
paused: boolean;
|
|
870
|
+
planChanged: boolean;
|
|
871
|
+
}): ReviewExecutionOutcome {
|
|
872
|
+
if (input.paused === true) return "paused";
|
|
873
|
+
if (input.ok === true) return input.planChanged ? "rewritten" : "unchanged";
|
|
874
|
+
if (input.aborted === true || input.abortedBeforeStart === true) {
|
|
875
|
+
return "aborted";
|
|
876
|
+
}
|
|
877
|
+
return "fallback";
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** True when a resume outcome ends the round (everything except a re-pause). */
|
|
881
|
+
export function reviewResumeSettles(result: ReviewExecutionResult): boolean {
|
|
882
|
+
return result.outcome !== "paused";
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Gate outcome for a settled resume. Only a real rewrite counts as reviewed;
|
|
887
|
+
* an unchanged plan, a skip, an abort or a fallback hands off the author's
|
|
888
|
+
* plan. `undefined` means "keep the gate parked" (the round paused again).
|
|
889
|
+
*/
|
|
890
|
+
export function reviewResumeGateOutcome(
|
|
891
|
+
result: ReviewExecutionResult,
|
|
892
|
+
): "reviewed" | "unreviewed" | undefined {
|
|
893
|
+
if (result.outcome === "paused") return undefined;
|
|
894
|
+
return result.outcome === "rewritten" ? "reviewed" : "unreviewed";
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** One accurate user-facing notice for a resume result. */
|
|
898
|
+
export function reviewResumeNotice(result: ReviewExecutionResult): string {
|
|
899
|
+
const reason = result.reason ? ` (${result.reason})` : "";
|
|
900
|
+
switch (result.outcome) {
|
|
901
|
+
case "paused":
|
|
902
|
+
return "Review paused again — /review-resume to continue, /review-abort to stop.";
|
|
903
|
+
case "rewritten":
|
|
904
|
+
return "Review finished — plan rewritten and saved.";
|
|
905
|
+
case "unchanged":
|
|
906
|
+
return "Review finished — the reviewer kept the plan as-is.";
|
|
907
|
+
case "aborted":
|
|
908
|
+
return "Review stopped — the author's plan stands.";
|
|
909
|
+
case "skipped":
|
|
910
|
+
return `Review skipped${reason} — the plan will be handed over as-is.`;
|
|
911
|
+
case "fallback":
|
|
912
|
+
default:
|
|
913
|
+
return `Review could not complete${reason} — the plan will be handed over unreviewed (run /review to retry).`;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The transcript line for the run that is about to start.
|
|
919
|
+
*
|
|
920
|
+
* `agent_start` fires for every prompt on the same child session, so it cannot
|
|
921
|
+
* distinguish a cold reviewer boot from a warm resume or a next round. The
|
|
922
|
+
* runtime knows both facts (`created`, `resumed`) at the moment it sends the
|
|
923
|
+
* prompt, and this helper turns them into one truthful line.
|
|
924
|
+
*
|
|
925
|
+
* `created` wins over `resumed`: a resume whose session had to be rebuilt (the
|
|
926
|
+
* reviewer model changed) really did start a new session, and saying so is more
|
|
927
|
+
* useful than claiming a warm resume that did not happen.
|
|
928
|
+
*/
|
|
929
|
+
export function reviewRunLabel(input: {
|
|
930
|
+
/** The runtime had to create a reviewer session for this round. */
|
|
931
|
+
created: boolean;
|
|
932
|
+
/** `/review-resume` is continuing a paused round on the warm session. */
|
|
933
|
+
resumed?: boolean;
|
|
934
|
+
/** 1 for the audit pass, 2 for the self-verification pass. */
|
|
935
|
+
pass?: 1 | 2;
|
|
936
|
+
}): string {
|
|
937
|
+
if (input.pass === 2) return "reviewer verifying — pass 2";
|
|
938
|
+
if (input.created === true) {
|
|
939
|
+
return input.resumed === true
|
|
940
|
+
? "reviewer restarted — new reviewer session"
|
|
941
|
+
: "reviewer started";
|
|
942
|
+
}
|
|
943
|
+
return input.resumed === true ? "reviewer resumed" : "reviewer continuing";
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Prefix a Review Mode notice exactly once.
|
|
948
|
+
*
|
|
949
|
+
* Callers already speak in sentences ("no reviewer model configured …"), while
|
|
950
|
+
* a few sources carried their own `Review Mode: `/`Review Mode ` prefix. Blindly
|
|
951
|
+
* interpolating produced `Review Mode: Review Mode: …`. Idempotent by design:
|
|
952
|
+
* anything already starting with `Review Mode` is returned unchanged.
|
|
953
|
+
*/
|
|
954
|
+
export function reviewNoticeText(message: string): string {
|
|
955
|
+
const text = String(message ?? "").trim();
|
|
956
|
+
if (text.length === 0) return "Review Mode: ";
|
|
957
|
+
return /^Review Mode\b/.test(text) ? text : `Review Mode: ${text}`;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Whether `/review-resume` may reuse the paused round or must start fresh. */
|
|
961
|
+
export type ReviewResumePlanChoice = "fresh" | "warm";
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Decide whether a paused review can still be resumed as-is.
|
|
965
|
+
*
|
|
966
|
+
* The plan *file* is not a reliable witness on its own: the reviewer writes its
|
|
967
|
+
* rewrite to that same file during the round (`review_submit_plan`), so after a
|
|
968
|
+
* pause during pass 2 the file no longer matches the text the round started
|
|
969
|
+
* with. Comparing against the hash captured at pause time means only a change
|
|
970
|
+
* made **after** the pause — an author edit — starts a fresh round.
|
|
971
|
+
*
|
|
972
|
+
* A missing file (or a snapshot from before this field existed) keeps today's
|
|
973
|
+
* behaviour: resume the paused text.
|
|
974
|
+
*/
|
|
975
|
+
export function reviewResumePlanChoice(input: {
|
|
976
|
+
/** Hash of the text the paused round was auditing. */
|
|
977
|
+
pausedPlanHash: string;
|
|
978
|
+
/** Plan-file hash captured when the pause was recorded. */
|
|
979
|
+
pausedFileHash?: string;
|
|
980
|
+
/** Plan-file hash read by `/review-resume`; undefined when the file is gone. */
|
|
981
|
+
currentFileHash?: string;
|
|
982
|
+
}): ReviewResumePlanChoice {
|
|
983
|
+
if (input.currentFileHash === undefined) return "warm";
|
|
984
|
+
const baseline = input.pausedFileHash ?? input.pausedPlanHash;
|
|
985
|
+
return input.currentFileHash === baseline ? "warm" : "fresh";
|
|
750
986
|
}
|
|
751
987
|
|
|
752
988
|
const DISPOSITIONS: readonly ReviewDisposition[] = [
|
|
@@ -926,7 +1162,7 @@ export function applySubmittedPlan(
|
|
|
926
1162
|
|
|
927
1163
|
try {
|
|
928
1164
|
options.notify?.(
|
|
929
|
-
`
|
|
1165
|
+
`submitted the plan (${options.verdict}, ${options.findings.length} finding${
|
|
930
1166
|
options.findings.length === 1 ? "" : "s"
|
|
931
1167
|
})`,
|
|
932
1168
|
"info",
|
|
@@ -993,6 +1229,10 @@ function deferredWait<T>(
|
|
|
993
1229
|
): Promise<T | undefined> {
|
|
994
1230
|
const existing = current();
|
|
995
1231
|
if (existing !== undefined) return Promise.resolve(existing);
|
|
1232
|
+
// A signal that is already aborted must resolve immediately: its `abort`
|
|
1233
|
+
// event has already fired, so a listener registered below would never run
|
|
1234
|
+
// and the caller (the gated handoff) would wait forever.
|
|
1235
|
+
if (signal?.aborted === true) return Promise.resolve(undefined);
|
|
996
1236
|
return new Promise<T | undefined>((resolve) => {
|
|
997
1237
|
let settled = false;
|
|
998
1238
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -1073,6 +1313,260 @@ export function createReviewRoundGate(): ReviewRoundGate {
|
|
|
1073
1313
|
};
|
|
1074
1314
|
}
|
|
1075
1315
|
|
|
1316
|
+
// ── Gated handoff (plan 2026-10-23 follow-up) ───────────────────────────
|
|
1317
|
+
//
|
|
1318
|
+
// The handoff waits for the review instead of racing it: `runHandoff`
|
|
1319
|
+
// awaits this gate, and `/review-resume` / `/review-abort` / teardown
|
|
1320
|
+
// settle it. A pause does NOT resolve the gate — the waiter keeps waiting
|
|
1321
|
+
// for the resume/abort outcome. Exactly-once: the first `resolve` wins;
|
|
1322
|
+
// a reload drops the gate (in-memory only), so a fresh `agent_end` simply
|
|
1323
|
+
// reviews again instead of rendering a phantom handoff.
|
|
1324
|
+
|
|
1325
|
+
/** Lifecycle of the gated handoff review phase (in-memory, never persisted). */
|
|
1326
|
+
export type ReviewHandoffPhase = "idle" | "auditing" | "paused";
|
|
1327
|
+
|
|
1328
|
+
/** How the gated review phase ended, for handoff rendering. */
|
|
1329
|
+
export type ReviewHandoffOutcome = "reviewed" | "unreviewed";
|
|
1330
|
+
|
|
1331
|
+
/** The settled value the handoff renderer consumes. */
|
|
1332
|
+
export interface ReviewHandoffResult {
|
|
1333
|
+
outcome: ReviewHandoffOutcome;
|
|
1334
|
+
/** Final plan text; `undefined` means "keep the author's draft". */
|
|
1335
|
+
planText: string | undefined;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
export interface ReviewHandoffGate {
|
|
1339
|
+
/** Clear the slot for a new gated phase. */
|
|
1340
|
+
reset(): void;
|
|
1341
|
+
/** Settle the gate; the first call wins, later calls are no-ops. */
|
|
1342
|
+
resolve(result: ReviewHandoffResult): void;
|
|
1343
|
+
/** True once `resolve` has run. */
|
|
1344
|
+
settled(): boolean;
|
|
1345
|
+
/** Latest settled result, if any (read-only peek for the waiter). */
|
|
1346
|
+
peek(): ReviewHandoffResult | undefined;
|
|
1347
|
+
/**
|
|
1348
|
+
* Await settlement. Resolves `undefined` if the caller's signal aborts
|
|
1349
|
+
* first — the handoff then falls back to the author's draft.
|
|
1350
|
+
*/
|
|
1351
|
+
wait(signal?: AbortSignal): Promise<ReviewHandoffResult | undefined>;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
export function createReviewHandoffGate(): ReviewHandoffGate {
|
|
1355
|
+
let result: ReviewHandoffResult | undefined;
|
|
1356
|
+
let waiters: Array<(value: ReviewHandoffResult | undefined) => void> = [];
|
|
1357
|
+
return {
|
|
1358
|
+
reset() {
|
|
1359
|
+
result = undefined;
|
|
1360
|
+
waiters = [];
|
|
1361
|
+
},
|
|
1362
|
+
resolve(r) {
|
|
1363
|
+
if (result !== undefined) return;
|
|
1364
|
+
result = r;
|
|
1365
|
+
for (const resolve of waiters.splice(0)) {
|
|
1366
|
+
try {
|
|
1367
|
+
resolve(r);
|
|
1368
|
+
} catch {
|
|
1369
|
+
/* ignore */
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
settled() {
|
|
1374
|
+
return result !== undefined;
|
|
1375
|
+
},
|
|
1376
|
+
peek() {
|
|
1377
|
+
return result;
|
|
1378
|
+
},
|
|
1379
|
+
wait: (signal) =>
|
|
1380
|
+
deferredWait<ReviewHandoffResult>(
|
|
1381
|
+
() => result,
|
|
1382
|
+
(resolve) => waiters.push(resolve),
|
|
1383
|
+
undefined,
|
|
1384
|
+
signal,
|
|
1385
|
+
),
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Pure ESC-routing decision for the review input listener.
|
|
1391
|
+
*
|
|
1392
|
+
* The caller pre-filters raw input (`isKeyRelease` / `matchesKey(data,
|
|
1393
|
+
* Key.escape)`); this decides from round state only: pause exactly once per
|
|
1394
|
+
* round — a press while no round runs, or while already paused, falls
|
|
1395
|
+
* through so the editor, questionnaire and handoff selector keep ESC.
|
|
1396
|
+
*/
|
|
1397
|
+
export function shouldPauseReviewOnEscape(input: {
|
|
1398
|
+
roundActive: boolean;
|
|
1399
|
+
paused: boolean;
|
|
1400
|
+
}): boolean {
|
|
1401
|
+
return input.roundActive === true && input.paused !== true;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** What the review ESC listener should do with one key press. */
|
|
1405
|
+
export type ReviewEscapeAction = "pause" | "swallow" | "pass";
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Route an ESC press while a review owns the turn.
|
|
1409
|
+
*
|
|
1410
|
+
* - A live, unpaused round pauses (exactly once per round).
|
|
1411
|
+
* - A paused **gated** handoff swallows ESC with a hint: letting it reach the
|
|
1412
|
+
* editor aborts the parked parent turn and poisons `ctx.signal` for every
|
|
1413
|
+
* later `/review-resume` (the 2026-10-25 stall). `/review-abort` is the
|
|
1414
|
+
* explicit exit.
|
|
1415
|
+
* - Everything else passes ESC through to the editor / questionnaire / handoff
|
|
1416
|
+
* selector.
|
|
1417
|
+
*/
|
|
1418
|
+
export function reviewEscapeAction(input: {
|
|
1419
|
+
roundActive: boolean;
|
|
1420
|
+
paused: boolean;
|
|
1421
|
+
gatedPause: boolean;
|
|
1422
|
+
}): ReviewEscapeAction {
|
|
1423
|
+
if (
|
|
1424
|
+
shouldPauseReviewOnEscape({
|
|
1425
|
+
roundActive: input.roundActive,
|
|
1426
|
+
paused: input.paused,
|
|
1427
|
+
})
|
|
1428
|
+
) {
|
|
1429
|
+
return "pause";
|
|
1430
|
+
}
|
|
1431
|
+
if (input.paused === true && input.gatedPause === true) return "swallow";
|
|
1432
|
+
return "pass";
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Footer segment for a live review phase, composed into the single
|
|
1437
|
+
* `"workflow"` status by `updateStatus`. Empty when no review is live, so
|
|
1438
|
+
* callers can append unconditionally.
|
|
1439
|
+
*/
|
|
1440
|
+
export function reviewFooterSegment(
|
|
1441
|
+
state: ReviewState | undefined,
|
|
1442
|
+
paused: boolean,
|
|
1443
|
+
): string {
|
|
1444
|
+
if (paused) return "🔍 review paused — /review-resume to continue";
|
|
1445
|
+
if (!state) return "";
|
|
1446
|
+
return reviewStateSummary(state);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Whether the review status surfaces (footer segment and status widget) should
|
|
1451
|
+
* be visible at all.
|
|
1452
|
+
*
|
|
1453
|
+
* A review owns the turn only while a round is in flight (including the
|
|
1454
|
+
* cold-start `activeReview` window before `isRoundActive()` flips) or a pause
|
|
1455
|
+
* exists. `reviewState` alone is deliberately NOT enough: it is retained after
|
|
1456
|
+
* a round for `/review-status` and the REVIEW SUMMARY band, and using it to
|
|
1457
|
+
* drive the widget left a stale "🔍 review … aborted" line in Plan/Build mode.
|
|
1458
|
+
*/
|
|
1459
|
+
export function reviewStatusVisible(input: {
|
|
1460
|
+
roundInFlight?: boolean;
|
|
1461
|
+
roundActive?: boolean;
|
|
1462
|
+
paused?: boolean;
|
|
1463
|
+
}): boolean {
|
|
1464
|
+
return (
|
|
1465
|
+
input.roundInFlight === true ||
|
|
1466
|
+
input.roundActive === true ||
|
|
1467
|
+
input.paused === true
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
|
|
1472
|
+
//
|
|
1473
|
+
// While a review owns the turn, mode transitions are refused: switching to
|
|
1474
|
+
// Build mid-review would swap the tool set and suspend the reviewer, and
|
|
1475
|
+
// switching to Default would leave the reviewer running outside Plan Mode.
|
|
1476
|
+
// The lock is a PROJECTION over existing state (never persisted), so every
|
|
1477
|
+
// existing exit — land, fail, pause/resume, abort, suspend, teardown —
|
|
1478
|
+
// releases it automatically. The post-review handoff prompt is deliberately
|
|
1479
|
+
// NOT part of the lock: the independent reviewer has already finished.
|
|
1480
|
+
|
|
1481
|
+
/** Which mode-switch lock is active, if any. Derived, never persisted. */
|
|
1482
|
+
export type ReviewModeLockKind = "running" | "paused";
|
|
1483
|
+
|
|
1484
|
+
export interface ReviewModeLockInput {
|
|
1485
|
+
/** `activeReview !== undefined` — set before the child session boots. */
|
|
1486
|
+
roundInFlight?: boolean;
|
|
1487
|
+
/** `reviewRuntime.isRoundActive()` at call time. */
|
|
1488
|
+
roundActive?: boolean;
|
|
1489
|
+
/** `pausedReview !== undefined`. */
|
|
1490
|
+
paused?: boolean;
|
|
1491
|
+
/** The gated handoff phase; `"auditing"` counts as running. */
|
|
1492
|
+
gatePhase?: ReviewHandoffPhase;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Decide whether a mode transition must be refused.
|
|
1497
|
+
*
|
|
1498
|
+
* A real pause snapshot wins over a round that is still aborting: ESC has
|
|
1499
|
+
* already been pressed, so the notice should tell the user how to resume.
|
|
1500
|
+
* A live round wins over a stale gate phase: `/review-resume` clears the
|
|
1501
|
+
* snapshot and starts the round before the parked handoff resets its phase,
|
|
1502
|
+
* so `gatePhase === "paused"` must not mask the running reviewer. A bare
|
|
1503
|
+
* paused gate (no round, no snapshot) is the fallback for the small window
|
|
1504
|
+
* between an abort and the handoff continuation.
|
|
1505
|
+
*/
|
|
1506
|
+
export function reviewModeLockKind(
|
|
1507
|
+
input: ReviewModeLockInput,
|
|
1508
|
+
): ReviewModeLockKind | undefined {
|
|
1509
|
+
if (input.paused === true) return "paused";
|
|
1510
|
+
if (input.roundInFlight === true || input.roundActive === true) {
|
|
1511
|
+
return "running";
|
|
1512
|
+
}
|
|
1513
|
+
if (input.gatePhase === "paused") return "paused";
|
|
1514
|
+
if (input.gatePhase === "auditing") return "running";
|
|
1515
|
+
return undefined;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Phase to use when `/review-resume` starts a paused gated round.
|
|
1520
|
+
* A gated pause becomes an active audit again; a manual review (idle gate)
|
|
1521
|
+
* is unchanged. This keeps a second ESC able to demote auditing → paused.
|
|
1522
|
+
*/
|
|
1523
|
+
export function reviewGatePhaseOnResume(
|
|
1524
|
+
phase: ReviewHandoffPhase,
|
|
1525
|
+
): ReviewHandoffPhase {
|
|
1526
|
+
return phase === "paused" ? "auditing" : phase;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Whether the gated handoff may park on the review gate.
|
|
1531
|
+
*
|
|
1532
|
+
* Invariant: parking requires all three — a paused phase, a pause snapshot for
|
|
1533
|
+
* the current revision, and an unsettled gate. A phase without a snapshot (a
|
|
1534
|
+
* stale/implicit pause) could never be released by `/review-resume`, so the
|
|
1535
|
+
* handoff must settle with the author's plan instead of waiting forever.
|
|
1536
|
+
*/
|
|
1537
|
+
export function reviewHandoffParks(input: {
|
|
1538
|
+
phase: ReviewHandoffPhase;
|
|
1539
|
+
paused: boolean;
|
|
1540
|
+
settled: boolean;
|
|
1541
|
+
}): boolean {
|
|
1542
|
+
return (
|
|
1543
|
+
input.phase === "paused" &&
|
|
1544
|
+
input.paused === true &&
|
|
1545
|
+
input.settled !== true
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** One short notice for a blocked mode transition. */
|
|
1550
|
+
export function reviewModeLockNotice(kind: ReviewModeLockKind): string {
|
|
1551
|
+
return kind === "paused"
|
|
1552
|
+
? "Review is paused — mode switching stays locked. /review-resume to continue, /review-abort to stop, /review-mode off to end review."
|
|
1553
|
+
: "Review is running — mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop, /review-mode off to end review.";
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Footer marker appended to the single `"workflow"` status while locked. */
|
|
1557
|
+
export function reviewModeLockFooter(
|
|
1558
|
+
kind: ReviewModeLockKind | undefined,
|
|
1559
|
+
): string {
|
|
1560
|
+
return kind ? "🔒 modes locked" : "";
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** `/review-status` row describing the lock and its allowed exits. */
|
|
1564
|
+
export function reviewModeLockStatusLine(
|
|
1565
|
+
kind: ReviewModeLockKind | undefined,
|
|
1566
|
+
): string {
|
|
1567
|
+
return kind ? ` lock : ${kind} — ${reviewModeLockNotice(kind)}` : "";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1076
1570
|
// ── Step 16: reviewer transcript stream ──────────────────────────────
|
|
1077
1571
|
|
|
1078
1572
|
/** Default cap on retained pane lines (the panes are a rolling window). */
|
|
@@ -1281,6 +1775,11 @@ export function formatActionLabel(toolName: unknown, args: unknown): string {
|
|
|
1281
1775
|
* `auto_retry_end` or an `agent_end` that stopped on `stopReason === "error"`
|
|
1282
1776
|
* appends an `error` line — a dead provider is then visible in the pane during
|
|
1283
1777
|
* the review instead of resurfacing later as a mysterious "no plan" failure.
|
|
1778
|
+
*
|
|
1779
|
+
* Run-start lines are NOT produced here. `agent_start` fires for every prompt on
|
|
1780
|
+
* the same child session, so it cannot tell a cold boot from a warm resume or a
|
|
1781
|
+
* next round; the runtime emits the truthful line with `reviewRunLabel` at the
|
|
1782
|
+
* moment it sends the prompt.
|
|
1284
1783
|
*/
|
|
1285
1784
|
export function translateReviewEvent(
|
|
1286
1785
|
event: unknown,
|
|
@@ -1315,9 +1814,6 @@ export function translateReviewEvent(
|
|
|
1315
1814
|
];
|
|
1316
1815
|
}
|
|
1317
1816
|
|
|
1318
|
-
if (type === "agent_start") {
|
|
1319
|
-
return [{ op: "new", kind: "status", text: "reviewer started" }];
|
|
1320
|
-
}
|
|
1321
1817
|
return providerErrorOps(e, type);
|
|
1322
1818
|
}
|
|
1323
1819
|
|
|
@@ -1325,9 +1821,10 @@ export function translateReviewEvent(
|
|
|
1325
1821
|
* Provider-error events → transcript lines, split out of
|
|
1326
1822
|
* `translateReviewEvent` to keep the dispatcher flat:
|
|
1327
1823
|
*
|
|
1328
|
-
* - `agent_end` →
|
|
1824
|
+
* - `agent_end` → neutral run-end line, plus an `error` line when the run
|
|
1329
1825
|
* stopped on `stopReason === "error"` (auto-retry disabled, or the first
|
|
1330
|
-
* failure was not retryable).
|
|
1826
|
+
* failure was not retryable). "Run ended" is truthful for a finished,
|
|
1827
|
+
* aborted or paused run alike.
|
|
1331
1828
|
* - `auto_retry_start` → notice carrying the provider's message (truncated).
|
|
1332
1829
|
* - `auto_retry_end` → `error` line when the retry budget is exhausted
|
|
1333
1830
|
* (`success: false`); a recovered retry (`success: true`) stays silent.
|
|
@@ -1340,7 +1837,7 @@ function providerErrorOps(
|
|
|
1340
1837
|
): ReviewTranscriptOp[] {
|
|
1341
1838
|
if (type === "agent_end") {
|
|
1342
1839
|
const ops: ReviewTranscriptOp[] = [
|
|
1343
|
-
{ op: "new", kind: "status", text: "reviewer
|
|
1840
|
+
{ op: "new", kind: "status", text: "reviewer run ended" },
|
|
1344
1841
|
];
|
|
1345
1842
|
const failed = lastAssistantErrorStop(e.messages);
|
|
1346
1843
|
if (failed) {
|
|
@@ -1484,15 +1981,18 @@ export const REVIEW_SYSTEM_PROMPT = [
|
|
|
1484
1981
|
"",
|
|
1485
1982
|
"## Tools",
|
|
1486
1983
|
"",
|
|
1487
|
-
"- `read`, `grep`, `find`, `ls` — inspect the repository freely.
|
|
1488
|
-
"
|
|
1984
|
+
"- `read`, `grep`, `find`, `ls` — inspect the repository freely. The pass-1",
|
|
1985
|
+
" prompt carries Plan Mode's file inventory and recon summaries: trust them",
|
|
1986
|
+
" for what they cover, and re-read the files your findings anchor (plus a",
|
|
1987
|
+
" spot check or two) before you cite them.",
|
|
1489
1988
|
"- `review_bash` — a READ-ONLY/VERIFY shell on the same role allowlist Plan Mode",
|
|
1490
1989
|
" uses (ls, cat, rg, fd, jq, diff, stat, git status/log/diff/show/branch, npm",
|
|
1491
1990
|
" test, npm run lint/typecheck, eslint, tsc --noEmit, node --test, pytest, cargo",
|
|
1492
1991
|
" clippy, ...). Mutating commands, redirects, installs and builds are refused.",
|
|
1493
1992
|
" Verify a plan's claims by reading the code and, where useful, by running its",
|
|
1494
1993
|
" tests or linters — but never assert that something passes unless you ran it.",
|
|
1495
|
-
"- `review_explore` — delegate focused read-only reconnaissance to subagents
|
|
1994
|
+
"- `review_explore` — delegate focused read-only reconnaissance to subagents",
|
|
1995
|
+
" (rarely needed: the pass-1 prompt already carries Plan Mode's recon).",
|
|
1496
1996
|
"- `review_submit_plan` — submit the final rewritten plan. This is the ONLY way",
|
|
1497
1997
|
" your work reaches the user. A review that never submits is a failed review.",
|
|
1498
1998
|
"- `review_pass_done` — end the current pass with a verdict and your findings.",
|