@dieulc/workflow 0.9.2 → 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 +69 -7
- package/index.ts +827 -155
- package/package.json +9 -6
- 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 +242 -2
package/index.ts
CHANGED
|
@@ -163,6 +163,8 @@ import {
|
|
|
163
163
|
roleArgumentCompletions,
|
|
164
164
|
saveRoleConfig,
|
|
165
165
|
getRole,
|
|
166
|
+
userRolesPath,
|
|
167
|
+
projectRolesPath,
|
|
166
168
|
formatRolesForDisplay,
|
|
167
169
|
formatModelRef,
|
|
168
170
|
parseModelRef,
|
|
@@ -204,11 +206,17 @@ import {
|
|
|
204
206
|
PROFILE_ROLES,
|
|
205
207
|
modelPickerItems,
|
|
206
208
|
profileActionClosesPicker,
|
|
209
|
+
userProfilesPath,
|
|
210
|
+
projectProfilesPath,
|
|
207
211
|
type ProfileFlowOutcome,
|
|
208
212
|
type ProfileConfig,
|
|
209
213
|
type Profile,
|
|
210
214
|
type ProfileRoleName,
|
|
211
215
|
} from "./profiles.ts";
|
|
216
|
+
import {
|
|
217
|
+
formatRetargetResult,
|
|
218
|
+
retargetProviderFiles,
|
|
219
|
+
} from "./provider-retarget.ts";
|
|
212
220
|
import { FilterableList, type FilterableItem } from "./picker.ts";
|
|
213
221
|
import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
|
|
214
222
|
import {
|
|
@@ -232,6 +240,7 @@ import {
|
|
|
232
240
|
} from "./model-default.ts";
|
|
233
241
|
import {
|
|
234
242
|
applySubmittedPlan,
|
|
243
|
+
buildReviewerFallbacks,
|
|
235
244
|
classifyReviewExecution,
|
|
236
245
|
createReviewHandoffGate,
|
|
237
246
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
@@ -253,6 +262,7 @@ import {
|
|
|
253
262
|
reviewResumeNotice,
|
|
254
263
|
reviewResumePlanChoice,
|
|
255
264
|
reviewEscapeAction,
|
|
265
|
+
reviewElapsedMs,
|
|
256
266
|
reviewHandoffParks,
|
|
257
267
|
reviewResumeSettles,
|
|
258
268
|
reviewStateSummary,
|
|
@@ -261,6 +271,7 @@ import {
|
|
|
261
271
|
writeReviewModeConfig,
|
|
262
272
|
writeReviewPrompt,
|
|
263
273
|
type ReviewFinding,
|
|
274
|
+
type ReviewFallbackModel,
|
|
264
275
|
type ReviewExecutionResult,
|
|
265
276
|
type ReviewHandoffGate,
|
|
266
277
|
type ReviewSdkLoader,
|
|
@@ -282,12 +293,35 @@ import {
|
|
|
282
293
|
type ReviewLineBuffer,
|
|
283
294
|
type ReviewModeBandData,
|
|
284
295
|
} from "./review-ui.ts";
|
|
296
|
+
import {
|
|
297
|
+
pendingHandoffIsStale,
|
|
298
|
+
reviewHandoffRecovery,
|
|
299
|
+
shouldHandoffManualResult,
|
|
300
|
+
type ReviewHandoffSource,
|
|
301
|
+
} from "./review-handoff.ts";
|
|
285
302
|
import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
|
|
286
303
|
|
|
287
304
|
// ── Plannotator Bridge ───────────────────────────────────────────────
|
|
288
305
|
const PLANNOTATOR_REQUEST = "plannotator:request" as const;
|
|
289
306
|
const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
|
|
290
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 };
|
|
291
325
|
|
|
292
326
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
293
327
|
|
|
@@ -499,6 +533,10 @@ function getFinalOutput(messages: any[]): string {
|
|
|
499
533
|
*/
|
|
500
534
|
export interface WorkflowExtensionDeps {
|
|
501
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;
|
|
502
540
|
}
|
|
503
541
|
|
|
504
542
|
export default function workflowExtension(
|
|
@@ -1517,6 +1555,48 @@ export default function workflowExtension(
|
|
|
1517
1555
|
}
|
|
1518
1556
|
}
|
|
1519
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
|
+
|
|
1520
1600
|
/** Reviewer model label for the mode band (best-effort). */
|
|
1521
1601
|
function reviewModelLabel(): string {
|
|
1522
1602
|
// While a round runs, the band names the snapshotted model — the one
|
|
@@ -1927,6 +2007,7 @@ export default function workflowExtension(
|
|
|
1927
2007
|
if (reviewModelSnapshot) return reviewModelSnapshot;
|
|
1928
2008
|
return resolveReviewerModel();
|
|
1929
2009
|
},
|
|
2010
|
+
fallbackReviewerModels: () => reviewFallbackModels(),
|
|
1930
2011
|
findModel: (provider: string, id: string) =>
|
|
1931
2012
|
(currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
|
|
1932
2013
|
// Structural probe of the live parent context, evaluated at reviewer
|
|
@@ -2180,6 +2261,9 @@ export default function workflowExtension(
|
|
|
2180
2261
|
try {
|
|
2181
2262
|
const ctx = currentSessionCtx;
|
|
2182
2263
|
if (!ctx || !(ctx as any).hasUI) return;
|
|
2264
|
+
if (message !== undefined) {
|
|
2265
|
+
(ctx as any).ui?.setWorkingVisible?.(true);
|
|
2266
|
+
}
|
|
2183
2267
|
(ctx as any).ui?.setWorkingMessage?.(message);
|
|
2184
2268
|
} catch (_e) {
|
|
2185
2269
|
void _e;
|
|
@@ -2251,6 +2335,9 @@ export default function workflowExtension(
|
|
|
2251
2335
|
// follow `reviewState`; the spinner row needs an explicit message while
|
|
2252
2336
|
// the child session streams off the main turn.
|
|
2253
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);
|
|
2254
2341
|
// One long-lived listener covers this round AND the paused window (see
|
|
2255
2342
|
// ensureReviewEscListener for why it is no longer attached per round).
|
|
2256
2343
|
ensureReviewEscListener(ctx);
|
|
@@ -2466,6 +2553,17 @@ export default function workflowExtension(
|
|
|
2466
2553
|
}
|
|
2467
2554
|
}
|
|
2468
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
|
+
|
|
2469
2567
|
// A different plan file is a new target even inside one user run: reset the
|
|
2470
2568
|
// budget/hashes/findings before the dedupe gates below.
|
|
2471
2569
|
const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
|
|
@@ -2634,6 +2732,16 @@ export default function workflowExtension(
|
|
|
2634
2732
|
const hadGate = reviewGatePhase !== "idle";
|
|
2635
2733
|
// Synchronously first: any old handoff continuation is stale from here on.
|
|
2636
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
|
+
}
|
|
2637
2745
|
if (hadActive || pausedReview !== undefined || hadGate) {
|
|
2638
2746
|
try {
|
|
2639
2747
|
await suspendReview();
|
|
@@ -2736,7 +2844,9 @@ export default function workflowExtension(
|
|
|
2736
2844
|
* they are not guarded. Clearing the review state releases the derived
|
|
2737
2845
|
* mode-switch lock (plan 2026-10-25).
|
|
2738
2846
|
*/
|
|
2739
|
-
async function teardownReview(
|
|
2847
|
+
async function teardownReview(
|
|
2848
|
+
opts: { keepHandoff?: boolean } = {},
|
|
2849
|
+
): Promise<void> {
|
|
2740
2850
|
try {
|
|
2741
2851
|
activeReview?.controller.abort();
|
|
2742
2852
|
} catch (_e) {
|
|
@@ -2749,6 +2859,7 @@ export default function workflowExtension(
|
|
|
2749
2859
|
}
|
|
2750
2860
|
reviewRuntime = undefined;
|
|
2751
2861
|
reviewState = undefined;
|
|
2862
|
+
if (opts.keepHandoff !== true) manualReviewCycle = false;
|
|
2752
2863
|
lastReviewedPlanHash = undefined;
|
|
2753
2864
|
reviewRound = 0;
|
|
2754
2865
|
reviewPreviousFindings = undefined;
|
|
@@ -2763,11 +2874,12 @@ export default function workflowExtension(
|
|
|
2763
2874
|
activeReview = undefined;
|
|
2764
2875
|
abortedReviewHash = undefined;
|
|
2765
2876
|
quietAbortHash = undefined;
|
|
2766
|
-
// A
|
|
2767
|
-
//
|
|
2768
|
-
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();
|
|
2769
2881
|
resetPlanRunProvenance();
|
|
2770
|
-
if (clearedDecision) {
|
|
2882
|
+
if (clearedDecision || opts.keepHandoff === true) {
|
|
2771
2883
|
try {
|
|
2772
2884
|
persistState();
|
|
2773
2885
|
} catch (_e) {
|
|
@@ -3179,6 +3291,7 @@ export default function workflowExtension(
|
|
|
3179
3291
|
[
|
|
3180
3292
|
`Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
|
|
3181
3293
|
" /review-resume to continue, /review-abort to stop.",
|
|
3294
|
+
pendingHandoffStatusLine(),
|
|
3182
3295
|
reviewModeLockStatusLine(lockKind),
|
|
3183
3296
|
]
|
|
3184
3297
|
.filter(Boolean)
|
|
@@ -3199,6 +3312,7 @@ export default function workflowExtension(
|
|
|
3199
3312
|
reviewEnabled()
|
|
3200
3313
|
? "Review Mode is enabled but no review has run yet."
|
|
3201
3314
|
: `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
|
|
3315
|
+
pendingHandoffStatusLine(),
|
|
3202
3316
|
reviewModeLockStatusLine(lockKind),
|
|
3203
3317
|
]
|
|
3204
3318
|
.filter(Boolean)
|
|
@@ -3211,6 +3325,10 @@ export default function workflowExtension(
|
|
|
3211
3325
|
reviewStateSummary(s),
|
|
3212
3326
|
` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
|
|
3213
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
|
+
}`,
|
|
3214
3332
|
` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
|
|
3215
3333
|
` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
|
|
3216
3334
|
s.verdict ? ` verdict : ${s.verdict}` : "",
|
|
@@ -3224,6 +3342,7 @@ export default function workflowExtension(
|
|
|
3224
3342
|
reviewRuntime?.isRoundActive()
|
|
3225
3343
|
? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
|
|
3226
3344
|
: "",
|
|
3345
|
+
pendingHandoffStatusLine(),
|
|
3227
3346
|
reviewModeLockStatusLine(lockKind),
|
|
3228
3347
|
...s.findings.map(
|
|
3229
3348
|
(f) =>
|
|
@@ -3398,6 +3517,16 @@ export default function workflowExtension(
|
|
|
3398
3517
|
const offSuffix = reviewEnabled()
|
|
3399
3518
|
? ""
|
|
3400
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
|
+
}
|
|
3401
3530
|
ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
|
|
3402
3531
|
},
|
|
3403
3532
|
});
|
|
@@ -3442,6 +3571,9 @@ export default function workflowExtension(
|
|
|
3442
3571
|
ctx.ui.notify("A review round is already running.", "warning");
|
|
3443
3572
|
return;
|
|
3444
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";
|
|
3445
3577
|
// A resumed gated review is auditing again. Without this, the parked
|
|
3446
3578
|
// handoff keeps `reviewGatePhase === "paused"` for the whole round and
|
|
3447
3579
|
// the lock/footer report the wrong state (plan 2026-10-25 follow-up).
|
|
@@ -3485,7 +3617,15 @@ export default function workflowExtension(
|
|
|
3485
3617
|
planText: result.planText,
|
|
3486
3618
|
});
|
|
3487
3619
|
}
|
|
3488
|
-
|
|
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
|
+
}
|
|
3489
3629
|
return;
|
|
3490
3630
|
}
|
|
3491
3631
|
pausedReview = undefined;
|
|
@@ -3510,7 +3650,11 @@ export default function workflowExtension(
|
|
|
3510
3650
|
planText: result.planText,
|
|
3511
3651
|
});
|
|
3512
3652
|
}
|
|
3513
|
-
|
|
3653
|
+
if (!gatedResume && reviewResumeSettles(result)) {
|
|
3654
|
+
await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
|
|
3655
|
+
} else {
|
|
3656
|
+
ctx.ui.notify(reviewResumeNotice(result), "info");
|
|
3657
|
+
}
|
|
3514
3658
|
},
|
|
3515
3659
|
});
|
|
3516
3660
|
|
|
@@ -4014,6 +4158,16 @@ export default function workflowExtension(
|
|
|
4014
4158
|
planFile: currentPlanFile,
|
|
4015
4159
|
awaitingDecision,
|
|
4016
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,
|
|
4017
4171
|
role: {
|
|
4018
4172
|
activeRole: roleConfig.activeRole,
|
|
4019
4173
|
// Session-scoped profile activation (see setActiveProfile): explicit
|
|
@@ -4310,6 +4464,365 @@ export default function workflowExtension(
|
|
|
4310
4464
|
return currentSessionCtx;
|
|
4311
4465
|
}
|
|
4312
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
|
+
|
|
4313
4826
|
function handlePlannotatorApprove(
|
|
4314
4827
|
planFilePath: string,
|
|
4315
4828
|
planContent: string,
|
|
@@ -4318,10 +4831,18 @@ export default function workflowExtension(
|
|
|
4318
4831
|
) {
|
|
4319
4832
|
const ctx = getActiveCtx();
|
|
4320
4833
|
if (!ctx) return;
|
|
4321
|
-
|
|
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;
|
|
4322
4837
|
// A stale browser approve while a review runs must not clear the pending
|
|
4323
4838
|
// decision or send the execute messages: the plan is still being audited.
|
|
4324
|
-
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
|
+
}
|
|
4325
4846
|
// No prompt means no decision to consume. A fresh prompt/mode switch
|
|
4326
4847
|
// clears this, so an old browser tab cannot approve the previous plan.
|
|
4327
4848
|
if (!awaitingDecision) return;
|
|
@@ -4377,6 +4898,7 @@ export default function workflowExtension(
|
|
|
4377
4898
|
} catch (_e) {
|
|
4378
4899
|
void _e;
|
|
4379
4900
|
}
|
|
4901
|
+
if (pendingHandoffSource === "manual") manualReviewCycle = false;
|
|
4380
4902
|
clearPendingHandoffDecision();
|
|
4381
4903
|
void enterBuildModeFromPlan(ctx as any, resolvedPlan)
|
|
4382
4904
|
.then(() => updateStatus(ctx as any))
|
|
@@ -4428,8 +4950,28 @@ export default function workflowExtension(
|
|
|
4428
4950
|
// Not a mode transition, but while a review owns the turn every browser
|
|
4429
4951
|
// decision is stale: injecting feedback here would mutate the plan under
|
|
4430
4952
|
// the reviewer. A legitimate deny only arrives after the gate resolves.
|
|
4431
|
-
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
|
+
}
|
|
4432
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
|
+
}
|
|
4433
4975
|
clearPendingHandoffDecision();
|
|
4434
4976
|
// Feedback cycle: the next plan revision gets a fresh review round, and the
|
|
4435
4977
|
// outstanding findings are carried forward so the reviewer can confirm they
|
|
@@ -4519,6 +5061,52 @@ export default function workflowExtension(
|
|
|
4519
5061
|
void _e;
|
|
4520
5062
|
}
|
|
4521
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
|
+
});
|
|
4522
5110
|
plannotatorListenersRegistered = true;
|
|
4523
5111
|
plannotatorActive = true;
|
|
4524
5112
|
const ctx = getActiveCtx();
|
|
@@ -5416,7 +6004,8 @@ export default function workflowExtension(
|
|
|
5416
6004
|
"Usage: /profile (picker) | /profile list | /profile show <name> | " +
|
|
5417
6005
|
"/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
|
|
5418
6006
|
"/profile rename <old> <new> | /profile delete <name> | " +
|
|
5419
|
-
"/profile default [name|none] [global|project]"
|
|
6007
|
+
"/profile default [name|none] [global|project] | " +
|
|
6008
|
+
"/profile retarget-provider <old> <new> [global|project] [--apply]";
|
|
5420
6009
|
|
|
5421
6010
|
/** Effective workflow.defaultProfile for display (never throws). */
|
|
5422
6011
|
function currentDefaultProfileName(cwd?: string): string | undefined {
|
|
@@ -6827,6 +7416,79 @@ export default function workflowExtension(
|
|
|
6827
7416
|
return;
|
|
6828
7417
|
}
|
|
6829
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
|
+
|
|
6830
7492
|
if (sub === "default") {
|
|
6831
7493
|
// /profile default [name|none] [global|project]
|
|
6832
7494
|
const target = rest.find(
|
|
@@ -8351,31 +9013,39 @@ export default function workflowExtension(
|
|
|
8351
9013
|
typeof d.awaitingDecisionAt === "number"
|
|
8352
9014
|
? d.awaitingDecisionAt
|
|
8353
9015
|
: undefined;
|
|
8354
|
-
//
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
)
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
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;
|
|
8379
9049
|
}
|
|
8380
9050
|
}
|
|
8381
9051
|
if (
|
|
@@ -8601,9 +9271,10 @@ export default function workflowExtension(
|
|
|
8601
9271
|
resetPlanRunProvenance();
|
|
8602
9272
|
lastPlanWritePath = currentPlanFile ?? null;
|
|
8603
9273
|
lastPersistedPlanFile = currentPlanFile;
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
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();
|
|
8607
9278
|
currentSessionCtx = ctx as any;
|
|
8608
9279
|
// Give Tab back to autocomplete while a suggestion list is visible; the raw
|
|
8609
9280
|
// `tab` shortcut keeps cycling modes otherwise.
|
|
@@ -8636,10 +9307,14 @@ export default function workflowExtension(
|
|
|
8636
9307
|
} catch (_e) {
|
|
8637
9308
|
void _e;
|
|
8638
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);
|
|
8639
9314
|
updateStatus(ctx as any);
|
|
8640
9315
|
});
|
|
8641
9316
|
|
|
8642
|
-
pi.on("session_shutdown", async (
|
|
9317
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
8643
9318
|
// A write denial is run-scoped; the session is going away, so drop it.
|
|
8644
9319
|
writeGateRunDenials.clear();
|
|
8645
9320
|
writeGateTurnAnswer = null;
|
|
@@ -8658,11 +9333,17 @@ export default function workflowExtension(
|
|
|
8658
9333
|
}
|
|
8659
9334
|
}
|
|
8660
9335
|
activeSubagents.clear();
|
|
8661
|
-
//
|
|
8662
|
-
|
|
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();
|
|
8663
9344
|
resetPlanRunProvenance();
|
|
8664
9345
|
resetSessionPlanProvenance();
|
|
8665
|
-
if (clearedAtShutdown) {
|
|
9346
|
+
if (clearedAtShutdown || keepPending === true) {
|
|
8666
9347
|
try {
|
|
8667
9348
|
persistState();
|
|
8668
9349
|
} catch (_e) {
|
|
@@ -8680,7 +9361,7 @@ export default function workflowExtension(
|
|
|
8680
9361
|
pendingCommitFlow = null;
|
|
8681
9362
|
// Release the reviewer session (and its custom tools) on shutdown so a
|
|
8682
9363
|
// reload or quit never leaves an orphaned in-process session behind.
|
|
8683
|
-
await teardownReview();
|
|
9364
|
+
await teardownReview({ keepHandoff: keepPending });
|
|
8684
9365
|
currentSessionCtx = null;
|
|
8685
9366
|
});
|
|
8686
9367
|
|
|
@@ -9895,6 +10576,25 @@ export default function workflowExtension(
|
|
|
9895
10576
|
/** Plan path/hash the currently rendered handoff prompt belongs to. */
|
|
9896
10577
|
let pendingHandoffPlanPath: string | null = null;
|
|
9897
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;
|
|
9898
10598
|
/** Last path announced by the suppressed-handoff notice (per run). */
|
|
9899
10599
|
let lastPlanReadyNoticeKey: string | null = null;
|
|
9900
10600
|
|
|
@@ -9919,18 +10619,88 @@ export default function workflowExtension(
|
|
|
9919
10619
|
* switch supersedes it; stale browser events must not approve the old plan.
|
|
9920
10620
|
* Returns true when persisted state changed so the caller can persist once.
|
|
9921
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
|
+
|
|
9922
10633
|
function clearPendingHandoffDecision(): boolean {
|
|
9923
10634
|
const changed =
|
|
9924
10635
|
awaitingDecision === true ||
|
|
9925
10636
|
lastHandoffAt !== undefined ||
|
|
9926
10637
|
lastReviewId !== null ||
|
|
9927
10638
|
pendingHandoffPlanPath !== null ||
|
|
9928
|
-
pendingHandoffPlanHash !== null
|
|
10639
|
+
pendingHandoffPlanHash !== null ||
|
|
10640
|
+
pendingHandoffRequestId !== null ||
|
|
10641
|
+
pendingHandoffSource !== null ||
|
|
10642
|
+
pendingHandoffCycleId !== 0 ||
|
|
10643
|
+
pendingHandoffReviewUrl !== null;
|
|
9929
10644
|
awaitingDecision = false;
|
|
9930
10645
|
lastHandoffAt = undefined;
|
|
9931
10646
|
lastReviewId = null;
|
|
9932
10647
|
pendingHandoffPlanPath = null;
|
|
9933
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
|
+
}
|
|
9934
10704
|
return changed;
|
|
9935
10705
|
}
|
|
9936
10706
|
|
|
@@ -10462,7 +11232,8 @@ export default function workflowExtension(
|
|
|
10462
11232
|
void _e;
|
|
10463
11233
|
}
|
|
10464
11234
|
};
|
|
10465
|
-
|
|
11235
|
+
const reviewThisCycle = reviewEnabled() || manualReviewCycle;
|
|
11236
|
+
if (planTextForRender && reviewThisCycle) {
|
|
10466
11237
|
const authorPlan: string = planTextForRender;
|
|
10467
11238
|
reviewHandoffGate.reset();
|
|
10468
11239
|
reviewGatePhase = "auditing";
|
|
@@ -10472,7 +11243,7 @@ export default function workflowExtension(
|
|
|
10472
11243
|
authorPlan,
|
|
10473
11244
|
ctx as ExtensionContext,
|
|
10474
11245
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10475
|
-
"auto",
|
|
11246
|
+
manualReviewCycle ? "manual" : "auto",
|
|
10476
11247
|
);
|
|
10477
11248
|
if (handoffCycle !== reviewCycleId) return;
|
|
10478
11249
|
const reviewedText = reviewed.planText;
|
|
@@ -10587,114 +11358,15 @@ export default function workflowExtension(
|
|
|
10587
11358
|
if (planTextForRender && hasUI && !awaitingDecision) {
|
|
10588
11359
|
// ── Plannotator webview branch (browser review) ──────────
|
|
10589
11360
|
if (plannotatorActive) {
|
|
10590
|
-
|
|
10591
|
-
|
|
10592
|
-
|
|
10593
|
-
|
|
10594
|
-
|
|
10595
|
-
|
|
10596
|
-
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
let settled = false;
|
|
10600
|
-
const timer = setTimeout(() => {
|
|
10601
|
-
if (!settled) {
|
|
10602
|
-
settled = true;
|
|
10603
|
-
resolve({ status: "timeout" });
|
|
10604
|
-
}
|
|
10605
|
-
}, 5000);
|
|
10606
|
-
try {
|
|
10607
|
-
bus.emit(PLANNOTATOR_REQUEST, {
|
|
10608
|
-
requestId,
|
|
10609
|
-
action: "plan-review",
|
|
10610
|
-
payload: {
|
|
10611
|
-
planContent: planTextForRender!,
|
|
10612
|
-
planFilePath: planPath,
|
|
10613
|
-
// The handoff only renders after the gated review
|
|
10614
|
-
// resolves, so a pending review is impossible here;
|
|
10615
|
-
// the field stays for the external browser app.
|
|
10616
|
-
reviewPending: false,
|
|
10617
|
-
// Provenance so the browser/inline view can show that this
|
|
10618
|
-
// plan was independently reviewed (and by what).
|
|
10619
|
-
reviewSummary: reviewState
|
|
10620
|
-
? {
|
|
10621
|
-
modelLabel: reviewState.modelLabel,
|
|
10622
|
-
round: reviewState.round,
|
|
10623
|
-
verdict: reviewState.verdict,
|
|
10624
|
-
findings: reviewState.findings.length,
|
|
10625
|
-
}
|
|
10626
|
-
: undefined,
|
|
10627
|
-
},
|
|
10628
|
-
respond: (res: any) => {
|
|
10629
|
-
if (!settled) {
|
|
10630
|
-
settled = true;
|
|
10631
|
-
clearTimeout(timer);
|
|
10632
|
-
resolve(res);
|
|
10633
|
-
}
|
|
10634
|
-
},
|
|
10635
|
-
});
|
|
10636
|
-
} catch (e) {
|
|
10637
|
-
if (!settled) {
|
|
10638
|
-
settled = true;
|
|
10639
|
-
clearTimeout(timer);
|
|
10640
|
-
resolve({ status: "error", error: String(e) });
|
|
10641
|
-
}
|
|
10642
|
-
}
|
|
10643
|
-
});
|
|
10644
|
-
if (
|
|
10645
|
-
response &&
|
|
10646
|
-
response.status === "handled" &&
|
|
10647
|
-
response.result &&
|
|
10648
|
-
response.result.reviewId
|
|
10649
|
-
) {
|
|
10650
|
-
lastReviewId = response.result.reviewId;
|
|
10651
|
-
try {
|
|
10652
|
-
pi.appendEntry("workflow-handoff-plannotator", {
|
|
10653
|
-
at: Date.now(),
|
|
10654
|
-
planPath,
|
|
10655
|
-
reviewId: lastReviewId,
|
|
10656
|
-
planTextLen: planTextForRender!.length,
|
|
10657
|
-
source: planSource,
|
|
10658
|
-
});
|
|
10659
|
-
} catch (_e) {
|
|
10660
|
-
void _e;
|
|
10661
|
-
}
|
|
10662
|
-
try {
|
|
10663
|
-
currentSessionCtx = ctx as any;
|
|
10664
|
-
(ctx as any).ui?.notify?.(
|
|
10665
|
-
"Plan opened in browser for review. Approve or annotate there.",
|
|
10666
|
-
"info",
|
|
10667
|
-
);
|
|
10668
|
-
updateStatus(ctx as any);
|
|
10669
|
-
} catch (_e) {
|
|
10670
|
-
void _e;
|
|
10671
|
-
}
|
|
10672
|
-
return; // plannotator owns the review — result arrives via plannotator:review-result / plan-approved events
|
|
10673
|
-
}
|
|
10674
|
-
if (response && response.status !== "timeout") {
|
|
10675
|
-
try {
|
|
10676
|
-
(ctx as any).ui?.notify?.(
|
|
10677
|
-
`Plannotator unavailable (${response.status}), falling back to inline review`,
|
|
10678
|
-
"warning",
|
|
10679
|
-
);
|
|
10680
|
-
} catch (_e) {
|
|
10681
|
-
void _e;
|
|
10682
|
-
}
|
|
10683
|
-
}
|
|
10684
|
-
} catch (e) {
|
|
10685
|
-
try {
|
|
10686
|
-
(ctx as any).ui?.notify?.(
|
|
10687
|
-
`Plannotator request failed, falling back to inline: ${String(e).slice(0, 80)}`,
|
|
10688
|
-
"warning",
|
|
10689
|
-
);
|
|
10690
|
-
} catch (_e) {
|
|
10691
|
-
void _e;
|
|
10692
|
-
}
|
|
10693
|
-
}
|
|
10694
|
-
// Browser branch failed — clear gate so inline can show
|
|
10695
|
-
clearPendingHandoffDecision();
|
|
10696
|
-
persistState();
|
|
10697
|
-
// 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.
|
|
10698
11370
|
}
|
|
10699
11371
|
awaitingDecision = true;
|
|
10700
11372
|
lastHandoffAt = Date.now();
|