@dieulc/workflow 0.9.0 → 0.9.2
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 +14 -9
- package/edit-policy.ts +11 -64
- package/index.ts +309 -45
- package/package.json +7 -3
- package/permissions.ts +393 -93
- package/review.ts +80 -3
- package/shell-text.ts +542 -0
- package/thinking-levels.ts +67 -0
- package/utils.ts +42 -0
- package/workflow-status.ts +81 -0
package/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
normalizeStepText,
|
|
57
57
|
todoKey,
|
|
58
58
|
isPlanWritePath,
|
|
59
|
+
samePlanPath,
|
|
59
60
|
findPlanWritePathInMessages,
|
|
60
61
|
findLatestPlanWriteInBranch,
|
|
61
62
|
choosePlanPath,
|
|
@@ -88,8 +89,10 @@ import {
|
|
|
88
89
|
} from "./utils.ts";
|
|
89
90
|
import {
|
|
90
91
|
WORKFLOW_ROLE_ENV,
|
|
92
|
+
describeCommandPolicy,
|
|
91
93
|
explainCommandRefusal,
|
|
92
94
|
gitCommitPolicyHit,
|
|
95
|
+
permissionsArgumentCompletions,
|
|
93
96
|
policyForRole,
|
|
94
97
|
resolveSessionRole,
|
|
95
98
|
subagentEnv,
|
|
@@ -238,13 +241,14 @@ import {
|
|
|
238
241
|
planHash,
|
|
239
242
|
readReviewModeConfig,
|
|
240
243
|
resolveReviewPrompt,
|
|
241
|
-
|
|
244
|
+
nextReviewRound,
|
|
242
245
|
reviewGatePhaseOnResume,
|
|
243
246
|
reviewModeLockFooter,
|
|
244
247
|
reviewModeLockKind,
|
|
245
248
|
reviewModeLockNotice,
|
|
246
249
|
reviewModeLockStatusLine,
|
|
247
250
|
reviewNoticeText,
|
|
251
|
+
reviewPauseIsCurrent,
|
|
248
252
|
reviewResumeGateOutcome,
|
|
249
253
|
reviewResumeNotice,
|
|
250
254
|
reviewResumePlanChoice,
|
|
@@ -252,7 +256,7 @@ import {
|
|
|
252
256
|
reviewHandoffParks,
|
|
253
257
|
reviewResumeSettles,
|
|
254
258
|
reviewStateSummary,
|
|
255
|
-
|
|
259
|
+
reviewStatusText,
|
|
256
260
|
shouldRouteInputToReviewer,
|
|
257
261
|
writeReviewModeConfig,
|
|
258
262
|
writeReviewPrompt,
|
|
@@ -1322,6 +1326,16 @@ export default function workflowExtension(
|
|
|
1322
1326
|
let lastReviewedPlanHash: string | undefined;
|
|
1323
1327
|
/** 1-based round within the current user-feedback cycle. */
|
|
1324
1328
|
let reviewRound = 0;
|
|
1329
|
+
/**
|
|
1330
|
+
* Monotonic cycle token. A fresh prompt, an explicit refine/deny, or Build
|
|
1331
|
+
* entry starts a new cycle; async handoffs capture it so a superseded cycle
|
|
1332
|
+
* can never render, apply todos, or clear a newer gate.
|
|
1333
|
+
*/
|
|
1334
|
+
let reviewCycleId = 0;
|
|
1335
|
+
/** Plan target the current review cycle belongs to (absolute, or null). */
|
|
1336
|
+
let reviewCyclePlanPath: string | null = null;
|
|
1337
|
+
/** Cycle that owns the parked gate; `0` when no gate is owned. */
|
|
1338
|
+
let reviewGateCycleId = 0;
|
|
1325
1339
|
/** Findings carried into the next round so the reviewer can verify fixes. */
|
|
1326
1340
|
let reviewPreviousFindings: ReviewFinding[] | undefined;
|
|
1327
1341
|
/**
|
|
@@ -1343,9 +1357,22 @@ export default function workflowExtension(
|
|
|
1343
1357
|
fileHashAtPause?: string;
|
|
1344
1358
|
round: number;
|
|
1345
1359
|
previousFindings: ReviewFinding[] | undefined;
|
|
1360
|
+
/** Cycle that recorded the pause; stale cycles are ignored. */
|
|
1361
|
+
cycleId: number;
|
|
1346
1362
|
at: number;
|
|
1347
1363
|
}
|
|
1348
1364
|
| undefined;
|
|
1365
|
+
|
|
1366
|
+
/** The pause only counts while it belongs to the current review cycle. */
|
|
1367
|
+
function currentPausedReview(): typeof pausedReview {
|
|
1368
|
+
if (!pausedReview) return undefined;
|
|
1369
|
+
return reviewPauseIsCurrent({
|
|
1370
|
+
pauseCycle: pausedReview.cycleId,
|
|
1371
|
+
currentCycle: reviewCycleId,
|
|
1372
|
+
})
|
|
1373
|
+
? pausedReview
|
|
1374
|
+
: undefined;
|
|
1375
|
+
}
|
|
1349
1376
|
/** In-flight round input (set while `runRound` awaits) — the pause source. */
|
|
1350
1377
|
let activeReview:
|
|
1351
1378
|
| { planText: string; round: number; controller: AbortController }
|
|
@@ -1384,8 +1411,10 @@ export default function workflowExtension(
|
|
|
1384
1411
|
*/
|
|
1385
1412
|
function reviewModeLockKindNow(): ReviewModeLockKind | undefined {
|
|
1386
1413
|
const roundInFlight = activeReview !== undefined;
|
|
1387
|
-
const paused =
|
|
1388
|
-
|
|
1414
|
+
const paused = currentPausedReview() !== undefined;
|
|
1415
|
+
// A gate from a superseded cycle must not keep the modes locked.
|
|
1416
|
+
const gatePhase =
|
|
1417
|
+
reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle";
|
|
1389
1418
|
let roundActive = false;
|
|
1390
1419
|
try {
|
|
1391
1420
|
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
@@ -1805,23 +1834,24 @@ export default function workflowExtension(
|
|
|
1805
1834
|
*/
|
|
1806
1835
|
function updateReviewStatus(ctx: ExtensionContext): void {
|
|
1807
1836
|
try {
|
|
1808
|
-
const paused =
|
|
1837
|
+
const paused = currentPausedReview() !== undefined;
|
|
1809
1838
|
let roundActive = false;
|
|
1810
1839
|
try {
|
|
1811
1840
|
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
1812
1841
|
} catch (_e) {
|
|
1813
1842
|
void _e;
|
|
1814
1843
|
}
|
|
1815
|
-
|
|
1844
|
+
// The review status is a LIVE indicator, not a result holder: the last
|
|
1845
|
+
// round's state stays for `/review-status` and the REVIEW SUMMARY band,
|
|
1846
|
+
// but the footer segment must disappear as soon as the round ends. A
|
|
1847
|
+
// retained terminal phase is not painted during a new round's cold boot,
|
|
1848
|
+
// and a pause from an old cycle is ignored by `currentPausedReview`.
|
|
1849
|
+
const reviewText = reviewStatusText({
|
|
1850
|
+
state: reviewState,
|
|
1816
1851
|
roundInFlight: activeReview !== undefined,
|
|
1817
1852
|
roundActive,
|
|
1818
1853
|
paused,
|
|
1819
1854
|
});
|
|
1820
|
-
// The review status is a LIVE indicator, not a result holder: the last
|
|
1821
|
-
// round's state stays for `/review-status` and the REVIEW SUMMARY band,
|
|
1822
|
-
// but the footer segment must disappear as soon as the round ends. While
|
|
1823
|
-
// paused it shows the paused form (never the terminal "aborted" summary).
|
|
1824
|
-
const reviewText = visible ? reviewFooterSegment(reviewState, paused) : "";
|
|
1825
1855
|
let lockText = "";
|
|
1826
1856
|
try {
|
|
1827
1857
|
lockText = reviewModeLockFooter(reviewModeLockKindNow());
|
|
@@ -1996,7 +2026,7 @@ export default function workflowExtension(
|
|
|
1996
2026
|
previousFindings: ReviewFinding[] | undefined;
|
|
1997
2027
|
}): boolean {
|
|
1998
2028
|
const hash = planHash(input.planText);
|
|
1999
|
-
if (
|
|
2029
|
+
if (currentPausedReview()?.planHash === hash) return false;
|
|
2000
2030
|
// Snapshot the file as well as the text: the reviewer may already have
|
|
2001
2031
|
// written its rewrite, and that must not look like an author edit later.
|
|
2002
2032
|
const currentFile = readCurrentPlanText(reviewCwd());
|
|
@@ -2007,6 +2037,7 @@ export default function workflowExtension(
|
|
|
2007
2037
|
currentFile !== undefined ? planHash(currentFile) : undefined,
|
|
2008
2038
|
round: input.round,
|
|
2009
2039
|
previousFindings: input.previousFindings,
|
|
2040
|
+
cycleId: reviewCycleId,
|
|
2010
2041
|
at: Date.now(),
|
|
2011
2042
|
};
|
|
2012
2043
|
// A gated audit becomes a paused gate (the handoff keeps waiting). A
|
|
@@ -2059,7 +2090,7 @@ export default function workflowExtension(
|
|
|
2059
2090
|
roundActive:
|
|
2060
2091
|
activeReview !== undefined ||
|
|
2061
2092
|
reviewRuntime?.isRoundActive() === true,
|
|
2062
|
-
paused:
|
|
2093
|
+
paused: currentPausedReview() !== undefined,
|
|
2063
2094
|
gatedPause: reviewGatePhase === "paused",
|
|
2064
2095
|
});
|
|
2065
2096
|
if (action === "pause") {
|
|
@@ -2173,6 +2204,7 @@ export default function workflowExtension(
|
|
|
2173
2204
|
// The parked handoff only awaits the gate; it does not re-read the phase
|
|
2174
2205
|
// after this point.
|
|
2175
2206
|
reviewGatePhase = "idle";
|
|
2207
|
+
reviewGateCycleId = 0;
|
|
2176
2208
|
} catch (_e) {
|
|
2177
2209
|
void _e;
|
|
2178
2210
|
}
|
|
@@ -2254,17 +2286,18 @@ export default function workflowExtension(
|
|
|
2254
2286
|
// `review_submit_plan` that landed in that window changed the file after
|
|
2255
2287
|
// the snapshot, so refresh it: otherwise `/review-resume` would read the
|
|
2256
2288
|
// reviewer's own late write as an author edit and start a fresh round.
|
|
2257
|
-
|
|
2289
|
+
const pausedNow = currentPausedReview();
|
|
2290
|
+
if (pausedNow?.planHash === hash) {
|
|
2258
2291
|
const currentFile = readCurrentPlanText(reviewCwd());
|
|
2259
2292
|
if (currentFile !== undefined) {
|
|
2260
|
-
|
|
2293
|
+
pausedNow.fileHashAtPause = planHash(currentFile);
|
|
2261
2294
|
}
|
|
2262
2295
|
}
|
|
2263
2296
|
reviewState = res.state;
|
|
2264
2297
|
updateStatus(ctx);
|
|
2265
2298
|
// The snapshot is the ONLY pause signal. A pre-aborted host signal or a
|
|
2266
2299
|
// host abort without this snapshot is an abort, never a pause.
|
|
2267
|
-
const pausedHere =
|
|
2300
|
+
const pausedHere = pausedNow?.planHash === hash;
|
|
2268
2301
|
if (res.ok) {
|
|
2269
2302
|
lastReviewedPlanHash = res.planHash;
|
|
2270
2303
|
abortedReviewHash = undefined;
|
|
@@ -2363,7 +2396,7 @@ export default function workflowExtension(
|
|
|
2363
2396
|
detachHostSignal = undefined;
|
|
2364
2397
|
// A pause keeps its own working message (pauseReviewRound sets it
|
|
2365
2398
|
// after the abort lands); every other exit restores the default.
|
|
2366
|
-
if (!(
|
|
2399
|
+
if (!(currentPausedReview()?.planHash === hash)) {
|
|
2367
2400
|
setReviewWorkingMessage(undefined);
|
|
2368
2401
|
}
|
|
2369
2402
|
// Ownership ended: refresh both surfaces now that `activeReview` is
|
|
@@ -2422,6 +2455,21 @@ export default function workflowExtension(
|
|
|
2422
2455
|
};
|
|
2423
2456
|
}
|
|
2424
2457
|
|
|
2458
|
+
// A fresh automatic round supersedes any paused one. Even when this
|
|
2459
|
+
// decision is skipped below, no stale pause/line may survive it.
|
|
2460
|
+
if (trigger === "auto" && pausedReview !== undefined) {
|
|
2461
|
+
pausedReview = undefined;
|
|
2462
|
+
try {
|
|
2463
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2464
|
+
} catch (_e) {
|
|
2465
|
+
void _e;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// A different plan file is a new target even inside one user run: reset the
|
|
2470
|
+
// budget/hashes/findings before the dedupe gates below.
|
|
2471
|
+
const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
|
|
2472
|
+
|
|
2425
2473
|
// Already reviewed this exact revision — do not re-review on re-emit.
|
|
2426
2474
|
if (hash === lastReviewedPlanHash) {
|
|
2427
2475
|
return {
|
|
@@ -2441,8 +2489,13 @@ export default function workflowExtension(
|
|
|
2441
2489
|
};
|
|
2442
2490
|
}
|
|
2443
2491
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2492
|
+
const budget = nextReviewRound({
|
|
2493
|
+
round: reviewRound,
|
|
2494
|
+
maxRounds: cfg.rounds,
|
|
2495
|
+
newCycle: cycleChanged,
|
|
2496
|
+
});
|
|
2497
|
+
reviewRound = budget.round;
|
|
2498
|
+
if (!budget.allowed) {
|
|
2446
2499
|
// Round cap reached: hand over with the outstanding findings visible.
|
|
2447
2500
|
const unresolved = (reviewState?.findings ?? []).filter(
|
|
2448
2501
|
(f) => f.disposition !== "rejected",
|
|
@@ -2481,6 +2534,11 @@ export default function workflowExtension(
|
|
|
2481
2534
|
|
|
2482
2535
|
// A fresh round supersedes any paused one (resume uses its own path).
|
|
2483
2536
|
pausedReview = undefined;
|
|
2537
|
+
try {
|
|
2538
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2539
|
+
} catch (_e) {
|
|
2540
|
+
void _e;
|
|
2541
|
+
}
|
|
2484
2542
|
|
|
2485
2543
|
try {
|
|
2486
2544
|
return await executeReviewRound({
|
|
@@ -2549,6 +2607,127 @@ export default function workflowExtension(
|
|
|
2549
2607
|
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2550
2608
|
}
|
|
2551
2609
|
|
|
2610
|
+
type ReviewCycleReason =
|
|
2611
|
+
| "prompt"
|
|
2612
|
+
| "refine"
|
|
2613
|
+
| "deny"
|
|
2614
|
+
| "build"
|
|
2615
|
+
| "session";
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* Start a new review cycle: one plan-authoring/feedback sequence.
|
|
2619
|
+
*
|
|
2620
|
+
* Every fresh prompt, explicit refine/deny, and Build entry owns a cycle.
|
|
2621
|
+
* The cycle token invalidates an old `runHandoff` continuation, the budget
|
|
2622
|
+
* restarts, and an abandoned pause/gate is cancelled (D2) so it cannot keep
|
|
2623
|
+
* the footer line, lock the modes, or short-circuit the next `agent_end`.
|
|
2624
|
+
* Findings survive a same-plan refinement and are cleared for a new file.
|
|
2625
|
+
* Never throws: a broken review surface must not block the prompt.
|
|
2626
|
+
*/
|
|
2627
|
+
async function beginReviewCycle(opts: {
|
|
2628
|
+
reason: ReviewCycleReason;
|
|
2629
|
+
keepFindings?: boolean;
|
|
2630
|
+
planPath?: string | null;
|
|
2631
|
+
}): Promise<void> {
|
|
2632
|
+
const hadActive = activeReview !== undefined;
|
|
2633
|
+
const hadPause = currentPausedReview() !== undefined;
|
|
2634
|
+
const hadGate = reviewGatePhase !== "idle";
|
|
2635
|
+
// Synchronously first: any old handoff continuation is stale from here on.
|
|
2636
|
+
reviewCycleId += 1;
|
|
2637
|
+
if (hadActive || pausedReview !== undefined || hadGate) {
|
|
2638
|
+
try {
|
|
2639
|
+
await suspendReview();
|
|
2640
|
+
} catch (_e) {
|
|
2641
|
+
void _e;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
reviewRound = 0;
|
|
2645
|
+
lastReviewedPlanHash = undefined;
|
|
2646
|
+
abortedReviewHash = undefined;
|
|
2647
|
+
if (!hadActive) quietAbortHash = undefined;
|
|
2648
|
+
|
|
2649
|
+
const rawNext = opts.planPath ?? currentPlanFile ?? null;
|
|
2650
|
+
let next: string | null = null;
|
|
2651
|
+
if (rawNext) {
|
|
2652
|
+
try {
|
|
2653
|
+
next = path.isAbsolute(rawNext)
|
|
2654
|
+
? rawNext
|
|
2655
|
+
: path.join(reviewCwd(), rawNext);
|
|
2656
|
+
} catch (_e) {
|
|
2657
|
+
void _e;
|
|
2658
|
+
next = rawNext;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
const pathChanged =
|
|
2662
|
+
reviewCyclePlanPath !== null &&
|
|
2663
|
+
next !== null &&
|
|
2664
|
+
!samePlanPath(next, reviewCyclePlanPath, reviewCwd());
|
|
2665
|
+
if (pathChanged || opts.keepFindings === false) {
|
|
2666
|
+
reviewPreviousFindings = undefined;
|
|
2667
|
+
}
|
|
2668
|
+
reviewCyclePlanPath = next;
|
|
2669
|
+
reviewGateCycleId = 0;
|
|
2670
|
+
|
|
2671
|
+
if (hadPause || hadGate) {
|
|
2672
|
+
notifyReview(
|
|
2673
|
+
"Paused review abandoned — new work started; the paused round will not resume.",
|
|
2674
|
+
"info",
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
try {
|
|
2678
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2679
|
+
} catch (_e) {
|
|
2680
|
+
void _e;
|
|
2681
|
+
}
|
|
2682
|
+
try {
|
|
2683
|
+
if (reviewEnabled() || hadActive || hadPause || hadGate || reviewState) {
|
|
2684
|
+
pi.appendEntry("workflow-review-cycle", {
|
|
2685
|
+
reason: opts.reason,
|
|
2686
|
+
cycleId: reviewCycleId,
|
|
2687
|
+
planPath: reviewCyclePlanPath,
|
|
2688
|
+
at: Date.now(),
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
} catch (_e) {
|
|
2692
|
+
void _e;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
/**
|
|
2697
|
+
* A different plan file is a new review target even inside one user run.
|
|
2698
|
+
* Resets the cycle budget and prior findings, but not the cycle token: the
|
|
2699
|
+
* handoff that owns this run must stay current.
|
|
2700
|
+
*/
|
|
2701
|
+
function notePlanTargetForReviewCycle(
|
|
2702
|
+
rawPath: string | null | undefined,
|
|
2703
|
+
): boolean {
|
|
2704
|
+
if (!rawPath) return false;
|
|
2705
|
+
const cwd = reviewCwd();
|
|
2706
|
+
let next: string;
|
|
2707
|
+
try {
|
|
2708
|
+
next = path.isAbsolute(rawPath) ? rawPath : path.join(cwd, rawPath);
|
|
2709
|
+
} catch (_e) {
|
|
2710
|
+
void _e;
|
|
2711
|
+
next = rawPath;
|
|
2712
|
+
}
|
|
2713
|
+
if (reviewCyclePlanPath === null) {
|
|
2714
|
+
reviewCyclePlanPath = next;
|
|
2715
|
+
return false;
|
|
2716
|
+
}
|
|
2717
|
+
if (samePlanPath(next, reviewCyclePlanPath, cwd)) return false;
|
|
2718
|
+
reviewCyclePlanPath = next;
|
|
2719
|
+
reviewRound = 0;
|
|
2720
|
+
lastReviewedPlanHash = undefined;
|
|
2721
|
+
abortedReviewHash = undefined;
|
|
2722
|
+
reviewPreviousFindings = undefined;
|
|
2723
|
+
try {
|
|
2724
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2725
|
+
} catch (_e) {
|
|
2726
|
+
void _e;
|
|
2727
|
+
}
|
|
2728
|
+
return true;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2552
2731
|
/**
|
|
2553
2732
|
* Stop the reviewer and clear all Review Mode state. Never throws.
|
|
2554
2733
|
*
|
|
@@ -2930,6 +3109,26 @@ export default function workflowExtension(
|
|
|
2930
3109
|
writePolicyArgumentCompletions(prefix),
|
|
2931
3110
|
});
|
|
2932
3111
|
|
|
3112
|
+
// ── /permissions: explain a command's verdict (diagnostics) ────────
|
|
3113
|
+
//
|
|
3114
|
+
// Answers "why was that blocked?" (or "would it be?") without touching a
|
|
3115
|
+
// gate: it reads the same analyzeCommand/explainCommandRefusal the gates
|
|
3116
|
+
// use, so the explanation cannot describe a policy the gate no longer
|
|
3117
|
+
// enforces. Read-only, so it is safe in every mode.
|
|
3118
|
+
pi.registerCommand("permissions", {
|
|
3119
|
+
description:
|
|
3120
|
+
"Explain why a shell command would be allowed or refused: /permissions explain <command>",
|
|
3121
|
+
handler: async (args, ctx) => {
|
|
3122
|
+
const raw = String(args ?? "").trim();
|
|
3123
|
+
const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
3124
|
+
// `/permissions explain <cmd>` and `/permissions <cmd>` both work.
|
|
3125
|
+
const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
|
|
3126
|
+
ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
|
|
3127
|
+
},
|
|
3128
|
+
getArgumentCompletions: (prefix: string) =>
|
|
3129
|
+
permissionsArgumentCompletions(prefix),
|
|
3130
|
+
});
|
|
3131
|
+
|
|
2933
3132
|
pi.registerCommand("review-prompt", {
|
|
2934
3133
|
description:
|
|
2935
3134
|
"Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
|
|
@@ -2974,10 +3173,11 @@ export default function workflowExtension(
|
|
|
2974
3173
|
const s = reviewState;
|
|
2975
3174
|
const lockKind = reviewModeLockKindNow();
|
|
2976
3175
|
if (!s) {
|
|
2977
|
-
|
|
3176
|
+
const paused = currentPausedReview();
|
|
3177
|
+
if (paused) {
|
|
2978
3178
|
ctx.ui.notify(
|
|
2979
3179
|
[
|
|
2980
|
-
`Review paused at round ${
|
|
3180
|
+
`Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
|
|
2981
3181
|
" /review-resume to continue, /review-abort to stop.",
|
|
2982
3182
|
reviewModeLockStatusLine(lockKind),
|
|
2983
3183
|
]
|
|
@@ -3015,8 +3215,8 @@ export default function workflowExtension(
|
|
|
3015
3215
|
` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
|
|
3016
3216
|
s.verdict ? ` verdict : ${s.verdict}` : "",
|
|
3017
3217
|
s.error ? ` error : ${s.error}` : "",
|
|
3018
|
-
|
|
3019
|
-
? ` paused : round ${
|
|
3218
|
+
currentPausedReview()
|
|
3219
|
+
? ` paused : round ${currentPausedReview()!.round} — /review-resume to continue, /review-abort to stop`
|
|
3020
3220
|
: "",
|
|
3021
3221
|
abortedReviewHash
|
|
3022
3222
|
? ` note : revision ${abortedReviewHash} was aborted — it will not be auto-reviewed until the plan changes`
|
|
@@ -3181,6 +3381,7 @@ export default function workflowExtension(
|
|
|
3181
3381
|
}
|
|
3182
3382
|
currentPlanFile = target.abs;
|
|
3183
3383
|
lastPlanWritePath = target.abs;
|
|
3384
|
+
notePlanTargetForReviewCycle(target.abs);
|
|
3184
3385
|
persistState();
|
|
3185
3386
|
lastReviewedPlanHash = undefined; // force a fresh review
|
|
3186
3387
|
abortedReviewHash = undefined;
|
|
@@ -3209,7 +3410,7 @@ export default function workflowExtension(
|
|
|
3209
3410
|
// Gate only on round ownership below — no `enabled` check here.
|
|
3210
3411
|
if (!activeReview) {
|
|
3211
3412
|
ctx.ui.notify(
|
|
3212
|
-
|
|
3413
|
+
currentPausedReview()
|
|
3213
3414
|
? "Review is already paused — /review-resume to continue, /review-abort to stop."
|
|
3214
3415
|
: "No review round running.",
|
|
3215
3416
|
"info",
|
|
@@ -3229,7 +3430,7 @@ export default function workflowExtension(
|
|
|
3229
3430
|
handler: async (_args, ctx) => {
|
|
3230
3431
|
// Manual control: resume works whether or not Review Mode is on.
|
|
3231
3432
|
// Gate only on paused-session state below — no `enabled` check here.
|
|
3232
|
-
const paused =
|
|
3433
|
+
const paused = currentPausedReview();
|
|
3233
3434
|
if (!paused) {
|
|
3234
3435
|
ctx.ui.notify("No paused review — nothing to resume.", "info");
|
|
3235
3436
|
return;
|
|
@@ -3323,17 +3524,18 @@ export default function workflowExtension(
|
|
|
3323
3524
|
const rt = reviewRuntime;
|
|
3324
3525
|
const active =
|
|
3325
3526
|
activeReview !== undefined || rt?.isRoundActive() === true;
|
|
3326
|
-
|
|
3527
|
+
const paused = currentPausedReview();
|
|
3528
|
+
if (!active && !paused) {
|
|
3327
3529
|
ctx.ui.notify("No review running or paused.", "info");
|
|
3328
3530
|
return;
|
|
3329
3531
|
}
|
|
3330
3532
|
const targetHash =
|
|
3331
|
-
|
|
3533
|
+
paused?.planHash ??
|
|
3332
3534
|
(activeReview ? planHash(activeReview.planText) : undefined);
|
|
3333
3535
|
// Text the gate hands off when one is open (resolved below after the
|
|
3334
3536
|
// abort lands; falls back to the author's draft when unknown).
|
|
3335
3537
|
const abortedText =
|
|
3336
|
-
|
|
3538
|
+
paused?.planText ?? activeReview?.planText;
|
|
3337
3539
|
pausedReview = undefined;
|
|
3338
3540
|
// An aborted revision is not auto-reviewed again until the plan changes.
|
|
3339
3541
|
if (targetHash) abortedReviewHash = targetHash;
|
|
@@ -3920,8 +4122,14 @@ export default function workflowExtension(
|
|
|
3920
4122
|
updateStatus(ctx);
|
|
3921
4123
|
persistState();
|
|
3922
4124
|
// Tab-to-Build leaves Plan behind; suspend review activity but keep the
|
|
3923
|
-
// reviewer session warm for the next round (step 10).
|
|
4125
|
+
// reviewer session warm for the next round (step 10). End the review cycle
|
|
4126
|
+
// so its budget/findings cannot leak into the next Plan round.
|
|
3924
4127
|
await suspendReview();
|
|
4128
|
+
try {
|
|
4129
|
+
await beginReviewCycle({ reason: "build", keepFindings: false });
|
|
4130
|
+
} catch (_e) {
|
|
4131
|
+
void _e;
|
|
4132
|
+
}
|
|
3925
4133
|
}
|
|
3926
4134
|
|
|
3927
4135
|
async function setDefaultMode(
|
|
@@ -4067,6 +4275,15 @@ export default function workflowExtension(
|
|
|
4067
4275
|
// user just approved (step 10).
|
|
4068
4276
|
await suspendReview();
|
|
4069
4277
|
currentPlanFile = planPath;
|
|
4278
|
+
try {
|
|
4279
|
+
await beginReviewCycle({
|
|
4280
|
+
reason: "build",
|
|
4281
|
+
keepFindings: false,
|
|
4282
|
+
planPath,
|
|
4283
|
+
});
|
|
4284
|
+
} catch (_e) {
|
|
4285
|
+
void _e;
|
|
4286
|
+
}
|
|
4070
4287
|
pi.setActiveTools(
|
|
4071
4288
|
toolsBeforePlanMode ?? getBuildTools(pi.getActiveTools()),
|
|
4072
4289
|
);
|
|
@@ -4205,7 +4422,7 @@ export default function workflowExtension(
|
|
|
4205
4422
|
}
|
|
4206
4423
|
}
|
|
4207
4424
|
|
|
4208
|
-
function handlePlannotatorDeny(feedback: string) {
|
|
4425
|
+
async function handlePlannotatorDeny(feedback: string) {
|
|
4209
4426
|
const ctx = getActiveCtx();
|
|
4210
4427
|
if (!ctx) return;
|
|
4211
4428
|
// Not a mode transition, but while a review owns the turn every browser
|
|
@@ -4217,8 +4434,7 @@ export default function workflowExtension(
|
|
|
4217
4434
|
// Feedback cycle: the next plan revision gets a fresh review round, and the
|
|
4218
4435
|
// outstanding findings are carried forward so the reviewer can confirm they
|
|
4219
4436
|
// were actually addressed rather than re-raising them.
|
|
4220
|
-
|
|
4221
|
-
lastReviewedPlanHash = undefined;
|
|
4437
|
+
await beginReviewCycle({ reason: "deny", keepFindings: true });
|
|
4222
4438
|
persistState();
|
|
4223
4439
|
try {
|
|
4224
4440
|
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
@@ -4295,7 +4511,9 @@ export default function workflowExtension(
|
|
|
4295
4511
|
(getActiveCtx() as any)?.cwd,
|
|
4296
4512
|
);
|
|
4297
4513
|
} else {
|
|
4298
|
-
handlePlannotatorDeny(
|
|
4514
|
+
void handlePlannotatorDeny(
|
|
4515
|
+
event.feedback || "Plan needs revision.",
|
|
4516
|
+
);
|
|
4299
4517
|
}
|
|
4300
4518
|
} catch (_e) {
|
|
4301
4519
|
void _e;
|
|
@@ -9485,6 +9703,18 @@ export default function workflowExtension(
|
|
|
9485
9703
|
} catch (_e) {
|
|
9486
9704
|
void _e;
|
|
9487
9705
|
}
|
|
9706
|
+
// Every fresh prompt is a new review cycle: reset the round budget, cancel
|
|
9707
|
+
// an abandoned pause/gate, and tag the cycle so an old handoff cannot
|
|
9708
|
+
// render after this prompt.
|
|
9709
|
+
try {
|
|
9710
|
+
await beginReviewCycle({
|
|
9711
|
+
reason: "prompt",
|
|
9712
|
+
keepFindings: true,
|
|
9713
|
+
planPath: currentPlanFile,
|
|
9714
|
+
});
|
|
9715
|
+
} catch (_e) {
|
|
9716
|
+
void _e;
|
|
9717
|
+
}
|
|
9488
9718
|
// Capture header for next checkpoint commit message (first line of prompt)
|
|
9489
9719
|
try {
|
|
9490
9720
|
const raw = String((event as any).prompt ?? (event as any).text ?? "");
|
|
@@ -9769,6 +9999,7 @@ export default function workflowExtension(
|
|
|
9769
9999
|
// The reviewer's `review_submit_plan` writes this path; a manual
|
|
9770
10000
|
// `/review` must also audit the file the user just wrote.
|
|
9771
10001
|
currentPlanFile = abs;
|
|
10002
|
+
notePlanTargetForReviewCycle(abs);
|
|
9772
10003
|
// A run that starts in Default/Build and switches into Plan mid-run
|
|
9773
10004
|
// still qualifies: what matters is the mode at write time.
|
|
9774
10005
|
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
@@ -9779,6 +10010,7 @@ export default function workflowExtension(
|
|
|
9779
10010
|
planWritePathThisRun = raw;
|
|
9780
10011
|
planWrittenThisRun = true;
|
|
9781
10012
|
currentPlanFile = raw;
|
|
10013
|
+
notePlanTargetForReviewCycle(raw);
|
|
9782
10014
|
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9783
10015
|
}
|
|
9784
10016
|
try {
|
|
@@ -9799,17 +10031,29 @@ export default function workflowExtension(
|
|
|
9799
10031
|
// (Checked before the mutex: awaiting the in-flight handoff would block
|
|
9800
10032
|
// for the whole review instead of short-circuiting.)
|
|
9801
10033
|
if (reviewGatePhase !== "idle") {
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
"
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
10034
|
+
// A gate from a superseded cycle must not swallow this run's handoff.
|
|
10035
|
+
// Settle it with the old pause text (the old runHandoff already holds the
|
|
10036
|
+
// author's plan) and continue as if it were idle.
|
|
10037
|
+
if (reviewGateCycleId !== reviewCycleId) {
|
|
10038
|
+
settleReviewGate({
|
|
10039
|
+
outcome: "unreviewed",
|
|
10040
|
+
planText: currentPausedReview()?.planText,
|
|
10041
|
+
});
|
|
10042
|
+
reviewGatePhase = "idle";
|
|
10043
|
+
reviewGateCycleId = 0;
|
|
10044
|
+
} else {
|
|
10045
|
+
try {
|
|
10046
|
+
ctx.ui.notify(
|
|
10047
|
+
reviewGatePhase === "paused"
|
|
10048
|
+
? "Review paused — /review-resume to continue, /review-abort to hand off the plan as-is."
|
|
10049
|
+
: "Review running — ESC pauses it, /review-abort hands off the plan as-is.",
|
|
10050
|
+
"info",
|
|
10051
|
+
);
|
|
10052
|
+
} catch (_e) {
|
|
10053
|
+
void _e;
|
|
10054
|
+
}
|
|
10055
|
+
return;
|
|
9811
10056
|
}
|
|
9812
|
-
return;
|
|
9813
10057
|
}
|
|
9814
10058
|
// Mutex: serialize concurrent agent_end handoffs (bus may emit Promise.all)
|
|
9815
10059
|
if (handoffInFlight) {
|
|
@@ -9831,6 +10075,10 @@ export default function workflowExtension(
|
|
|
9831
10075
|
}
|
|
9832
10076
|
}
|
|
9833
10077
|
const runHandoff = async () => {
|
|
10078
|
+
// Capture the cycle that owns this handoff. A fresh prompt/refine/deny
|
|
10079
|
+
// increments `reviewCycleId`; every later continuation must stop instead
|
|
10080
|
+
// of rendering or applying a superseded plan.
|
|
10081
|
+
const handoffCycle = reviewCycleId;
|
|
9834
10082
|
// Per-run handoff gate (plan 2026-10-25 follow-up): a Plan-mode run
|
|
9835
10083
|
// that wrote no plan this run must not reconcile, review, or hand off
|
|
9836
10084
|
// an older plan. This runs before the reviewer prefetch so a new task
|
|
@@ -9862,6 +10110,7 @@ export default function workflowExtension(
|
|
|
9862
10110
|
: path.join(cwd, planWritePathThisRun);
|
|
9863
10111
|
currentPlanFile = abs;
|
|
9864
10112
|
lastPlanWritePath = abs;
|
|
10113
|
+
notePlanTargetForReviewCycle(abs);
|
|
9865
10114
|
// Persist once per run even when the tool_result handler already set
|
|
9866
10115
|
// the in-memory target: resume must find the plan without a mode
|
|
9867
10116
|
// switch or a todo update happening to persist it first.
|
|
@@ -10217,6 +10466,7 @@ export default function workflowExtension(
|
|
|
10217
10466
|
const authorPlan: string = planTextForRender;
|
|
10218
10467
|
reviewHandoffGate.reset();
|
|
10219
10468
|
reviewGatePhase = "auditing";
|
|
10469
|
+
reviewGateCycleId = handoffCycle;
|
|
10220
10470
|
try {
|
|
10221
10471
|
const reviewed = await runReviewStep(
|
|
10222
10472
|
authorPlan,
|
|
@@ -10224,6 +10474,7 @@ export default function workflowExtension(
|
|
|
10224
10474
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10225
10475
|
"auto",
|
|
10226
10476
|
);
|
|
10477
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10227
10478
|
const reviewedText = reviewed.planText;
|
|
10228
10479
|
// Fresh read: the phase may have moved to "paused" (ESC /
|
|
10229
10480
|
// /review-pause) while the round was returning. The `as` widens
|
|
@@ -10233,7 +10484,7 @@ export default function workflowExtension(
|
|
|
10233
10484
|
if (
|
|
10234
10485
|
reviewHandoffParks({
|
|
10235
10486
|
phase: phaseAfterRound,
|
|
10236
|
-
paused:
|
|
10487
|
+
paused: currentPausedReview() !== undefined,
|
|
10237
10488
|
settled: reviewHandoffGate.settled(),
|
|
10238
10489
|
})
|
|
10239
10490
|
) {
|
|
@@ -10246,6 +10497,7 @@ export default function workflowExtension(
|
|
|
10246
10497
|
const res = await reviewHandoffGate.wait(
|
|
10247
10498
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10248
10499
|
);
|
|
10500
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10249
10501
|
if (res === undefined) {
|
|
10250
10502
|
// Host abort while parked: stop the reviewer, drop the pause,
|
|
10251
10503
|
// do not auto-review this revision again, and hand the author's
|
|
@@ -10290,11 +10542,15 @@ export default function workflowExtension(
|
|
|
10290
10542
|
} catch (_e) {
|
|
10291
10543
|
void _e;
|
|
10292
10544
|
} finally {
|
|
10293
|
-
|
|
10545
|
+
if (reviewGateCycleId === handoffCycle) {
|
|
10546
|
+
reviewGatePhase = "idle";
|
|
10547
|
+
reviewGateCycleId = 0;
|
|
10548
|
+
}
|
|
10294
10549
|
}
|
|
10295
10550
|
// A mode switch mid-gate (e.g. Tab-to-Build) already left plan mode:
|
|
10296
10551
|
// the gate settled as skipped, so there is nothing to hand off.
|
|
10297
10552
|
if (workflowMode !== "plan") return;
|
|
10553
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10298
10554
|
}
|
|
10299
10555
|
// Fallback when hasUI is false: still show handoff via followUp message (Claude Code parity for headless)
|
|
10300
10556
|
if (!hasUI && planTextForRender && !awaitingDecision) {
|
|
@@ -10750,6 +11006,14 @@ export default function workflowExtension(
|
|
|
10750
11006
|
"",
|
|
10751
11007
|
);
|
|
10752
11008
|
if (refinement?.trim()) {
|
|
11009
|
+
try {
|
|
11010
|
+
await beginReviewCycle({
|
|
11011
|
+
reason: "refine",
|
|
11012
|
+
keepFindings: true,
|
|
11013
|
+
});
|
|
11014
|
+
} catch (_e) {
|
|
11015
|
+
void _e;
|
|
11016
|
+
}
|
|
10753
11017
|
const todoListText = formatTodoNumberedList(todoItems, {
|
|
10754
11018
|
glyph: true,
|
|
10755
11019
|
});
|