@dieulc/workflow 0.9.1 → 0.10.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 +82 -15
- package/edit-policy.ts +11 -64
- package/index.ts +1136 -200
- package/package.json +10 -6
- package/permissions.ts +393 -93
- package/provider-retarget.ts +258 -0
- package/review-handoff.ts +125 -0
- package/review-recovery.ts +65 -0
- package/review-runtime.ts +318 -48
- package/review-tools.ts +303 -6
- package/review.ts +321 -4
- package/shell-text.ts +542 -0
- package/utils.ts +42 -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,
|
|
@@ -160,6 +163,8 @@ import {
|
|
|
160
163
|
roleArgumentCompletions,
|
|
161
164
|
saveRoleConfig,
|
|
162
165
|
getRole,
|
|
166
|
+
userRolesPath,
|
|
167
|
+
projectRolesPath,
|
|
163
168
|
formatRolesForDisplay,
|
|
164
169
|
formatModelRef,
|
|
165
170
|
parseModelRef,
|
|
@@ -201,11 +206,17 @@ import {
|
|
|
201
206
|
PROFILE_ROLES,
|
|
202
207
|
modelPickerItems,
|
|
203
208
|
profileActionClosesPicker,
|
|
209
|
+
userProfilesPath,
|
|
210
|
+
projectProfilesPath,
|
|
204
211
|
type ProfileFlowOutcome,
|
|
205
212
|
type ProfileConfig,
|
|
206
213
|
type Profile,
|
|
207
214
|
type ProfileRoleName,
|
|
208
215
|
} from "./profiles.ts";
|
|
216
|
+
import {
|
|
217
|
+
formatRetargetResult,
|
|
218
|
+
retargetProviderFiles,
|
|
219
|
+
} from "./provider-retarget.ts";
|
|
209
220
|
import { FilterableList, type FilterableItem } from "./picker.ts";
|
|
210
221
|
import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
|
|
211
222
|
import {
|
|
@@ -229,6 +240,7 @@ import {
|
|
|
229
240
|
} from "./model-default.ts";
|
|
230
241
|
import {
|
|
231
242
|
applySubmittedPlan,
|
|
243
|
+
buildReviewerFallbacks,
|
|
232
244
|
classifyReviewExecution,
|
|
233
245
|
createReviewHandoffGate,
|
|
234
246
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
@@ -238,25 +250,28 @@ import {
|
|
|
238
250
|
planHash,
|
|
239
251
|
readReviewModeConfig,
|
|
240
252
|
resolveReviewPrompt,
|
|
241
|
-
|
|
253
|
+
nextReviewRound,
|
|
242
254
|
reviewGatePhaseOnResume,
|
|
243
255
|
reviewModeLockFooter,
|
|
244
256
|
reviewModeLockKind,
|
|
245
257
|
reviewModeLockNotice,
|
|
246
258
|
reviewModeLockStatusLine,
|
|
247
259
|
reviewNoticeText,
|
|
260
|
+
reviewPauseIsCurrent,
|
|
248
261
|
reviewResumeGateOutcome,
|
|
249
262
|
reviewResumeNotice,
|
|
250
263
|
reviewResumePlanChoice,
|
|
251
264
|
reviewEscapeAction,
|
|
265
|
+
reviewElapsedMs,
|
|
252
266
|
reviewHandoffParks,
|
|
253
267
|
reviewResumeSettles,
|
|
254
268
|
reviewStateSummary,
|
|
255
|
-
|
|
269
|
+
reviewStatusText,
|
|
256
270
|
shouldRouteInputToReviewer,
|
|
257
271
|
writeReviewModeConfig,
|
|
258
272
|
writeReviewPrompt,
|
|
259
273
|
type ReviewFinding,
|
|
274
|
+
type ReviewFallbackModel,
|
|
260
275
|
type ReviewExecutionResult,
|
|
261
276
|
type ReviewHandoffGate,
|
|
262
277
|
type ReviewSdkLoader,
|
|
@@ -278,12 +293,35 @@ import {
|
|
|
278
293
|
type ReviewLineBuffer,
|
|
279
294
|
type ReviewModeBandData,
|
|
280
295
|
} from "./review-ui.ts";
|
|
296
|
+
import {
|
|
297
|
+
pendingHandoffIsStale,
|
|
298
|
+
reviewHandoffRecovery,
|
|
299
|
+
shouldHandoffManualResult,
|
|
300
|
+
type ReviewHandoffSource,
|
|
301
|
+
} from "./review-handoff.ts";
|
|
281
302
|
import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
|
|
282
303
|
|
|
283
304
|
// ── Plannotator Bridge ───────────────────────────────────────────────
|
|
284
305
|
const PLANNOTATOR_REQUEST = "plannotator:request" as const;
|
|
285
306
|
const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
|
|
286
307
|
const PLANNOTATOR_PLAN_APPROVED = "plannotator:plan-approved" as const;
|
|
308
|
+
const PLANNOTATOR_SESSION_OPENED = "plannotator:session-opened" as const;
|
|
309
|
+
const PLANNOTATOR_SESSION_CLOSED = "plannotator:session-closed" as const;
|
|
310
|
+
|
|
311
|
+
/** Structural shape of the optional Plannotator lifecycle events. */
|
|
312
|
+
interface PlannotatorSessionEventLike {
|
|
313
|
+
id?: string;
|
|
314
|
+
action?: string;
|
|
315
|
+
url?: string;
|
|
316
|
+
reviewId?: string;
|
|
317
|
+
requestId?: string;
|
|
318
|
+
outcome?: "approved" | "submitted" | "closed";
|
|
319
|
+
closedBy?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
type PlanReviewRequestOutcome =
|
|
323
|
+
| { status: "handled"; reviewId?: string; url?: string }
|
|
324
|
+
| { status: "timeout" | "error" | "unavailable"; reason: string };
|
|
287
325
|
|
|
288
326
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
289
327
|
|
|
@@ -495,6 +533,10 @@ function getFinalOutput(messages: any[]): string {
|
|
|
495
533
|
*/
|
|
496
534
|
export interface WorkflowExtensionDeps {
|
|
497
535
|
loadSdk?: ReviewSdkLoader;
|
|
536
|
+
/** Test seam: how long a plan-review request waits for Plannotator. */
|
|
537
|
+
plannotatorRequestTimeoutMs?: number;
|
|
538
|
+
/** Test seam: how long a reload recovery status query waits. */
|
|
539
|
+
plannotatorRecoveryTimeoutMs?: number;
|
|
498
540
|
}
|
|
499
541
|
|
|
500
542
|
export default function workflowExtension(
|
|
@@ -1322,6 +1364,16 @@ export default function workflowExtension(
|
|
|
1322
1364
|
let lastReviewedPlanHash: string | undefined;
|
|
1323
1365
|
/** 1-based round within the current user-feedback cycle. */
|
|
1324
1366
|
let reviewRound = 0;
|
|
1367
|
+
/**
|
|
1368
|
+
* Monotonic cycle token. A fresh prompt, an explicit refine/deny, or Build
|
|
1369
|
+
* entry starts a new cycle; async handoffs capture it so a superseded cycle
|
|
1370
|
+
* can never render, apply todos, or clear a newer gate.
|
|
1371
|
+
*/
|
|
1372
|
+
let reviewCycleId = 0;
|
|
1373
|
+
/** Plan target the current review cycle belongs to (absolute, or null). */
|
|
1374
|
+
let reviewCyclePlanPath: string | null = null;
|
|
1375
|
+
/** Cycle that owns the parked gate; `0` when no gate is owned. */
|
|
1376
|
+
let reviewGateCycleId = 0;
|
|
1325
1377
|
/** Findings carried into the next round so the reviewer can verify fixes. */
|
|
1326
1378
|
let reviewPreviousFindings: ReviewFinding[] | undefined;
|
|
1327
1379
|
/**
|
|
@@ -1343,9 +1395,22 @@ export default function workflowExtension(
|
|
|
1343
1395
|
fileHashAtPause?: string;
|
|
1344
1396
|
round: number;
|
|
1345
1397
|
previousFindings: ReviewFinding[] | undefined;
|
|
1398
|
+
/** Cycle that recorded the pause; stale cycles are ignored. */
|
|
1399
|
+
cycleId: number;
|
|
1346
1400
|
at: number;
|
|
1347
1401
|
}
|
|
1348
1402
|
| undefined;
|
|
1403
|
+
|
|
1404
|
+
/** The pause only counts while it belongs to the current review cycle. */
|
|
1405
|
+
function currentPausedReview(): typeof pausedReview {
|
|
1406
|
+
if (!pausedReview) return undefined;
|
|
1407
|
+
return reviewPauseIsCurrent({
|
|
1408
|
+
pauseCycle: pausedReview.cycleId,
|
|
1409
|
+
currentCycle: reviewCycleId,
|
|
1410
|
+
})
|
|
1411
|
+
? pausedReview
|
|
1412
|
+
: undefined;
|
|
1413
|
+
}
|
|
1349
1414
|
/** In-flight round input (set while `runRound` awaits) — the pause source. */
|
|
1350
1415
|
let activeReview:
|
|
1351
1416
|
| { planText: string; round: number; controller: AbortController }
|
|
@@ -1384,8 +1449,10 @@ export default function workflowExtension(
|
|
|
1384
1449
|
*/
|
|
1385
1450
|
function reviewModeLockKindNow(): ReviewModeLockKind | undefined {
|
|
1386
1451
|
const roundInFlight = activeReview !== undefined;
|
|
1387
|
-
const paused =
|
|
1388
|
-
|
|
1452
|
+
const paused = currentPausedReview() !== undefined;
|
|
1453
|
+
// A gate from a superseded cycle must not keep the modes locked.
|
|
1454
|
+
const gatePhase =
|
|
1455
|
+
reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle";
|
|
1389
1456
|
let roundActive = false;
|
|
1390
1457
|
try {
|
|
1391
1458
|
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
@@ -1488,6 +1555,48 @@ export default function workflowExtension(
|
|
|
1488
1555
|
}
|
|
1489
1556
|
}
|
|
1490
1557
|
|
|
1558
|
+
/**
|
|
1559
|
+
* Automatic provider-recovery chain: the parent model that just planned,
|
|
1560
|
+
* then the base reviewer and planner roles from roles.json (ignoring the
|
|
1561
|
+
* active profile). Deduped and thinking-clamped through the parent registry.
|
|
1562
|
+
*/
|
|
1563
|
+
function reviewFallbackModels(): ReviewFallbackModel[] {
|
|
1564
|
+
try {
|
|
1565
|
+
ensureRoleConfig(reviewCwd());
|
|
1566
|
+
ensureProfilesConfig(reviewCwd());
|
|
1567
|
+
const parent = currentSessionCtx?.model;
|
|
1568
|
+
const parentThinking = currentSessionCtx?.thinkingLevel;
|
|
1569
|
+
const baseReviewer = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
|
|
1570
|
+
const basePlanner = getRole(roleConfig, "planner")?.model;
|
|
1571
|
+
return buildReviewerFallbacks({
|
|
1572
|
+
...(parent
|
|
1573
|
+
? {
|
|
1574
|
+
parentModel: {
|
|
1575
|
+
provider: parent.provider,
|
|
1576
|
+
id: parent.id,
|
|
1577
|
+
thinking: parentThinking ?? "high",
|
|
1578
|
+
},
|
|
1579
|
+
}
|
|
1580
|
+
: {}),
|
|
1581
|
+
...(baseReviewer ? { baseReviewer } : {}),
|
|
1582
|
+
...(basePlanner ? { basePlanner } : {}),
|
|
1583
|
+
clampThinking: (model) => {
|
|
1584
|
+
const found = (currentSessionCtx as any)?.modelRegistry?.find?.(
|
|
1585
|
+
model.provider,
|
|
1586
|
+
model.id,
|
|
1587
|
+
);
|
|
1588
|
+
return clampThinkingForModel(
|
|
1589
|
+
found,
|
|
1590
|
+
isValidThinkingLevel(model.thinking) ? model.thinking : "high",
|
|
1591
|
+
);
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
} catch (_e) {
|
|
1595
|
+
void _e;
|
|
1596
|
+
return [];
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1491
1600
|
/** Reviewer model label for the mode band (best-effort). */
|
|
1492
1601
|
function reviewModelLabel(): string {
|
|
1493
1602
|
// While a round runs, the band names the snapshotted model — the one
|
|
@@ -1805,23 +1914,24 @@ export default function workflowExtension(
|
|
|
1805
1914
|
*/
|
|
1806
1915
|
function updateReviewStatus(ctx: ExtensionContext): void {
|
|
1807
1916
|
try {
|
|
1808
|
-
const paused =
|
|
1917
|
+
const paused = currentPausedReview() !== undefined;
|
|
1809
1918
|
let roundActive = false;
|
|
1810
1919
|
try {
|
|
1811
1920
|
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
1812
1921
|
} catch (_e) {
|
|
1813
1922
|
void _e;
|
|
1814
1923
|
}
|
|
1815
|
-
|
|
1924
|
+
// The review status is a LIVE indicator, not a result holder: the last
|
|
1925
|
+
// round's state stays for `/review-status` and the REVIEW SUMMARY band,
|
|
1926
|
+
// but the footer segment must disappear as soon as the round ends. A
|
|
1927
|
+
// retained terminal phase is not painted during a new round's cold boot,
|
|
1928
|
+
// and a pause from an old cycle is ignored by `currentPausedReview`.
|
|
1929
|
+
const reviewText = reviewStatusText({
|
|
1930
|
+
state: reviewState,
|
|
1816
1931
|
roundInFlight: activeReview !== undefined,
|
|
1817
1932
|
roundActive,
|
|
1818
1933
|
paused,
|
|
1819
1934
|
});
|
|
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
1935
|
let lockText = "";
|
|
1826
1936
|
try {
|
|
1827
1937
|
lockText = reviewModeLockFooter(reviewModeLockKindNow());
|
|
@@ -1897,6 +2007,7 @@ export default function workflowExtension(
|
|
|
1897
2007
|
if (reviewModelSnapshot) return reviewModelSnapshot;
|
|
1898
2008
|
return resolveReviewerModel();
|
|
1899
2009
|
},
|
|
2010
|
+
fallbackReviewerModels: () => reviewFallbackModels(),
|
|
1900
2011
|
findModel: (provider: string, id: string) =>
|
|
1901
2012
|
(currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
|
|
1902
2013
|
// Structural probe of the live parent context, evaluated at reviewer
|
|
@@ -1996,7 +2107,7 @@ export default function workflowExtension(
|
|
|
1996
2107
|
previousFindings: ReviewFinding[] | undefined;
|
|
1997
2108
|
}): boolean {
|
|
1998
2109
|
const hash = planHash(input.planText);
|
|
1999
|
-
if (
|
|
2110
|
+
if (currentPausedReview()?.planHash === hash) return false;
|
|
2000
2111
|
// Snapshot the file as well as the text: the reviewer may already have
|
|
2001
2112
|
// written its rewrite, and that must not look like an author edit later.
|
|
2002
2113
|
const currentFile = readCurrentPlanText(reviewCwd());
|
|
@@ -2007,6 +2118,7 @@ export default function workflowExtension(
|
|
|
2007
2118
|
currentFile !== undefined ? planHash(currentFile) : undefined,
|
|
2008
2119
|
round: input.round,
|
|
2009
2120
|
previousFindings: input.previousFindings,
|
|
2121
|
+
cycleId: reviewCycleId,
|
|
2010
2122
|
at: Date.now(),
|
|
2011
2123
|
};
|
|
2012
2124
|
// A gated audit becomes a paused gate (the handoff keeps waiting). A
|
|
@@ -2059,7 +2171,7 @@ export default function workflowExtension(
|
|
|
2059
2171
|
roundActive:
|
|
2060
2172
|
activeReview !== undefined ||
|
|
2061
2173
|
reviewRuntime?.isRoundActive() === true,
|
|
2062
|
-
paused:
|
|
2174
|
+
paused: currentPausedReview() !== undefined,
|
|
2063
2175
|
gatedPause: reviewGatePhase === "paused",
|
|
2064
2176
|
});
|
|
2065
2177
|
if (action === "pause") {
|
|
@@ -2149,6 +2261,9 @@ export default function workflowExtension(
|
|
|
2149
2261
|
try {
|
|
2150
2262
|
const ctx = currentSessionCtx;
|
|
2151
2263
|
if (!ctx || !(ctx as any).hasUI) return;
|
|
2264
|
+
if (message !== undefined) {
|
|
2265
|
+
(ctx as any).ui?.setWorkingVisible?.(true);
|
|
2266
|
+
}
|
|
2152
2267
|
(ctx as any).ui?.setWorkingMessage?.(message);
|
|
2153
2268
|
} catch (_e) {
|
|
2154
2269
|
void _e;
|
|
@@ -2173,6 +2288,7 @@ export default function workflowExtension(
|
|
|
2173
2288
|
// The parked handoff only awaits the gate; it does not re-read the phase
|
|
2174
2289
|
// after this point.
|
|
2175
2290
|
reviewGatePhase = "idle";
|
|
2291
|
+
reviewGateCycleId = 0;
|
|
2176
2292
|
} catch (_e) {
|
|
2177
2293
|
void _e;
|
|
2178
2294
|
}
|
|
@@ -2219,6 +2335,9 @@ export default function workflowExtension(
|
|
|
2219
2335
|
// follow `reviewState`; the spinner row needs an explicit message while
|
|
2220
2336
|
// the child session streams off the main turn.
|
|
2221
2337
|
setReviewWorkingMessage("🔍 Reviewing plan…");
|
|
2338
|
+
// Paint the live review line now, not after the child session's first
|
|
2339
|
+
// state event: a manual `/review` must look alive from the first tick.
|
|
2340
|
+
updateStatus(ctx as any);
|
|
2222
2341
|
// One long-lived listener covers this round AND the paused window (see
|
|
2223
2342
|
// ensureReviewEscListener for why it is no longer attached per round).
|
|
2224
2343
|
ensureReviewEscListener(ctx);
|
|
@@ -2254,17 +2373,18 @@ export default function workflowExtension(
|
|
|
2254
2373
|
// `review_submit_plan` that landed in that window changed the file after
|
|
2255
2374
|
// the snapshot, so refresh it: otherwise `/review-resume` would read the
|
|
2256
2375
|
// reviewer's own late write as an author edit and start a fresh round.
|
|
2257
|
-
|
|
2376
|
+
const pausedNow = currentPausedReview();
|
|
2377
|
+
if (pausedNow?.planHash === hash) {
|
|
2258
2378
|
const currentFile = readCurrentPlanText(reviewCwd());
|
|
2259
2379
|
if (currentFile !== undefined) {
|
|
2260
|
-
|
|
2380
|
+
pausedNow.fileHashAtPause = planHash(currentFile);
|
|
2261
2381
|
}
|
|
2262
2382
|
}
|
|
2263
2383
|
reviewState = res.state;
|
|
2264
2384
|
updateStatus(ctx);
|
|
2265
2385
|
// The snapshot is the ONLY pause signal. A pre-aborted host signal or a
|
|
2266
2386
|
// host abort without this snapshot is an abort, never a pause.
|
|
2267
|
-
const pausedHere =
|
|
2387
|
+
const pausedHere = pausedNow?.planHash === hash;
|
|
2268
2388
|
if (res.ok) {
|
|
2269
2389
|
lastReviewedPlanHash = res.planHash;
|
|
2270
2390
|
abortedReviewHash = undefined;
|
|
@@ -2363,7 +2483,7 @@ export default function workflowExtension(
|
|
|
2363
2483
|
detachHostSignal = undefined;
|
|
2364
2484
|
// A pause keeps its own working message (pauseReviewRound sets it
|
|
2365
2485
|
// after the abort lands); every other exit restores the default.
|
|
2366
|
-
if (!(
|
|
2486
|
+
if (!(currentPausedReview()?.planHash === hash)) {
|
|
2367
2487
|
setReviewWorkingMessage(undefined);
|
|
2368
2488
|
}
|
|
2369
2489
|
// Ownership ended: refresh both surfaces now that `activeReview` is
|
|
@@ -2422,6 +2542,32 @@ export default function workflowExtension(
|
|
|
2422
2542
|
};
|
|
2423
2543
|
}
|
|
2424
2544
|
|
|
2545
|
+
// A fresh automatic round supersedes any paused one. Even when this
|
|
2546
|
+
// decision is skipped below, no stale pause/line may survive it.
|
|
2547
|
+
if (trigger === "auto" && pausedReview !== undefined) {
|
|
2548
|
+
pausedReview = undefined;
|
|
2549
|
+
try {
|
|
2550
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2551
|
+
} catch (_e) {
|
|
2552
|
+
void _e;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// A new round can never inherit an older browser gate: the old tab's
|
|
2557
|
+
// decision would otherwise suppress this round's handoff after it lands.
|
|
2558
|
+
if (awaitingDecision) {
|
|
2559
|
+
supersedePendingHandoff(
|
|
2560
|
+
trigger === "manual"
|
|
2561
|
+
? "a manual /review started"
|
|
2562
|
+
: "a new review round started",
|
|
2563
|
+
{ force: true, nextPlanHash: hash, nextCycleId: reviewCycleId },
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// A different plan file is a new target even inside one user run: reset the
|
|
2568
|
+
// budget/hashes/findings before the dedupe gates below.
|
|
2569
|
+
const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
|
|
2570
|
+
|
|
2425
2571
|
// Already reviewed this exact revision — do not re-review on re-emit.
|
|
2426
2572
|
if (hash === lastReviewedPlanHash) {
|
|
2427
2573
|
return {
|
|
@@ -2441,8 +2587,13 @@ export default function workflowExtension(
|
|
|
2441
2587
|
};
|
|
2442
2588
|
}
|
|
2443
2589
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2590
|
+
const budget = nextReviewRound({
|
|
2591
|
+
round: reviewRound,
|
|
2592
|
+
maxRounds: cfg.rounds,
|
|
2593
|
+
newCycle: cycleChanged,
|
|
2594
|
+
});
|
|
2595
|
+
reviewRound = budget.round;
|
|
2596
|
+
if (!budget.allowed) {
|
|
2446
2597
|
// Round cap reached: hand over with the outstanding findings visible.
|
|
2447
2598
|
const unresolved = (reviewState?.findings ?? []).filter(
|
|
2448
2599
|
(f) => f.disposition !== "rejected",
|
|
@@ -2481,6 +2632,11 @@ export default function workflowExtension(
|
|
|
2481
2632
|
|
|
2482
2633
|
// A fresh round supersedes any paused one (resume uses its own path).
|
|
2483
2634
|
pausedReview = undefined;
|
|
2635
|
+
try {
|
|
2636
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2637
|
+
} catch (_e) {
|
|
2638
|
+
void _e;
|
|
2639
|
+
}
|
|
2484
2640
|
|
|
2485
2641
|
try {
|
|
2486
2642
|
return await executeReviewRound({
|
|
@@ -2549,6 +2705,137 @@ export default function workflowExtension(
|
|
|
2549
2705
|
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2550
2706
|
}
|
|
2551
2707
|
|
|
2708
|
+
type ReviewCycleReason =
|
|
2709
|
+
| "prompt"
|
|
2710
|
+
| "refine"
|
|
2711
|
+
| "deny"
|
|
2712
|
+
| "build"
|
|
2713
|
+
| "session";
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* Start a new review cycle: one plan-authoring/feedback sequence.
|
|
2717
|
+
*
|
|
2718
|
+
* Every fresh prompt, explicit refine/deny, and Build entry owns a cycle.
|
|
2719
|
+
* The cycle token invalidates an old `runHandoff` continuation, the budget
|
|
2720
|
+
* restarts, and an abandoned pause/gate is cancelled (D2) so it cannot keep
|
|
2721
|
+
* the footer line, lock the modes, or short-circuit the next `agent_end`.
|
|
2722
|
+
* Findings survive a same-plan refinement and are cleared for a new file.
|
|
2723
|
+
* Never throws: a broken review surface must not block the prompt.
|
|
2724
|
+
*/
|
|
2725
|
+
async function beginReviewCycle(opts: {
|
|
2726
|
+
reason: ReviewCycleReason;
|
|
2727
|
+
keepFindings?: boolean;
|
|
2728
|
+
planPath?: string | null;
|
|
2729
|
+
}): Promise<void> {
|
|
2730
|
+
const hadActive = activeReview !== undefined;
|
|
2731
|
+
const hadPause = currentPausedReview() !== undefined;
|
|
2732
|
+
const hadGate = reviewGatePhase !== "idle";
|
|
2733
|
+
// Synchronously first: any old handoff continuation is stale from here on.
|
|
2734
|
+
reviewCycleId += 1;
|
|
2735
|
+
if (
|
|
2736
|
+
opts.reason === "prompt" ||
|
|
2737
|
+
opts.reason === "build" ||
|
|
2738
|
+
opts.reason === "session"
|
|
2739
|
+
) {
|
|
2740
|
+
supersedePendingHandoff(`a new ${opts.reason} cycle started`, {
|
|
2741
|
+
force: true,
|
|
2742
|
+
});
|
|
2743
|
+
manualReviewCycle = false;
|
|
2744
|
+
}
|
|
2745
|
+
if (hadActive || pausedReview !== undefined || hadGate) {
|
|
2746
|
+
try {
|
|
2747
|
+
await suspendReview();
|
|
2748
|
+
} catch (_e) {
|
|
2749
|
+
void _e;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
reviewRound = 0;
|
|
2753
|
+
lastReviewedPlanHash = undefined;
|
|
2754
|
+
abortedReviewHash = undefined;
|
|
2755
|
+
if (!hadActive) quietAbortHash = undefined;
|
|
2756
|
+
|
|
2757
|
+
const rawNext = opts.planPath ?? currentPlanFile ?? null;
|
|
2758
|
+
let next: string | null = null;
|
|
2759
|
+
if (rawNext) {
|
|
2760
|
+
try {
|
|
2761
|
+
next = path.isAbsolute(rawNext)
|
|
2762
|
+
? rawNext
|
|
2763
|
+
: path.join(reviewCwd(), rawNext);
|
|
2764
|
+
} catch (_e) {
|
|
2765
|
+
void _e;
|
|
2766
|
+
next = rawNext;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
const pathChanged =
|
|
2770
|
+
reviewCyclePlanPath !== null &&
|
|
2771
|
+
next !== null &&
|
|
2772
|
+
!samePlanPath(next, reviewCyclePlanPath, reviewCwd());
|
|
2773
|
+
if (pathChanged || opts.keepFindings === false) {
|
|
2774
|
+
reviewPreviousFindings = undefined;
|
|
2775
|
+
}
|
|
2776
|
+
reviewCyclePlanPath = next;
|
|
2777
|
+
reviewGateCycleId = 0;
|
|
2778
|
+
|
|
2779
|
+
if (hadPause || hadGate) {
|
|
2780
|
+
notifyReview(
|
|
2781
|
+
"Paused review abandoned — new work started; the paused round will not resume.",
|
|
2782
|
+
"info",
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
try {
|
|
2786
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2787
|
+
} catch (_e) {
|
|
2788
|
+
void _e;
|
|
2789
|
+
}
|
|
2790
|
+
try {
|
|
2791
|
+
if (reviewEnabled() || hadActive || hadPause || hadGate || reviewState) {
|
|
2792
|
+
pi.appendEntry("workflow-review-cycle", {
|
|
2793
|
+
reason: opts.reason,
|
|
2794
|
+
cycleId: reviewCycleId,
|
|
2795
|
+
planPath: reviewCyclePlanPath,
|
|
2796
|
+
at: Date.now(),
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
} catch (_e) {
|
|
2800
|
+
void _e;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
/**
|
|
2805
|
+
* A different plan file is a new review target even inside one user run.
|
|
2806
|
+
* Resets the cycle budget and prior findings, but not the cycle token: the
|
|
2807
|
+
* handoff that owns this run must stay current.
|
|
2808
|
+
*/
|
|
2809
|
+
function notePlanTargetForReviewCycle(
|
|
2810
|
+
rawPath: string | null | undefined,
|
|
2811
|
+
): boolean {
|
|
2812
|
+
if (!rawPath) return false;
|
|
2813
|
+
const cwd = reviewCwd();
|
|
2814
|
+
let next: string;
|
|
2815
|
+
try {
|
|
2816
|
+
next = path.isAbsolute(rawPath) ? rawPath : path.join(cwd, rawPath);
|
|
2817
|
+
} catch (_e) {
|
|
2818
|
+
void _e;
|
|
2819
|
+
next = rawPath;
|
|
2820
|
+
}
|
|
2821
|
+
if (reviewCyclePlanPath === null) {
|
|
2822
|
+
reviewCyclePlanPath = next;
|
|
2823
|
+
return false;
|
|
2824
|
+
}
|
|
2825
|
+
if (samePlanPath(next, reviewCyclePlanPath, cwd)) return false;
|
|
2826
|
+
reviewCyclePlanPath = next;
|
|
2827
|
+
reviewRound = 0;
|
|
2828
|
+
lastReviewedPlanHash = undefined;
|
|
2829
|
+
abortedReviewHash = undefined;
|
|
2830
|
+
reviewPreviousFindings = undefined;
|
|
2831
|
+
try {
|
|
2832
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
2833
|
+
} catch (_e) {
|
|
2834
|
+
void _e;
|
|
2835
|
+
}
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2552
2839
|
/**
|
|
2553
2840
|
* Stop the reviewer and clear all Review Mode state. Never throws.
|
|
2554
2841
|
*
|
|
@@ -2557,7 +2844,9 @@ export default function workflowExtension(
|
|
|
2557
2844
|
* they are not guarded. Clearing the review state releases the derived
|
|
2558
2845
|
* mode-switch lock (plan 2026-10-25).
|
|
2559
2846
|
*/
|
|
2560
|
-
async function teardownReview(
|
|
2847
|
+
async function teardownReview(
|
|
2848
|
+
opts: { keepHandoff?: boolean } = {},
|
|
2849
|
+
): Promise<void> {
|
|
2561
2850
|
try {
|
|
2562
2851
|
activeReview?.controller.abort();
|
|
2563
2852
|
} catch (_e) {
|
|
@@ -2570,6 +2859,7 @@ export default function workflowExtension(
|
|
|
2570
2859
|
}
|
|
2571
2860
|
reviewRuntime = undefined;
|
|
2572
2861
|
reviewState = undefined;
|
|
2862
|
+
if (opts.keepHandoff !== true) manualReviewCycle = false;
|
|
2573
2863
|
lastReviewedPlanHash = undefined;
|
|
2574
2864
|
reviewRound = 0;
|
|
2575
2865
|
reviewPreviousFindings = undefined;
|
|
@@ -2584,11 +2874,12 @@ export default function workflowExtension(
|
|
|
2584
2874
|
activeReview = undefined;
|
|
2585
2875
|
abortedReviewHash = undefined;
|
|
2586
2876
|
quietAbortHash = undefined;
|
|
2587
|
-
// A
|
|
2588
|
-
//
|
|
2589
|
-
const clearedDecision =
|
|
2877
|
+
// A reload/resume keeps the browser correlation so the next session_start
|
|
2878
|
+
// can recover it; every other teardown ends the decision and provenance.
|
|
2879
|
+
const clearedDecision =
|
|
2880
|
+
opts.keepHandoff === true ? false : clearPendingHandoffDecision();
|
|
2590
2881
|
resetPlanRunProvenance();
|
|
2591
|
-
if (clearedDecision) {
|
|
2882
|
+
if (clearedDecision || opts.keepHandoff === true) {
|
|
2592
2883
|
try {
|
|
2593
2884
|
persistState();
|
|
2594
2885
|
} catch (_e) {
|
|
@@ -2930,6 +3221,26 @@ export default function workflowExtension(
|
|
|
2930
3221
|
writePolicyArgumentCompletions(prefix),
|
|
2931
3222
|
});
|
|
2932
3223
|
|
|
3224
|
+
// ── /permissions: explain a command's verdict (diagnostics) ────────
|
|
3225
|
+
//
|
|
3226
|
+
// Answers "why was that blocked?" (or "would it be?") without touching a
|
|
3227
|
+
// gate: it reads the same analyzeCommand/explainCommandRefusal the gates
|
|
3228
|
+
// use, so the explanation cannot describe a policy the gate no longer
|
|
3229
|
+
// enforces. Read-only, so it is safe in every mode.
|
|
3230
|
+
pi.registerCommand("permissions", {
|
|
3231
|
+
description:
|
|
3232
|
+
"Explain why a shell command would be allowed or refused: /permissions explain <command>",
|
|
3233
|
+
handler: async (args, ctx) => {
|
|
3234
|
+
const raw = String(args ?? "").trim();
|
|
3235
|
+
const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
3236
|
+
// `/permissions explain <cmd>` and `/permissions <cmd>` both work.
|
|
3237
|
+
const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
|
|
3238
|
+
ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
|
|
3239
|
+
},
|
|
3240
|
+
getArgumentCompletions: (prefix: string) =>
|
|
3241
|
+
permissionsArgumentCompletions(prefix),
|
|
3242
|
+
});
|
|
3243
|
+
|
|
2933
3244
|
pi.registerCommand("review-prompt", {
|
|
2934
3245
|
description:
|
|
2935
3246
|
"Edit the reviewer's guidance (framework alignment / best practice) used by Review Mode",
|
|
@@ -2974,11 +3285,13 @@ export default function workflowExtension(
|
|
|
2974
3285
|
const s = reviewState;
|
|
2975
3286
|
const lockKind = reviewModeLockKindNow();
|
|
2976
3287
|
if (!s) {
|
|
2977
|
-
|
|
3288
|
+
const paused = currentPausedReview();
|
|
3289
|
+
if (paused) {
|
|
2978
3290
|
ctx.ui.notify(
|
|
2979
3291
|
[
|
|
2980
|
-
`Review paused at round ${
|
|
3292
|
+
`Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
|
|
2981
3293
|
" /review-resume to continue, /review-abort to stop.",
|
|
3294
|
+
pendingHandoffStatusLine(),
|
|
2982
3295
|
reviewModeLockStatusLine(lockKind),
|
|
2983
3296
|
]
|
|
2984
3297
|
.filter(Boolean)
|
|
@@ -2999,6 +3312,7 @@ export default function workflowExtension(
|
|
|
2999
3312
|
reviewEnabled()
|
|
3000
3313
|
? "Review Mode is enabled but no review has run yet."
|
|
3001
3314
|
: `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
|
|
3315
|
+
pendingHandoffStatusLine(),
|
|
3002
3316
|
reviewModeLockStatusLine(lockKind),
|
|
3003
3317
|
]
|
|
3004
3318
|
.filter(Boolean)
|
|
@@ -3011,12 +3325,16 @@ export default function workflowExtension(
|
|
|
3011
3325
|
reviewStateSummary(s),
|
|
3012
3326
|
` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
|
|
3013
3327
|
` model : ${s.modelLabel}`,
|
|
3328
|
+
` attempt : ${s.recoveryAttempts?.length ? s.recoveryAttempts.join(" → ") : "single model"}`,
|
|
3329
|
+
` elapsed : ${Math.round(reviewElapsedMs(s) / 1000)}s${
|
|
3330
|
+
s.providerError ? ` provider: ${s.providerError.slice(0, 200)}` : ""
|
|
3331
|
+
}`,
|
|
3014
3332
|
` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
|
|
3015
3333
|
` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
|
|
3016
3334
|
s.verdict ? ` verdict : ${s.verdict}` : "",
|
|
3017
3335
|
s.error ? ` error : ${s.error}` : "",
|
|
3018
|
-
|
|
3019
|
-
? ` paused : round ${
|
|
3336
|
+
currentPausedReview()
|
|
3337
|
+
? ` paused : round ${currentPausedReview()!.round} — /review-resume to continue, /review-abort to stop`
|
|
3020
3338
|
: "",
|
|
3021
3339
|
abortedReviewHash
|
|
3022
3340
|
? ` note : revision ${abortedReviewHash} was aborted — it will not be auto-reviewed until the plan changes`
|
|
@@ -3024,6 +3342,7 @@ export default function workflowExtension(
|
|
|
3024
3342
|
reviewRuntime?.isRoundActive()
|
|
3025
3343
|
? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
|
|
3026
3344
|
: "",
|
|
3345
|
+
pendingHandoffStatusLine(),
|
|
3027
3346
|
reviewModeLockStatusLine(lockKind),
|
|
3028
3347
|
...s.findings.map(
|
|
3029
3348
|
(f) =>
|
|
@@ -3181,6 +3500,7 @@ export default function workflowExtension(
|
|
|
3181
3500
|
}
|
|
3182
3501
|
currentPlanFile = target.abs;
|
|
3183
3502
|
lastPlanWritePath = target.abs;
|
|
3503
|
+
notePlanTargetForReviewCycle(target.abs);
|
|
3184
3504
|
persistState();
|
|
3185
3505
|
lastReviewedPlanHash = undefined; // force a fresh review
|
|
3186
3506
|
abortedReviewHash = undefined;
|
|
@@ -3197,6 +3517,16 @@ export default function workflowExtension(
|
|
|
3197
3517
|
const offSuffix = reviewEnabled()
|
|
3198
3518
|
? ""
|
|
3199
3519
|
: " (Review Mode stays OFF — /review-mode on for automatic review.)";
|
|
3520
|
+
if (
|
|
3521
|
+
await openManualHandoff(
|
|
3522
|
+
result,
|
|
3523
|
+
target.abs,
|
|
3524
|
+
ctx as any,
|
|
3525
|
+
offSuffix,
|
|
3526
|
+
)
|
|
3527
|
+
) {
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3200
3530
|
ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
|
|
3201
3531
|
},
|
|
3202
3532
|
});
|
|
@@ -3209,7 +3539,7 @@ export default function workflowExtension(
|
|
|
3209
3539
|
// Gate only on round ownership below — no `enabled` check here.
|
|
3210
3540
|
if (!activeReview) {
|
|
3211
3541
|
ctx.ui.notify(
|
|
3212
|
-
|
|
3542
|
+
currentPausedReview()
|
|
3213
3543
|
? "Review is already paused — /review-resume to continue, /review-abort to stop."
|
|
3214
3544
|
: "No review round running.",
|
|
3215
3545
|
"info",
|
|
@@ -3229,7 +3559,7 @@ export default function workflowExtension(
|
|
|
3229
3559
|
handler: async (_args, ctx) => {
|
|
3230
3560
|
// Manual control: resume works whether or not Review Mode is on.
|
|
3231
3561
|
// Gate only on paused-session state below — no `enabled` check here.
|
|
3232
|
-
const paused =
|
|
3562
|
+
const paused = currentPausedReview();
|
|
3233
3563
|
if (!paused) {
|
|
3234
3564
|
ctx.ui.notify("No paused review — nothing to resume.", "info");
|
|
3235
3565
|
return;
|
|
@@ -3241,6 +3571,9 @@ export default function workflowExtension(
|
|
|
3241
3571
|
ctx.ui.notify("A review round is already running.", "warning");
|
|
3242
3572
|
return;
|
|
3243
3573
|
}
|
|
3574
|
+
// A gated resume is settled by its parked runHandoff; a manual pause
|
|
3575
|
+
// (idle gate) opens its own browser handoff after the round lands.
|
|
3576
|
+
const gatedResume = reviewGatePhase !== "idle";
|
|
3244
3577
|
// A resumed gated review is auditing again. Without this, the parked
|
|
3245
3578
|
// handoff keeps `reviewGatePhase === "paused"` for the whole round and
|
|
3246
3579
|
// the lock/footer report the wrong state (plan 2026-10-25 follow-up).
|
|
@@ -3284,7 +3617,15 @@ export default function workflowExtension(
|
|
|
3284
3617
|
planText: result.planText,
|
|
3285
3618
|
});
|
|
3286
3619
|
}
|
|
3287
|
-
|
|
3620
|
+
if (!gatedResume && reviewResumeSettles(result)) {
|
|
3621
|
+
await openManualHandoff(
|
|
3622
|
+
result,
|
|
3623
|
+
currentPlanFile ?? "",
|
|
3624
|
+
ctx as any,
|
|
3625
|
+
);
|
|
3626
|
+
} else {
|
|
3627
|
+
ctx.ui.notify(reviewResumeNotice(result), "info");
|
|
3628
|
+
}
|
|
3288
3629
|
return;
|
|
3289
3630
|
}
|
|
3290
3631
|
pausedReview = undefined;
|
|
@@ -3309,7 +3650,11 @@ export default function workflowExtension(
|
|
|
3309
3650
|
planText: result.planText,
|
|
3310
3651
|
});
|
|
3311
3652
|
}
|
|
3312
|
-
|
|
3653
|
+
if (!gatedResume && reviewResumeSettles(result)) {
|
|
3654
|
+
await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
|
|
3655
|
+
} else {
|
|
3656
|
+
ctx.ui.notify(reviewResumeNotice(result), "info");
|
|
3657
|
+
}
|
|
3313
3658
|
},
|
|
3314
3659
|
});
|
|
3315
3660
|
|
|
@@ -3323,17 +3668,18 @@ export default function workflowExtension(
|
|
|
3323
3668
|
const rt = reviewRuntime;
|
|
3324
3669
|
const active =
|
|
3325
3670
|
activeReview !== undefined || rt?.isRoundActive() === true;
|
|
3326
|
-
|
|
3671
|
+
const paused = currentPausedReview();
|
|
3672
|
+
if (!active && !paused) {
|
|
3327
3673
|
ctx.ui.notify("No review running or paused.", "info");
|
|
3328
3674
|
return;
|
|
3329
3675
|
}
|
|
3330
3676
|
const targetHash =
|
|
3331
|
-
|
|
3677
|
+
paused?.planHash ??
|
|
3332
3678
|
(activeReview ? planHash(activeReview.planText) : undefined);
|
|
3333
3679
|
// Text the gate hands off when one is open (resolved below after the
|
|
3334
3680
|
// abort lands; falls back to the author's draft when unknown).
|
|
3335
3681
|
const abortedText =
|
|
3336
|
-
|
|
3682
|
+
paused?.planText ?? activeReview?.planText;
|
|
3337
3683
|
pausedReview = undefined;
|
|
3338
3684
|
// An aborted revision is not auto-reviewed again until the plan changes.
|
|
3339
3685
|
if (targetHash) abortedReviewHash = targetHash;
|
|
@@ -3812,6 +4158,16 @@ export default function workflowExtension(
|
|
|
3812
4158
|
planFile: currentPlanFile,
|
|
3813
4159
|
awaitingDecision,
|
|
3814
4160
|
awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
|
|
4161
|
+
// Full correlation: a restored gate must know which browser review it
|
|
4162
|
+
// belongs to, or it can only suppress the next handoff.
|
|
4163
|
+
lastReviewId: awaitingDecision ? lastReviewId : null,
|
|
4164
|
+
pendingHandoffPlanPath: awaitingDecision ? pendingHandoffPlanPath : null,
|
|
4165
|
+
pendingHandoffPlanHash: awaitingDecision ? pendingHandoffPlanHash : null,
|
|
4166
|
+
pendingHandoffRequestId: awaitingDecision ? pendingHandoffRequestId : null,
|
|
4167
|
+
pendingHandoffSource: awaitingDecision ? pendingHandoffSource : null,
|
|
4168
|
+
pendingHandoffCycleId: awaitingDecision ? pendingHandoffCycleId : 0,
|
|
4169
|
+
pendingHandoffReviewUrl: awaitingDecision ? pendingHandoffReviewUrl : null,
|
|
4170
|
+
manualReviewCycle,
|
|
3815
4171
|
role: {
|
|
3816
4172
|
activeRole: roleConfig.activeRole,
|
|
3817
4173
|
// Session-scoped profile activation (see setActiveProfile): explicit
|
|
@@ -3920,8 +4276,14 @@ export default function workflowExtension(
|
|
|
3920
4276
|
updateStatus(ctx);
|
|
3921
4277
|
persistState();
|
|
3922
4278
|
// Tab-to-Build leaves Plan behind; suspend review activity but keep the
|
|
3923
|
-
// reviewer session warm for the next round (step 10).
|
|
4279
|
+
// reviewer session warm for the next round (step 10). End the review cycle
|
|
4280
|
+
// so its budget/findings cannot leak into the next Plan round.
|
|
3924
4281
|
await suspendReview();
|
|
4282
|
+
try {
|
|
4283
|
+
await beginReviewCycle({ reason: "build", keepFindings: false });
|
|
4284
|
+
} catch (_e) {
|
|
4285
|
+
void _e;
|
|
4286
|
+
}
|
|
3925
4287
|
}
|
|
3926
4288
|
|
|
3927
4289
|
async function setDefaultMode(
|
|
@@ -4067,6 +4429,15 @@ export default function workflowExtension(
|
|
|
4067
4429
|
// user just approved (step 10).
|
|
4068
4430
|
await suspendReview();
|
|
4069
4431
|
currentPlanFile = planPath;
|
|
4432
|
+
try {
|
|
4433
|
+
await beginReviewCycle({
|
|
4434
|
+
reason: "build",
|
|
4435
|
+
keepFindings: false,
|
|
4436
|
+
planPath,
|
|
4437
|
+
});
|
|
4438
|
+
} catch (_e) {
|
|
4439
|
+
void _e;
|
|
4440
|
+
}
|
|
4070
4441
|
pi.setActiveTools(
|
|
4071
4442
|
toolsBeforePlanMode ?? getBuildTools(pi.getActiveTools()),
|
|
4072
4443
|
);
|
|
@@ -4093,6 +4464,365 @@ export default function workflowExtension(
|
|
|
4093
4464
|
return currentSessionCtx;
|
|
4094
4465
|
}
|
|
4095
4466
|
|
|
4467
|
+
/**
|
|
4468
|
+
* Adopt the review id/url Plannotator announced. This is the only place the
|
|
4469
|
+
* transcript handoff entry is written, so a `respond` callback and a
|
|
4470
|
+
* `session-opened` event for the same review cannot duplicate it.
|
|
4471
|
+
*/
|
|
4472
|
+
function adoptPlannotatorSession(event: PlannotatorSessionEventLike): void {
|
|
4473
|
+
if (!awaitingDecision) return;
|
|
4474
|
+
if (typeof event.reviewId === "string" && event.reviewId) {
|
|
4475
|
+
lastReviewId = event.reviewId;
|
|
4476
|
+
}
|
|
4477
|
+
if (!lastReviewId) return;
|
|
4478
|
+
if (typeof event.url === "string" && event.url) {
|
|
4479
|
+
pendingHandoffReviewUrl = event.url;
|
|
4480
|
+
}
|
|
4481
|
+
if (recordedHandoffReviewId !== lastReviewId) {
|
|
4482
|
+
recordedHandoffReviewId = lastReviewId;
|
|
4483
|
+
try {
|
|
4484
|
+
pi.appendEntry("workflow-handoff-plannotator", {
|
|
4485
|
+
at: Date.now(),
|
|
4486
|
+
planPath: pendingHandoffPlanPath ?? currentPlanFile,
|
|
4487
|
+
reviewId: lastReviewId,
|
|
4488
|
+
...(pendingHandoffReviewUrl
|
|
4489
|
+
? { reviewUrl: pendingHandoffReviewUrl }
|
|
4490
|
+
: {}),
|
|
4491
|
+
planTextLen: pendingHandoffPlanTextLen,
|
|
4492
|
+
source: pendingHandoffSource ?? "auto",
|
|
4493
|
+
});
|
|
4494
|
+
} catch (_e) {
|
|
4495
|
+
void _e;
|
|
4496
|
+
}
|
|
4497
|
+
notifyReview(
|
|
4498
|
+
"Plan opened in browser for review. Approve or annotate there.",
|
|
4499
|
+
"info",
|
|
4500
|
+
);
|
|
4501
|
+
}
|
|
4502
|
+
try {
|
|
4503
|
+
persistState();
|
|
4504
|
+
} catch (_e) {
|
|
4505
|
+
void _e;
|
|
4506
|
+
}
|
|
4507
|
+
try {
|
|
4508
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
4509
|
+
} catch (_e) {
|
|
4510
|
+
void _e;
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
/** Settle the one in-flight plan-review request exactly once. */
|
|
4515
|
+
function settlePlanReviewRequest(outcome: PlanReviewRequestOutcome): boolean {
|
|
4516
|
+
const pending = pendingPlanReviewRequest;
|
|
4517
|
+
if (!pending || pending.settled) return false;
|
|
4518
|
+
pending.settled = true;
|
|
4519
|
+
pendingPlanReviewRequest = undefined;
|
|
4520
|
+
pending.finish(outcome);
|
|
4521
|
+
return true;
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
/**
|
|
4525
|
+
* Ask Plannotator to open a plan review and wait for its lifecycle.
|
|
4526
|
+
*
|
|
4527
|
+
* The `respond` callback is the fast path; `plannotator:session-opened` is
|
|
4528
|
+
* the authoritative one (it carries the URL and survives a slow handler).
|
|
4529
|
+
* A timeout clears the gate so the caller can render its own fallback; it
|
|
4530
|
+
* never retries, because a late event would then open a duplicate review.
|
|
4531
|
+
*/
|
|
4532
|
+
async function requestPlanReview(input: {
|
|
4533
|
+
planText: string;
|
|
4534
|
+
planPath: string;
|
|
4535
|
+
source: ReviewHandoffSource;
|
|
4536
|
+
ctx: ExtensionContext;
|
|
4537
|
+
cycleId?: number;
|
|
4538
|
+
}): Promise<{ ok: true; reviewId?: string } | { ok: false; reason: string }> {
|
|
4539
|
+
const bus: any = (pi as any).events;
|
|
4540
|
+
if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
|
|
4541
|
+
return { ok: false, reason: "Plannotator is not available in this session" };
|
|
4542
|
+
}
|
|
4543
|
+
if (!input.planText.trim() || !input.planPath) {
|
|
4544
|
+
return { ok: false, reason: "the reviewed plan has no file to hand off" };
|
|
4545
|
+
}
|
|
4546
|
+
// A second request supersedes the first waiter without opening a race.
|
|
4547
|
+
settlePlanReviewRequest({
|
|
4548
|
+
status: "error",
|
|
4549
|
+
reason: "superseded by a newer handoff",
|
|
4550
|
+
});
|
|
4551
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4552
|
+
awaitingDecision = true;
|
|
4553
|
+
lastHandoffAt = Date.now();
|
|
4554
|
+
pendingHandoffPlanPath = input.planPath;
|
|
4555
|
+
pendingHandoffPlanHash = planHash(input.planText);
|
|
4556
|
+
pendingHandoffPlanTextLen = input.planText.length;
|
|
4557
|
+
pendingHandoffRequestId = requestId;
|
|
4558
|
+
pendingHandoffSource = input.source;
|
|
4559
|
+
pendingHandoffCycleId = input.cycleId ?? reviewCycleId;
|
|
4560
|
+
pendingHandoffReviewUrl = null;
|
|
4561
|
+
lastReviewId = null;
|
|
4562
|
+
recordedHandoffReviewId = null;
|
|
4563
|
+
persistState();
|
|
4564
|
+
const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
|
|
4565
|
+
const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
|
|
4566
|
+
let settled = false;
|
|
4567
|
+
const settle = (outcome: PlanReviewRequestOutcome): void => {
|
|
4568
|
+
if (settled) return;
|
|
4569
|
+
settled = true;
|
|
4570
|
+
clearTimeout(timer);
|
|
4571
|
+
if (pendingPlanReviewRequest?.requestId === requestId) {
|
|
4572
|
+
pendingPlanReviewRequest = undefined;
|
|
4573
|
+
}
|
|
4574
|
+
resolve(outcome);
|
|
4575
|
+
};
|
|
4576
|
+
const timer = setTimeout(() => {
|
|
4577
|
+
settle({
|
|
4578
|
+
status: "timeout",
|
|
4579
|
+
reason: `Plannotator did not answer within ${Math.round(timeoutMs / 1000)}s`,
|
|
4580
|
+
});
|
|
4581
|
+
}, timeoutMs);
|
|
4582
|
+
pendingPlanReviewRequest = {
|
|
4583
|
+
requestId,
|
|
4584
|
+
settled: false,
|
|
4585
|
+
finish: settle,
|
|
4586
|
+
};
|
|
4587
|
+
try {
|
|
4588
|
+
bus.emit(PLANNOTATOR_REQUEST, {
|
|
4589
|
+
requestId,
|
|
4590
|
+
action: "plan-review",
|
|
4591
|
+
payload: {
|
|
4592
|
+
planContent: input.planText,
|
|
4593
|
+
planFilePath: input.planPath,
|
|
4594
|
+
reviewPending: false,
|
|
4595
|
+
reviewSummary: reviewState
|
|
4596
|
+
? {
|
|
4597
|
+
modelLabel: reviewState.modelLabel,
|
|
4598
|
+
round: reviewState.round,
|
|
4599
|
+
verdict: reviewState.verdict,
|
|
4600
|
+
findings: reviewState.findings.length,
|
|
4601
|
+
}
|
|
4602
|
+
: undefined,
|
|
4603
|
+
},
|
|
4604
|
+
respond: (res: any) => {
|
|
4605
|
+
if (
|
|
4606
|
+
res &&
|
|
4607
|
+
res.status === "handled" &&
|
|
4608
|
+
typeof res.result?.reviewId === "string"
|
|
4609
|
+
) {
|
|
4610
|
+
settle({
|
|
4611
|
+
status: "handled",
|
|
4612
|
+
reviewId: res.result.reviewId,
|
|
4613
|
+
});
|
|
4614
|
+
} else {
|
|
4615
|
+
settle({
|
|
4616
|
+
status: "error",
|
|
4617
|
+
reason: `Plannotator answered ${String(res?.status ?? "nothing")}`,
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
},
|
|
4621
|
+
});
|
|
4622
|
+
} catch (e: any) {
|
|
4623
|
+
settle({
|
|
4624
|
+
status: "error",
|
|
4625
|
+
reason: `Plannotator request failed: ${String(e?.message ?? e)}`,
|
|
4626
|
+
});
|
|
4627
|
+
}
|
|
4628
|
+
});
|
|
4629
|
+
if (response.status === "handled") {
|
|
4630
|
+
adoptPlannotatorSession({
|
|
4631
|
+
...(response.reviewId ? { reviewId: response.reviewId } : {}),
|
|
4632
|
+
...(response.url ? { url: response.url } : {}),
|
|
4633
|
+
});
|
|
4634
|
+
return { ok: true, ...(response.reviewId ? { reviewId: response.reviewId } : {}) };
|
|
4635
|
+
}
|
|
4636
|
+
const changed = clearPendingHandoffDecision();
|
|
4637
|
+
if (changed) persistState();
|
|
4638
|
+
try {
|
|
4639
|
+
pi.appendEntry("workflow-handoff-plannotator-failed", {
|
|
4640
|
+
at: Date.now(),
|
|
4641
|
+
reason: response.reason,
|
|
4642
|
+
source: input.source,
|
|
4643
|
+
planPath: input.planPath,
|
|
4644
|
+
});
|
|
4645
|
+
} catch (_e) {
|
|
4646
|
+
void _e;
|
|
4647
|
+
}
|
|
4648
|
+
notifyReview(`Plannotator handoff unavailable — ${response.reason}.`, "warning");
|
|
4649
|
+
return { ok: false, reason: response.reason };
|
|
4650
|
+
}
|
|
4651
|
+
|
|
4652
|
+
/** Ask Plannotator for the durable status of one review. */
|
|
4653
|
+
async function queryPlannotatorReviewStatus(reviewId: string): Promise<{
|
|
4654
|
+
status: "pending" | "completed" | "missing";
|
|
4655
|
+
approved?: boolean;
|
|
4656
|
+
feedback?: string;
|
|
4657
|
+
savedPath?: string;
|
|
4658
|
+
}> {
|
|
4659
|
+
const bus: any = (pi as any).events;
|
|
4660
|
+
if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
|
|
4661
|
+
return { status: "missing" };
|
|
4662
|
+
}
|
|
4663
|
+
const timeoutMs = Math.max(50, deps.plannotatorRecoveryTimeoutMs ?? 3_000);
|
|
4664
|
+
return await new Promise((resolve) => {
|
|
4665
|
+
let settled = false;
|
|
4666
|
+
const finish = (value: any): void => {
|
|
4667
|
+
if (settled) return;
|
|
4668
|
+
settled = true;
|
|
4669
|
+
clearTimeout(timer);
|
|
4670
|
+
resolve(
|
|
4671
|
+
value &&
|
|
4672
|
+
(value.status === "pending" ||
|
|
4673
|
+
value.status === "completed" ||
|
|
4674
|
+
value.status === "missing")
|
|
4675
|
+
? value
|
|
4676
|
+
: { status: "missing" },
|
|
4677
|
+
);
|
|
4678
|
+
};
|
|
4679
|
+
const timer = setTimeout(() => finish({ status: "missing" }), timeoutMs);
|
|
4680
|
+
try {
|
|
4681
|
+
bus.emit(PLANNOTATOR_REQUEST, {
|
|
4682
|
+
requestId: `${Date.now()}-status`,
|
|
4683
|
+
action: "review-status",
|
|
4684
|
+
payload: { reviewId },
|
|
4685
|
+
respond: (res: any) => {
|
|
4686
|
+
if (res?.status === "handled" && res.result) finish(res.result);
|
|
4687
|
+
else finish({ status: "missing" });
|
|
4688
|
+
},
|
|
4689
|
+
});
|
|
4690
|
+
} catch {
|
|
4691
|
+
finish({ status: "missing" });
|
|
4692
|
+
}
|
|
4693
|
+
});
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
/**
|
|
4697
|
+
* Recover the one pending browser handoff after a session reload/resume.
|
|
4698
|
+
*
|
|
4699
|
+
* `startup`/`new` cannot own the old loopback server, so they clear. For a
|
|
4700
|
+
* reload/resume/fork, Plannotator's durable `review-status` decides: pending
|
|
4701
|
+
* keeps the gate (when the plan hash still matches), completed applies the
|
|
4702
|
+
* recorded decision, and missing/changed clears with a visible reason.
|
|
4703
|
+
*/
|
|
4704
|
+
async function recoverPendingHandoff(
|
|
4705
|
+
ctx: ExtensionContext,
|
|
4706
|
+
reason: string,
|
|
4707
|
+
): Promise<void> {
|
|
4708
|
+
if (recoveryInFlight) return recoveryInFlight;
|
|
4709
|
+
recoveryInFlight = (async () => {
|
|
4710
|
+
const record = (action: string, detail: Record<string, unknown> = {}) => {
|
|
4711
|
+
try {
|
|
4712
|
+
pi.appendEntry("workflow-handoff-recovery", {
|
|
4713
|
+
action,
|
|
4714
|
+
reason,
|
|
4715
|
+
at: Date.now(),
|
|
4716
|
+
...detail,
|
|
4717
|
+
});
|
|
4718
|
+
} catch (_e) {
|
|
4719
|
+
void _e;
|
|
4720
|
+
}
|
|
4721
|
+
};
|
|
4722
|
+
if (!awaitingDecision) return;
|
|
4723
|
+
if (reason === "startup" || reason === "new" || !lastReviewId) {
|
|
4724
|
+
const changed = clearPendingHandoffDecision();
|
|
4725
|
+
if (changed) persistState();
|
|
4726
|
+
record("clear", { why: "no restorable review" });
|
|
4727
|
+
return;
|
|
4728
|
+
}
|
|
4729
|
+
const currentFile = readCurrentPlanText(reviewCwd());
|
|
4730
|
+
const status = await queryPlannotatorReviewStatus(lastReviewId);
|
|
4731
|
+
const decision = reviewHandoffRecovery({
|
|
4732
|
+
status: status.status,
|
|
4733
|
+
approved: status.approved,
|
|
4734
|
+
feedback: status.feedback,
|
|
4735
|
+
savedPath: status.savedPath,
|
|
4736
|
+
pendingHash: pendingHandoffPlanHash ?? undefined,
|
|
4737
|
+
currentHash:
|
|
4738
|
+
currentFile !== undefined ? planHash(currentFile) : undefined,
|
|
4739
|
+
});
|
|
4740
|
+
if (decision.action === "keep") {
|
|
4741
|
+
if (pendingHandoffSource === "manual") manualReviewCycle = true;
|
|
4742
|
+
record("keep", {
|
|
4743
|
+
reviewId: lastReviewId,
|
|
4744
|
+
...(pendingHandoffReviewUrl
|
|
4745
|
+
? { reviewUrl: pendingHandoffReviewUrl }
|
|
4746
|
+
: {}),
|
|
4747
|
+
});
|
|
4748
|
+
notifyReview(
|
|
4749
|
+
`Browser review still open${pendingHandoffReviewUrl ? `: ${pendingHandoffReviewUrl}` : ""} — approve or annotate there.`,
|
|
4750
|
+
"info",
|
|
4751
|
+
);
|
|
4752
|
+
updateStatus(ctx as any);
|
|
4753
|
+
return;
|
|
4754
|
+
}
|
|
4755
|
+
if (decision.action === "apply") {
|
|
4756
|
+
record("apply", {
|
|
4757
|
+
reviewId: lastReviewId,
|
|
4758
|
+
approved: decision.approved,
|
|
4759
|
+
});
|
|
4760
|
+
if (decision.approved) {
|
|
4761
|
+
handlePlannotatorApprove(
|
|
4762
|
+
pendingHandoffPlanPath ?? currentPlanFile ?? "",
|
|
4763
|
+
currentFile ?? "",
|
|
4764
|
+
decision.feedback,
|
|
4765
|
+
ctx.cwd,
|
|
4766
|
+
);
|
|
4767
|
+
} else {
|
|
4768
|
+
await handlePlannotatorDeny(
|
|
4769
|
+
decision.feedback || "Plan needs revision.",
|
|
4770
|
+
);
|
|
4771
|
+
}
|
|
4772
|
+
return;
|
|
4773
|
+
}
|
|
4774
|
+
const changed = clearPendingHandoffDecision();
|
|
4775
|
+
if (changed) persistState();
|
|
4776
|
+
record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
|
|
4777
|
+
notifyReview(
|
|
4778
|
+
`Previous browser review could not be recovered — ${decision.reason}.`,
|
|
4779
|
+
"warning",
|
|
4780
|
+
);
|
|
4781
|
+
updateStatus(ctx as any);
|
|
4782
|
+
})();
|
|
4783
|
+
try {
|
|
4784
|
+
await recoveryInFlight;
|
|
4785
|
+
} finally {
|
|
4786
|
+
recoveryInFlight = null;
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
/**
|
|
4791
|
+
* Open the browser handoff for a settled manual `/review` or resume.
|
|
4792
|
+
* Returns true when the browser owns the decision (or the result is not
|
|
4793
|
+
* handoff-eligible); false when the round paused/aborted and the caller
|
|
4794
|
+
* should show its normal resume notice.
|
|
4795
|
+
*/
|
|
4796
|
+
async function openManualHandoff(
|
|
4797
|
+
result: ReviewExecutionResult,
|
|
4798
|
+
planPath: string,
|
|
4799
|
+
ctx: ExtensionContext,
|
|
4800
|
+
suffix = "",
|
|
4801
|
+
): Promise<boolean> {
|
|
4802
|
+
if (!shouldHandoffManualResult(result)) return false;
|
|
4803
|
+
const previousManualCycle = manualReviewCycle;
|
|
4804
|
+
manualReviewCycle = true;
|
|
4805
|
+
const handoff = await requestPlanReview({
|
|
4806
|
+
planText: result.planText,
|
|
4807
|
+
planPath,
|
|
4808
|
+
source: "manual",
|
|
4809
|
+
ctx,
|
|
4810
|
+
});
|
|
4811
|
+
if (handoff.ok) {
|
|
4812
|
+
ctx.ui.notify(
|
|
4813
|
+
"Plan opened in browser for review. Approve or annotate there." + suffix,
|
|
4814
|
+
"info",
|
|
4815
|
+
);
|
|
4816
|
+
return true;
|
|
4817
|
+
}
|
|
4818
|
+
manualReviewCycle = previousManualCycle;
|
|
4819
|
+
ctx.ui.notify(
|
|
4820
|
+
`Manual review finished, but Plannotator is unavailable (${handoff.reason}). The reviewed plan is at ${planPath}.`,
|
|
4821
|
+
"warning",
|
|
4822
|
+
);
|
|
4823
|
+
return true;
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4096
4826
|
function handlePlannotatorApprove(
|
|
4097
4827
|
planFilePath: string,
|
|
4098
4828
|
planContent: string,
|
|
@@ -4101,10 +4831,18 @@ export default function workflowExtension(
|
|
|
4101
4831
|
) {
|
|
4102
4832
|
const ctx = getActiveCtx();
|
|
4103
4833
|
if (!ctx) return;
|
|
4104
|
-
|
|
4834
|
+
// A manual `/review` handoff may be approved from Default/Build; an
|
|
4835
|
+
// automatic handoff only exists in Plan mode.
|
|
4836
|
+
if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
|
|
4105
4837
|
// A stale browser approve while a review runs must not clear the pending
|
|
4106
4838
|
// decision or send the execute messages: the plan is still being audited.
|
|
4107
|
-
if (blockModeSwitchDuringReview(ctx as any))
|
|
4839
|
+
if (blockModeSwitchDuringReview(ctx as any)) {
|
|
4840
|
+
supersedePendingHandoff(
|
|
4841
|
+
"a browser decision arrived while a new review round was running",
|
|
4842
|
+
{ force: true },
|
|
4843
|
+
);
|
|
4844
|
+
return;
|
|
4845
|
+
}
|
|
4108
4846
|
// No prompt means no decision to consume. A fresh prompt/mode switch
|
|
4109
4847
|
// clears this, so an old browser tab cannot approve the previous plan.
|
|
4110
4848
|
if (!awaitingDecision) return;
|
|
@@ -4160,6 +4898,7 @@ export default function workflowExtension(
|
|
|
4160
4898
|
} catch (_e) {
|
|
4161
4899
|
void _e;
|
|
4162
4900
|
}
|
|
4901
|
+
if (pendingHandoffSource === "manual") manualReviewCycle = false;
|
|
4163
4902
|
clearPendingHandoffDecision();
|
|
4164
4903
|
void enterBuildModeFromPlan(ctx as any, resolvedPlan)
|
|
4165
4904
|
.then(() => updateStatus(ctx as any))
|
|
@@ -4205,20 +4944,39 @@ export default function workflowExtension(
|
|
|
4205
4944
|
}
|
|
4206
4945
|
}
|
|
4207
4946
|
|
|
4208
|
-
function handlePlannotatorDeny(feedback: string) {
|
|
4947
|
+
async function handlePlannotatorDeny(feedback: string) {
|
|
4209
4948
|
const ctx = getActiveCtx();
|
|
4210
4949
|
if (!ctx) return;
|
|
4211
4950
|
// Not a mode transition, but while a review owns the turn every browser
|
|
4212
4951
|
// decision is stale: injecting feedback here would mutate the plan under
|
|
4213
4952
|
// the reviewer. A legitimate deny only arrives after the gate resolves.
|
|
4214
|
-
if (blockModeSwitchDuringReview(ctx as any))
|
|
4953
|
+
if (blockModeSwitchDuringReview(ctx as any)) {
|
|
4954
|
+
supersedePendingHandoff(
|
|
4955
|
+
"a browser decision arrived while a new review round was running",
|
|
4956
|
+
{ force: true },
|
|
4957
|
+
);
|
|
4958
|
+
return;
|
|
4959
|
+
}
|
|
4215
4960
|
if (!awaitingDecision) return;
|
|
4961
|
+
const manualSource = pendingHandoffSource === "manual";
|
|
4962
|
+
if (manualSource && workflowMode !== "plan") {
|
|
4963
|
+
// A manual review can be launched from Default/Build. Its feedback is a
|
|
4964
|
+
// plan revision, so switch to Plan before the follow-up turn starts.
|
|
4965
|
+
try {
|
|
4966
|
+
await setPlanMode(ctx as any, { quiet: true });
|
|
4967
|
+
} catch (_e) {
|
|
4968
|
+
void _e;
|
|
4969
|
+
}
|
|
4970
|
+
notifyReview(
|
|
4971
|
+
"Review feedback — switched to Plan mode for the revision.",
|
|
4972
|
+
"info",
|
|
4973
|
+
);
|
|
4974
|
+
}
|
|
4216
4975
|
clearPendingHandoffDecision();
|
|
4217
4976
|
// Feedback cycle: the next plan revision gets a fresh review round, and the
|
|
4218
4977
|
// outstanding findings are carried forward so the reviewer can confirm they
|
|
4219
4978
|
// were actually addressed rather than re-raising them.
|
|
4220
|
-
|
|
4221
|
-
lastReviewedPlanHash = undefined;
|
|
4979
|
+
await beginReviewCycle({ reason: "deny", keepFindings: true });
|
|
4222
4980
|
persistState();
|
|
4223
4981
|
try {
|
|
4224
4982
|
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
@@ -4295,12 +5053,60 @@ export default function workflowExtension(
|
|
|
4295
5053
|
(getActiveCtx() as any)?.cwd,
|
|
4296
5054
|
);
|
|
4297
5055
|
} else {
|
|
4298
|
-
handlePlannotatorDeny(
|
|
5056
|
+
void handlePlannotatorDeny(
|
|
5057
|
+
event.feedback || "Plan needs revision.",
|
|
5058
|
+
);
|
|
4299
5059
|
}
|
|
4300
5060
|
} catch (_e) {
|
|
4301
5061
|
void _e;
|
|
4302
5062
|
}
|
|
4303
5063
|
});
|
|
5064
|
+
// session-opened is authoritative: it carries the review id and URL even
|
|
5065
|
+
// when the request's respond callback is slow or lost. It can arrive
|
|
5066
|
+
// before or after respond; both paths settle the same waiter once.
|
|
5067
|
+
bus.on(PLANNOTATOR_SESSION_OPENED, (event: any) => {
|
|
5068
|
+
try {
|
|
5069
|
+
if (!event || typeof event !== "object") return;
|
|
5070
|
+
const matchesWaiting =
|
|
5071
|
+
typeof event.requestId === "string" &&
|
|
5072
|
+
event.requestId === pendingPlanReviewRequest?.requestId;
|
|
5073
|
+
const matchesCurrent =
|
|
5074
|
+
typeof event.requestId === "string" &&
|
|
5075
|
+
event.requestId === pendingHandoffRequestId;
|
|
5076
|
+
if (!matchesWaiting && !matchesCurrent) return;
|
|
5077
|
+
adoptPlannotatorSession({
|
|
5078
|
+
reviewId: event.reviewId,
|
|
5079
|
+
url: event.url,
|
|
5080
|
+
});
|
|
5081
|
+
if (matchesWaiting) {
|
|
5082
|
+
settlePlanReviewRequest({
|
|
5083
|
+
status: "handled",
|
|
5084
|
+
reviewId:
|
|
5085
|
+
typeof event.reviewId === "string" ? event.reviewId : undefined,
|
|
5086
|
+
url: typeof event.url === "string" ? event.url : undefined,
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
} catch (_e) {
|
|
5090
|
+
void _e;
|
|
5091
|
+
}
|
|
5092
|
+
});
|
|
5093
|
+
// A review closed without a decision must not leave a phantom gate that
|
|
5094
|
+
// suppresses the next round. approved/submitted decisions still flow
|
|
5095
|
+
// through review-result (and, after a reload, review-status).
|
|
5096
|
+
bus.on(PLANNOTATOR_SESSION_CLOSED, (event: any) => {
|
|
5097
|
+
try {
|
|
5098
|
+
if (!event || typeof event !== "object") return;
|
|
5099
|
+
if (event.outcome !== "closed") return;
|
|
5100
|
+
if (!lastReviewId || event.reviewId !== lastReviewId) return;
|
|
5101
|
+
if (!awaitingDecision) return;
|
|
5102
|
+
supersedePendingHandoff(
|
|
5103
|
+
"the browser review closed without a decision",
|
|
5104
|
+
{ force: true },
|
|
5105
|
+
);
|
|
5106
|
+
} catch (_e) {
|
|
5107
|
+
void _e;
|
|
5108
|
+
}
|
|
5109
|
+
});
|
|
4304
5110
|
plannotatorListenersRegistered = true;
|
|
4305
5111
|
plannotatorActive = true;
|
|
4306
5112
|
const ctx = getActiveCtx();
|
|
@@ -5198,7 +6004,8 @@ export default function workflowExtension(
|
|
|
5198
6004
|
"Usage: /profile (picker) | /profile list | /profile show <name> | " +
|
|
5199
6005
|
"/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
|
|
5200
6006
|
"/profile rename <old> <new> | /profile delete <name> | " +
|
|
5201
|
-
"/profile default [name|none] [global|project]"
|
|
6007
|
+
"/profile default [name|none] [global|project] | " +
|
|
6008
|
+
"/profile retarget-provider <old> <new> [global|project] [--apply]";
|
|
5202
6009
|
|
|
5203
6010
|
/** Effective workflow.defaultProfile for display (never throws). */
|
|
5204
6011
|
function currentDefaultProfileName(cwd?: string): string | undefined {
|
|
@@ -6609,6 +7416,79 @@ export default function workflowExtension(
|
|
|
6609
7416
|
return;
|
|
6610
7417
|
}
|
|
6611
7418
|
|
|
7419
|
+
if (sub === "retarget-provider" || sub === "retarget") {
|
|
7420
|
+
const [oldProvider, newProvider, ...flags] = rest;
|
|
7421
|
+
if (!oldProvider || !newProvider) {
|
|
7422
|
+
ctx.ui.notify(
|
|
7423
|
+
"Usage: /profile retarget-provider <old> <new> [global|project] [--apply]\n" +
|
|
7424
|
+
"Rewrite every role/profile model ref from one provider to another. " +
|
|
7425
|
+
"Without --apply it only prints the planned changes.",
|
|
7426
|
+
"info",
|
|
7427
|
+
);
|
|
7428
|
+
return;
|
|
7429
|
+
}
|
|
7430
|
+
if (oldProvider === newProvider) {
|
|
7431
|
+
ctx.ui.notify(
|
|
7432
|
+
`Provider is already "${oldProvider}" — nothing to retarget.`,
|
|
7433
|
+
"info",
|
|
7434
|
+
);
|
|
7435
|
+
return;
|
|
7436
|
+
}
|
|
7437
|
+
const apply = flags.some((flag) => {
|
|
7438
|
+
const value = flag.toLowerCase();
|
|
7439
|
+
return value === "--apply" || value === "apply";
|
|
7440
|
+
});
|
|
7441
|
+
const scope = flags.some((flag) => flag.toLowerCase() === "project")
|
|
7442
|
+
? "project"
|
|
7443
|
+
: "global";
|
|
7444
|
+
const targetCwd = cwd ?? (ctx as any).cwd ?? process.cwd();
|
|
7445
|
+
const result = retargetProviderFiles({
|
|
7446
|
+
profilesPath:
|
|
7447
|
+
scope === "project"
|
|
7448
|
+
? projectProfilesPath(targetCwd)
|
|
7449
|
+
: userProfilesPath(),
|
|
7450
|
+
rolesPath:
|
|
7451
|
+
scope === "project"
|
|
7452
|
+
? projectRolesPath(targetCwd)
|
|
7453
|
+
: userRolesPath(),
|
|
7454
|
+
oldProvider,
|
|
7455
|
+
newProvider,
|
|
7456
|
+
dryRun: !apply,
|
|
7457
|
+
});
|
|
7458
|
+
const summary = formatRetargetResult(result);
|
|
7459
|
+
if (
|
|
7460
|
+
result.profiles.status === "invalid" ||
|
|
7461
|
+
result.roles.status === "invalid"
|
|
7462
|
+
) {
|
|
7463
|
+
ctx.ui.notify(summary, "error");
|
|
7464
|
+
return;
|
|
7465
|
+
}
|
|
7466
|
+
if (!apply) {
|
|
7467
|
+
ctx.ui.notify(
|
|
7468
|
+
`${summary}\n\nScope: ${scope}. A malformed file blocks both writes.`,
|
|
7469
|
+
"info",
|
|
7470
|
+
);
|
|
7471
|
+
return;
|
|
7472
|
+
}
|
|
7473
|
+
// The files changed underneath the cached configs. Force a reload so
|
|
7474
|
+
// the active profile/role resolves the new provider immediately.
|
|
7475
|
+
roleConfigLoadedFor = undefined;
|
|
7476
|
+
profilesLoadedFor = undefined;
|
|
7477
|
+
invalidateEffectiveRoles();
|
|
7478
|
+
ensureRoleConfig(targetCwd);
|
|
7479
|
+
ensureProfilesConfig(targetCwd);
|
|
7480
|
+
updateStatus(ctx as any);
|
|
7481
|
+
if (workflowMode) {
|
|
7482
|
+
try {
|
|
7483
|
+
await applyModeModel(ctx as any);
|
|
7484
|
+
} catch (_e) {
|
|
7485
|
+
void _e;
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
ctx.ui.notify(`${summary}\n\nScope: ${scope}.`, "info");
|
|
7489
|
+
return;
|
|
7490
|
+
}
|
|
7491
|
+
|
|
6612
7492
|
if (sub === "default") {
|
|
6613
7493
|
// /profile default [name|none] [global|project]
|
|
6614
7494
|
const target = rest.find(
|
|
@@ -8133,31 +9013,39 @@ export default function workflowExtension(
|
|
|
8133
9013
|
typeof d.awaitingDecisionAt === "number"
|
|
8134
9014
|
? d.awaitingDecisionAt
|
|
8135
9015
|
: undefined;
|
|
8136
|
-
//
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
)
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
9016
|
+
// Restore the whole correlation, not just the boolean: recovery
|
|
9017
|
+
// and stale-browser correlation both need the review identity.
|
|
9018
|
+
lastReviewId =
|
|
9019
|
+
typeof (d as any).lastReviewId === "string"
|
|
9020
|
+
? (d as any).lastReviewId
|
|
9021
|
+
: null;
|
|
9022
|
+
pendingHandoffPlanPath =
|
|
9023
|
+
typeof (d as any).pendingHandoffPlanPath === "string"
|
|
9024
|
+
? (d as any).pendingHandoffPlanPath
|
|
9025
|
+
: null;
|
|
9026
|
+
pendingHandoffPlanHash =
|
|
9027
|
+
typeof (d as any).pendingHandoffPlanHash === "string"
|
|
9028
|
+
? (d as any).pendingHandoffPlanHash
|
|
9029
|
+
: null;
|
|
9030
|
+
pendingHandoffRequestId =
|
|
9031
|
+
typeof (d as any).pendingHandoffRequestId === "string"
|
|
9032
|
+
? (d as any).pendingHandoffRequestId
|
|
9033
|
+
: null;
|
|
9034
|
+
pendingHandoffSource =
|
|
9035
|
+
(d as any).pendingHandoffSource === "manual"
|
|
9036
|
+
? "manual"
|
|
9037
|
+
: (d as any).pendingHandoffSource === "auto"
|
|
9038
|
+
? "auto"
|
|
9039
|
+
: null;
|
|
9040
|
+
pendingHandoffCycleId =
|
|
9041
|
+
typeof (d as any).pendingHandoffCycleId === "number"
|
|
9042
|
+
? (d as any).pendingHandoffCycleId
|
|
9043
|
+
: 0;
|
|
9044
|
+
pendingHandoffReviewUrl =
|
|
9045
|
+
typeof (d as any).pendingHandoffReviewUrl === "string"
|
|
9046
|
+
? (d as any).pendingHandoffReviewUrl
|
|
9047
|
+
: null;
|
|
9048
|
+
manualReviewCycle = (d as any).manualReviewCycle === true;
|
|
8161
9049
|
}
|
|
8162
9050
|
}
|
|
8163
9051
|
if (
|
|
@@ -8383,9 +9271,10 @@ export default function workflowExtension(
|
|
|
8383
9271
|
resetPlanRunProvenance();
|
|
8384
9272
|
lastPlanWritePath = currentPlanFile ?? null;
|
|
8385
9273
|
lastPersistedPlanFile = currentPlanFile;
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
9274
|
+
// The pending correlation is restored above. Recovery (step 15) decides
|
|
9275
|
+
// whether it survives; do not null it here, or a restored gate can only
|
|
9276
|
+
// suppress the next handoff.
|
|
9277
|
+
if (!awaitingDecision) clearPendingHandoffDecision();
|
|
8389
9278
|
currentSessionCtx = ctx as any;
|
|
8390
9279
|
// Give Tab back to autocomplete while a suggestion list is visible; the raw
|
|
8391
9280
|
// `tab` shortcut keeps cycling modes otherwise.
|
|
@@ -8418,10 +9307,14 @@ export default function workflowExtension(
|
|
|
8418
9307
|
} catch (_e) {
|
|
8419
9308
|
void _e;
|
|
8420
9309
|
}
|
|
9310
|
+
// Recovery is asynchronous (it may query Plannotator); session start must
|
|
9311
|
+
// not wait up to the status timeout. The gate is restored/cleared before
|
|
9312
|
+
// the next user turn.
|
|
9313
|
+
void recoverPendingHandoff(ctx as ExtensionContext, event.reason);
|
|
8421
9314
|
updateStatus(ctx as any);
|
|
8422
9315
|
});
|
|
8423
9316
|
|
|
8424
|
-
pi.on("session_shutdown", async (
|
|
9317
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
8425
9318
|
// A write denial is run-scoped; the session is going away, so drop it.
|
|
8426
9319
|
writeGateRunDenials.clear();
|
|
8427
9320
|
writeGateTurnAnswer = null;
|
|
@@ -8440,11 +9333,17 @@ export default function workflowExtension(
|
|
|
8440
9333
|
}
|
|
8441
9334
|
}
|
|
8442
9335
|
activeSubagents.clear();
|
|
8443
|
-
//
|
|
8444
|
-
|
|
9336
|
+
// A reload/resume/fork can recover a browser review in the replacement
|
|
9337
|
+
// session; quitting or starting a new session cannot own the old server.
|
|
9338
|
+
const keepPending =
|
|
9339
|
+
event.reason === "reload" ||
|
|
9340
|
+
event.reason === "resume" ||
|
|
9341
|
+
event.reason === "fork";
|
|
9342
|
+
const clearedAtShutdown =
|
|
9343
|
+
keepPending === true ? false : clearPendingHandoffDecision();
|
|
8445
9344
|
resetPlanRunProvenance();
|
|
8446
9345
|
resetSessionPlanProvenance();
|
|
8447
|
-
if (clearedAtShutdown) {
|
|
9346
|
+
if (clearedAtShutdown || keepPending === true) {
|
|
8448
9347
|
try {
|
|
8449
9348
|
persistState();
|
|
8450
9349
|
} catch (_e) {
|
|
@@ -8462,7 +9361,7 @@ export default function workflowExtension(
|
|
|
8462
9361
|
pendingCommitFlow = null;
|
|
8463
9362
|
// Release the reviewer session (and its custom tools) on shutdown so a
|
|
8464
9363
|
// reload or quit never leaves an orphaned in-process session behind.
|
|
8465
|
-
await teardownReview();
|
|
9364
|
+
await teardownReview({ keepHandoff: keepPending });
|
|
8466
9365
|
currentSessionCtx = null;
|
|
8467
9366
|
});
|
|
8468
9367
|
|
|
@@ -9485,6 +10384,18 @@ export default function workflowExtension(
|
|
|
9485
10384
|
} catch (_e) {
|
|
9486
10385
|
void _e;
|
|
9487
10386
|
}
|
|
10387
|
+
// Every fresh prompt is a new review cycle: reset the round budget, cancel
|
|
10388
|
+
// an abandoned pause/gate, and tag the cycle so an old handoff cannot
|
|
10389
|
+
// render after this prompt.
|
|
10390
|
+
try {
|
|
10391
|
+
await beginReviewCycle({
|
|
10392
|
+
reason: "prompt",
|
|
10393
|
+
keepFindings: true,
|
|
10394
|
+
planPath: currentPlanFile,
|
|
10395
|
+
});
|
|
10396
|
+
} catch (_e) {
|
|
10397
|
+
void _e;
|
|
10398
|
+
}
|
|
9488
10399
|
// Capture header for next checkpoint commit message (first line of prompt)
|
|
9489
10400
|
try {
|
|
9490
10401
|
const raw = String((event as any).prompt ?? (event as any).text ?? "");
|
|
@@ -9665,6 +10576,25 @@ export default function workflowExtension(
|
|
|
9665
10576
|
/** Plan path/hash the currently rendered handoff prompt belongs to. */
|
|
9666
10577
|
let pendingHandoffPlanPath: string | null = null;
|
|
9667
10578
|
let pendingHandoffPlanHash: string | null = null;
|
|
10579
|
+
let pendingHandoffRequestId: string | null = null;
|
|
10580
|
+
let pendingHandoffSource: ReviewHandoffSource | null = null;
|
|
10581
|
+
let pendingHandoffCycleId = 0;
|
|
10582
|
+
let pendingHandoffReviewUrl: string | null = null;
|
|
10583
|
+
let pendingHandoffPlanTextLen = 0;
|
|
10584
|
+
/** The one in-flight `plannotator:request` waiter; settles exactly once. */
|
|
10585
|
+
let pendingPlanReviewRequest:
|
|
10586
|
+
| {
|
|
10587
|
+
requestId: string;
|
|
10588
|
+
settled: boolean;
|
|
10589
|
+
finish: (outcome: PlanReviewRequestOutcome) => void;
|
|
10590
|
+
}
|
|
10591
|
+
| undefined;
|
|
10592
|
+
/** The review id already recorded in the transcript (one entry per review). */
|
|
10593
|
+
let recordedHandoffReviewId: string | null = null;
|
|
10594
|
+
/** A manual `/review` owns the following feedback cycle (even when auto is off). */
|
|
10595
|
+
let manualReviewCycle = false;
|
|
10596
|
+
/** One reload recovery at a time (session_start may re-enter). */
|
|
10597
|
+
let recoveryInFlight: Promise<void> | null = null;
|
|
9668
10598
|
/** Last path announced by the suppressed-handoff notice (per run). */
|
|
9669
10599
|
let lastPlanReadyNoticeKey: string | null = null;
|
|
9670
10600
|
|
|
@@ -9689,18 +10619,88 @@ export default function workflowExtension(
|
|
|
9689
10619
|
* switch supersedes it; stale browser events must not approve the old plan.
|
|
9690
10620
|
* Returns true when persisted state changed so the caller can persist once.
|
|
9691
10621
|
*/
|
|
10622
|
+
/** One `/review-status` row for a browser handoff awaiting a decision. */
|
|
10623
|
+
function pendingHandoffStatusLine(): string {
|
|
10624
|
+
if (!awaitingDecision) return "";
|
|
10625
|
+
const age = lastHandoffAt
|
|
10626
|
+
? Math.max(0, Math.round((Date.now() - lastHandoffAt) / 1000))
|
|
10627
|
+
: 0;
|
|
10628
|
+
return ` handoff : browser pending — ${lastReviewId ?? "review id unknown"}${
|
|
10629
|
+
pendingHandoffReviewUrl ? ` @ ${pendingHandoffReviewUrl}` : ""
|
|
10630
|
+
} (${pendingHandoffSource ?? "auto"}, ${age}s)`;
|
|
10631
|
+
}
|
|
10632
|
+
|
|
9692
10633
|
function clearPendingHandoffDecision(): boolean {
|
|
9693
10634
|
const changed =
|
|
9694
10635
|
awaitingDecision === true ||
|
|
9695
10636
|
lastHandoffAt !== undefined ||
|
|
9696
10637
|
lastReviewId !== null ||
|
|
9697
10638
|
pendingHandoffPlanPath !== null ||
|
|
9698
|
-
pendingHandoffPlanHash !== null
|
|
10639
|
+
pendingHandoffPlanHash !== null ||
|
|
10640
|
+
pendingHandoffRequestId !== null ||
|
|
10641
|
+
pendingHandoffSource !== null ||
|
|
10642
|
+
pendingHandoffCycleId !== 0 ||
|
|
10643
|
+
pendingHandoffReviewUrl !== null;
|
|
9699
10644
|
awaitingDecision = false;
|
|
9700
10645
|
lastHandoffAt = undefined;
|
|
9701
10646
|
lastReviewId = null;
|
|
9702
10647
|
pendingHandoffPlanPath = null;
|
|
9703
10648
|
pendingHandoffPlanHash = null;
|
|
10649
|
+
pendingHandoffRequestId = null;
|
|
10650
|
+
pendingHandoffSource = null;
|
|
10651
|
+
pendingHandoffCycleId = 0;
|
|
10652
|
+
pendingHandoffReviewUrl = null;
|
|
10653
|
+
pendingHandoffPlanTextLen = 0;
|
|
10654
|
+
recordedHandoffReviewId = null;
|
|
10655
|
+
return changed;
|
|
10656
|
+
}
|
|
10657
|
+
|
|
10658
|
+
/**
|
|
10659
|
+
* Drop a gate that cannot belong to the round/plan about to start.
|
|
10660
|
+
*
|
|
10661
|
+
* A new automatic round, a manual `/review`, or a browser decision arriving
|
|
10662
|
+
* while another round runs all supersede the old gate. A duplicate event for
|
|
10663
|
+
* the same plan outside a new round must not clear a legitimate pending
|
|
10664
|
+
* prompt, so the pure `pendingHandoffIsStale` predicate decides.
|
|
10665
|
+
*/
|
|
10666
|
+
function supersedePendingHandoff(
|
|
10667
|
+
reason: string,
|
|
10668
|
+
opts: {
|
|
10669
|
+
force?: boolean;
|
|
10670
|
+
nextCycleId?: number;
|
|
10671
|
+
nextPlanHash?: string;
|
|
10672
|
+
} = {},
|
|
10673
|
+
): boolean {
|
|
10674
|
+
if (
|
|
10675
|
+
!pendingHandoffIsStale({
|
|
10676
|
+
awaiting: awaitingDecision,
|
|
10677
|
+
force: opts.force,
|
|
10678
|
+
pendingCycleId:
|
|
10679
|
+
pendingHandoffCycleId > 0 ? pendingHandoffCycleId : undefined,
|
|
10680
|
+
currentCycleId: opts.nextCycleId ?? reviewCycleId,
|
|
10681
|
+
pendingPlanHash: pendingHandoffPlanHash ?? undefined,
|
|
10682
|
+
nextPlanHash: opts.nextPlanHash,
|
|
10683
|
+
})
|
|
10684
|
+
) {
|
|
10685
|
+
return false;
|
|
10686
|
+
}
|
|
10687
|
+
const changed = clearPendingHandoffDecision();
|
|
10688
|
+
if (changed) {
|
|
10689
|
+
notifyReview(`Plan handoff superseded — ${reason}.`, "info");
|
|
10690
|
+
try {
|
|
10691
|
+
pi.appendEntry("workflow-handoff-superseded", {
|
|
10692
|
+
reason,
|
|
10693
|
+
at: Date.now(),
|
|
10694
|
+
});
|
|
10695
|
+
} catch (_e) {
|
|
10696
|
+
void _e;
|
|
10697
|
+
}
|
|
10698
|
+
try {
|
|
10699
|
+
persistState();
|
|
10700
|
+
} catch (_e) {
|
|
10701
|
+
void _e;
|
|
10702
|
+
}
|
|
10703
|
+
}
|
|
9704
10704
|
return changed;
|
|
9705
10705
|
}
|
|
9706
10706
|
|
|
@@ -9769,6 +10769,7 @@ export default function workflowExtension(
|
|
|
9769
10769
|
// The reviewer's `review_submit_plan` writes this path; a manual
|
|
9770
10770
|
// `/review` must also audit the file the user just wrote.
|
|
9771
10771
|
currentPlanFile = abs;
|
|
10772
|
+
notePlanTargetForReviewCycle(abs);
|
|
9772
10773
|
// A run that starts in Default/Build and switches into Plan mid-run
|
|
9773
10774
|
// still qualifies: what matters is the mode at write time.
|
|
9774
10775
|
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
@@ -9779,6 +10780,7 @@ export default function workflowExtension(
|
|
|
9779
10780
|
planWritePathThisRun = raw;
|
|
9780
10781
|
planWrittenThisRun = true;
|
|
9781
10782
|
currentPlanFile = raw;
|
|
10783
|
+
notePlanTargetForReviewCycle(raw);
|
|
9782
10784
|
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9783
10785
|
}
|
|
9784
10786
|
try {
|
|
@@ -9799,17 +10801,29 @@ export default function workflowExtension(
|
|
|
9799
10801
|
// (Checked before the mutex: awaiting the in-flight handoff would block
|
|
9800
10802
|
// for the whole review instead of short-circuiting.)
|
|
9801
10803
|
if (reviewGatePhase !== "idle") {
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
"
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
10804
|
+
// A gate from a superseded cycle must not swallow this run's handoff.
|
|
10805
|
+
// Settle it with the old pause text (the old runHandoff already holds the
|
|
10806
|
+
// author's plan) and continue as if it were idle.
|
|
10807
|
+
if (reviewGateCycleId !== reviewCycleId) {
|
|
10808
|
+
settleReviewGate({
|
|
10809
|
+
outcome: "unreviewed",
|
|
10810
|
+
planText: currentPausedReview()?.planText,
|
|
10811
|
+
});
|
|
10812
|
+
reviewGatePhase = "idle";
|
|
10813
|
+
reviewGateCycleId = 0;
|
|
10814
|
+
} else {
|
|
10815
|
+
try {
|
|
10816
|
+
ctx.ui.notify(
|
|
10817
|
+
reviewGatePhase === "paused"
|
|
10818
|
+
? "Review paused — /review-resume to continue, /review-abort to hand off the plan as-is."
|
|
10819
|
+
: "Review running — ESC pauses it, /review-abort hands off the plan as-is.",
|
|
10820
|
+
"info",
|
|
10821
|
+
);
|
|
10822
|
+
} catch (_e) {
|
|
10823
|
+
void _e;
|
|
10824
|
+
}
|
|
10825
|
+
return;
|
|
9811
10826
|
}
|
|
9812
|
-
return;
|
|
9813
10827
|
}
|
|
9814
10828
|
// Mutex: serialize concurrent agent_end handoffs (bus may emit Promise.all)
|
|
9815
10829
|
if (handoffInFlight) {
|
|
@@ -9831,6 +10845,10 @@ export default function workflowExtension(
|
|
|
9831
10845
|
}
|
|
9832
10846
|
}
|
|
9833
10847
|
const runHandoff = async () => {
|
|
10848
|
+
// Capture the cycle that owns this handoff. A fresh prompt/refine/deny
|
|
10849
|
+
// increments `reviewCycleId`; every later continuation must stop instead
|
|
10850
|
+
// of rendering or applying a superseded plan.
|
|
10851
|
+
const handoffCycle = reviewCycleId;
|
|
9834
10852
|
// Per-run handoff gate (plan 2026-10-25 follow-up): a Plan-mode run
|
|
9835
10853
|
// that wrote no plan this run must not reconcile, review, or hand off
|
|
9836
10854
|
// an older plan. This runs before the reviewer prefetch so a new task
|
|
@@ -9862,6 +10880,7 @@ export default function workflowExtension(
|
|
|
9862
10880
|
: path.join(cwd, planWritePathThisRun);
|
|
9863
10881
|
currentPlanFile = abs;
|
|
9864
10882
|
lastPlanWritePath = abs;
|
|
10883
|
+
notePlanTargetForReviewCycle(abs);
|
|
9865
10884
|
// Persist once per run even when the tool_result handler already set
|
|
9866
10885
|
// the in-memory target: resume must find the plan without a mode
|
|
9867
10886
|
// switch or a todo update happening to persist it first.
|
|
@@ -10213,17 +11232,20 @@ export default function workflowExtension(
|
|
|
10213
11232
|
void _e;
|
|
10214
11233
|
}
|
|
10215
11234
|
};
|
|
10216
|
-
|
|
11235
|
+
const reviewThisCycle = reviewEnabled() || manualReviewCycle;
|
|
11236
|
+
if (planTextForRender && reviewThisCycle) {
|
|
10217
11237
|
const authorPlan: string = planTextForRender;
|
|
10218
11238
|
reviewHandoffGate.reset();
|
|
10219
11239
|
reviewGatePhase = "auditing";
|
|
11240
|
+
reviewGateCycleId = handoffCycle;
|
|
10220
11241
|
try {
|
|
10221
11242
|
const reviewed = await runReviewStep(
|
|
10222
11243
|
authorPlan,
|
|
10223
11244
|
ctx as ExtensionContext,
|
|
10224
11245
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10225
|
-
"auto",
|
|
11246
|
+
manualReviewCycle ? "manual" : "auto",
|
|
10226
11247
|
);
|
|
11248
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10227
11249
|
const reviewedText = reviewed.planText;
|
|
10228
11250
|
// Fresh read: the phase may have moved to "paused" (ESC /
|
|
10229
11251
|
// /review-pause) while the round was returning. The `as` widens
|
|
@@ -10233,7 +11255,7 @@ export default function workflowExtension(
|
|
|
10233
11255
|
if (
|
|
10234
11256
|
reviewHandoffParks({
|
|
10235
11257
|
phase: phaseAfterRound,
|
|
10236
|
-
paused:
|
|
11258
|
+
paused: currentPausedReview() !== undefined,
|
|
10237
11259
|
settled: reviewHandoffGate.settled(),
|
|
10238
11260
|
})
|
|
10239
11261
|
) {
|
|
@@ -10246,6 +11268,7 @@ export default function workflowExtension(
|
|
|
10246
11268
|
const res = await reviewHandoffGate.wait(
|
|
10247
11269
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10248
11270
|
);
|
|
11271
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10249
11272
|
if (res === undefined) {
|
|
10250
11273
|
// Host abort while parked: stop the reviewer, drop the pause,
|
|
10251
11274
|
// do not auto-review this revision again, and hand the author's
|
|
@@ -10290,11 +11313,15 @@ export default function workflowExtension(
|
|
|
10290
11313
|
} catch (_e) {
|
|
10291
11314
|
void _e;
|
|
10292
11315
|
} finally {
|
|
10293
|
-
|
|
11316
|
+
if (reviewGateCycleId === handoffCycle) {
|
|
11317
|
+
reviewGatePhase = "idle";
|
|
11318
|
+
reviewGateCycleId = 0;
|
|
11319
|
+
}
|
|
10294
11320
|
}
|
|
10295
11321
|
// A mode switch mid-gate (e.g. Tab-to-Build) already left plan mode:
|
|
10296
11322
|
// the gate settled as skipped, so there is nothing to hand off.
|
|
10297
11323
|
if (workflowMode !== "plan") return;
|
|
11324
|
+
if (handoffCycle !== reviewCycleId) return;
|
|
10298
11325
|
}
|
|
10299
11326
|
// Fallback when hasUI is false: still show handoff via followUp message (Claude Code parity for headless)
|
|
10300
11327
|
if (!hasUI && planTextForRender && !awaitingDecision) {
|
|
@@ -10331,114 +11358,15 @@ export default function workflowExtension(
|
|
|
10331
11358
|
if (planTextForRender && hasUI && !awaitingDecision) {
|
|
10332
11359
|
// ── Plannotator webview branch (browser review) ──────────
|
|
10333
11360
|
if (plannotatorActive) {
|
|
10334
|
-
|
|
10335
|
-
|
|
10336
|
-
|
|
10337
|
-
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
let settled = false;
|
|
10344
|
-
const timer = setTimeout(() => {
|
|
10345
|
-
if (!settled) {
|
|
10346
|
-
settled = true;
|
|
10347
|
-
resolve({ status: "timeout" });
|
|
10348
|
-
}
|
|
10349
|
-
}, 5000);
|
|
10350
|
-
try {
|
|
10351
|
-
bus.emit(PLANNOTATOR_REQUEST, {
|
|
10352
|
-
requestId,
|
|
10353
|
-
action: "plan-review",
|
|
10354
|
-
payload: {
|
|
10355
|
-
planContent: planTextForRender!,
|
|
10356
|
-
planFilePath: planPath,
|
|
10357
|
-
// The handoff only renders after the gated review
|
|
10358
|
-
// resolves, so a pending review is impossible here;
|
|
10359
|
-
// the field stays for the external browser app.
|
|
10360
|
-
reviewPending: false,
|
|
10361
|
-
// Provenance so the browser/inline view can show that this
|
|
10362
|
-
// plan was independently reviewed (and by what).
|
|
10363
|
-
reviewSummary: reviewState
|
|
10364
|
-
? {
|
|
10365
|
-
modelLabel: reviewState.modelLabel,
|
|
10366
|
-
round: reviewState.round,
|
|
10367
|
-
verdict: reviewState.verdict,
|
|
10368
|
-
findings: reviewState.findings.length,
|
|
10369
|
-
}
|
|
10370
|
-
: undefined,
|
|
10371
|
-
},
|
|
10372
|
-
respond: (res: any) => {
|
|
10373
|
-
if (!settled) {
|
|
10374
|
-
settled = true;
|
|
10375
|
-
clearTimeout(timer);
|
|
10376
|
-
resolve(res);
|
|
10377
|
-
}
|
|
10378
|
-
},
|
|
10379
|
-
});
|
|
10380
|
-
} catch (e) {
|
|
10381
|
-
if (!settled) {
|
|
10382
|
-
settled = true;
|
|
10383
|
-
clearTimeout(timer);
|
|
10384
|
-
resolve({ status: "error", error: String(e) });
|
|
10385
|
-
}
|
|
10386
|
-
}
|
|
10387
|
-
});
|
|
10388
|
-
if (
|
|
10389
|
-
response &&
|
|
10390
|
-
response.status === "handled" &&
|
|
10391
|
-
response.result &&
|
|
10392
|
-
response.result.reviewId
|
|
10393
|
-
) {
|
|
10394
|
-
lastReviewId = response.result.reviewId;
|
|
10395
|
-
try {
|
|
10396
|
-
pi.appendEntry("workflow-handoff-plannotator", {
|
|
10397
|
-
at: Date.now(),
|
|
10398
|
-
planPath,
|
|
10399
|
-
reviewId: lastReviewId,
|
|
10400
|
-
planTextLen: planTextForRender!.length,
|
|
10401
|
-
source: planSource,
|
|
10402
|
-
});
|
|
10403
|
-
} catch (_e) {
|
|
10404
|
-
void _e;
|
|
10405
|
-
}
|
|
10406
|
-
try {
|
|
10407
|
-
currentSessionCtx = ctx as any;
|
|
10408
|
-
(ctx as any).ui?.notify?.(
|
|
10409
|
-
"Plan opened in browser for review. Approve or annotate there.",
|
|
10410
|
-
"info",
|
|
10411
|
-
);
|
|
10412
|
-
updateStatus(ctx as any);
|
|
10413
|
-
} catch (_e) {
|
|
10414
|
-
void _e;
|
|
10415
|
-
}
|
|
10416
|
-
return; // plannotator owns the review — result arrives via plannotator:review-result / plan-approved events
|
|
10417
|
-
}
|
|
10418
|
-
if (response && response.status !== "timeout") {
|
|
10419
|
-
try {
|
|
10420
|
-
(ctx as any).ui?.notify?.(
|
|
10421
|
-
`Plannotator unavailable (${response.status}), falling back to inline review`,
|
|
10422
|
-
"warning",
|
|
10423
|
-
);
|
|
10424
|
-
} catch (_e) {
|
|
10425
|
-
void _e;
|
|
10426
|
-
}
|
|
10427
|
-
}
|
|
10428
|
-
} catch (e) {
|
|
10429
|
-
try {
|
|
10430
|
-
(ctx as any).ui?.notify?.(
|
|
10431
|
-
`Plannotator request failed, falling back to inline: ${String(e).slice(0, 80)}`,
|
|
10432
|
-
"warning",
|
|
10433
|
-
);
|
|
10434
|
-
} catch (_e) {
|
|
10435
|
-
void _e;
|
|
10436
|
-
}
|
|
10437
|
-
}
|
|
10438
|
-
// Browser branch failed — clear gate so inline can show
|
|
10439
|
-
clearPendingHandoffDecision();
|
|
10440
|
-
persistState();
|
|
10441
|
-
// fall through to inline handoff below
|
|
11361
|
+
const handoff = await requestPlanReview({
|
|
11362
|
+
planText: planTextForRender,
|
|
11363
|
+
planPath,
|
|
11364
|
+
source: "auto",
|
|
11365
|
+
ctx: ctx as ExtensionContext,
|
|
11366
|
+
cycleId: handoffCycle,
|
|
11367
|
+
});
|
|
11368
|
+
if (handoff.ok) return;
|
|
11369
|
+
// The helper already cleared the gate; fall through to inline.
|
|
10442
11370
|
}
|
|
10443
11371
|
awaitingDecision = true;
|
|
10444
11372
|
lastHandoffAt = Date.now();
|
|
@@ -10750,6 +11678,14 @@ export default function workflowExtension(
|
|
|
10750
11678
|
"",
|
|
10751
11679
|
);
|
|
10752
11680
|
if (refinement?.trim()) {
|
|
11681
|
+
try {
|
|
11682
|
+
await beginReviewCycle({
|
|
11683
|
+
reason: "refine",
|
|
11684
|
+
keepFindings: true,
|
|
11685
|
+
});
|
|
11686
|
+
} catch (_e) {
|
|
11687
|
+
void _e;
|
|
11688
|
+
}
|
|
10753
11689
|
const todoListText = formatTodoNumberedList(todoItems, {
|
|
10754
11690
|
glyph: true,
|
|
10755
11691
|
});
|