@dieulc/workflow 0.9.2 → 0.11.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 +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
package/index.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
19
|
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
|
20
20
|
import type {
|
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
type EditorTheme,
|
|
42
42
|
type SelectItem,
|
|
43
43
|
wrapTextWithAnsi,
|
|
44
|
-
truncateToWidth,
|
|
45
44
|
} from "@earendil-works/pi-tui";
|
|
46
45
|
import { Type } from "typebox";
|
|
47
46
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -121,6 +120,49 @@ import {
|
|
|
121
120
|
type PendingCommitFlow,
|
|
122
121
|
type StepCommitCandidate,
|
|
123
122
|
} from "./commit-policy.ts";
|
|
123
|
+
import {
|
|
124
|
+
baseFolderRoots,
|
|
125
|
+
decideFolderGate,
|
|
126
|
+
describeFolderEvaluation,
|
|
127
|
+
detectRepoRoot,
|
|
128
|
+
isBroadGrantRoot,
|
|
129
|
+
outsideProjectRootForTarget,
|
|
130
|
+
evaluateFolderTargets,
|
|
131
|
+
matchSensitivePath,
|
|
132
|
+
readFolderPolicyConfig,
|
|
133
|
+
writeFolderPolicyConfig,
|
|
134
|
+
folderPolicyArgumentCompletions,
|
|
135
|
+
folderPolicyConfigPaths,
|
|
136
|
+
DEFAULT_SENSITIVE_PATTERNS,
|
|
137
|
+
type FolderPolicyConfig,
|
|
138
|
+
type FolderTarget,
|
|
139
|
+
} from "./folder-policy.ts";
|
|
140
|
+
import { analyzeShellPaths } from "./shell-paths.ts";
|
|
141
|
+
import {
|
|
142
|
+
buildReadDenyReason,
|
|
143
|
+
buildReadGuidanceMessage,
|
|
144
|
+
decideReadGate,
|
|
145
|
+
parseReadGateChoice,
|
|
146
|
+
readGateItemFromTarget,
|
|
147
|
+
readGateItemsKind,
|
|
148
|
+
readGateOptions,
|
|
149
|
+
readGateTargetKey,
|
|
150
|
+
readGateTargetsLabel,
|
|
151
|
+
readGateTitle,
|
|
152
|
+
readReadPolicyConfig,
|
|
153
|
+
sanitizeReadInstruction,
|
|
154
|
+
writeReadPolicyConfig,
|
|
155
|
+
readPolicyArgumentCompletions,
|
|
156
|
+
readPolicyConfigPaths,
|
|
157
|
+
revokeReadPolicyPath,
|
|
158
|
+
describeReadPolicy,
|
|
159
|
+
READ_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
160
|
+
READ_INSTRUCTION_PROMPT_TITLE,
|
|
161
|
+
type ReadGateItem,
|
|
162
|
+
type ReadPolicyConfig,
|
|
163
|
+
} from "./read-policy.ts";
|
|
164
|
+
import { classifyReadCall } from "./read-policy-tools.ts";
|
|
165
|
+
import { redactToolResult } from "./read-policy-redact.ts";
|
|
124
166
|
import {
|
|
125
167
|
buildWriteDenyReason,
|
|
126
168
|
buildWriteGuidanceMessage,
|
|
@@ -128,6 +170,7 @@ import {
|
|
|
128
170
|
decideWriteGate,
|
|
129
171
|
describeWritePolicyMode,
|
|
130
172
|
isWriteCallAllowed,
|
|
173
|
+
normalizeWritePath,
|
|
131
174
|
parseWriteGateChoice,
|
|
132
175
|
readWritePolicyConfig,
|
|
133
176
|
sanitizeWriteInstruction,
|
|
@@ -143,6 +186,7 @@ import {
|
|
|
143
186
|
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
144
187
|
type WriteGateChoice,
|
|
145
188
|
type WriteGateItem,
|
|
189
|
+
type WriteGateSource,
|
|
146
190
|
type WriteCallClassification,
|
|
147
191
|
type WritePolicyConfig,
|
|
148
192
|
type WritePolicyMode,
|
|
@@ -163,6 +207,8 @@ import {
|
|
|
163
207
|
roleArgumentCompletions,
|
|
164
208
|
saveRoleConfig,
|
|
165
209
|
getRole,
|
|
210
|
+
userRolesPath,
|
|
211
|
+
projectRolesPath,
|
|
166
212
|
formatRolesForDisplay,
|
|
167
213
|
formatModelRef,
|
|
168
214
|
parseModelRef,
|
|
@@ -185,6 +231,17 @@ import {
|
|
|
185
231
|
setWorkflowStatus,
|
|
186
232
|
WORKFLOW_STATUS_KEYS,
|
|
187
233
|
} from "./workflow-status.ts";
|
|
234
|
+
import {
|
|
235
|
+
buildTodoRows,
|
|
236
|
+
deriveTodoPanel,
|
|
237
|
+
isTodoListSettled,
|
|
238
|
+
readPersistedTodoPanel,
|
|
239
|
+
renderTodoSummary,
|
|
240
|
+
todoCompletionSignature,
|
|
241
|
+
todoListKey,
|
|
242
|
+
type TodoPanelOverride,
|
|
243
|
+
type TodoPanelState,
|
|
244
|
+
} from "./todo-panel.ts";
|
|
188
245
|
import {
|
|
189
246
|
defaultProfileConfig,
|
|
190
247
|
loadProfilesDetailed,
|
|
@@ -204,11 +261,17 @@ import {
|
|
|
204
261
|
PROFILE_ROLES,
|
|
205
262
|
modelPickerItems,
|
|
206
263
|
profileActionClosesPicker,
|
|
264
|
+
userProfilesPath,
|
|
265
|
+
projectProfilesPath,
|
|
207
266
|
type ProfileFlowOutcome,
|
|
208
267
|
type ProfileConfig,
|
|
209
268
|
type Profile,
|
|
210
269
|
type ProfileRoleName,
|
|
211
270
|
} from "./profiles.ts";
|
|
271
|
+
import {
|
|
272
|
+
formatRetargetResult,
|
|
273
|
+
retargetProviderFiles,
|
|
274
|
+
} from "./provider-retarget.ts";
|
|
212
275
|
import { FilterableList, type FilterableItem } from "./picker.ts";
|
|
213
276
|
import { QUESTIONNAIRE_OVERLAY_MAX_HEIGHT } from "./questionnaire-layout.ts";
|
|
214
277
|
import {
|
|
@@ -232,6 +295,7 @@ import {
|
|
|
232
295
|
} from "./model-default.ts";
|
|
233
296
|
import {
|
|
234
297
|
applySubmittedPlan,
|
|
298
|
+
buildReviewerFallbacks,
|
|
235
299
|
classifyReviewExecution,
|
|
236
300
|
createReviewHandoffGate,
|
|
237
301
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
@@ -247,12 +311,16 @@ import {
|
|
|
247
311
|
reviewModeLockKind,
|
|
248
312
|
reviewModeLockNotice,
|
|
249
313
|
reviewModeLockStatusLine,
|
|
314
|
+
reviewInputDecision,
|
|
315
|
+
reviewRoundOwnsTurn,
|
|
316
|
+
reviewTurnOwns,
|
|
250
317
|
reviewNoticeText,
|
|
251
318
|
reviewPauseIsCurrent,
|
|
252
319
|
reviewResumeGateOutcome,
|
|
253
320
|
reviewResumeNotice,
|
|
254
321
|
reviewResumePlanChoice,
|
|
255
322
|
reviewEscapeAction,
|
|
323
|
+
reviewElapsedMs,
|
|
256
324
|
reviewHandoffParks,
|
|
257
325
|
reviewResumeSettles,
|
|
258
326
|
reviewStateSummary,
|
|
@@ -261,12 +329,14 @@ import {
|
|
|
261
329
|
writeReviewModeConfig,
|
|
262
330
|
writeReviewPrompt,
|
|
263
331
|
type ReviewFinding,
|
|
332
|
+
type ReviewFallbackModel,
|
|
264
333
|
type ReviewExecutionResult,
|
|
265
334
|
type ReviewHandoffGate,
|
|
266
335
|
type ReviewSdkLoader,
|
|
267
336
|
type ReviewHandoffPhase,
|
|
268
337
|
type ReviewHandoffResult,
|
|
269
338
|
type ReviewModeLockKind,
|
|
339
|
+
type ReviewTurnPhase,
|
|
270
340
|
type ReviewState,
|
|
271
341
|
type ReviewTranscriptOp,
|
|
272
342
|
type ReviewTrigger,
|
|
@@ -282,12 +352,35 @@ import {
|
|
|
282
352
|
type ReviewLineBuffer,
|
|
283
353
|
type ReviewModeBandData,
|
|
284
354
|
} from "./review-ui.ts";
|
|
355
|
+
import {
|
|
356
|
+
pendingHandoffIsStale,
|
|
357
|
+
reviewHandoffRecovery,
|
|
358
|
+
shouldHandoffManualResult,
|
|
359
|
+
type ReviewHandoffSource,
|
|
360
|
+
} from "./review-handoff.ts";
|
|
285
361
|
import { seedPromptHistory, WorkflowTabEditor } from "./tab-editor.ts";
|
|
286
362
|
|
|
287
363
|
// ── Plannotator Bridge ───────────────────────────────────────────────
|
|
288
364
|
const PLANNOTATOR_REQUEST = "plannotator:request" as const;
|
|
289
365
|
const PLANNOTATOR_REVIEW_RESULT = "plannotator:review-result" as const;
|
|
290
366
|
const PLANNOTATOR_PLAN_APPROVED = "plannotator:plan-approved" as const;
|
|
367
|
+
const PLANNOTATOR_SESSION_OPENED = "plannotator:session-opened" as const;
|
|
368
|
+
const PLANNOTATOR_SESSION_CLOSED = "plannotator:session-closed" as const;
|
|
369
|
+
|
|
370
|
+
/** Structural shape of the optional Plannotator lifecycle events. */
|
|
371
|
+
interface PlannotatorSessionEventLike {
|
|
372
|
+
id?: string;
|
|
373
|
+
action?: string;
|
|
374
|
+
url?: string;
|
|
375
|
+
reviewId?: string;
|
|
376
|
+
requestId?: string;
|
|
377
|
+
outcome?: "approved" | "submitted" | "closed";
|
|
378
|
+
closedBy?: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
type PlanReviewRequestOutcome =
|
|
382
|
+
| { status: "handled"; reviewId?: string; url?: string }
|
|
383
|
+
| { status: "timeout" | "error" | "unavailable"; reason: string };
|
|
291
384
|
|
|
292
385
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
293
386
|
|
|
@@ -415,8 +508,17 @@ async function runSingleAgent(
|
|
|
415
508
|
shell: false,
|
|
416
509
|
stdio: ["ignore", "pipe", "pipe"],
|
|
417
510
|
// Carry the role into the child so its workflow extension gates bash
|
|
418
|
-
// with the same policy the parent applies (see permissions.ts)
|
|
419
|
-
|
|
511
|
+
// with the same policy the parent applies (see permissions.ts), and
|
|
512
|
+
// lock the folder roots so the child cannot expand via a sibling
|
|
513
|
+
// project's settings.
|
|
514
|
+
env: {
|
|
515
|
+
...subagentEnv(process.env, agent.role),
|
|
516
|
+
PI_FOLDER_POLICY_LOCKED: "1",
|
|
517
|
+
PI_FOLDER_POLICY_ROOTS: JSON.stringify([
|
|
518
|
+
detectRepoRoot(cwd) ?? cwd,
|
|
519
|
+
]),
|
|
520
|
+
PI_FOLDER_POLICY_SOURCE: "parent",
|
|
521
|
+
},
|
|
420
522
|
},
|
|
421
523
|
);
|
|
422
524
|
// Track for cleanup on session_shutdown — prevents orphaned subagents
|
|
@@ -499,6 +601,10 @@ function getFinalOutput(messages: any[]): string {
|
|
|
499
601
|
*/
|
|
500
602
|
export interface WorkflowExtensionDeps {
|
|
501
603
|
loadSdk?: ReviewSdkLoader;
|
|
604
|
+
/** Test seam: how long a plan-review request waits for Plannotator. */
|
|
605
|
+
plannotatorRequestTimeoutMs?: number;
|
|
606
|
+
/** Test seam: how long a reload recovery status query waits. */
|
|
607
|
+
plannotatorRecoveryTimeoutMs?: number;
|
|
502
608
|
}
|
|
503
609
|
|
|
504
610
|
export default function workflowExtension(
|
|
@@ -569,12 +675,33 @@ export default function workflowExtension(
|
|
|
569
675
|
null;
|
|
570
676
|
let writeGateTurnSteered = false;
|
|
571
677
|
const writeGateRunDenials = new Map<string, { instruction: string }>();
|
|
678
|
+
// ── Read-policy gate state (turn-scoped grants, run-scoped denials) ──
|
|
679
|
+
let readGateBatchCalls: { name: string; args: unknown }[] = [];
|
|
680
|
+
let readGateBatchCallIds = new Set<string>();
|
|
681
|
+
const readGateTurnGrantedRoots = new Set<string>();
|
|
682
|
+
const readGateTurnUncertainAllowed = new Set<string>();
|
|
683
|
+
let readGateTurnAnswer: {
|
|
684
|
+
choice: "allow-once" | "always" | "deny";
|
|
685
|
+
instruction: string;
|
|
686
|
+
} | null = null;
|
|
687
|
+
let readGateTurnSteered = false;
|
|
688
|
+
const readGateRunDenials = new Map<string, { instruction: string }>();
|
|
572
689
|
// ── Reminder throttle + staleness tracking (passive enforcement layer) ──
|
|
573
690
|
let turnsSinceLastTodoWrite = 0;
|
|
574
691
|
let turnsSinceLastReminder = 0;
|
|
575
692
|
let mutatedSinceLastTodoWrite = false;
|
|
576
693
|
let lastRefMisses: { refs: string[] } | undefined;
|
|
577
694
|
let todoStale = false;
|
|
695
|
+
// ── Todo panel presentation (pinned widget above the editor) ──
|
|
696
|
+
// The widget can never scroll with the transcript, so it collapses to one
|
|
697
|
+
// line when the plan settles and hides at the next user prompt. Overrides
|
|
698
|
+
// are scoped to `todoPanelOverrideKey` (ref/text/group identity), so status
|
|
699
|
+
// churn keeps the user's choice while a new/renamed list expires it.
|
|
700
|
+
let todoPanelOverride: TodoPanelOverride = null;
|
|
701
|
+
let todoPanelOverrideKey: string | null = null;
|
|
702
|
+
let todoPanelPromptAfterSettle = false;
|
|
703
|
+
let lastCompletedSignature: string | null = null;
|
|
704
|
+
let todoPanelAppliedState: TodoPanelState | "unregistered" = "unregistered";
|
|
578
705
|
let footerInjectedThisTurn = false;
|
|
579
706
|
let lastStaleNoticeAt: number | undefined;
|
|
580
707
|
// Path+content hash of the last plan-file reconciliation (skip no-op merges).
|
|
@@ -1151,83 +1278,81 @@ export default function workflowExtension(
|
|
|
1151
1278
|
todoStale = false;
|
|
1152
1279
|
}
|
|
1153
1280
|
|
|
1154
|
-
/** Display ref for a todo row (stable canonical GLOBAL step). */
|
|
1155
|
-
function todoLabel(it: TodoItem, index: number): string {
|
|
1156
|
-
return todoRef(it) || String(it.step || index + 1);
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
1281
|
/**
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1282
|
+
* Reconcile the pinned todo widget with the derived presentation state.
|
|
1283
|
+
* Called from `updateStatus` after the footer writes; only re-registers the
|
|
1284
|
+
* component when the state changes (marking a step done repaints through
|
|
1285
|
+
* the host, so the widget is not disposed/rebuilt on every refresh).
|
|
1162
1286
|
*/
|
|
1163
|
-
function
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const contentWidth = Math.max(1, width - 2);
|
|
1171
|
-
type Row = { group?: string; item?: TodoItem; index: number };
|
|
1172
|
-
const rows: Row[] = [];
|
|
1173
|
-
let lastGroup: string | undefined;
|
|
1174
|
-
todoItems.forEach((item, index) => {
|
|
1175
|
-
if (item.group && item.group !== lastGroup) {
|
|
1176
|
-
rows.push({ group: item.group, index });
|
|
1177
|
-
lastGroup = item.group;
|
|
1178
|
-
}
|
|
1179
|
-
rows.push({ item, index });
|
|
1287
|
+
function refreshTodoPanel(ctx: ExtensionContext) {
|
|
1288
|
+
const view = deriveTodoPanel({
|
|
1289
|
+
items: todoItems,
|
|
1290
|
+
override: todoPanelOverride,
|
|
1291
|
+
overrideKey: todoPanelOverrideKey,
|
|
1292
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
1293
|
+
stale: todoStale,
|
|
1180
1294
|
});
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
if (
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
const isCurrent = row.index === anchorIndex && !isDone(item);
|
|
1201
|
-
const prefix =
|
|
1202
|
-
isCurrent || status === "in_progress"
|
|
1203
|
-
? theme.fg("accent", "▶ ")
|
|
1204
|
-
: status === "completed"
|
|
1205
|
-
? theme.fg("success", "☑ ")
|
|
1206
|
-
: status === "cancelled"
|
|
1207
|
-
? theme.fg("dim", "✖ ")
|
|
1208
|
-
: theme.fg("dim", "☐ ");
|
|
1209
|
-
const labelPart =
|
|
1210
|
-
item.label !== undefined && String(item.label) !== ref
|
|
1211
|
-
? theme.fg("muted", ` (${item.label})`)
|
|
1212
|
-
: "";
|
|
1213
|
-
const body = `${ref}. ${item.text}${labelPart}`;
|
|
1214
|
-
let styled: string;
|
|
1215
|
-
if (isDone(item)) {
|
|
1216
|
-
styled = theme.fg("muted", theme.strikethrough(body));
|
|
1217
|
-
} else if (isCurrent && mode === "build") {
|
|
1218
|
-
styled = theme.fg("accent", body);
|
|
1219
|
-
} else if (mode === "plan" || mode === null) {
|
|
1220
|
-
styled = theme.fg("muted", body);
|
|
1221
|
-
} else {
|
|
1222
|
-
styled = body;
|
|
1295
|
+
// A choice made for a different list must not hide a new task.
|
|
1296
|
+
if (todoPanelOverrideKey !== view.listKey) {
|
|
1297
|
+
todoPanelOverride = null;
|
|
1298
|
+
todoPanelOverrideKey = null;
|
|
1299
|
+
}
|
|
1300
|
+
if (!view.settled && todoPanelPromptAfterSettle) {
|
|
1301
|
+
todoPanelPromptAfterSettle = false;
|
|
1302
|
+
}
|
|
1303
|
+
// Reopened list: a later completion may emit its own card again.
|
|
1304
|
+
if (!view.settled && lastCompletedSignature !== null) {
|
|
1305
|
+
lastCompletedSignature = null;
|
|
1306
|
+
}
|
|
1307
|
+
if (view.settled && todoStale) todoStale = false;
|
|
1308
|
+
if (view.state === todoPanelAppliedState) return;
|
|
1309
|
+
todoPanelAppliedState = view.state;
|
|
1310
|
+
try {
|
|
1311
|
+
if (view.state === "hidden") {
|
|
1312
|
+
ctx.ui.setWidget("workflow-todos", undefined);
|
|
1313
|
+
return;
|
|
1223
1314
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1315
|
+
ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
|
|
1316
|
+
const render = (w: number) => {
|
|
1317
|
+
// Re-derive so the component always shows live items and the live
|
|
1318
|
+
// mode, even though it was registered for an earlier state.
|
|
1319
|
+
const live = deriveTodoPanel({
|
|
1320
|
+
items: todoItems,
|
|
1321
|
+
override: todoPanelOverride,
|
|
1322
|
+
overrideKey: todoPanelOverrideKey,
|
|
1323
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
1324
|
+
stale: todoStale,
|
|
1325
|
+
});
|
|
1326
|
+
if (live.state === "hidden") return [];
|
|
1327
|
+
return live.state === "collapsed"
|
|
1328
|
+
? [renderTodoSummary(theme, w, live)]
|
|
1329
|
+
: buildTodoRows(theme, w, todoItems, workflowMode);
|
|
1330
|
+
};
|
|
1331
|
+
return { render, invalidate: () => {}, dispose: () => {} };
|
|
1332
|
+
});
|
|
1333
|
+
} catch (_e) {
|
|
1334
|
+
void _e;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function setTodoPanelOverride(
|
|
1339
|
+
ctx: ExtensionContext,
|
|
1340
|
+
override: TodoPanelOverride,
|
|
1341
|
+
note?: string,
|
|
1342
|
+
) {
|
|
1343
|
+
if (!todoItems.length && override === "expanded") {
|
|
1344
|
+
ctx.ui.notify("No todos.", "info");
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
todoPanelOverride = override;
|
|
1348
|
+
todoPanelOverrideKey = override === null ? null : todoListKey(todoItems);
|
|
1349
|
+
updateStatus(ctx);
|
|
1350
|
+
try {
|
|
1351
|
+
persistState();
|
|
1352
|
+
} catch (_e) {
|
|
1353
|
+
void _e;
|
|
1229
1354
|
}
|
|
1230
|
-
|
|
1355
|
+
if (note) ctx.ui.notify(note, "info");
|
|
1231
1356
|
}
|
|
1232
1357
|
|
|
1233
1358
|
function updateStatus(ctx: ExtensionContext) {
|
|
@@ -1242,6 +1367,7 @@ export default function workflowExtension(
|
|
|
1242
1367
|
commitMode = readCommitPolicyConfig(
|
|
1243
1368
|
(ctx as any).cwd ?? process.cwd(),
|
|
1244
1369
|
getAgentDir(),
|
|
1370
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1245
1371
|
).config.mode;
|
|
1246
1372
|
} catch {
|
|
1247
1373
|
commitMode = "never";
|
|
@@ -1255,27 +1381,41 @@ export default function workflowExtension(
|
|
|
1255
1381
|
writeMode = readWritePolicyConfig(
|
|
1256
1382
|
(ctx as any).cwd ?? process.cwd(),
|
|
1257
1383
|
getAgentDir(),
|
|
1384
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1258
1385
|
).config.mode;
|
|
1259
1386
|
} catch {
|
|
1260
1387
|
writeMode = "allow";
|
|
1261
1388
|
}
|
|
1389
|
+
let folderMode: string | null = null;
|
|
1390
|
+
try {
|
|
1391
|
+
folderMode = readFolderPolicyConfig(
|
|
1392
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
1393
|
+
getAgentDir(),
|
|
1394
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1395
|
+
).config.mode;
|
|
1396
|
+
} catch {
|
|
1397
|
+
folderMode = null;
|
|
1398
|
+
}
|
|
1262
1399
|
const segments = buildWorkflowStatusSegments({
|
|
1263
1400
|
mode: workflowMode,
|
|
1264
|
-
|
|
1401
|
+
reviewPhase: reviewTurn,
|
|
1265
1402
|
done: todoItems.filter((t) => isDone(t)).length,
|
|
1266
1403
|
total: todoItems.length,
|
|
1267
|
-
stale: todoStale,
|
|
1404
|
+
stale: todoStale && !isTodoListSettled(todoItems),
|
|
1268
1405
|
profileLabel,
|
|
1269
1406
|
roleLabel,
|
|
1270
1407
|
commitMode,
|
|
1271
1408
|
writeMode: workflowMode === null ? writeMode : null,
|
|
1409
|
+
folderMode,
|
|
1272
1410
|
});
|
|
1273
1411
|
const modeTone =
|
|
1274
|
-
|
|
1412
|
+
reviewTurn !== "idle"
|
|
1275
1413
|
? "accent"
|
|
1276
|
-
: workflowMode === "
|
|
1277
|
-
? "
|
|
1278
|
-
: "
|
|
1414
|
+
: workflowMode === "build"
|
|
1415
|
+
? "accent"
|
|
1416
|
+
: workflowMode === "plan"
|
|
1417
|
+
? "warning"
|
|
1418
|
+
: "muted";
|
|
1279
1419
|
setWorkflowStatus(
|
|
1280
1420
|
ctx.ui,
|
|
1281
1421
|
WORKFLOW_STATUS_KEYS.mode,
|
|
@@ -1292,15 +1432,10 @@ export default function workflowExtension(
|
|
|
1292
1432
|
ctx.ui.theme.fg("dim", segments.policy),
|
|
1293
1433
|
);
|
|
1294
1434
|
if (todoItems.length > 0) {
|
|
1295
|
-
ctx
|
|
1296
|
-
return {
|
|
1297
|
-
render: (w: number) => buildTodoRows(theme, w, workflowMode),
|
|
1298
|
-
invalidate: () => {},
|
|
1299
|
-
dispose: () => {},
|
|
1300
|
-
};
|
|
1301
|
-
});
|
|
1435
|
+
refreshTodoPanel(ctx);
|
|
1302
1436
|
} else {
|
|
1303
1437
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
1438
|
+
todoPanelAppliedState = "hidden";
|
|
1304
1439
|
}
|
|
1305
1440
|
// One status owner: every footer refresh (mode switch, model change, round
|
|
1306
1441
|
// phase, pause) also re-evaluates the review widget. This is what clears a
|
|
@@ -1377,6 +1512,15 @@ export default function workflowExtension(
|
|
|
1377
1512
|
let activeReview:
|
|
1378
1513
|
| { planText: string; round: number; controller: AbortController }
|
|
1379
1514
|
| undefined;
|
|
1515
|
+
/**
|
|
1516
|
+
* The exclusive Review turn (plan 2026-11-14). Unlike the old derived lock,
|
|
1517
|
+
* this also covers the browser/inline handoff, where no child session runs.
|
|
1518
|
+
* In-memory: a reload restores `handoff` from `awaitingDecision`, otherwise
|
|
1519
|
+
* idle (a running round cannot survive the process).
|
|
1520
|
+
*/
|
|
1521
|
+
let reviewTurn: ReviewTurnPhase = "idle";
|
|
1522
|
+
/** The next plan turn is the feedback/refine continuation of a Deny. */
|
|
1523
|
+
let reviewFeedbackTurn = false;
|
|
1380
1524
|
/** Long-lived ESC listener covering a round and the paused window. */
|
|
1381
1525
|
let detachReviewEscListener: (() => void) | undefined;
|
|
1382
1526
|
/**
|
|
@@ -1428,9 +1572,47 @@ export default function workflowExtension(
|
|
|
1428
1572
|
roundActive,
|
|
1429
1573
|
paused,
|
|
1430
1574
|
gatePhase,
|
|
1575
|
+
handoff: reviewTurn === "handoff" || awaitingDecision,
|
|
1576
|
+
transition: reviewTurn === "transition",
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** True while a reviewer round or a real pause owns the turn (not handoff). */
|
|
1581
|
+
function reviewRoundInFlight(): boolean {
|
|
1582
|
+
let roundActive = false;
|
|
1583
|
+
try {
|
|
1584
|
+
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
1585
|
+
} catch (_e) {
|
|
1586
|
+
void _e;
|
|
1587
|
+
}
|
|
1588
|
+
return reviewRoundOwnsTurn({
|
|
1589
|
+
roundInFlight: activeReview !== undefined,
|
|
1590
|
+
roundActive,
|
|
1591
|
+
paused: currentPausedReview() !== undefined,
|
|
1592
|
+
gatePhase:
|
|
1593
|
+
reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle",
|
|
1431
1594
|
});
|
|
1432
1595
|
}
|
|
1433
1596
|
|
|
1597
|
+
/** One status refresh for every review-turn transition. */
|
|
1598
|
+
function setReviewTurn(phase: ReviewTurnPhase): void {
|
|
1599
|
+
if (reviewTurn === phase) return;
|
|
1600
|
+
reviewTurn = phase;
|
|
1601
|
+
try {
|
|
1602
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
1603
|
+
} catch (_e) {
|
|
1604
|
+
void _e;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function beginReviewTurn(): void {
|
|
1609
|
+
setReviewTurn("running");
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function endReviewTurn(): void {
|
|
1613
|
+
setReviewTurn("idle");
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1434
1616
|
/** Refuse a mode transition while a review round or pause owns the turn. */
|
|
1435
1617
|
function blockModeSwitchDuringReview(
|
|
1436
1618
|
ctx?: ExtensionContext,
|
|
@@ -1517,6 +1699,48 @@ export default function workflowExtension(
|
|
|
1517
1699
|
}
|
|
1518
1700
|
}
|
|
1519
1701
|
|
|
1702
|
+
/**
|
|
1703
|
+
* Automatic provider-recovery chain: the parent model that just planned,
|
|
1704
|
+
* then the base reviewer and planner roles from roles.json (ignoring the
|
|
1705
|
+
* active profile). Deduped and thinking-clamped through the parent registry.
|
|
1706
|
+
*/
|
|
1707
|
+
function reviewFallbackModels(): ReviewFallbackModel[] {
|
|
1708
|
+
try {
|
|
1709
|
+
ensureRoleConfig(reviewCwd());
|
|
1710
|
+
ensureProfilesConfig(reviewCwd());
|
|
1711
|
+
const parent = currentSessionCtx?.model;
|
|
1712
|
+
const parentThinking = currentSessionCtx?.thinkingLevel;
|
|
1713
|
+
const baseReviewer = getRole(roleConfig, REVIEW_ROLE_NAME)?.model;
|
|
1714
|
+
const basePlanner = getRole(roleConfig, "planner")?.model;
|
|
1715
|
+
return buildReviewerFallbacks({
|
|
1716
|
+
...(parent
|
|
1717
|
+
? {
|
|
1718
|
+
parentModel: {
|
|
1719
|
+
provider: parent.provider,
|
|
1720
|
+
id: parent.id,
|
|
1721
|
+
thinking: parentThinking ?? "high",
|
|
1722
|
+
},
|
|
1723
|
+
}
|
|
1724
|
+
: {}),
|
|
1725
|
+
...(baseReviewer ? { baseReviewer } : {}),
|
|
1726
|
+
...(basePlanner ? { basePlanner } : {}),
|
|
1727
|
+
clampThinking: (model) => {
|
|
1728
|
+
const found = (currentSessionCtx as any)?.modelRegistry?.find?.(
|
|
1729
|
+
model.provider,
|
|
1730
|
+
model.id,
|
|
1731
|
+
);
|
|
1732
|
+
return clampThinkingForModel(
|
|
1733
|
+
found,
|
|
1734
|
+
isValidThinkingLevel(model.thinking) ? model.thinking : "high",
|
|
1735
|
+
);
|
|
1736
|
+
},
|
|
1737
|
+
});
|
|
1738
|
+
} catch (_e) {
|
|
1739
|
+
void _e;
|
|
1740
|
+
return [];
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1520
1744
|
/** Reviewer model label for the mode band (best-effort). */
|
|
1521
1745
|
function reviewModelLabel(): string {
|
|
1522
1746
|
// While a round runs, the band names the snapshotted model — the one
|
|
@@ -1927,6 +2151,7 @@ export default function workflowExtension(
|
|
|
1927
2151
|
if (reviewModelSnapshot) return reviewModelSnapshot;
|
|
1928
2152
|
return resolveReviewerModel();
|
|
1929
2153
|
},
|
|
2154
|
+
fallbackReviewerModels: () => reviewFallbackModels(),
|
|
1930
2155
|
findModel: (provider: string, id: string) =>
|
|
1931
2156
|
(currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
|
|
1932
2157
|
// Structural probe of the live parent context, evaluated at reviewer
|
|
@@ -2043,6 +2268,7 @@ export default function workflowExtension(
|
|
|
2043
2268
|
// A gated audit becomes a paused gate (the handoff keeps waiting). A
|
|
2044
2269
|
// manual review (idle gate) leaves the phase alone.
|
|
2045
2270
|
if (reviewGatePhase === "auditing") reviewGatePhase = "paused";
|
|
2271
|
+
setReviewTurn("paused");
|
|
2046
2272
|
refreshReviewPauseUi();
|
|
2047
2273
|
return true;
|
|
2048
2274
|
}
|
|
@@ -2180,6 +2406,9 @@ export default function workflowExtension(
|
|
|
2180
2406
|
try {
|
|
2181
2407
|
const ctx = currentSessionCtx;
|
|
2182
2408
|
if (!ctx || !(ctx as any).hasUI) return;
|
|
2409
|
+
if (message !== undefined) {
|
|
2410
|
+
(ctx as any).ui?.setWorkingVisible?.(true);
|
|
2411
|
+
}
|
|
2183
2412
|
(ctx as any).ui?.setWorkingMessage?.(message);
|
|
2184
2413
|
} catch (_e) {
|
|
2185
2414
|
void _e;
|
|
@@ -2251,6 +2480,10 @@ export default function workflowExtension(
|
|
|
2251
2480
|
// follow `reviewState`; the spinner row needs an explicit message while
|
|
2252
2481
|
// the child session streams off the main turn.
|
|
2253
2482
|
setReviewWorkingMessage("🔍 Reviewing plan…");
|
|
2483
|
+
setReviewTurn("running");
|
|
2484
|
+
// Paint the live review line now, not after the child session's first
|
|
2485
|
+
// state event: a manual `/review` must look alive from the first tick.
|
|
2486
|
+
updateStatus(ctx as any);
|
|
2254
2487
|
// One long-lived listener covers this round AND the paused window (see
|
|
2255
2488
|
// ensureReviewEscListener for why it is no longer attached per round).
|
|
2256
2489
|
ensureReviewEscListener(ctx);
|
|
@@ -2466,6 +2699,17 @@ export default function workflowExtension(
|
|
|
2466
2699
|
}
|
|
2467
2700
|
}
|
|
2468
2701
|
|
|
2702
|
+
// A new round can never inherit an older browser gate: the old tab's
|
|
2703
|
+
// decision would otherwise suppress this round's handoff after it lands.
|
|
2704
|
+
if (awaitingDecision) {
|
|
2705
|
+
supersedePendingHandoff(
|
|
2706
|
+
trigger === "manual"
|
|
2707
|
+
? "a manual /review started"
|
|
2708
|
+
: "a new review round started",
|
|
2709
|
+
{ force: true, nextPlanHash: hash, nextCycleId: reviewCycleId },
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2469
2713
|
// A different plan file is a new target even inside one user run: reset the
|
|
2470
2714
|
// budget/hashes/findings before the dedupe gates below.
|
|
2471
2715
|
const cycleChanged = notePlanTargetForReviewCycle(currentPlanFile);
|
|
@@ -2634,6 +2878,16 @@ export default function workflowExtension(
|
|
|
2634
2878
|
const hadGate = reviewGatePhase !== "idle";
|
|
2635
2879
|
// Synchronously first: any old handoff continuation is stale from here on.
|
|
2636
2880
|
reviewCycleId += 1;
|
|
2881
|
+
if (
|
|
2882
|
+
opts.reason === "prompt" ||
|
|
2883
|
+
opts.reason === "build" ||
|
|
2884
|
+
opts.reason === "session"
|
|
2885
|
+
) {
|
|
2886
|
+
supersedePendingHandoff(`a new ${opts.reason} cycle started`, {
|
|
2887
|
+
force: true,
|
|
2888
|
+
});
|
|
2889
|
+
manualReviewCycle = false;
|
|
2890
|
+
}
|
|
2637
2891
|
if (hadActive || pausedReview !== undefined || hadGate) {
|
|
2638
2892
|
try {
|
|
2639
2893
|
await suspendReview();
|
|
@@ -2736,7 +2990,9 @@ export default function workflowExtension(
|
|
|
2736
2990
|
* they are not guarded. Clearing the review state releases the derived
|
|
2737
2991
|
* mode-switch lock (plan 2026-10-25).
|
|
2738
2992
|
*/
|
|
2739
|
-
async function teardownReview(
|
|
2993
|
+
async function teardownReview(
|
|
2994
|
+
opts: { keepHandoff?: boolean } = {},
|
|
2995
|
+
): Promise<void> {
|
|
2740
2996
|
try {
|
|
2741
2997
|
activeReview?.controller.abort();
|
|
2742
2998
|
} catch (_e) {
|
|
@@ -2749,6 +3005,7 @@ export default function workflowExtension(
|
|
|
2749
3005
|
}
|
|
2750
3006
|
reviewRuntime = undefined;
|
|
2751
3007
|
reviewState = undefined;
|
|
3008
|
+
if (opts.keepHandoff !== true) manualReviewCycle = false;
|
|
2752
3009
|
lastReviewedPlanHash = undefined;
|
|
2753
3010
|
reviewRound = 0;
|
|
2754
3011
|
reviewPreviousFindings = undefined;
|
|
@@ -2763,11 +3020,12 @@ export default function workflowExtension(
|
|
|
2763
3020
|
activeReview = undefined;
|
|
2764
3021
|
abortedReviewHash = undefined;
|
|
2765
3022
|
quietAbortHash = undefined;
|
|
2766
|
-
// A
|
|
2767
|
-
//
|
|
2768
|
-
const clearedDecision =
|
|
3023
|
+
// A reload/resume keeps the browser correlation so the next session_start
|
|
3024
|
+
// can recover it; every other teardown ends the decision and provenance.
|
|
3025
|
+
const clearedDecision =
|
|
3026
|
+
opts.keepHandoff === true ? false : clearPendingHandoffDecision();
|
|
2769
3027
|
resetPlanRunProvenance();
|
|
2770
|
-
if (clearedDecision) {
|
|
3028
|
+
if (clearedDecision || opts.keepHandoff === true) {
|
|
2771
3029
|
try {
|
|
2772
3030
|
persistState();
|
|
2773
3031
|
} catch (_e) {
|
|
@@ -2778,6 +3036,7 @@ export default function workflowExtension(
|
|
|
2778
3036
|
flushReviewEntries(true);
|
|
2779
3037
|
resetReviewStream();
|
|
2780
3038
|
detachReviewEscListenerNow();
|
|
3039
|
+
endReviewTurn();
|
|
2781
3040
|
try {
|
|
2782
3041
|
currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
2783
3042
|
} catch (_e) {
|
|
@@ -2903,6 +3162,15 @@ export default function workflowExtension(
|
|
|
2903
3162
|
|
|
2904
3163
|
const scope = scopeFor("global");
|
|
2905
3164
|
const enable = sub === "on";
|
|
3165
|
+
// Disabling would tear down the review (and its pending handoff) behind
|
|
3166
|
+
// the lock. Abort is the explicit exit while Review owns the turn.
|
|
3167
|
+
if (!enable && reviewTurnOwns(reviewTurn)) {
|
|
3168
|
+
ctx.ui.notify(
|
|
3169
|
+
"Review is active — /review-abort first (or resume/approve in the review tab).",
|
|
3170
|
+
"info",
|
|
3171
|
+
);
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
2906
3174
|
|
|
2907
3175
|
const res = writeReviewModeConfig(scope, cwd, agentDir, {
|
|
2908
3176
|
enabled: enable,
|
|
@@ -2954,7 +3222,9 @@ export default function workflowExtension(
|
|
|
2954
3222
|
: "global";
|
|
2955
3223
|
|
|
2956
3224
|
if (!sub || sub === "status") {
|
|
2957
|
-
const resolved = readCommitPolicyConfig(cwd, agentDir
|
|
3225
|
+
const resolved = readCommitPolicyConfig(cwd, agentDir, {
|
|
3226
|
+
trusted: (ctx as any).isProjectTrusted?.() === true,
|
|
3227
|
+
});
|
|
2958
3228
|
const paths = commitConfigPaths(cwd, agentDir);
|
|
2959
3229
|
const where =
|
|
2960
3230
|
resolved.sources.mode === "default"
|
|
@@ -3013,8 +3283,9 @@ export default function workflowExtension(
|
|
|
3013
3283
|
});
|
|
3014
3284
|
|
|
3015
3285
|
// ── /write-policy: view/set the write permission (workflow.writePolicy) ──
|
|
3016
|
-
// Available in every mode: it only writes settings JSON
|
|
3017
|
-
//
|
|
3286
|
+
// Available in every mode: it only writes settings JSON. The write *mode*
|
|
3287
|
+
// applies in Default mode only; the shared folder floor applies in every
|
|
3288
|
+
// mode, Build included.
|
|
3018
3289
|
pi.registerCommand("write-policy", {
|
|
3019
3290
|
description:
|
|
3020
3291
|
"View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
|
|
@@ -3032,7 +3303,9 @@ export default function workflowExtension(
|
|
|
3032
3303
|
: "global";
|
|
3033
3304
|
|
|
3034
3305
|
if (!sub || sub === "status") {
|
|
3035
|
-
const resolved = readWritePolicyConfig(cwd, agentDir
|
|
3306
|
+
const resolved = readWritePolicyConfig(cwd, agentDir, {
|
|
3307
|
+
trusted: (ctx as any).isProjectTrusted?.() === true,
|
|
3308
|
+
});
|
|
3036
3309
|
const paths = writePolicyConfigPaths(cwd, agentDir);
|
|
3037
3310
|
const where =
|
|
3038
3311
|
resolved.sources.mode === "default"
|
|
@@ -3041,7 +3314,8 @@ export default function workflowExtension(
|
|
|
3041
3314
|
const lines = [
|
|
3042
3315
|
`Write policy: ${resolved.config.mode} (${where})`,
|
|
3043
3316
|
` ${describeWritePolicyMode(resolved.config.mode)}`,
|
|
3044
|
-
"
|
|
3317
|
+
" The write mode applies to Default mode only; the shared folder floor applies in every mode.",
|
|
3318
|
+
" Plan mode is read-only already; /build is the execution escape hatch.",
|
|
3045
3319
|
];
|
|
3046
3320
|
if (resolved.config.allowedPaths.length > 0) {
|
|
3047
3321
|
lines.push(
|
|
@@ -3115,6 +3389,133 @@ export default function workflowExtension(
|
|
|
3115
3389
|
// gate: it reads the same analyzeCommand/explainCommandRefusal the gates
|
|
3116
3390
|
// use, so the explanation cannot describe a policy the gate no longer
|
|
3117
3391
|
// enforces. Read-only, so it is safe in every mode.
|
|
3392
|
+
pi.registerCommand("folder-policy", {
|
|
3393
|
+
description: "Shared folder scope for read/write/commit: repo|cwd|parent|any, outside ask|block, sensitive list",
|
|
3394
|
+
handler: async (args, ctx) => {
|
|
3395
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3396
|
+
const agentDir = getAgentDir();
|
|
3397
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3398
|
+
const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
3399
|
+
const sub = (tokens[0] || "status").toLowerCase();
|
|
3400
|
+
const second = (tokens[1] || "").toLowerCase();
|
|
3401
|
+
const scope: "global" | "project" = tokens.includes("project")
|
|
3402
|
+
? "project"
|
|
3403
|
+
: "global";
|
|
3404
|
+
const resolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3405
|
+
if (sub === "status") {
|
|
3406
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
3407
|
+
const lines = [
|
|
3408
|
+
`Folder policy: ${resolved.config.mode} (${resolved.sources.mode === "default" ? "default" : resolved.sources.mode + " settings"})`,
|
|
3409
|
+
` outside: ${resolved.config.outside}; grantScope: ${resolved.config.grantScope}; templates: ${resolved.config.sensitiveTemplates}`,
|
|
3410
|
+
` base: ${detectRepoRoot(cwd) ?? cwd}`,
|
|
3411
|
+
" unverifiable writes: not prompted (use /write-policy ask for strictness)",
|
|
3412
|
+
` sensitive: ${DEFAULT_SENSITIVE_PATTERNS.length} default, ${resolved.config.sensitivePatterns.length} extra, ${resolved.config.sensitiveExemptions.length} exemption(s)`,
|
|
3413
|
+
`Global: ${paths.global}`,
|
|
3414
|
+
`Project: ${paths.project}${resolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
|
|
3415
|
+
"Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive",
|
|
3416
|
+
];
|
|
3417
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
if (sub === "sensitive") {
|
|
3421
|
+
ctx.ui.notify(
|
|
3422
|
+
[
|
|
3423
|
+
"Sensitive deny list (always blocked for read/write/commit):",
|
|
3424
|
+
...DEFAULT_SENSITIVE_PATTERNS.map((p) => ` ${p}`),
|
|
3425
|
+
...(resolved.config.sensitivePatterns.length
|
|
3426
|
+
? ["Extra:", ...resolved.config.sensitivePatterns.map((p) => ` ${p}`)]
|
|
3427
|
+
: []),
|
|
3428
|
+
...(resolved.config.sensitiveExemptions.length
|
|
3429
|
+
? ["Exemptions (manual):", ...resolved.config.sensitiveExemptions.map((p) => ` ${p}`)]
|
|
3430
|
+
: []),
|
|
3431
|
+
].join("\n"),
|
|
3432
|
+
"info",
|
|
3433
|
+
);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
let patch: Record<string, unknown> | undefined;
|
|
3437
|
+
if (["repo", "cwd", "parent", "any"].includes(sub)) patch = { mode: sub };
|
|
3438
|
+
else if (sub === "outside" && ["ask", "block"].includes(second)) patch = { outside: second };
|
|
3439
|
+
else if (sub === "grant" && ["project", "global"].includes(second)) patch = { grantScope: second };
|
|
3440
|
+
else if (sub === "templates" && ["allow", "block"].includes(second)) patch = { sensitiveTemplates: second };
|
|
3441
|
+
if (!patch) {
|
|
3442
|
+
ctx.ui.notify("Usage: /folder-policy repo|cwd|parent|any · outside ask|block · grant project|global · templates allow|block · sensitive", "info");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
const written = writeFolderPolicyConfig(scope, cwd, agentDir, patch as any);
|
|
3446
|
+
ctx.ui.notify(
|
|
3447
|
+
written.ok
|
|
3448
|
+
? `Folder policy updated (${scope}): ${written.path}`
|
|
3449
|
+
: `Could not write folder policy: ${written.error ?? "unknown error"}`,
|
|
3450
|
+
written.ok ? "info" : "error",
|
|
3451
|
+
);
|
|
3452
|
+
updateStatus(ctx as any);
|
|
3453
|
+
},
|
|
3454
|
+
getArgumentCompletions: (prefix: string) => folderPolicyArgumentCompletions(prefix),
|
|
3455
|
+
});
|
|
3456
|
+
|
|
3457
|
+
pi.registerCommand("read-policy", {
|
|
3458
|
+
description: "Read permission: grants, shell paths|strict, guarded tools and the shared folder scope",
|
|
3459
|
+
handler: async (args, ctx) => {
|
|
3460
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3461
|
+
const agentDir = getAgentDir();
|
|
3462
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3463
|
+
const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
3464
|
+
const sub = (tokens[0] || "status").toLowerCase();
|
|
3465
|
+
const readResolved = readReadPolicyConfig(cwd, agentDir, { trusted });
|
|
3466
|
+
const folderResolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3467
|
+
const paths = readPolicyConfigPaths(cwd, agentDir);
|
|
3468
|
+
if (sub === "status" || !sub) {
|
|
3469
|
+
ctx.ui.notify(
|
|
3470
|
+
[
|
|
3471
|
+
describeReadPolicy(readResolved.config, folderResolved.config),
|
|
3472
|
+
` grants: ${readResolved.config.allowedPaths.join(", ") || "none"}`,
|
|
3473
|
+
` guarded tools: ${readResolved.config.guardedTools.join(", ") || "none"}`,
|
|
3474
|
+
`Global: ${paths.global}`,
|
|
3475
|
+
`Project: ${paths.project}${readResolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
|
|
3476
|
+
"Usage: /read-policy allow <path> [global|project] · revoke <path> · shell paths|strict · status",
|
|
3477
|
+
].join("\n"),
|
|
3478
|
+
"info",
|
|
3479
|
+
);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
const scope: "global" | "project" = tokens.includes("project")
|
|
3483
|
+
? "project"
|
|
3484
|
+
: "global";
|
|
3485
|
+
if (sub === "shell") {
|
|
3486
|
+
const value = (tokens[1] || "").toLowerCase();
|
|
3487
|
+
if (value !== "paths" && value !== "strict") {
|
|
3488
|
+
ctx.ui.notify("Usage: /read-policy shell paths|strict [global|project]", "info");
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
const written = writeReadPolicyConfig(scope, cwd, agentDir, { shell: value });
|
|
3492
|
+
ctx.ui.notify(written.ok ? `Read shell → ${value} (${scope})` : `Could not save: ${written.error}`, written.ok ? "info" : "error");
|
|
3493
|
+
updateStatus(ctx as any);
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
if (sub === "allow" || sub === "revoke") {
|
|
3497
|
+
const target = tokens.slice(1).find((t) => t !== "global" && t !== "project");
|
|
3498
|
+
if (!target) {
|
|
3499
|
+
ctx.ui.notify(`Usage: /read-policy ${sub} <path> [global|project]`, "info");
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const result =
|
|
3503
|
+
sub === "allow"
|
|
3504
|
+
? writeReadPolicyConfig(scope, cwd, agentDir, { allowedPaths: [target] })
|
|
3505
|
+
: revokeReadPolicyPath(scope, cwd, agentDir, target);
|
|
3506
|
+
ctx.ui.notify(
|
|
3507
|
+
result.ok
|
|
3508
|
+
? `Read grant ${sub === "allow" ? "added" : "removed"} (${scope}): ${target}`
|
|
3509
|
+
: `Could not save read grant: ${result.error}`,
|
|
3510
|
+
result.ok ? "info" : "error",
|
|
3511
|
+
);
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
ctx.ui.notify("Usage: /read-policy status|allow <path>|revoke <path>|shell paths|strict", "info");
|
|
3515
|
+
},
|
|
3516
|
+
getArgumentCompletions: (prefix: string) => readPolicyArgumentCompletions(prefix),
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3118
3519
|
pi.registerCommand("permissions", {
|
|
3119
3520
|
description:
|
|
3120
3521
|
"Explain why a shell command would be allowed or refused: /permissions explain <command>",
|
|
@@ -3123,7 +3524,55 @@ export default function workflowExtension(
|
|
|
3123
3524
|
const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
3124
3525
|
// `/permissions explain <cmd>` and `/permissions <cmd>` both work.
|
|
3125
3526
|
const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
|
|
3126
|
-
|
|
3527
|
+
const lines = describeCommandPolicy(command);
|
|
3528
|
+
if (command.trim()) {
|
|
3529
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3530
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3531
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
3532
|
+
trusted,
|
|
3533
|
+
}).config;
|
|
3534
|
+
const writeConfig = readWritePolicyConfig(cwd, getAgentDir(), {
|
|
3535
|
+
trusted,
|
|
3536
|
+
}).config;
|
|
3537
|
+
const readConfig = readReadPolicyConfig(cwd, getAgentDir(), {
|
|
3538
|
+
trusted,
|
|
3539
|
+
}).config;
|
|
3540
|
+
const analysis = analyzeShellPaths(command, cwd);
|
|
3541
|
+
const classification = classifyWriteCall(
|
|
3542
|
+
"bash",
|
|
3543
|
+
{ command },
|
|
3544
|
+
writeConfig,
|
|
3545
|
+
);
|
|
3546
|
+
const writeTargets = writeFolderTargetsFor(
|
|
3547
|
+
"bash",
|
|
3548
|
+
{ command },
|
|
3549
|
+
classification,
|
|
3550
|
+
cwd,
|
|
3551
|
+
);
|
|
3552
|
+
const evaluation = evaluateFolderTargets(
|
|
3553
|
+
[...analysis.reads, ...writeTargets],
|
|
3554
|
+
{
|
|
3555
|
+
config: folderConfig,
|
|
3556
|
+
grants: [...writeConfig.allowedPaths, ...readConfig.allowedPaths],
|
|
3557
|
+
cwd,
|
|
3558
|
+
action: "write",
|
|
3559
|
+
},
|
|
3560
|
+
);
|
|
3561
|
+
lines.push(
|
|
3562
|
+
"",
|
|
3563
|
+
...describeFolderEvaluation(
|
|
3564
|
+
evaluation,
|
|
3565
|
+
detectRepoRoot(cwd) ?? cwd,
|
|
3566
|
+
),
|
|
3567
|
+
);
|
|
3568
|
+
if (analysis.uncertain.length > 0) {
|
|
3569
|
+
lines.push(` shell analysis: ${analysis.uncertain.join("; ")}`);
|
|
3570
|
+
}
|
|
3571
|
+
lines.push(
|
|
3572
|
+
" unverifiable writes are not prompted under writePolicy allow/Build; use /write-policy ask for strictness",
|
|
3573
|
+
);
|
|
3574
|
+
}
|
|
3575
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3127
3576
|
},
|
|
3128
3577
|
getArgumentCompletions: (prefix: string) =>
|
|
3129
3578
|
permissionsArgumentCompletions(prefix),
|
|
@@ -3179,6 +3628,7 @@ export default function workflowExtension(
|
|
|
3179
3628
|
[
|
|
3180
3629
|
`Review paused at round ${paused.round} (plan hash ${paused.planHash})`,
|
|
3181
3630
|
" /review-resume to continue, /review-abort to stop.",
|
|
3631
|
+
pendingHandoffStatusLine(),
|
|
3182
3632
|
reviewModeLockStatusLine(lockKind),
|
|
3183
3633
|
]
|
|
3184
3634
|
.filter(Boolean)
|
|
@@ -3199,6 +3649,7 @@ export default function workflowExtension(
|
|
|
3199
3649
|
reviewEnabled()
|
|
3200
3650
|
? "Review Mode is enabled but no review has run yet."
|
|
3201
3651
|
: `Review Mode is OFF — automatic review disabled (/review-mode on to enable).${cfgLine} /review runs a one-shot review.`,
|
|
3652
|
+
pendingHandoffStatusLine(),
|
|
3202
3653
|
reviewModeLockStatusLine(lockKind),
|
|
3203
3654
|
]
|
|
3204
3655
|
.filter(Boolean)
|
|
@@ -3211,6 +3662,10 @@ export default function workflowExtension(
|
|
|
3211
3662
|
reviewStateSummary(s),
|
|
3212
3663
|
` phase : ${s.phase} round: ${s.round} pass: ${s.pass}`,
|
|
3213
3664
|
` model : ${s.modelLabel}`,
|
|
3665
|
+
` attempt : ${s.recoveryAttempts?.length ? s.recoveryAttempts.join(" → ") : "single model"}`,
|
|
3666
|
+
` elapsed : ${Math.round(reviewElapsedMs(s) / 1000)}s${
|
|
3667
|
+
s.providerError ? ` provider: ${s.providerError.slice(0, 200)}` : ""
|
|
3668
|
+
}`,
|
|
3214
3669
|
` plan : ${s.planPath ?? "(unknown)"} hash ${s.planHash}`,
|
|
3215
3670
|
` explore : ${s.exploreUsed} subagent(s) tokens≈${s.tokenEstimate ?? "n/a"}`,
|
|
3216
3671
|
s.verdict ? ` verdict : ${s.verdict}` : "",
|
|
@@ -3224,6 +3679,7 @@ export default function workflowExtension(
|
|
|
3224
3679
|
reviewRuntime?.isRoundActive()
|
|
3225
3680
|
? " live : round in flight — /role and /profile switches apply next round; /review say <msg> to steer"
|
|
3226
3681
|
: "",
|
|
3682
|
+
pendingHandoffStatusLine(),
|
|
3227
3683
|
reviewModeLockStatusLine(lockKind),
|
|
3228
3684
|
...s.findings.map(
|
|
3229
3685
|
(f) =>
|
|
@@ -3387,6 +3843,9 @@ export default function workflowExtension(
|
|
|
3387
3843
|
abortedReviewHash = undefined;
|
|
3388
3844
|
pausedReview = undefined;
|
|
3389
3845
|
reviewRound = 0;
|
|
3846
|
+
// Manual /review is a Review turn from the first tick: the mode status,
|
|
3847
|
+
// input gate and mode lock must be active before the child boots.
|
|
3848
|
+
beginReviewTurn();
|
|
3390
3849
|
const result = await runReviewStep(
|
|
3391
3850
|
target.text,
|
|
3392
3851
|
ctx as any,
|
|
@@ -3398,6 +3857,16 @@ export default function workflowExtension(
|
|
|
3398
3857
|
const offSuffix = reviewEnabled()
|
|
3399
3858
|
? ""
|
|
3400
3859
|
: " (Review Mode stays OFF — /review-mode on for automatic review.)";
|
|
3860
|
+
if (
|
|
3861
|
+
await openManualHandoff(
|
|
3862
|
+
result,
|
|
3863
|
+
target.abs,
|
|
3864
|
+
ctx as any,
|
|
3865
|
+
offSuffix,
|
|
3866
|
+
)
|
|
3867
|
+
) {
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3401
3870
|
ctx.ui.notify(`${reviewResumeNotice(result)}${offSuffix}`, "info");
|
|
3402
3871
|
},
|
|
3403
3872
|
});
|
|
@@ -3442,12 +3911,16 @@ export default function workflowExtension(
|
|
|
3442
3911
|
ctx.ui.notify("A review round is already running.", "warning");
|
|
3443
3912
|
return;
|
|
3444
3913
|
}
|
|
3914
|
+
// A gated resume is settled by its parked runHandoff; a manual pause
|
|
3915
|
+
// (idle gate) opens its own browser handoff after the round lands.
|
|
3916
|
+
const gatedResume = reviewGatePhase !== "idle";
|
|
3445
3917
|
// A resumed gated review is auditing again. Without this, the parked
|
|
3446
3918
|
// handoff keeps `reviewGatePhase === "paused"` for the whole round and
|
|
3447
3919
|
// the lock/footer report the wrong state (plan 2026-10-25 follow-up).
|
|
3448
3920
|
// A manual pause (idle gate) is unchanged; the runtime/activeReview
|
|
3449
3921
|
// flags carry that lock.
|
|
3450
3922
|
reviewGatePhase = reviewGatePhaseOnResume(reviewGatePhase);
|
|
3923
|
+
setReviewTurn("running");
|
|
3451
3924
|
// A stale quiet-abort marker must not silence this explicit resume.
|
|
3452
3925
|
quietAbortHash = undefined;
|
|
3453
3926
|
// If the plan file moved on while paused, review the new revision fresh
|
|
@@ -3485,7 +3958,15 @@ export default function workflowExtension(
|
|
|
3485
3958
|
planText: result.planText,
|
|
3486
3959
|
});
|
|
3487
3960
|
}
|
|
3488
|
-
|
|
3961
|
+
if (!gatedResume && reviewResumeSettles(result)) {
|
|
3962
|
+
await openManualHandoff(
|
|
3963
|
+
result,
|
|
3964
|
+
currentPlanFile ?? "",
|
|
3965
|
+
ctx as any,
|
|
3966
|
+
);
|
|
3967
|
+
} else {
|
|
3968
|
+
ctx.ui.notify(reviewResumeNotice(result), "info");
|
|
3969
|
+
}
|
|
3489
3970
|
return;
|
|
3490
3971
|
}
|
|
3491
3972
|
pausedReview = undefined;
|
|
@@ -3510,7 +3991,11 @@ export default function workflowExtension(
|
|
|
3510
3991
|
planText: result.planText,
|
|
3511
3992
|
});
|
|
3512
3993
|
}
|
|
3513
|
-
|
|
3994
|
+
if (!gatedResume && reviewResumeSettles(result)) {
|
|
3995
|
+
await openManualHandoff(result, currentPlanFile ?? "", ctx as any);
|
|
3996
|
+
} else {
|
|
3997
|
+
ctx.ui.notify(reviewResumeNotice(result), "info");
|
|
3998
|
+
}
|
|
3514
3999
|
},
|
|
3515
4000
|
});
|
|
3516
4001
|
|
|
@@ -3525,6 +4010,46 @@ export default function workflowExtension(
|
|
|
3525
4010
|
const active =
|
|
3526
4011
|
activeReview !== undefined || rt?.isRoundActive() === true;
|
|
3527
4012
|
const paused = currentPausedReview();
|
|
4013
|
+
// A browser/inline decision can own the turn with no round and no pause.
|
|
4014
|
+
if (!active && !paused && awaitingDecision) {
|
|
4015
|
+
const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
|
|
4016
|
+
const planText =
|
|
4017
|
+
(planPath &&
|
|
4018
|
+
(() => {
|
|
4019
|
+
try {
|
|
4020
|
+
return fs.existsSync(planPath)
|
|
4021
|
+
? fs.readFileSync(planPath, "utf8")
|
|
4022
|
+
: undefined;
|
|
4023
|
+
} catch {
|
|
4024
|
+
return undefined;
|
|
4025
|
+
}
|
|
4026
|
+
})()) ??
|
|
4027
|
+
"";
|
|
4028
|
+
if (pendingHandoffReviewUrl) {
|
|
4029
|
+
setReviewTurn("handoff");
|
|
4030
|
+
ctx.ui.notify(
|
|
4031
|
+
"Review skipped — approve or annotate in the review tab.",
|
|
4032
|
+
"info",
|
|
4033
|
+
);
|
|
4034
|
+
return;
|
|
4035
|
+
}
|
|
4036
|
+
if (planText && planPath) {
|
|
4037
|
+
const handed = await openManualHandoff(
|
|
4038
|
+
{ planText, outcome: "aborted", paused: false },
|
|
4039
|
+
planPath,
|
|
4040
|
+
ctx as any,
|
|
4041
|
+
);
|
|
4042
|
+
if (!handed) setReviewTurn("handoff");
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
clearPendingHandoffDecision();
|
|
4046
|
+
setReviewTurn("idle");
|
|
4047
|
+
ctx.ui.notify(
|
|
4048
|
+
`Review skipped — the plan file is ${planPath || "unavailable"}.`,
|
|
4049
|
+
"info",
|
|
4050
|
+
);
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
3528
4053
|
if (!active && !paused) {
|
|
3529
4054
|
ctx.ui.notify("No review running or paused.", "info");
|
|
3530
4055
|
return;
|
|
@@ -3536,6 +4061,10 @@ export default function workflowExtension(
|
|
|
3536
4061
|
// abort lands; falls back to the author's draft when unknown).
|
|
3537
4062
|
const abortedText =
|
|
3538
4063
|
paused?.planText ?? activeReview?.planText;
|
|
4064
|
+
const pausedPlanPath = currentPlanFile ?? "";
|
|
4065
|
+
// A parked automatic handoff owns its own presentation: settle the gate
|
|
4066
|
+
// and let `runHandoff` render, never present twice.
|
|
4067
|
+
const gated = reviewGatePhase !== "idle" && !reviewHandoffGate.settled();
|
|
3539
4068
|
pausedReview = undefined;
|
|
3540
4069
|
// An aborted revision is not auto-reviewed again until the plan changes.
|
|
3541
4070
|
if (targetHash) abortedReviewHash = targetHash;
|
|
@@ -3558,9 +4087,15 @@ export default function workflowExtension(
|
|
|
3558
4087
|
"Review stopped — the author's plan stands unreviewed.",
|
|
3559
4088
|
"info",
|
|
3560
4089
|
);
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
4090
|
+
if (gated) {
|
|
4091
|
+
setReviewTurn("handoff");
|
|
4092
|
+
settleReviewGate({ outcome: "unreviewed", planText: abortedText });
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
// A manual /review command is awaiting this round; it will present the
|
|
4096
|
+
// aborted plan through `openManualHandoff` (shouldHandoffManualResult
|
|
4097
|
+
// now includes aborted). Keep Review visible until then.
|
|
4098
|
+
setReviewTurn("handoff");
|
|
3564
4099
|
return;
|
|
3565
4100
|
}
|
|
3566
4101
|
flushReviewEntries(true);
|
|
@@ -3569,8 +4104,20 @@ export default function workflowExtension(
|
|
|
3569
4104
|
"Paused review discarded — the author's plan stands.",
|
|
3570
4105
|
"info",
|
|
3571
4106
|
);
|
|
3572
|
-
|
|
3573
|
-
|
|
4107
|
+
if (gated) {
|
|
4108
|
+
setReviewTurn("handoff");
|
|
4109
|
+
settleReviewGate({ outcome: "unreviewed", planText: abortedText });
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
if (abortedText && pausedPlanPath) {
|
|
4113
|
+
await openManualHandoff(
|
|
4114
|
+
{ planText: abortedText, outcome: "aborted", paused: false },
|
|
4115
|
+
pausedPlanPath,
|
|
4116
|
+
ctx as any,
|
|
4117
|
+
);
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
setReviewTurn("idle");
|
|
3574
4121
|
updateStatus(ctx as any);
|
|
3575
4122
|
},
|
|
3576
4123
|
});
|
|
@@ -3598,6 +4145,12 @@ export default function workflowExtension(
|
|
|
3598
4145
|
*/
|
|
3599
4146
|
interface ModeSwitchOptions {
|
|
3600
4147
|
quiet?: boolean;
|
|
4148
|
+
/**
|
|
4149
|
+
* Internal review-decision exit: the caller already validated the pending
|
|
4150
|
+
* decision and owns the `transition` state, so the full review lock must
|
|
4151
|
+
* not refuse it. Never set this from a user-facing mode switch.
|
|
4152
|
+
*/
|
|
4153
|
+
fromReview?: boolean;
|
|
3601
4154
|
}
|
|
3602
4155
|
|
|
3603
4156
|
/** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
|
|
@@ -4014,6 +4567,16 @@ export default function workflowExtension(
|
|
|
4014
4567
|
planFile: currentPlanFile,
|
|
4015
4568
|
awaitingDecision,
|
|
4016
4569
|
awaitingDecisionAt: awaitingDecision ? lastHandoffAt : undefined,
|
|
4570
|
+
// Full correlation: a restored gate must know which browser review it
|
|
4571
|
+
// belongs to, or it can only suppress the next handoff.
|
|
4572
|
+
lastReviewId: awaitingDecision ? lastReviewId : null,
|
|
4573
|
+
pendingHandoffPlanPath: awaitingDecision ? pendingHandoffPlanPath : null,
|
|
4574
|
+
pendingHandoffPlanHash: awaitingDecision ? pendingHandoffPlanHash : null,
|
|
4575
|
+
pendingHandoffRequestId: awaitingDecision ? pendingHandoffRequestId : null,
|
|
4576
|
+
pendingHandoffSource: awaitingDecision ? pendingHandoffSource : null,
|
|
4577
|
+
pendingHandoffCycleId: awaitingDecision ? pendingHandoffCycleId : 0,
|
|
4578
|
+
pendingHandoffReviewUrl: awaitingDecision ? pendingHandoffReviewUrl : null,
|
|
4579
|
+
manualReviewCycle,
|
|
4017
4580
|
role: {
|
|
4018
4581
|
activeRole: roleConfig.activeRole,
|
|
4019
4582
|
// Session-scoped profile activation (see setActiveProfile): explicit
|
|
@@ -4023,6 +4586,12 @@ export default function workflowExtension(
|
|
|
4023
4586
|
},
|
|
4024
4587
|
defaultModel,
|
|
4025
4588
|
lastModeSwitch,
|
|
4589
|
+
todoPanel: {
|
|
4590
|
+
override: todoPanelOverride,
|
|
4591
|
+
overrideKey: todoPanelOverrideKey,
|
|
4592
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
4593
|
+
completedSignature: lastCompletedSignature,
|
|
4594
|
+
},
|
|
4026
4595
|
} as any);
|
|
4027
4596
|
lastPersistedPlanFile = currentPlanFile;
|
|
4028
4597
|
}
|
|
@@ -4039,9 +4608,10 @@ export default function workflowExtension(
|
|
|
4039
4608
|
return;
|
|
4040
4609
|
}
|
|
4041
4610
|
// A live review owns the turn: no mode transition until it lands, fails,
|
|
4042
|
-
// pauses or is aborted.
|
|
4043
|
-
// `
|
|
4044
|
-
if (blockModeSwitchDuringReview(ctx)) return;
|
|
4611
|
+
// pauses or is aborted. A validated browser/inline decision passes
|
|
4612
|
+
// `fromReview` because it is the exit, not a competing mode switch.
|
|
4613
|
+
if (opts?.fromReview !== true && blockModeSwitchDuringReview(ctx)) return;
|
|
4614
|
+
if (opts?.fromReview === true && reviewRoundInFlight()) return;
|
|
4045
4615
|
// Leaving Default mode: remember its model so it can be restored on return.
|
|
4046
4616
|
if (workflowMode === null) captureDefaultModel(ctx);
|
|
4047
4617
|
workflowMode = "plan";
|
|
@@ -4260,11 +4830,19 @@ export default function workflowExtension(
|
|
|
4260
4830
|
async function enterBuildModeFromPlan(
|
|
4261
4831
|
ctx: ExtensionContext,
|
|
4262
4832
|
planPath: string,
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
//
|
|
4266
|
-
//
|
|
4267
|
-
|
|
4833
|
+
opts?: { fromReview?: boolean },
|
|
4834
|
+
): Promise<boolean> {
|
|
4835
|
+
// Defense in depth: a stale approve must never switch tools while a real
|
|
4836
|
+
// reviewer round/pause owns the turn. The handoff itself is consumed by
|
|
4837
|
+
// the caller (fromReview), so only `reviewRoundInFlight()` can refuse it.
|
|
4838
|
+
if (opts?.fromReview === true) {
|
|
4839
|
+
if (reviewRoundInFlight()) {
|
|
4840
|
+
ctx.ui.notify(reviewModeLockNotice("running"), "info");
|
|
4841
|
+
return false;
|
|
4842
|
+
}
|
|
4843
|
+
} else if (blockModeSwitchDuringReview(ctx)) {
|
|
4844
|
+
return false;
|
|
4845
|
+
}
|
|
4268
4846
|
workflowMode = "build";
|
|
4269
4847
|
syncLegacyFlags();
|
|
4270
4848
|
// The plan is being handed to Build; this Plan cycle cannot hand off again.
|
|
@@ -4303,6 +4881,7 @@ export default function workflowExtension(
|
|
|
4303
4881
|
: `Build mode: ${loaded.count} todos synced from plan (progress kept).`,
|
|
4304
4882
|
"info",
|
|
4305
4883
|
);
|
|
4884
|
+
return true;
|
|
4306
4885
|
}
|
|
4307
4886
|
|
|
4308
4887
|
// ── Plannotator bridge helpers ─────────────────────────────────
|
|
@@ -4310,83 +4889,377 @@ export default function workflowExtension(
|
|
|
4310
4889
|
return currentSessionCtx;
|
|
4311
4890
|
}
|
|
4312
4891
|
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
) {
|
|
4319
|
-
const ctx = getActiveCtx();
|
|
4320
|
-
if (!ctx) return;
|
|
4321
|
-
if (workflowMode !== "plan") return;
|
|
4322
|
-
// A stale browser approve while a review runs must not clear the pending
|
|
4323
|
-
// decision or send the execute messages: the plan is still being audited.
|
|
4324
|
-
if (blockModeSwitchDuringReview(ctx as any)) return;
|
|
4325
|
-
// No prompt means no decision to consume. A fresh prompt/mode switch
|
|
4326
|
-
// clears this, so an old browser tab cannot approve the previous plan.
|
|
4892
|
+
/**
|
|
4893
|
+
* Adopt the review id/url Plannotator announced. This is the only place the
|
|
4894
|
+
* transcript handoff entry is written, so a `respond` callback and a
|
|
4895
|
+
* `session-opened` event for the same review cannot duplicate it.
|
|
4896
|
+
*/
|
|
4897
|
+
function adoptPlannotatorSession(event: PlannotatorSessionEventLike): void {
|
|
4327
4898
|
if (!awaitingDecision) return;
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
try {
|
|
4331
|
-
if (cwd && !path.isAbsolute(planFilePath)) {
|
|
4332
|
-
resolvedPlan = path.join(cwd, planFilePath);
|
|
4333
|
-
} else if (!path.isAbsolute(planFilePath)) {
|
|
4334
|
-
resolvedPlan = path.join(
|
|
4335
|
-
(ctx as any).cwd ?? cwd ?? process.cwd(),
|
|
4336
|
-
planFilePath,
|
|
4337
|
-
);
|
|
4338
|
-
}
|
|
4339
|
-
} catch (_e) {
|
|
4340
|
-
void _e;
|
|
4899
|
+
if (typeof event.reviewId === "string" && event.reviewId) {
|
|
4900
|
+
lastReviewId = event.reviewId;
|
|
4341
4901
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
if (
|
|
4346
|
-
!pendingHandoffPlanPath ||
|
|
4347
|
-
path.resolve(resolvedPlan) !== path.resolve(pendingHandoffPlanPath)
|
|
4348
|
-
) {
|
|
4349
|
-
ctx.ui.notify(
|
|
4350
|
-
"Ignoring a stale browser approval from a previous plan.",
|
|
4351
|
-
"info",
|
|
4352
|
-
);
|
|
4353
|
-
return;
|
|
4354
|
-
}
|
|
4355
|
-
} catch (_e) {
|
|
4356
|
-
void _e;
|
|
4357
|
-
if (!pendingHandoffPlanPath || resolvedPlan !== pendingHandoffPlanPath)
|
|
4358
|
-
return;
|
|
4902
|
+
if (!lastReviewId) return;
|
|
4903
|
+
if (typeof event.url === "string" && event.url) {
|
|
4904
|
+
pendingHandoffReviewUrl = event.url;
|
|
4359
4905
|
}
|
|
4360
|
-
if (
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4906
|
+
if (recordedHandoffReviewId !== lastReviewId) {
|
|
4907
|
+
recordedHandoffReviewId = lastReviewId;
|
|
4908
|
+
try {
|
|
4909
|
+
pi.appendEntry("workflow-handoff-plannotator", {
|
|
4910
|
+
at: Date.now(),
|
|
4911
|
+
planPath: pendingHandoffPlanPath ?? currentPlanFile,
|
|
4912
|
+
reviewId: lastReviewId,
|
|
4913
|
+
...(pendingHandoffReviewUrl
|
|
4914
|
+
? { reviewUrl: pendingHandoffReviewUrl }
|
|
4915
|
+
: {}),
|
|
4916
|
+
planTextLen: pendingHandoffPlanTextLen,
|
|
4917
|
+
source: pendingHandoffSource ?? "auto",
|
|
4918
|
+
});
|
|
4919
|
+
} catch (_e) {
|
|
4920
|
+
void _e;
|
|
4921
|
+
}
|
|
4922
|
+
notifyReview(
|
|
4923
|
+
"Plan opened in browser for review. Approve or annotate there.",
|
|
4367
4924
|
"info",
|
|
4368
4925
|
);
|
|
4369
|
-
return;
|
|
4370
4926
|
}
|
|
4371
|
-
// Ensure file exists (fallback write if agent only gave content)
|
|
4372
4927
|
try {
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4928
|
+
persistState();
|
|
4929
|
+
} catch (_e) {
|
|
4930
|
+
void _e;
|
|
4931
|
+
}
|
|
4932
|
+
try {
|
|
4933
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
4934
|
+
} catch (_e) {
|
|
4935
|
+
void _e;
|
|
4936
|
+
}
|
|
4937
|
+
}
|
|
4938
|
+
|
|
4939
|
+
/** Settle the one in-flight plan-review request exactly once. */
|
|
4940
|
+
function settlePlanReviewRequest(outcome: PlanReviewRequestOutcome): boolean {
|
|
4941
|
+
const pending = pendingPlanReviewRequest;
|
|
4942
|
+
if (!pending || pending.settled) return false;
|
|
4943
|
+
pending.settled = true;
|
|
4944
|
+
pendingPlanReviewRequest = undefined;
|
|
4945
|
+
pending.finish(outcome);
|
|
4946
|
+
return true;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
/**
|
|
4950
|
+
* Ask Plannotator to open a plan review and wait for its lifecycle.
|
|
4951
|
+
*
|
|
4952
|
+
* The `respond` callback is the fast path; `plannotator:session-opened` is
|
|
4953
|
+
* the authoritative one (it carries the URL and survives a slow handler).
|
|
4954
|
+
* A timeout clears the gate so the caller can render its own fallback; it
|
|
4955
|
+
* never retries, because a late event would then open a duplicate review.
|
|
4956
|
+
*/
|
|
4957
|
+
async function requestPlanReview(input: {
|
|
4958
|
+
planText: string;
|
|
4959
|
+
planPath: string;
|
|
4960
|
+
source: ReviewHandoffSource;
|
|
4961
|
+
ctx: ExtensionContext;
|
|
4962
|
+
cycleId?: number;
|
|
4963
|
+
}): Promise<{ ok: true; reviewId?: string } | { ok: false; reason: string }> {
|
|
4964
|
+
const bus: any = (pi as any).events;
|
|
4965
|
+
if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
|
|
4966
|
+
return { ok: false, reason: "Plannotator is not available in this session" };
|
|
4967
|
+
}
|
|
4968
|
+
if (!input.planText.trim() || !input.planPath) {
|
|
4969
|
+
return { ok: false, reason: "the reviewed plan has no file to hand off" };
|
|
4970
|
+
}
|
|
4971
|
+
// A second request supersedes the first waiter without opening a race.
|
|
4972
|
+
settlePlanReviewRequest({
|
|
4973
|
+
status: "error",
|
|
4974
|
+
reason: "superseded by a newer handoff",
|
|
4975
|
+
});
|
|
4976
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4977
|
+
awaitingDecision = true;
|
|
4978
|
+
lastHandoffAt = Date.now();
|
|
4979
|
+
pendingHandoffPlanPath = input.planPath;
|
|
4980
|
+
pendingHandoffPlanHash = planHash(input.planText);
|
|
4981
|
+
pendingHandoffPlanTextLen = input.planText.length;
|
|
4982
|
+
pendingHandoffRequestId = requestId;
|
|
4983
|
+
pendingHandoffSource = input.source;
|
|
4984
|
+
pendingHandoffCycleId = input.cycleId ?? reviewCycleId;
|
|
4985
|
+
pendingHandoffReviewUrl = null;
|
|
4986
|
+
lastReviewId = null;
|
|
4987
|
+
recordedHandoffReviewId = null;
|
|
4988
|
+
// Keep the Review mode visible while the browser/inline decision owns the
|
|
4989
|
+
// turn. The reviewer has finished, but prompts and modes stay locked.
|
|
4990
|
+
setReviewTurn("handoff");
|
|
4991
|
+
persistState();
|
|
4992
|
+
const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
|
|
4993
|
+
const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
|
|
4994
|
+
let settled = false;
|
|
4995
|
+
const settle = (outcome: PlanReviewRequestOutcome): void => {
|
|
4996
|
+
if (settled) return;
|
|
4997
|
+
settled = true;
|
|
4998
|
+
clearTimeout(timer);
|
|
4999
|
+
if (pendingPlanReviewRequest?.requestId === requestId) {
|
|
5000
|
+
pendingPlanReviewRequest = undefined;
|
|
5001
|
+
}
|
|
5002
|
+
resolve(outcome);
|
|
5003
|
+
};
|
|
5004
|
+
const timer = setTimeout(() => {
|
|
5005
|
+
settle({
|
|
5006
|
+
status: "timeout",
|
|
5007
|
+
reason: `Plannotator did not answer within ${Math.round(timeoutMs / 1000)}s`,
|
|
5008
|
+
});
|
|
5009
|
+
}, timeoutMs);
|
|
5010
|
+
pendingPlanReviewRequest = {
|
|
5011
|
+
requestId,
|
|
5012
|
+
settled: false,
|
|
5013
|
+
finish: settle,
|
|
5014
|
+
};
|
|
5015
|
+
try {
|
|
5016
|
+
bus.emit(PLANNOTATOR_REQUEST, {
|
|
5017
|
+
requestId,
|
|
5018
|
+
action: "plan-review",
|
|
5019
|
+
payload: {
|
|
5020
|
+
planContent: input.planText,
|
|
5021
|
+
planFilePath: input.planPath,
|
|
5022
|
+
reviewPending: false,
|
|
5023
|
+
reviewSummary: reviewState
|
|
5024
|
+
? {
|
|
5025
|
+
modelLabel: reviewState.modelLabel,
|
|
5026
|
+
round: reviewState.round,
|
|
5027
|
+
verdict: reviewState.verdict,
|
|
5028
|
+
findings: reviewState.findings.length,
|
|
5029
|
+
}
|
|
5030
|
+
: undefined,
|
|
5031
|
+
},
|
|
5032
|
+
respond: (res: any) => {
|
|
5033
|
+
if (
|
|
5034
|
+
res &&
|
|
5035
|
+
res.status === "handled" &&
|
|
5036
|
+
typeof res.result?.reviewId === "string"
|
|
5037
|
+
) {
|
|
5038
|
+
settle({
|
|
5039
|
+
status: "handled",
|
|
5040
|
+
reviewId: res.result.reviewId,
|
|
5041
|
+
});
|
|
5042
|
+
} else {
|
|
5043
|
+
settle({
|
|
5044
|
+
status: "error",
|
|
5045
|
+
reason: `Plannotator answered ${String(res?.status ?? "nothing")}`,
|
|
5046
|
+
});
|
|
5047
|
+
}
|
|
5048
|
+
},
|
|
5049
|
+
});
|
|
5050
|
+
} catch (e: any) {
|
|
5051
|
+
settle({
|
|
5052
|
+
status: "error",
|
|
5053
|
+
reason: `Plannotator request failed: ${String(e?.message ?? e)}`,
|
|
5054
|
+
});
|
|
4376
5055
|
}
|
|
5056
|
+
});
|
|
5057
|
+
if (response.status === "handled") {
|
|
5058
|
+
adoptPlannotatorSession({
|
|
5059
|
+
...(response.reviewId ? { reviewId: response.reviewId } : {}),
|
|
5060
|
+
...(response.url ? { url: response.url } : {}),
|
|
5061
|
+
});
|
|
5062
|
+
return { ok: true, ...(response.reviewId ? { reviewId: response.reviewId } : {}) };
|
|
5063
|
+
}
|
|
5064
|
+
const changed = clearPendingHandoffDecision();
|
|
5065
|
+
if (changed) persistState();
|
|
5066
|
+
try {
|
|
5067
|
+
pi.appendEntry("workflow-handoff-plannotator-failed", {
|
|
5068
|
+
at: Date.now(),
|
|
5069
|
+
reason: response.reason,
|
|
5070
|
+
source: input.source,
|
|
5071
|
+
planPath: input.planPath,
|
|
5072
|
+
});
|
|
4377
5073
|
} catch (_e) {
|
|
4378
5074
|
void _e;
|
|
4379
5075
|
}
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
5076
|
+
notifyReview(`Plannotator handoff unavailable — ${response.reason}.`, "warning");
|
|
5077
|
+
return { ok: false, reason: response.reason };
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
/** Ask Plannotator for the durable status of one review. */
|
|
5081
|
+
async function queryPlannotatorReviewStatus(reviewId: string): Promise<{
|
|
5082
|
+
status: "pending" | "completed" | "missing";
|
|
5083
|
+
approved?: boolean;
|
|
5084
|
+
feedback?: string;
|
|
5085
|
+
savedPath?: string;
|
|
5086
|
+
}> {
|
|
5087
|
+
const bus: any = (pi as any).events;
|
|
5088
|
+
if (!plannotatorActive || !bus || typeof bus.emit !== "function") {
|
|
5089
|
+
return { status: "missing" };
|
|
5090
|
+
}
|
|
5091
|
+
const timeoutMs = Math.max(50, deps.plannotatorRecoveryTimeoutMs ?? 3_000);
|
|
5092
|
+
return await new Promise((resolve) => {
|
|
5093
|
+
let settled = false;
|
|
5094
|
+
const finish = (value: any): void => {
|
|
5095
|
+
if (settled) return;
|
|
5096
|
+
settled = true;
|
|
5097
|
+
clearTimeout(timer);
|
|
5098
|
+
resolve(
|
|
5099
|
+
value &&
|
|
5100
|
+
(value.status === "pending" ||
|
|
5101
|
+
value.status === "completed" ||
|
|
5102
|
+
value.status === "missing")
|
|
5103
|
+
? value
|
|
5104
|
+
: { status: "missing" },
|
|
5105
|
+
);
|
|
5106
|
+
};
|
|
5107
|
+
const timer = setTimeout(() => finish({ status: "missing" }), timeoutMs);
|
|
5108
|
+
try {
|
|
5109
|
+
bus.emit(PLANNOTATOR_REQUEST, {
|
|
5110
|
+
requestId: `${Date.now()}-status`,
|
|
5111
|
+
action: "review-status",
|
|
5112
|
+
payload: { reviewId },
|
|
5113
|
+
respond: (res: any) => {
|
|
5114
|
+
if (res?.status === "handled" && res.result) finish(res.result);
|
|
5115
|
+
else finish({ status: "missing" });
|
|
5116
|
+
},
|
|
5117
|
+
});
|
|
5118
|
+
} catch {
|
|
5119
|
+
finish({ status: "missing" });
|
|
5120
|
+
}
|
|
5121
|
+
});
|
|
5122
|
+
}
|
|
5123
|
+
|
|
5124
|
+
/**
|
|
5125
|
+
* Recover the one pending browser handoff after a session reload/resume.
|
|
5126
|
+
*
|
|
5127
|
+
* `startup`/`new` cannot own the old loopback server, so they clear. For a
|
|
5128
|
+
* reload/resume/fork, Plannotator's durable `review-status` decides: pending
|
|
5129
|
+
* keeps the gate (when the plan hash still matches), completed applies the
|
|
5130
|
+
* recorded decision, and missing/changed clears with a visible reason.
|
|
5131
|
+
*/
|
|
5132
|
+
async function recoverPendingHandoff(
|
|
5133
|
+
ctx: ExtensionContext,
|
|
5134
|
+
reason: string,
|
|
5135
|
+
): Promise<void> {
|
|
5136
|
+
if (recoveryInFlight) return recoveryInFlight;
|
|
5137
|
+
recoveryInFlight = (async () => {
|
|
5138
|
+
const record = (action: string, detail: Record<string, unknown> = {}) => {
|
|
4384
5139
|
try {
|
|
4385
|
-
|
|
5140
|
+
pi.appendEntry("workflow-handoff-recovery", {
|
|
5141
|
+
action,
|
|
5142
|
+
reason,
|
|
5143
|
+
at: Date.now(),
|
|
5144
|
+
...detail,
|
|
5145
|
+
});
|
|
4386
5146
|
} catch (_e) {
|
|
4387
5147
|
void _e;
|
|
4388
5148
|
}
|
|
5149
|
+
};
|
|
5150
|
+
if (!awaitingDecision) return;
|
|
5151
|
+
if (reason === "startup" || reason === "new" || !lastReviewId) {
|
|
5152
|
+
const changed = clearPendingHandoffDecision();
|
|
5153
|
+
if (changed) persistState();
|
|
5154
|
+
endReviewTurn();
|
|
5155
|
+
record("clear", { why: "no restorable review" });
|
|
5156
|
+
return;
|
|
5157
|
+
}
|
|
5158
|
+
const currentFile = readCurrentPlanText(reviewCwd());
|
|
5159
|
+
const status = await queryPlannotatorReviewStatus(lastReviewId);
|
|
5160
|
+
const decision = reviewHandoffRecovery({
|
|
5161
|
+
status: status.status,
|
|
5162
|
+
approved: status.approved,
|
|
5163
|
+
feedback: status.feedback,
|
|
5164
|
+
savedPath: status.savedPath,
|
|
5165
|
+
pendingHash: pendingHandoffPlanHash ?? undefined,
|
|
5166
|
+
currentHash:
|
|
5167
|
+
currentFile !== undefined ? planHash(currentFile) : undefined,
|
|
4389
5168
|
});
|
|
5169
|
+
if (decision.action === "keep") {
|
|
5170
|
+
if (pendingHandoffSource === "manual") manualReviewCycle = true;
|
|
5171
|
+
setReviewTurn("handoff");
|
|
5172
|
+
record("keep", {
|
|
5173
|
+
reviewId: lastReviewId,
|
|
5174
|
+
...(pendingHandoffReviewUrl
|
|
5175
|
+
? { reviewUrl: pendingHandoffReviewUrl }
|
|
5176
|
+
: {}),
|
|
5177
|
+
});
|
|
5178
|
+
notifyReview(
|
|
5179
|
+
`Browser review still open${pendingHandoffReviewUrl ? `: ${pendingHandoffReviewUrl}` : ""} — approve or annotate there.`,
|
|
5180
|
+
"info",
|
|
5181
|
+
);
|
|
5182
|
+
updateStatus(ctx as any);
|
|
5183
|
+
return;
|
|
5184
|
+
}
|
|
5185
|
+
if (decision.action === "apply") {
|
|
5186
|
+
record("apply", {
|
|
5187
|
+
reviewId: lastReviewId,
|
|
5188
|
+
approved: decision.approved,
|
|
5189
|
+
});
|
|
5190
|
+
if (decision.approved) {
|
|
5191
|
+
await handlePlannotatorApprove(
|
|
5192
|
+
pendingHandoffPlanPath ?? currentPlanFile ?? "",
|
|
5193
|
+
currentFile ?? "",
|
|
5194
|
+
decision.feedback,
|
|
5195
|
+
ctx.cwd,
|
|
5196
|
+
);
|
|
5197
|
+
} else {
|
|
5198
|
+
await handlePlannotatorDeny(
|
|
5199
|
+
decision.feedback || "Plan needs revision.",
|
|
5200
|
+
);
|
|
5201
|
+
}
|
|
5202
|
+
return;
|
|
5203
|
+
}
|
|
5204
|
+
const changed = clearPendingHandoffDecision();
|
|
5205
|
+
if (changed) persistState();
|
|
5206
|
+
endReviewTurn();
|
|
5207
|
+
record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
|
|
5208
|
+
notifyReview(
|
|
5209
|
+
`Previous browser review could not be recovered — ${decision.reason}.`,
|
|
5210
|
+
"warning",
|
|
5211
|
+
);
|
|
5212
|
+
updateStatus(ctx as any);
|
|
5213
|
+
})();
|
|
5214
|
+
try {
|
|
5215
|
+
await recoveryInFlight;
|
|
5216
|
+
} finally {
|
|
5217
|
+
recoveryInFlight = null;
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5221
|
+
/**
|
|
5222
|
+
* Open the browser handoff for a settled manual `/review` or resume.
|
|
5223
|
+
* Returns true when the browser owns the decision (or the result is not
|
|
5224
|
+
* handoff-eligible); false when the round paused/aborted and the caller
|
|
5225
|
+
* should show its normal resume notice.
|
|
5226
|
+
*/
|
|
5227
|
+
async function openManualHandoff(
|
|
5228
|
+
result: ReviewExecutionResult,
|
|
5229
|
+
planPath: string,
|
|
5230
|
+
ctx: ExtensionContext,
|
|
5231
|
+
suffix = "",
|
|
5232
|
+
): Promise<boolean> {
|
|
5233
|
+
if (!shouldHandoffManualResult(result)) return false;
|
|
5234
|
+
const previousManualCycle = manualReviewCycle;
|
|
5235
|
+
manualReviewCycle = true;
|
|
5236
|
+
const handoff = await requestPlanReview({
|
|
5237
|
+
planText: result.planText,
|
|
5238
|
+
planPath,
|
|
5239
|
+
source: "manual",
|
|
5240
|
+
ctx,
|
|
5241
|
+
});
|
|
5242
|
+
if (handoff.ok) {
|
|
5243
|
+
ctx.ui.notify(
|
|
5244
|
+
"Plan opened in browser for review. Approve or annotate there." + suffix,
|
|
5245
|
+
"info",
|
|
5246
|
+
);
|
|
5247
|
+
return true;
|
|
5248
|
+
}
|
|
5249
|
+
manualReviewCycle = previousManualCycle;
|
|
5250
|
+
ctx.ui.notify(
|
|
5251
|
+
`Manual review finished, but Plannotator is unavailable (${handoff.reason}). The reviewed plan is at ${planPath}.`,
|
|
5252
|
+
"warning",
|
|
5253
|
+
);
|
|
5254
|
+
return true;
|
|
5255
|
+
}
|
|
5256
|
+
|
|
5257
|
+
/** One execute handoff: todo list + the Build-starting message. */
|
|
5258
|
+
function sendExecuteMessages(
|
|
5259
|
+
ctx: ExtensionContext,
|
|
5260
|
+
planPath: string,
|
|
5261
|
+
feedback?: string,
|
|
5262
|
+
): void {
|
|
4390
5263
|
try {
|
|
4391
5264
|
const remainingList = formatTodoNumberedList(todoItems);
|
|
4392
5265
|
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
@@ -4415,26 +5288,157 @@ export default function workflowExtension(
|
|
|
4415
5288
|
at: Date.now(),
|
|
4416
5289
|
choice: "execute",
|
|
4417
5290
|
source: "plannotator",
|
|
4418
|
-
planPath
|
|
5291
|
+
planPath,
|
|
4419
5292
|
});
|
|
4420
5293
|
} catch (_e) {
|
|
4421
5294
|
void _e;
|
|
4422
5295
|
}
|
|
4423
5296
|
}
|
|
4424
5297
|
|
|
5298
|
+
async function handlePlannotatorApprove(
|
|
5299
|
+
planFilePath: string,
|
|
5300
|
+
planContent: string,
|
|
5301
|
+
feedback: string | undefined,
|
|
5302
|
+
cwd: string | undefined,
|
|
5303
|
+
): Promise<void> {
|
|
5304
|
+
const ctx = getActiveCtx();
|
|
5305
|
+
if (!ctx) return;
|
|
5306
|
+
// A manual `/review` handoff may be approved from Default/Build; an
|
|
5307
|
+
// automatic handoff only exists in Plan mode.
|
|
5308
|
+
if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
|
|
5309
|
+
// A stale browser approve while a reviewer round/pause runs must not clear
|
|
5310
|
+
// the pending decision or send execute. The handoff itself is deliberately
|
|
5311
|
+
// NOT part of this predicate: it is the decision being consumed here.
|
|
5312
|
+
if (reviewRoundInFlight()) {
|
|
5313
|
+
supersedePendingHandoff(
|
|
5314
|
+
"a browser decision arrived while a new review round was running",
|
|
5315
|
+
{ force: true },
|
|
5316
|
+
);
|
|
5317
|
+
return;
|
|
5318
|
+
}
|
|
5319
|
+
// No prompt means no decision to consume. A fresh prompt/mode switch
|
|
5320
|
+
// clears this, so an old browser tab cannot approve the previous plan.
|
|
5321
|
+
if (!awaitingDecision) return;
|
|
5322
|
+
// Resolve plan path (plannotator gives cwd + relative path)
|
|
5323
|
+
let resolvedPlan = planFilePath;
|
|
5324
|
+
try {
|
|
5325
|
+
if (cwd && !path.isAbsolute(planFilePath)) {
|
|
5326
|
+
resolvedPlan = path.join(cwd, planFilePath);
|
|
5327
|
+
} else if (!path.isAbsolute(planFilePath)) {
|
|
5328
|
+
resolvedPlan = path.join(
|
|
5329
|
+
(ctx as any).cwd ?? cwd ?? process.cwd(),
|
|
5330
|
+
planFilePath,
|
|
5331
|
+
);
|
|
5332
|
+
}
|
|
5333
|
+
} catch (_e) {
|
|
5334
|
+
void _e;
|
|
5335
|
+
}
|
|
5336
|
+
// Correlate the event with the plan the current handoff prompt showed.
|
|
5337
|
+
// A stale browser tab must not approve an older plan after a new task.
|
|
5338
|
+
try {
|
|
5339
|
+
if (
|
|
5340
|
+
!pendingHandoffPlanPath ||
|
|
5341
|
+
path.resolve(resolvedPlan) !== path.resolve(pendingHandoffPlanPath)
|
|
5342
|
+
) {
|
|
5343
|
+
ctx.ui.notify(
|
|
5344
|
+
"Ignoring a stale browser approval from a previous plan.",
|
|
5345
|
+
"info",
|
|
5346
|
+
);
|
|
5347
|
+
return;
|
|
5348
|
+
}
|
|
5349
|
+
} catch (_e) {
|
|
5350
|
+
void _e;
|
|
5351
|
+
if (!pendingHandoffPlanPath || resolvedPlan !== pendingHandoffPlanPath)
|
|
5352
|
+
return;
|
|
5353
|
+
}
|
|
5354
|
+
if (
|
|
5355
|
+
planContent &&
|
|
5356
|
+
pendingHandoffPlanHash &&
|
|
5357
|
+
planHash(planContent) !== pendingHandoffPlanHash
|
|
5358
|
+
) {
|
|
5359
|
+
ctx.ui.notify(
|
|
5360
|
+
"Ignoring a stale browser approval — the plan content changed.",
|
|
5361
|
+
"info",
|
|
5362
|
+
);
|
|
5363
|
+
return;
|
|
5364
|
+
}
|
|
5365
|
+
// Ensure file exists (fallback write if agent only gave content)
|
|
5366
|
+
try {
|
|
5367
|
+
if (!fs.existsSync(resolvedPlan) && planContent) {
|
|
5368
|
+
fs.mkdirSync(path.dirname(resolvedPlan), { recursive: true });
|
|
5369
|
+
fs.writeFileSync(resolvedPlan, planContent, "utf8");
|
|
5370
|
+
}
|
|
5371
|
+
} catch (_e) {
|
|
5372
|
+
void _e;
|
|
5373
|
+
}
|
|
5374
|
+
// Atomic decision exit: stop the reviewer and apply Build BEFORE any
|
|
5375
|
+
// follow-up message. `transition` keeps input/mode gates closed while the
|
|
5376
|
+
// async mode switch runs, so a queued prompt cannot race it.
|
|
5377
|
+
setReviewTurn("transition");
|
|
5378
|
+
if (pendingHandoffSource === "manual") manualReviewCycle = false;
|
|
5379
|
+
clearPendingHandoffDecision();
|
|
5380
|
+
let enteredBuild = false;
|
|
5381
|
+
try {
|
|
5382
|
+
enteredBuild = await enterBuildModeFromPlan(
|
|
5383
|
+
ctx as any,
|
|
5384
|
+
resolvedPlan,
|
|
5385
|
+
{ fromReview: true },
|
|
5386
|
+
);
|
|
5387
|
+
} catch (e: any) {
|
|
5388
|
+
ctx.ui.notify(
|
|
5389
|
+
`Could not enter Build mode (${String(e?.message ?? e)}); the plan was not executed.`,
|
|
5390
|
+
"warning",
|
|
5391
|
+
);
|
|
5392
|
+
}
|
|
5393
|
+
if (!enteredBuild) {
|
|
5394
|
+
setReviewTurn("handoff");
|
|
5395
|
+
updateStatus(ctx as any);
|
|
5396
|
+
return;
|
|
5397
|
+
}
|
|
5398
|
+
setReviewTurn("idle");
|
|
5399
|
+
updateStatus(ctx as any);
|
|
5400
|
+
sendExecuteMessages(ctx as any, resolvedPlan, feedback);
|
|
5401
|
+
}
|
|
5402
|
+
|
|
4425
5403
|
async function handlePlannotatorDeny(feedback: string) {
|
|
4426
5404
|
const ctx = getActiveCtx();
|
|
4427
5405
|
if (!ctx) return;
|
|
4428
|
-
//
|
|
4429
|
-
//
|
|
4430
|
-
//
|
|
4431
|
-
if (
|
|
5406
|
+
// A stale deny while a new reviewer round/pause runs must not inject
|
|
5407
|
+
// feedback under the reviewer. The pending handoff (if any) is the decision
|
|
5408
|
+
// being consumed, so only the round predicate can refuse it.
|
|
5409
|
+
if (reviewRoundInFlight()) {
|
|
5410
|
+
supersedePendingHandoff(
|
|
5411
|
+
"a browser decision arrived while a new review round was running",
|
|
5412
|
+
{ force: true },
|
|
5413
|
+
);
|
|
5414
|
+
return;
|
|
5415
|
+
}
|
|
4432
5416
|
if (!awaitingDecision) return;
|
|
5417
|
+
const manualSource = pendingHandoffSource === "manual";
|
|
5418
|
+
setReviewTurn("transition");
|
|
5419
|
+
if (manualSource && workflowMode !== "plan") {
|
|
5420
|
+
// A manual review can be launched from Default/Build. Its feedback is a
|
|
5421
|
+
// plan revision, so switch to Plan before the follow-up turn starts.
|
|
5422
|
+
try {
|
|
5423
|
+
await setPlanMode(ctx as any, { quiet: true, fromReview: true });
|
|
5424
|
+
} catch (_e) {
|
|
5425
|
+
void _e;
|
|
5426
|
+
}
|
|
5427
|
+
notifyReview(
|
|
5428
|
+
"Review feedback — switched to Plan mode for the revision.",
|
|
5429
|
+
"info",
|
|
5430
|
+
);
|
|
5431
|
+
}
|
|
4433
5432
|
clearPendingHandoffDecision();
|
|
4434
5433
|
// Feedback cycle: the next plan revision gets a fresh review round, and the
|
|
4435
5434
|
// outstanding findings are carried forward so the reviewer can confirm they
|
|
4436
5435
|
// were actually addressed rather than re-raising them.
|
|
4437
5436
|
await beginReviewCycle({ reason: "deny", keepFindings: true });
|
|
5437
|
+
setReviewTurn("idle");
|
|
5438
|
+
// The feedback turn is not a fresh user prompt: keeping the refine reason
|
|
5439
|
+
// preserves manualReviewCycle, so a manual review with automatic Review
|
|
5440
|
+
// Mode off still re-reviews the revision.
|
|
5441
|
+
reviewFeedbackTurn = true;
|
|
4438
5442
|
persistState();
|
|
4439
5443
|
try {
|
|
4440
5444
|
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
@@ -4468,6 +5472,91 @@ export default function workflowExtension(
|
|
|
4468
5472
|
}
|
|
4469
5473
|
}
|
|
4470
5474
|
|
|
5475
|
+
/**
|
|
5476
|
+
* The browser review closed without a decision. The user must still get a
|
|
5477
|
+
* handoff: present a compact inline Approve/Revise choice with the author's
|
|
5478
|
+
* plan. Reopening the browser here could loop on every close, so this path is
|
|
5479
|
+
* deliberately inline-only. Never leaves Review stuck: cancel/idle clears it.
|
|
5480
|
+
*/
|
|
5481
|
+
async function presentClosedHandoff(
|
|
5482
|
+
ctx: ExtensionContext,
|
|
5483
|
+
planPath: string,
|
|
5484
|
+
planText: string,
|
|
5485
|
+
): Promise<void> {
|
|
5486
|
+
clearPendingHandoffDecision();
|
|
5487
|
+
setReviewTurn("handoff");
|
|
5488
|
+
if (!planText || !planPath) {
|
|
5489
|
+
endReviewTurn();
|
|
5490
|
+
ctx.ui.notify(
|
|
5491
|
+
`Plan review closed — the plan file is ${planPath || "unavailable"}.`,
|
|
5492
|
+
"info",
|
|
5493
|
+
);
|
|
5494
|
+
return;
|
|
5495
|
+
}
|
|
5496
|
+
if ((ctx as any).hasUI !== true) {
|
|
5497
|
+
try {
|
|
5498
|
+
(pi as any).sendMessage?.(
|
|
5499
|
+
{
|
|
5500
|
+
customType: "workflow-handoff-fallback",
|
|
5501
|
+
content: `**Plan ready**\n\nPlan review closed without a decision. The plan is at ${planPath}.\n\nType "Execute the plan" to build, or tell me what to refine.`,
|
|
5502
|
+
display: true,
|
|
5503
|
+
},
|
|
5504
|
+
{ triggerTurn: false },
|
|
5505
|
+
);
|
|
5506
|
+
} catch (_e) {
|
|
5507
|
+
void _e;
|
|
5508
|
+
}
|
|
5509
|
+
endReviewTurn();
|
|
5510
|
+
return;
|
|
5511
|
+
}
|
|
5512
|
+
const execute = "Execute the plan in Build Mode";
|
|
5513
|
+
const refine = "Refine the plan in Plan Mode";
|
|
5514
|
+
let choice: string | undefined;
|
|
5515
|
+
try {
|
|
5516
|
+
choice = await (ctx as any).ui.select(
|
|
5517
|
+
"Plan review closed — choose next step",
|
|
5518
|
+
[execute, refine],
|
|
5519
|
+
);
|
|
5520
|
+
} catch {
|
|
5521
|
+
choice = undefined;
|
|
5522
|
+
}
|
|
5523
|
+
if (choice === execute) {
|
|
5524
|
+
setReviewTurn("transition");
|
|
5525
|
+
const ok = await enterBuildModeFromPlan(ctx as any, planPath, {
|
|
5526
|
+
fromReview: true,
|
|
5527
|
+
});
|
|
5528
|
+
if (!ok) {
|
|
5529
|
+
setReviewTurn("handoff");
|
|
5530
|
+
return;
|
|
5531
|
+
}
|
|
5532
|
+
setReviewTurn("idle");
|
|
5533
|
+
sendExecuteMessages(ctx, planPath);
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
if (choice === refine) {
|
|
5537
|
+
setReviewTurn("transition");
|
|
5538
|
+
if (workflowMode !== "plan") {
|
|
5539
|
+
try {
|
|
5540
|
+
await setPlanMode(ctx as any, { quiet: true, fromReview: true });
|
|
5541
|
+
} catch (_e) {
|
|
5542
|
+
void _e;
|
|
5543
|
+
}
|
|
5544
|
+
}
|
|
5545
|
+
await beginReviewCycle({ reason: "refine", keepFindings: true });
|
|
5546
|
+
setReviewTurn("idle");
|
|
5547
|
+
ctx.ui.notify(
|
|
5548
|
+
"Plan review closed — staying in Plan mode. Tell me what to change.",
|
|
5549
|
+
"info",
|
|
5550
|
+
);
|
|
5551
|
+
return;
|
|
5552
|
+
}
|
|
5553
|
+
endReviewTurn();
|
|
5554
|
+
ctx.ui.notify(
|
|
5555
|
+
`Plan review closed — the plan is at ${planPath}.`,
|
|
5556
|
+
"info",
|
|
5557
|
+
);
|
|
5558
|
+
}
|
|
5559
|
+
|
|
4471
5560
|
function ensurePlannotatorListeners() {
|
|
4472
5561
|
if (plannotatorListenersRegistered) return;
|
|
4473
5562
|
try {
|
|
@@ -4475,16 +5564,17 @@ export default function workflowExtension(
|
|
|
4475
5564
|
if (!bus || typeof bus.on !== "function") return;
|
|
4476
5565
|
// plan-approved (external executionMode) — direct handoff
|
|
4477
5566
|
bus.on(PLANNOTATOR_PLAN_APPROVED, (event: any) => {
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
5567
|
+
void handlePlannotatorApprove(
|
|
5568
|
+
event?.planFilePath,
|
|
5569
|
+
event?.planContent,
|
|
5570
|
+
event?.feedback,
|
|
5571
|
+
event?.cwd,
|
|
5572
|
+
).catch((e: unknown) => {
|
|
5573
|
+
notifyReview(
|
|
5574
|
+
`Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5575
|
+
"error",
|
|
4484
5576
|
);
|
|
4485
|
-
}
|
|
4486
|
-
void _e;
|
|
4487
|
-
}
|
|
5577
|
+
});
|
|
4488
5578
|
});
|
|
4489
5579
|
// review-result (plan-review shared API) — approve/deny from browser
|
|
4490
5580
|
bus.on(PLANNOTATOR_REVIEW_RESULT, (event: any) => {
|
|
@@ -4504,12 +5594,17 @@ export default function workflowExtension(
|
|
|
4504
5594
|
} catch (_e) {
|
|
4505
5595
|
void _e;
|
|
4506
5596
|
}
|
|
4507
|
-
handlePlannotatorApprove(
|
|
5597
|
+
void handlePlannotatorApprove(
|
|
4508
5598
|
planPath,
|
|
4509
5599
|
content,
|
|
4510
5600
|
event.feedback,
|
|
4511
5601
|
(getActiveCtx() as any)?.cwd,
|
|
4512
|
-
)
|
|
5602
|
+
).catch((e: unknown) => {
|
|
5603
|
+
notifyReview(
|
|
5604
|
+
`Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5605
|
+
"error",
|
|
5606
|
+
);
|
|
5607
|
+
});
|
|
4513
5608
|
} else {
|
|
4514
5609
|
void handlePlannotatorDeny(
|
|
4515
5610
|
event.feedback || "Plan needs revision.",
|
|
@@ -4519,6 +5614,74 @@ export default function workflowExtension(
|
|
|
4519
5614
|
void _e;
|
|
4520
5615
|
}
|
|
4521
5616
|
});
|
|
5617
|
+
// session-opened is authoritative: it carries the review id and URL even
|
|
5618
|
+
// when the request's respond callback is slow or lost. It can arrive
|
|
5619
|
+
// before or after respond; both paths settle the same waiter once.
|
|
5620
|
+
bus.on(PLANNOTATOR_SESSION_OPENED, (event: any) => {
|
|
5621
|
+
try {
|
|
5622
|
+
if (!event || typeof event !== "object") return;
|
|
5623
|
+
const matchesWaiting =
|
|
5624
|
+
typeof event.requestId === "string" &&
|
|
5625
|
+
event.requestId === pendingPlanReviewRequest?.requestId;
|
|
5626
|
+
const matchesCurrent =
|
|
5627
|
+
typeof event.requestId === "string" &&
|
|
5628
|
+
event.requestId === pendingHandoffRequestId;
|
|
5629
|
+
if (!matchesWaiting && !matchesCurrent) return;
|
|
5630
|
+
adoptPlannotatorSession({
|
|
5631
|
+
reviewId: event.reviewId,
|
|
5632
|
+
url: event.url,
|
|
5633
|
+
});
|
|
5634
|
+
if (matchesWaiting) {
|
|
5635
|
+
settlePlanReviewRequest({
|
|
5636
|
+
status: "handled",
|
|
5637
|
+
reviewId:
|
|
5638
|
+
typeof event.reviewId === "string" ? event.reviewId : undefined,
|
|
5639
|
+
url: typeof event.url === "string" ? event.url : undefined,
|
|
5640
|
+
});
|
|
5641
|
+
}
|
|
5642
|
+
} catch (_e) {
|
|
5643
|
+
void _e;
|
|
5644
|
+
}
|
|
5645
|
+
});
|
|
5646
|
+
// A review closed without a decision must not leave a phantom gate that
|
|
5647
|
+
// suppresses the next round. approved/submitted decisions still flow
|
|
5648
|
+
// through review-result (and, after a reload, review-status).
|
|
5649
|
+
bus.on(PLANNOTATOR_SESSION_CLOSED, (event: any) => {
|
|
5650
|
+
try {
|
|
5651
|
+
if (!event || typeof event !== "object") return;
|
|
5652
|
+
if (event.outcome !== "closed") return;
|
|
5653
|
+
if (!lastReviewId || event.reviewId !== lastReviewId) return;
|
|
5654
|
+
if (!awaitingDecision) return;
|
|
5655
|
+
const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
|
|
5656
|
+
let planText = "";
|
|
5657
|
+
try {
|
|
5658
|
+
if (planPath && fs.existsSync(planPath)) {
|
|
5659
|
+
planText = fs.readFileSync(planPath, "utf8");
|
|
5660
|
+
}
|
|
5661
|
+
} catch (_e) {
|
|
5662
|
+
void _e;
|
|
5663
|
+
}
|
|
5664
|
+
const ctx = getActiveCtx();
|
|
5665
|
+
if (!ctx) {
|
|
5666
|
+
supersedePendingHandoff(
|
|
5667
|
+
"the browser review closed without a decision",
|
|
5668
|
+
{ force: true },
|
|
5669
|
+
);
|
|
5670
|
+
return;
|
|
5671
|
+
}
|
|
5672
|
+
void presentClosedHandoff(ctx as any, planPath, planText).catch(
|
|
5673
|
+
(e: unknown) => {
|
|
5674
|
+
notifyReview(
|
|
5675
|
+
`Plan handoff after review close failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5676
|
+
"warning",
|
|
5677
|
+
);
|
|
5678
|
+
endReviewTurn();
|
|
5679
|
+
},
|
|
5680
|
+
);
|
|
5681
|
+
} catch (_e) {
|
|
5682
|
+
void _e;
|
|
5683
|
+
}
|
|
5684
|
+
});
|
|
4522
5685
|
plannotatorListenersRegistered = true;
|
|
4523
5686
|
plannotatorActive = true;
|
|
4524
5687
|
const ctx = getActiveCtx();
|
|
@@ -4708,6 +5871,34 @@ export default function workflowExtension(
|
|
|
4708
5871
|
void _e;
|
|
4709
5872
|
}
|
|
4710
5873
|
|
|
5874
|
+
// Todo panel toggle: expanded ↔ collapsed, and hidden → expanded.
|
|
5875
|
+
// Ctrl+Alt+T is the Linux default for opening a terminal, so this uses O.
|
|
5876
|
+
try {
|
|
5877
|
+
pi.registerShortcut(Key.ctrlAlt("o"), {
|
|
5878
|
+
description: "Toggle the workflow todo panel",
|
|
5879
|
+
handler: async (ctx: any) => {
|
|
5880
|
+
if (overlayDepth > 0) return;
|
|
5881
|
+
if (todoItems.length === 0) {
|
|
5882
|
+
ctx.ui.notify("No todos.", "info");
|
|
5883
|
+
return;
|
|
5884
|
+
}
|
|
5885
|
+
const view = deriveTodoPanel({
|
|
5886
|
+
items: todoItems,
|
|
5887
|
+
override: todoPanelOverride,
|
|
5888
|
+
overrideKey: todoPanelOverrideKey,
|
|
5889
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
5890
|
+
stale: todoStale,
|
|
5891
|
+
});
|
|
5892
|
+
setTodoPanelOverride(
|
|
5893
|
+
ctx as any,
|
|
5894
|
+
view.state === "expanded" ? "collapsed" : "expanded",
|
|
5895
|
+
);
|
|
5896
|
+
},
|
|
5897
|
+
});
|
|
5898
|
+
} catch (_e) {
|
|
5899
|
+
void _e;
|
|
5900
|
+
}
|
|
5901
|
+
|
|
4711
5902
|
// Tab cycles workflow modes (context-aware: not when an overlay is active).
|
|
4712
5903
|
// WorkflowTabEditor gives Tab back to autocomplete while a suggestion list is
|
|
4713
5904
|
// visible; this registration remains the no-list path. Single registration to
|
|
@@ -4971,7 +6162,10 @@ export default function workflowExtension(
|
|
|
4971
6162
|
};
|
|
4972
6163
|
todoItems.push(item);
|
|
4973
6164
|
try {
|
|
4974
|
-
if (currentSessionCtx)
|
|
6165
|
+
if (currentSessionCtx) {
|
|
6166
|
+
updateStatus(currentSessionCtx);
|
|
6167
|
+
persistState();
|
|
6168
|
+
}
|
|
4975
6169
|
} catch (_e) {
|
|
4976
6170
|
void _e;
|
|
4977
6171
|
}
|
|
@@ -5092,6 +6286,10 @@ export default function workflowExtension(
|
|
|
5092
6286
|
const c = todoItems.length;
|
|
5093
6287
|
todoItems = [];
|
|
5094
6288
|
todoPlanPath = undefined;
|
|
6289
|
+
todoPanelOverride = null;
|
|
6290
|
+
todoPanelOverrideKey = null;
|
|
6291
|
+
todoPanelPromptAfterSettle = false;
|
|
6292
|
+
lastCompletedSignature = null;
|
|
5095
6293
|
try {
|
|
5096
6294
|
if (currentSessionCtx) {
|
|
5097
6295
|
updateStatus(currentSessionCtx);
|
|
@@ -5123,19 +6321,119 @@ export default function workflowExtension(
|
|
|
5123
6321
|
});
|
|
5124
6322
|
|
|
5125
6323
|
pi.registerCommand("todos", {
|
|
5126
|
-
description:
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
6324
|
+
description:
|
|
6325
|
+
"Show or control the todo panel: /todos [show|expand|collapse|hide|auto|clear]",
|
|
6326
|
+
handler: async (args, ctx) => {
|
|
6327
|
+
const tokens = String(args || "")
|
|
6328
|
+
.trim()
|
|
6329
|
+
.split(/\s+/)
|
|
6330
|
+
.filter(Boolean);
|
|
6331
|
+
const sub = (tokens[0] || "").toLowerCase();
|
|
6332
|
+
const usage = [
|
|
6333
|
+
"Usage: /todos [show|expand|collapse|hide|auto|clear]",
|
|
6334
|
+
" (no arg) list every step with global refs",
|
|
6335
|
+
" expand show the full pinned panel",
|
|
6336
|
+
" collapse keep one summary line",
|
|
6337
|
+
" hide remove the pinned panel (footer keeps progress)",
|
|
6338
|
+
" auto follow the automatic collapse/hide sequence",
|
|
6339
|
+
" clear delete the todo list (asks first when UI is available)",
|
|
6340
|
+
].join("\n");
|
|
6341
|
+
|
|
6342
|
+
if (!sub) {
|
|
6343
|
+
const list = todoItems.length
|
|
6344
|
+
? `Steps are numbered GLOBALLY 1..${todoItems.length}:
|
|
5130
6345
|
` +
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
6346
|
+
todoItems
|
|
6347
|
+
.map((t) => {
|
|
6348
|
+
const g = t.group ? `[${t.group}] ` : "";
|
|
6349
|
+
return `${g}${formatTodoLine(t)}`;
|
|
6350
|
+
})
|
|
6351
|
+
.join("\n")
|
|
6352
|
+
: "No todos.";
|
|
6353
|
+
ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
|
|
6354
|
+
return;
|
|
6355
|
+
}
|
|
6356
|
+
|
|
6357
|
+
if (sub === "show" || sub === "expand") {
|
|
6358
|
+
setTodoPanelOverride(ctx, "expanded");
|
|
6359
|
+
return;
|
|
6360
|
+
}
|
|
6361
|
+
if (sub === "collapse") {
|
|
6362
|
+
setTodoPanelOverride(ctx, "collapsed");
|
|
6363
|
+
return;
|
|
6364
|
+
}
|
|
6365
|
+
if (sub === "hide") {
|
|
6366
|
+
setTodoPanelOverride(ctx, "hidden");
|
|
6367
|
+
return;
|
|
6368
|
+
}
|
|
6369
|
+
if (sub === "auto") {
|
|
6370
|
+
setTodoPanelOverride(ctx, null);
|
|
6371
|
+
return;
|
|
6372
|
+
}
|
|
6373
|
+
if (sub === "clear") {
|
|
6374
|
+
const count = todoItems.length;
|
|
6375
|
+
if (count === 0) {
|
|
6376
|
+
ctx.ui.notify("No todos.", "info");
|
|
6377
|
+
return;
|
|
6378
|
+
}
|
|
6379
|
+
// Headless confirm() resolves false and would silently turn this into
|
|
6380
|
+
// a no-op; the command is explicit, so only ask when there is UI.
|
|
6381
|
+
const confirmed =
|
|
6382
|
+
(ctx as any).hasUI === true
|
|
6383
|
+
? await ctx.ui.confirm(
|
|
6384
|
+
"Clear todos?",
|
|
6385
|
+
"This resets the list. In Build mode, edits are blocked until a list exists again.",
|
|
6386
|
+
)
|
|
6387
|
+
: true;
|
|
6388
|
+
if (!confirmed) {
|
|
6389
|
+
ctx.ui.notify("Clear cancelled.", "info");
|
|
6390
|
+
return;
|
|
6391
|
+
}
|
|
6392
|
+
todoItems = [];
|
|
6393
|
+
todoPlanPath = undefined;
|
|
6394
|
+
todoPanelOverride = null;
|
|
6395
|
+
todoPanelOverrideKey = null;
|
|
6396
|
+
todoPanelPromptAfterSettle = false;
|
|
6397
|
+
lastCompletedSignature = null;
|
|
6398
|
+
updateStatus(ctx);
|
|
6399
|
+
try {
|
|
6400
|
+
persistState();
|
|
6401
|
+
} catch (_e) {
|
|
6402
|
+
void _e;
|
|
6403
|
+
}
|
|
6404
|
+
onTodoWrite();
|
|
6405
|
+
ctx.ui.notify(
|
|
6406
|
+
`Cleared ${count} todos. In Build mode, edits are blocked until a list exists again (approve a plan or add a step).`,
|
|
6407
|
+
"info",
|
|
6408
|
+
);
|
|
6409
|
+
return;
|
|
6410
|
+
}
|
|
6411
|
+
if (sub === "help") {
|
|
6412
|
+
ctx.ui.notify(usage, "info");
|
|
6413
|
+
return;
|
|
6414
|
+
}
|
|
6415
|
+
ctx.ui.notify(`Unknown /todos action: ${sub}\n${usage}`, "info");
|
|
6416
|
+
},
|
|
6417
|
+
getArgumentCompletions: (prefix: string) => {
|
|
6418
|
+
const raw = String(prefix ?? "");
|
|
6419
|
+
if (/\s/.test(raw.trim())) return null;
|
|
6420
|
+
const lower = raw.trim().toLowerCase();
|
|
6421
|
+
const verbs = [
|
|
6422
|
+
{ name: "show", description: "Show the full pinned panel" },
|
|
6423
|
+
{ name: "expand", description: "Show the full pinned panel" },
|
|
6424
|
+
{ name: "collapse", description: "Keep one summary line" },
|
|
6425
|
+
{ name: "hide", description: "Remove the pinned panel" },
|
|
6426
|
+
{ name: "auto", description: "Automatic collapse/hide sequence" },
|
|
6427
|
+
{ name: "clear", description: "Delete the todo list" },
|
|
6428
|
+
{ name: "help", description: "Show usage" },
|
|
6429
|
+
];
|
|
6430
|
+
const matching = verbs.filter((v) => v.name.startsWith(lower));
|
|
6431
|
+
if (matching.length === 0) return null;
|
|
6432
|
+
return matching.map((v) => ({
|
|
6433
|
+
value: `${v.name} `,
|
|
6434
|
+
label: v.name,
|
|
6435
|
+
description: v.description,
|
|
6436
|
+
}));
|
|
5139
6437
|
},
|
|
5140
6438
|
});
|
|
5141
6439
|
|
|
@@ -5416,7 +6714,8 @@ export default function workflowExtension(
|
|
|
5416
6714
|
"Usage: /profile (picker) | /profile list | /profile show <name> | " +
|
|
5417
6715
|
"/profile use <name|none> | /profile create <name> | /profile edit <name> | " +
|
|
5418
6716
|
"/profile rename <old> <new> | /profile delete <name> | " +
|
|
5419
|
-
"/profile default [name|none] [global|project]"
|
|
6717
|
+
"/profile default [name|none] [global|project] | " +
|
|
6718
|
+
"/profile retarget-provider <old> <new> [global|project] [--apply]";
|
|
5420
6719
|
|
|
5421
6720
|
/** Effective workflow.defaultProfile for display (never throws). */
|
|
5422
6721
|
function currentDefaultProfileName(cwd?: string): string | undefined {
|
|
@@ -6827,6 +8126,79 @@ export default function workflowExtension(
|
|
|
6827
8126
|
return;
|
|
6828
8127
|
}
|
|
6829
8128
|
|
|
8129
|
+
if (sub === "retarget-provider" || sub === "retarget") {
|
|
8130
|
+
const [oldProvider, newProvider, ...flags] = rest;
|
|
8131
|
+
if (!oldProvider || !newProvider) {
|
|
8132
|
+
ctx.ui.notify(
|
|
8133
|
+
"Usage: /profile retarget-provider <old> <new> [global|project] [--apply]\n" +
|
|
8134
|
+
"Rewrite every role/profile model ref from one provider to another. " +
|
|
8135
|
+
"Without --apply it only prints the planned changes.",
|
|
8136
|
+
"info",
|
|
8137
|
+
);
|
|
8138
|
+
return;
|
|
8139
|
+
}
|
|
8140
|
+
if (oldProvider === newProvider) {
|
|
8141
|
+
ctx.ui.notify(
|
|
8142
|
+
`Provider is already "${oldProvider}" — nothing to retarget.`,
|
|
8143
|
+
"info",
|
|
8144
|
+
);
|
|
8145
|
+
return;
|
|
8146
|
+
}
|
|
8147
|
+
const apply = flags.some((flag) => {
|
|
8148
|
+
const value = flag.toLowerCase();
|
|
8149
|
+
return value === "--apply" || value === "apply";
|
|
8150
|
+
});
|
|
8151
|
+
const scope = flags.some((flag) => flag.toLowerCase() === "project")
|
|
8152
|
+
? "project"
|
|
8153
|
+
: "global";
|
|
8154
|
+
const targetCwd = cwd ?? (ctx as any).cwd ?? process.cwd();
|
|
8155
|
+
const result = retargetProviderFiles({
|
|
8156
|
+
profilesPath:
|
|
8157
|
+
scope === "project"
|
|
8158
|
+
? projectProfilesPath(targetCwd)
|
|
8159
|
+
: userProfilesPath(),
|
|
8160
|
+
rolesPath:
|
|
8161
|
+
scope === "project"
|
|
8162
|
+
? projectRolesPath(targetCwd)
|
|
8163
|
+
: userRolesPath(),
|
|
8164
|
+
oldProvider,
|
|
8165
|
+
newProvider,
|
|
8166
|
+
dryRun: !apply,
|
|
8167
|
+
});
|
|
8168
|
+
const summary = formatRetargetResult(result);
|
|
8169
|
+
if (
|
|
8170
|
+
result.profiles.status === "invalid" ||
|
|
8171
|
+
result.roles.status === "invalid"
|
|
8172
|
+
) {
|
|
8173
|
+
ctx.ui.notify(summary, "error");
|
|
8174
|
+
return;
|
|
8175
|
+
}
|
|
8176
|
+
if (!apply) {
|
|
8177
|
+
ctx.ui.notify(
|
|
8178
|
+
`${summary}\n\nScope: ${scope}. A malformed file blocks both writes.`,
|
|
8179
|
+
"info",
|
|
8180
|
+
);
|
|
8181
|
+
return;
|
|
8182
|
+
}
|
|
8183
|
+
// The files changed underneath the cached configs. Force a reload so
|
|
8184
|
+
// the active profile/role resolves the new provider immediately.
|
|
8185
|
+
roleConfigLoadedFor = undefined;
|
|
8186
|
+
profilesLoadedFor = undefined;
|
|
8187
|
+
invalidateEffectiveRoles();
|
|
8188
|
+
ensureRoleConfig(targetCwd);
|
|
8189
|
+
ensureProfilesConfig(targetCwd);
|
|
8190
|
+
updateStatus(ctx as any);
|
|
8191
|
+
if (workflowMode) {
|
|
8192
|
+
try {
|
|
8193
|
+
await applyModeModel(ctx as any);
|
|
8194
|
+
} catch (_e) {
|
|
8195
|
+
void _e;
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
8198
|
+
ctx.ui.notify(`${summary}\n\nScope: ${scope}.`, "info");
|
|
8199
|
+
return;
|
|
8200
|
+
}
|
|
8201
|
+
|
|
6830
8202
|
if (sub === "default") {
|
|
6831
8203
|
// /profile default [name|none] [global|project]
|
|
6832
8204
|
const target = rest.find(
|
|
@@ -8143,6 +9515,20 @@ export default function workflowExtension(
|
|
|
8143
9515
|
// command reaches the reviewer now; the write-gate steer guard below stays
|
|
8144
9516
|
// (a write instruction must never be steered into the reviewer).
|
|
8145
9517
|
pi.on("input", async (event, _ctx) => {
|
|
9518
|
+
// Review owns the turn: only the explicit controls and model/profile
|
|
9519
|
+
// changes may become a prompt. This is the terminal-Pi half of the lock;
|
|
9520
|
+
// the Desktop store refuses prose before it reaches this hook.
|
|
9521
|
+
if (reviewTurnOwns(reviewTurn)) {
|
|
9522
|
+
const decision = reviewInputDecision(
|
|
9523
|
+
typeof event.text === "string" ? event.text : "",
|
|
9524
|
+
reviewTurn,
|
|
9525
|
+
);
|
|
9526
|
+
if (decision.action === "block") {
|
|
9527
|
+
notifyReview(decision.notice, "info");
|
|
9528
|
+
return { action: "handled" } as any;
|
|
9529
|
+
}
|
|
9530
|
+
return { action: "continue" } as any;
|
|
9531
|
+
}
|
|
8146
9532
|
if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
|
|
8147
9533
|
const note = event.text.slice(5).trim();
|
|
8148
9534
|
if (note) {
|
|
@@ -8238,12 +9624,27 @@ export default function workflowExtension(
|
|
|
8238
9624
|
// A session boundary owns the session-scoped plan fallback; the restore
|
|
8239
9625
|
// loop below re-seeds it from the persisted `planFile`.
|
|
8240
9626
|
resetSessionPlanProvenance();
|
|
9627
|
+
// The branch is the source of truth for the panel's presentation. The
|
|
9628
|
+
// host has cleared every extension widget on a session switch/reload, so
|
|
9629
|
+
// the reconciler must register it again from the restored state.
|
|
9630
|
+
todoPanelOverride = null;
|
|
9631
|
+
todoPanelOverrideKey = null;
|
|
9632
|
+
todoPanelPromptAfterSettle = false;
|
|
9633
|
+
lastCompletedSignature = null;
|
|
9634
|
+
todoPanelAppliedState = "unregistered";
|
|
8241
9635
|
// A reloaded/forked session must never inherit a stale write denial.
|
|
8242
9636
|
writeGateRunDenials.clear();
|
|
8243
9637
|
writeGateTurnAnswer = null;
|
|
8244
9638
|
writeGateTurnSteered = false;
|
|
8245
9639
|
writeGateBatchCalls = [];
|
|
8246
9640
|
writeGateBatchCallIds = new Set();
|
|
9641
|
+
readGateRunDenials.clear();
|
|
9642
|
+
readGateTurnAnswer = null;
|
|
9643
|
+
readGateTurnSteered = false;
|
|
9644
|
+
readGateBatchCalls = [];
|
|
9645
|
+
readGateBatchCallIds = new Set();
|
|
9646
|
+
readGateTurnGrantedRoots.clear();
|
|
9647
|
+
readGateTurnUncertainAllowed.clear();
|
|
8247
9648
|
// Roles are file-backed; load them up-front so every read path (status,
|
|
8248
9649
|
// band label, reviewer runtime) sees the real config even in a fresh
|
|
8249
9650
|
// session that has no persisted workflow entry yet. Profiles likewise.
|
|
@@ -8283,6 +9684,15 @@ export default function workflowExtension(
|
|
|
8283
9684
|
}
|
|
8284
9685
|
syncLegacyFlags();
|
|
8285
9686
|
if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
|
|
9687
|
+
// Entries written before this feature have no `todoPanel`; they
|
|
9688
|
+
// must not erase a value restored from an earlier entry.
|
|
9689
|
+
if (d.todoPanel && typeof d.todoPanel === "object") {
|
|
9690
|
+
const panel = readPersistedTodoPanel(d);
|
|
9691
|
+
todoPanelOverride = panel.override;
|
|
9692
|
+
todoPanelOverrideKey = panel.overrideKey;
|
|
9693
|
+
todoPanelPromptAfterSettle = panel.promptAfterSettle;
|
|
9694
|
+
lastCompletedSignature = panel.completedSignature;
|
|
9695
|
+
}
|
|
8286
9696
|
todoPlanPath = d.planFile;
|
|
8287
9697
|
toolsBeforePlanMode = d.toolsBeforePlanMode;
|
|
8288
9698
|
// An entry without a planFile must not erase a value restored
|
|
@@ -8351,31 +9761,39 @@ export default function workflowExtension(
|
|
|
8351
9761
|
typeof d.awaitingDecisionAt === "number"
|
|
8352
9762
|
? d.awaitingDecisionAt
|
|
8353
9763
|
: 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
|
-
|
|
9764
|
+
// Restore the whole correlation, not just the boolean: recovery
|
|
9765
|
+
// and stale-browser correlation both need the review identity.
|
|
9766
|
+
lastReviewId =
|
|
9767
|
+
typeof (d as any).lastReviewId === "string"
|
|
9768
|
+
? (d as any).lastReviewId
|
|
9769
|
+
: null;
|
|
9770
|
+
pendingHandoffPlanPath =
|
|
9771
|
+
typeof (d as any).pendingHandoffPlanPath === "string"
|
|
9772
|
+
? (d as any).pendingHandoffPlanPath
|
|
9773
|
+
: null;
|
|
9774
|
+
pendingHandoffPlanHash =
|
|
9775
|
+
typeof (d as any).pendingHandoffPlanHash === "string"
|
|
9776
|
+
? (d as any).pendingHandoffPlanHash
|
|
9777
|
+
: null;
|
|
9778
|
+
pendingHandoffRequestId =
|
|
9779
|
+
typeof (d as any).pendingHandoffRequestId === "string"
|
|
9780
|
+
? (d as any).pendingHandoffRequestId
|
|
9781
|
+
: null;
|
|
9782
|
+
pendingHandoffSource =
|
|
9783
|
+
(d as any).pendingHandoffSource === "manual"
|
|
9784
|
+
? "manual"
|
|
9785
|
+
: (d as any).pendingHandoffSource === "auto"
|
|
9786
|
+
? "auto"
|
|
9787
|
+
: null;
|
|
9788
|
+
pendingHandoffCycleId =
|
|
9789
|
+
typeof (d as any).pendingHandoffCycleId === "number"
|
|
9790
|
+
? (d as any).pendingHandoffCycleId
|
|
9791
|
+
: 0;
|
|
9792
|
+
pendingHandoffReviewUrl =
|
|
9793
|
+
typeof (d as any).pendingHandoffReviewUrl === "string"
|
|
9794
|
+
? (d as any).pendingHandoffReviewUrl
|
|
9795
|
+
: null;
|
|
9796
|
+
manualReviewCycle = (d as any).manualReviewCycle === true;
|
|
8379
9797
|
}
|
|
8380
9798
|
}
|
|
8381
9799
|
if (
|
|
@@ -8601,9 +10019,13 @@ export default function workflowExtension(
|
|
|
8601
10019
|
resetPlanRunProvenance();
|
|
8602
10020
|
lastPlanWritePath = currentPlanFile ?? null;
|
|
8603
10021
|
lastPersistedPlanFile = currentPlanFile;
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
10022
|
+
// The pending correlation is restored above. Recovery (step 15) decides
|
|
10023
|
+
// whether it survives; do not null it here, or a restored gate can only
|
|
10024
|
+
// suppress the next handoff.
|
|
10025
|
+
if (!awaitingDecision) clearPendingHandoffDecision();
|
|
10026
|
+
// A reload cannot preserve a running/paused reviewer child. Only a
|
|
10027
|
+
// persisted pending decision survives, and it owns the turn as `handoff`.
|
|
10028
|
+
reviewTurn = awaitingDecision ? "handoff" : "idle";
|
|
8607
10029
|
currentSessionCtx = ctx as any;
|
|
8608
10030
|
// Give Tab back to autocomplete while a suggestion list is visible; the raw
|
|
8609
10031
|
// `tab` shortcut keeps cycling modes otherwise.
|
|
@@ -8636,16 +10058,27 @@ export default function workflowExtension(
|
|
|
8636
10058
|
} catch (_e) {
|
|
8637
10059
|
void _e;
|
|
8638
10060
|
}
|
|
10061
|
+
// Recovery is asynchronous (it may query Plannotator); session start must
|
|
10062
|
+
// not wait up to the status timeout. The gate is restored/cleared before
|
|
10063
|
+
// the next user turn.
|
|
10064
|
+
void recoverPendingHandoff(ctx as ExtensionContext, event.reason);
|
|
8639
10065
|
updateStatus(ctx as any);
|
|
8640
10066
|
});
|
|
8641
10067
|
|
|
8642
|
-
pi.on("session_shutdown", async (
|
|
10068
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
8643
10069
|
// A write denial is run-scoped; the session is going away, so drop it.
|
|
8644
10070
|
writeGateRunDenials.clear();
|
|
8645
10071
|
writeGateTurnAnswer = null;
|
|
8646
10072
|
writeGateTurnSteered = false;
|
|
8647
10073
|
writeGateBatchCalls = [];
|
|
8648
10074
|
writeGateBatchCallIds = new Set();
|
|
10075
|
+
readGateRunDenials.clear();
|
|
10076
|
+
readGateTurnAnswer = null;
|
|
10077
|
+
readGateTurnSteered = false;
|
|
10078
|
+
readGateBatchCalls = [];
|
|
10079
|
+
readGateBatchCallIds = new Set();
|
|
10080
|
+
readGateTurnGrantedRoots.clear();
|
|
10081
|
+
readGateTurnUncertainAllowed.clear();
|
|
8649
10082
|
// Kill any active explore subagents to prevent orphan leaks.
|
|
8650
10083
|
// Without this, `pi --mode json` children spawned by runSingleAgent
|
|
8651
10084
|
// (up to 4 concurrent) could survive if VS Code closes the terminal
|
|
@@ -8658,11 +10091,17 @@ export default function workflowExtension(
|
|
|
8658
10091
|
}
|
|
8659
10092
|
}
|
|
8660
10093
|
activeSubagents.clear();
|
|
8661
|
-
//
|
|
8662
|
-
|
|
10094
|
+
// A reload/resume/fork can recover a browser review in the replacement
|
|
10095
|
+
// session; quitting or starting a new session cannot own the old server.
|
|
10096
|
+
const keepPending =
|
|
10097
|
+
event.reason === "reload" ||
|
|
10098
|
+
event.reason === "resume" ||
|
|
10099
|
+
event.reason === "fork";
|
|
10100
|
+
const clearedAtShutdown =
|
|
10101
|
+
keepPending === true ? false : clearPendingHandoffDecision();
|
|
8663
10102
|
resetPlanRunProvenance();
|
|
8664
10103
|
resetSessionPlanProvenance();
|
|
8665
|
-
if (clearedAtShutdown) {
|
|
10104
|
+
if (clearedAtShutdown || keepPending === true) {
|
|
8666
10105
|
try {
|
|
8667
10106
|
persistState();
|
|
8668
10107
|
} catch (_e) {
|
|
@@ -8674,13 +10113,15 @@ export default function workflowExtension(
|
|
|
8674
10113
|
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
|
|
8675
10114
|
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
8676
10115
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
10116
|
+
// The host dropped the widget; a later session_start re-registers it.
|
|
10117
|
+
todoPanelAppliedState = "unregistered";
|
|
8677
10118
|
// Drop any /commit authorization or flow: a reload/quit must never leave
|
|
8678
10119
|
// an exemption armed for whatever runs next.
|
|
8679
10120
|
commitAuthorization = null;
|
|
8680
10121
|
pendingCommitFlow = null;
|
|
8681
10122
|
// Release the reviewer session (and its custom tools) on shutdown so a
|
|
8682
10123
|
// reload or quit never leaves an orphaned in-process session behind.
|
|
8683
|
-
await teardownReview();
|
|
10124
|
+
await teardownReview({ keepHandoff: keepPending });
|
|
8684
10125
|
currentSessionCtx = null;
|
|
8685
10126
|
});
|
|
8686
10127
|
|
|
@@ -8689,6 +10130,12 @@ export default function workflowExtension(
|
|
|
8689
10130
|
todoItems = [];
|
|
8690
10131
|
todoPlanPath = undefined;
|
|
8691
10132
|
btwNotes = [];
|
|
10133
|
+
// A rewind is a branch boundary: the panel choice belongs to the branch.
|
|
10134
|
+
todoPanelOverride = null;
|
|
10135
|
+
todoPanelOverrideKey = null;
|
|
10136
|
+
todoPanelPromptAfterSettle = false;
|
|
10137
|
+
lastCompletedSignature = null;
|
|
10138
|
+
todoPanelAppliedState = "unregistered";
|
|
8692
10139
|
// A rewind is a branch boundary: no per-run write from the old branch
|
|
8693
10140
|
// may authorize a handoff or a manual review on the new one.
|
|
8694
10141
|
resetPlanRunProvenance();
|
|
@@ -8699,13 +10146,18 @@ export default function workflowExtension(
|
|
|
8699
10146
|
try {
|
|
8700
10147
|
const branch = ctx.sessionManager.getBranch();
|
|
8701
10148
|
for (const e of branch as any[]) {
|
|
8702
|
-
if (
|
|
8703
|
-
e.
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
10149
|
+
if (e.type === "custom" && e.customType === "workflow") {
|
|
10150
|
+
if (Array.isArray(e.data?.todos)) {
|
|
10151
|
+
todoItems = ensureTodoLabels(e.data.todos);
|
|
10152
|
+
if (e.data.planFile) todoPlanPath = e.data.planFile;
|
|
10153
|
+
}
|
|
10154
|
+
if (e.data?.todoPanel && typeof e.data.todoPanel === "object") {
|
|
10155
|
+
const panel = readPersistedTodoPanel(e.data);
|
|
10156
|
+
todoPanelOverride = panel.override;
|
|
10157
|
+
todoPanelOverrideKey = panel.overrideKey;
|
|
10158
|
+
todoPanelPromptAfterSettle = panel.promptAfterSettle;
|
|
10159
|
+
lastCompletedSignature = panel.completedSignature;
|
|
10160
|
+
}
|
|
8709
10161
|
}
|
|
8710
10162
|
if (
|
|
8711
10163
|
e.type === "message" &&
|
|
@@ -8745,6 +10197,126 @@ export default function workflowExtension(
|
|
|
8745
10197
|
* The decision itself is the pure `decideCommitGate`; this wrapper supplies
|
|
8746
10198
|
* the runtime inputs and renders the block reasons / ask overlay.
|
|
8747
10199
|
*/
|
|
10200
|
+
function runGitZ(
|
|
10201
|
+
repoRoot: string,
|
|
10202
|
+
args: string[],
|
|
10203
|
+
): { ok: boolean; paths: string[]; error?: string } {
|
|
10204
|
+
// The Pi host's PATH is not the shell's PATH (Windows especially). Honour
|
|
10205
|
+
// an explicit override so a desktop install without git on PATH can still
|
|
10206
|
+
// run the commit gate's synchronous checks.
|
|
10207
|
+
const gitBinary = process.env.PI_GIT_BIN?.trim() || "git";
|
|
10208
|
+
try {
|
|
10209
|
+
const res = spawnSync(gitBinary, ["-C", repoRoot, ...args], {
|
|
10210
|
+
encoding: "utf8",
|
|
10211
|
+
timeout: 5000,
|
|
10212
|
+
windowsHide: true,
|
|
10213
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
10214
|
+
});
|
|
10215
|
+
if (res.error) {
|
|
10216
|
+
const code = (res.error as NodeJS.ErrnoException).code;
|
|
10217
|
+
const missing =
|
|
10218
|
+
code === "ENOENT" || /\bENOENT\b/.test(res.error.message);
|
|
10219
|
+
return {
|
|
10220
|
+
ok: false,
|
|
10221
|
+
paths: [],
|
|
10222
|
+
error: missing
|
|
10223
|
+
? `git executable not found (${gitBinary}); set PI_GIT_BIN to the git binary`
|
|
10224
|
+
: res.error.message,
|
|
10225
|
+
};
|
|
10226
|
+
}
|
|
10227
|
+
if (res.status !== 0)
|
|
10228
|
+
return {
|
|
10229
|
+
ok: false,
|
|
10230
|
+
paths: [],
|
|
10231
|
+
error: String(res.stderr ?? "").trim() || `git exited ${res.status}`,
|
|
10232
|
+
};
|
|
10233
|
+
const paths = String(res.stdout ?? "")
|
|
10234
|
+
.split("\0")
|
|
10235
|
+
.map((entry) => entry.replace(/\r?\n$/, ""))
|
|
10236
|
+
.map((entry) => entry.replace(/^..\s+/, ""))
|
|
10237
|
+
.map((entry) => entry.split(" -> ").pop() ?? entry)
|
|
10238
|
+
.filter(Boolean);
|
|
10239
|
+
return { ok: true, paths };
|
|
10240
|
+
} catch (error: unknown) {
|
|
10241
|
+
return {
|
|
10242
|
+
ok: false,
|
|
10243
|
+
paths: [],
|
|
10244
|
+
error: error instanceof Error ? error.message : String(error),
|
|
10245
|
+
};
|
|
10246
|
+
}
|
|
10247
|
+
}
|
|
10248
|
+
|
|
10249
|
+
function resolveCommitRoot(
|
|
10250
|
+
candidate: string,
|
|
10251
|
+
cwd: string,
|
|
10252
|
+
): { root: string; uncertain: boolean } {
|
|
10253
|
+
const res = runGitZ(candidate, ["rev-parse", "--show-toplevel"]);
|
|
10254
|
+
if (res.ok && res.paths[0]) {
|
|
10255
|
+
return {
|
|
10256
|
+
root: normalizeWritePath(res.paths[0], candidate),
|
|
10257
|
+
uncertain: false,
|
|
10258
|
+
};
|
|
10259
|
+
}
|
|
10260
|
+
const detected = detectRepoRoot(candidate) ?? detectRepoRoot(cwd);
|
|
10261
|
+
return detected
|
|
10262
|
+
? { root: detected, uncertain: false }
|
|
10263
|
+
: { root: candidate, uncertain: true };
|
|
10264
|
+
}
|
|
10265
|
+
|
|
10266
|
+
function commitSensitiveHit(
|
|
10267
|
+
scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
|
|
10268
|
+
folderConfig: FolderPolicyConfig,
|
|
10269
|
+
): { path: string; pattern: string } | { failed: string } | undefined {
|
|
10270
|
+
const check = (paths: readonly string[]) => {
|
|
10271
|
+
for (const p of paths) {
|
|
10272
|
+
const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
|
|
10273
|
+
if (hit.sensitive)
|
|
10274
|
+
return { path: p, pattern: hit.pattern ?? "sensitive" };
|
|
10275
|
+
}
|
|
10276
|
+
return undefined;
|
|
10277
|
+
};
|
|
10278
|
+
const explicit = check(scope.explicitPaths);
|
|
10279
|
+
if (explicit) return explicit;
|
|
10280
|
+
|
|
10281
|
+
if (scope.hit === "add" && scope.allPaths) {
|
|
10282
|
+
const status = runGitZ(scope.repoRoot, [
|
|
10283
|
+
"status",
|
|
10284
|
+
"--porcelain=v1",
|
|
10285
|
+
"-z",
|
|
10286
|
+
"--untracked-files=all",
|
|
10287
|
+
]);
|
|
10288
|
+
if (!status.ok)
|
|
10289
|
+
return { failed: status.error ?? "git status failed" };
|
|
10290
|
+
const hit = check(status.paths);
|
|
10291
|
+
if (hit) return hit;
|
|
10292
|
+
}
|
|
10293
|
+
if (scope.hit === "commit") {
|
|
10294
|
+
const staged = runGitZ(scope.repoRoot, [
|
|
10295
|
+
"diff",
|
|
10296
|
+
"--cached",
|
|
10297
|
+
"--name-only",
|
|
10298
|
+
"-z",
|
|
10299
|
+
]);
|
|
10300
|
+
if (!staged.ok)
|
|
10301
|
+
return { failed: staged.error ?? "git diff --cached failed" };
|
|
10302
|
+
const hit = check(staged.paths);
|
|
10303
|
+
if (hit) return hit;
|
|
10304
|
+
if (scope.allPaths) {
|
|
10305
|
+
const status = runGitZ(scope.repoRoot, [
|
|
10306
|
+
"status",
|
|
10307
|
+
"--porcelain=v1",
|
|
10308
|
+
"-z",
|
|
10309
|
+
"--untracked-files=all",
|
|
10310
|
+
]);
|
|
10311
|
+
if (!status.ok)
|
|
10312
|
+
return { failed: status.error ?? "git status failed" };
|
|
10313
|
+
const modified = check(status.paths);
|
|
10314
|
+
if (modified) return modified;
|
|
10315
|
+
}
|
|
10316
|
+
}
|
|
10317
|
+
return undefined;
|
|
10318
|
+
}
|
|
10319
|
+
|
|
8748
10320
|
async function commitPolicyGate(
|
|
8749
10321
|
event: any,
|
|
8750
10322
|
ctx: any,
|
|
@@ -8753,7 +10325,54 @@ export default function workflowExtension(
|
|
|
8753
10325
|
const cmd = String(event?.input?.command ?? "");
|
|
8754
10326
|
const hit = gitCommitPolicyHit(cmd);
|
|
8755
10327
|
if (!hit) return undefined;
|
|
8756
|
-
const
|
|
10328
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
10329
|
+
const policyConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
10330
|
+
trusted,
|
|
10331
|
+
}).config;
|
|
10332
|
+
const policy = policyConfig.mode;
|
|
10333
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
10334
|
+
trusted,
|
|
10335
|
+
}).config;
|
|
10336
|
+
const git = analyzeShellPaths(cmd, cwd).git;
|
|
10337
|
+
const gitDir = /\bGIT_DIR=([^\s;&|]+)/.exec(cmd)?.[1];
|
|
10338
|
+
const candidateRoot = gitDir
|
|
10339
|
+
? normalizeWritePath(gitDir.replace(/^['"]|['"]$/g, ""), cwd)
|
|
10340
|
+
: (git?.repoRoot ?? normalizeWritePath(cwd, cwd));
|
|
10341
|
+
const resolvedRoot = resolveCommitRoot(candidateRoot, cwd);
|
|
10342
|
+
const repoRoot = resolvedRoot.root;
|
|
10343
|
+
const gitScope = git ? { ...git, repoRoot } : undefined;
|
|
10344
|
+
const folderEvaluation = evaluateFolderTargets(
|
|
10345
|
+
[{ path: repoRoot, kind: "dir" }],
|
|
10346
|
+
{
|
|
10347
|
+
config: folderConfig,
|
|
10348
|
+
grants: policyConfig.allowedPaths,
|
|
10349
|
+
cwd,
|
|
10350
|
+
action: "commit",
|
|
10351
|
+
},
|
|
10352
|
+
);
|
|
10353
|
+
if (resolvedRoot.uncertain)
|
|
10354
|
+
folderEvaluation.uncertain.push(repoRoot);
|
|
10355
|
+
const folderOutside = folderEvaluation.outside.length > 0;
|
|
10356
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
10357
|
+
const folderDecision = decideFolderGate({
|
|
10358
|
+
sensitive: false,
|
|
10359
|
+
outside: folderEvaluation.outside.length > 0,
|
|
10360
|
+
outsideAction: folderConfig.outside,
|
|
10361
|
+
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10362
|
+
});
|
|
10363
|
+
// decideFolderGate already turns uncertainty into ask (outside: ask) or
|
|
10364
|
+
// block (outside: block); there is no "allow" case left to escalate.
|
|
10365
|
+
const sensitiveHit = gitScope
|
|
10366
|
+
? commitSensitiveHit(gitScope, folderConfig)
|
|
10367
|
+
: undefined;
|
|
10368
|
+
if (sensitiveHit && "failed" in sensitiveHit) {
|
|
10369
|
+
return {
|
|
10370
|
+
block: true,
|
|
10371
|
+
reason:
|
|
10372
|
+
`Commit policy: the sensitive-file check could not run (${sensitiveHit.failed}). ` +
|
|
10373
|
+
"Refusing to commit unverified. Do not retry; ask the user to commit manually or fix git access.",
|
|
10374
|
+
};
|
|
10375
|
+
}
|
|
8757
10376
|
const now = Date.now();
|
|
8758
10377
|
const decision = decideCommitGate({
|
|
8759
10378
|
policy,
|
|
@@ -8761,6 +10380,8 @@ export default function workflowExtension(
|
|
|
8761
10380
|
hasTodos: todoItems.length > 0,
|
|
8762
10381
|
auth: commitAuthorization,
|
|
8763
10382
|
now,
|
|
10383
|
+
folder: folderDecision,
|
|
10384
|
+
sensitive: Boolean(sensitiveHit && !("failed" in sensitiveHit)),
|
|
8764
10385
|
});
|
|
8765
10386
|
if (decision === "allow") {
|
|
8766
10387
|
// Count a granted `git commit` against the authorization, but only when
|
|
@@ -8783,6 +10404,25 @@ export default function workflowExtension(
|
|
|
8783
10404
|
}
|
|
8784
10405
|
|
|
8785
10406
|
if (decision === "block") {
|
|
10407
|
+
if (sensitiveHit && !("failed" in sensitiveHit)) {
|
|
10408
|
+
return {
|
|
10409
|
+
block: true,
|
|
10410
|
+
reason:
|
|
10411
|
+
`Commit policy: \`${sensitiveHit.path}\` is a sensitive file (${sensitiveHit.pattern}). ` +
|
|
10412
|
+
"Committing it is blocked even when the commit mode is 'allow'. Do not retry; remove it from the index, or ask the user to add a manual sensitiveExemptions entry.",
|
|
10413
|
+
};
|
|
10414
|
+
}
|
|
10415
|
+
if (folderDecision === "block" && policy !== "never") {
|
|
10416
|
+
return {
|
|
10417
|
+
block: true,
|
|
10418
|
+
reason:
|
|
10419
|
+
`Folder policy is 'block' (workflow.folderPolicy): \`git ${hit}\` targets ${repoRoot}, ` +
|
|
10420
|
+
(folderOutside
|
|
10421
|
+
? `which is outside the permitted folders (${baseRoot}). `
|
|
10422
|
+
: "which could not be verified against the permitted folders. ") +
|
|
10423
|
+
"Do not retry. Ask the user to grant that repository folder or change /folder-policy.",
|
|
10424
|
+
};
|
|
10425
|
+
}
|
|
8786
10426
|
return {
|
|
8787
10427
|
block: true,
|
|
8788
10428
|
reason:
|
|
@@ -8803,20 +10443,60 @@ export default function workflowExtension(
|
|
|
8803
10443
|
return {
|
|
8804
10444
|
block: true,
|
|
8805
10445
|
reason:
|
|
8806
|
-
|
|
10446
|
+
`${folderOutside ? "Folder policy is 'ask' (workflow.folderPolicy)" : "Commit policy is 'ask' (workflow.commitPolicy)"}: \`git ${hit}\` needs user approval, ` +
|
|
8807
10447
|
"but no interactive UI is available. Leave the change uncommitted and let the user decide.",
|
|
8808
10448
|
};
|
|
8809
10449
|
}
|
|
10450
|
+
const commitGrant = folderOutside
|
|
10451
|
+
? (outsideProjectRootForTarget({ path: repoRoot, kind: "dir" }, baseRoot) ??
|
|
10452
|
+
repoRoot)
|
|
10453
|
+
: repoRoot;
|
|
8810
10454
|
let sel: unknown;
|
|
8811
10455
|
try {
|
|
8812
10456
|
sel = await ui.select(
|
|
8813
|
-
|
|
8814
|
-
|
|
10457
|
+
`${folderOutside ? "Folder" : "Commit"} policy 'ask': allow \`git ${hit}\`?` +
|
|
10458
|
+
`\nRepository: ${repoRoot}` +
|
|
10459
|
+
(folderOutside ? `\nOutside: ${baseRoot}` : ""),
|
|
10460
|
+
folderOutside
|
|
10461
|
+
? ["Allow once", "Always allow this repository folder", "Deny"]
|
|
10462
|
+
: ["Allow once", "Deny"],
|
|
8815
10463
|
);
|
|
8816
10464
|
} catch {
|
|
8817
10465
|
sel = undefined;
|
|
8818
10466
|
}
|
|
8819
|
-
|
|
10467
|
+
const answer = String(sel ?? "");
|
|
10468
|
+
if (answer.startsWith("Always allow")) {
|
|
10469
|
+
if (folderConfig.grantScope === "project" && !trusted) {
|
|
10470
|
+
return {
|
|
10471
|
+
block: true,
|
|
10472
|
+
reason:
|
|
10473
|
+
"Folder policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
10474
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
10475
|
+
};
|
|
10476
|
+
}
|
|
10477
|
+
const written = writeCommitPolicyConfig(
|
|
10478
|
+
folderConfig.grantScope,
|
|
10479
|
+
cwd,
|
|
10480
|
+
getAgentDir(),
|
|
10481
|
+
{ allowedPaths: [commitGrant] },
|
|
10482
|
+
);
|
|
10483
|
+
if (!written.ok) {
|
|
10484
|
+
return {
|
|
10485
|
+
block: true,
|
|
10486
|
+
reason: `Could not save the commit folder grant (${written.error ?? "unknown error"}); the commit was not allowed.`,
|
|
10487
|
+
};
|
|
10488
|
+
}
|
|
10489
|
+
return undefined;
|
|
10490
|
+
}
|
|
10491
|
+
if (answer.startsWith("Allow")) return undefined;
|
|
10492
|
+
if (folderOutside) {
|
|
10493
|
+
return {
|
|
10494
|
+
block: true,
|
|
10495
|
+
reason:
|
|
10496
|
+
`Folder policy 'ask': the user declined \`git ${hit}\` for ${repoRoot}, which is outside ${baseRoot}. ` +
|
|
10497
|
+
"Do not retry — leave the changes uncommitted for the user.",
|
|
10498
|
+
};
|
|
10499
|
+
}
|
|
8820
10500
|
return {
|
|
8821
10501
|
block: true,
|
|
8822
10502
|
reason:
|
|
@@ -8833,7 +10513,9 @@ export default function workflowExtension(
|
|
|
8833
10513
|
function commitDirectiveLine(ctx: any): string {
|
|
8834
10514
|
try {
|
|
8835
10515
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
8836
|
-
const mode = readCommitPolicyConfig(cwd, getAgentDir()
|
|
10516
|
+
const mode = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
10517
|
+
trusted: ctx?.isProjectTrusted?.() === true,
|
|
10518
|
+
}).config.mode;
|
|
8837
10519
|
switch (mode) {
|
|
8838
10520
|
case "never":
|
|
8839
10521
|
return (
|
|
@@ -8869,16 +10551,90 @@ export default function workflowExtension(
|
|
|
8869
10551
|
* part of the list (its arguments may have been rewritten by another
|
|
8870
10552
|
* extension's tool_call handler after the assistant message was sealed).
|
|
8871
10553
|
*/
|
|
10554
|
+
function writeFolderTargetsFor(
|
|
10555
|
+
toolName: string,
|
|
10556
|
+
input: any,
|
|
10557
|
+
classification: WriteCallClassification,
|
|
10558
|
+
cwd: string,
|
|
10559
|
+
): FolderTarget[] {
|
|
10560
|
+
if (classification.paths.length > 0) {
|
|
10561
|
+
return classification.paths.map((p) => ({ path: p, kind: "file" as const }));
|
|
10562
|
+
}
|
|
10563
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
10564
|
+
const command = String(input?.command ?? "");
|
|
10565
|
+
return analyzeShellPaths(command, cwd).writes;
|
|
10566
|
+
}
|
|
10567
|
+
return [];
|
|
10568
|
+
}
|
|
10569
|
+
|
|
10570
|
+
function withFolderGrant(
|
|
10571
|
+
item: WriteGateItem,
|
|
10572
|
+
targets: readonly FolderTarget[],
|
|
10573
|
+
cwd: string,
|
|
10574
|
+
baseRoot: string,
|
|
10575
|
+
): WriteGateItem {
|
|
10576
|
+
const grantEntries = targets
|
|
10577
|
+
.map((target) => {
|
|
10578
|
+
const exact = normalizeWritePath(target.path, cwd);
|
|
10579
|
+
const outsideRoot = outsideProjectRootForTarget(target, baseRoot);
|
|
10580
|
+
const path = outsideRoot ?? exact;
|
|
10581
|
+
// A dir/repo target (or a glob's static prefix) is granted as a
|
|
10582
|
+
// folder; so is a containing folder computed for an outside file. A
|
|
10583
|
+
// file directly in the common parent is granted as that file alone.
|
|
10584
|
+
const folder =
|
|
10585
|
+
target.kind !== "file" ||
|
|
10586
|
+
(outsideRoot !== undefined && outsideRoot !== exact);
|
|
10587
|
+
return { path, folder };
|
|
10588
|
+
})
|
|
10589
|
+
.filter((entry) => entry.path.length > 0);
|
|
10590
|
+
if (grantEntries.length === 0) return item;
|
|
10591
|
+
const unique = [...new Set(grantEntries.map((entry) => entry.path))].sort();
|
|
10592
|
+
return {
|
|
10593
|
+
...item,
|
|
10594
|
+
target: unique.join(", "),
|
|
10595
|
+
grantPaths: [...new Set([...(item.grantPaths ?? []), ...unique])],
|
|
10596
|
+
grantKind: grantEntries.every((entry) => entry.folder)
|
|
10597
|
+
? "folder"
|
|
10598
|
+
: "file",
|
|
10599
|
+
grantCommand: undefined,
|
|
10600
|
+
key: `path:${unique.join("|")}`,
|
|
10601
|
+
};
|
|
10602
|
+
}
|
|
10603
|
+
|
|
8872
10604
|
function collectWriteGateItems(
|
|
8873
10605
|
currentEvent: any,
|
|
8874
10606
|
currentClassification: WriteCallClassification,
|
|
8875
10607
|
config: WritePolicyConfig,
|
|
10608
|
+
folderConfig: FolderPolicyConfig,
|
|
8876
10609
|
cwd: string,
|
|
10610
|
+
baseRoot: string,
|
|
8877
10611
|
): WriteGateItem[] {
|
|
8878
10612
|
const items: WriteGateItem[] = [];
|
|
8879
10613
|
const seen = new Set<string>();
|
|
8880
|
-
const add = (
|
|
8881
|
-
|
|
10614
|
+
const add = (
|
|
10615
|
+
name: string,
|
|
10616
|
+
classification: WriteCallClassification,
|
|
10617
|
+
input: any,
|
|
10618
|
+
) => {
|
|
10619
|
+
const targets = writeFolderTargetsFor(
|
|
10620
|
+
name,
|
|
10621
|
+
input,
|
|
10622
|
+
classification,
|
|
10623
|
+
cwd,
|
|
10624
|
+
);
|
|
10625
|
+
const evaluation = evaluateFolderTargets(targets, {
|
|
10626
|
+
config: folderConfig,
|
|
10627
|
+
grants: config.allowedPaths,
|
|
10628
|
+
cwd,
|
|
10629
|
+
action: "write",
|
|
10630
|
+
});
|
|
10631
|
+
const needsFolder = evaluation.outside.length > 0;
|
|
10632
|
+
if (!needsFolder && isWriteCallAllowed(classification, config, cwd))
|
|
10633
|
+
return;
|
|
10634
|
+
const base = writeGateItemFromClassification(name, classification, cwd);
|
|
10635
|
+
const item = needsFolder
|
|
10636
|
+
? withFolderGrant(base, targets, cwd, baseRoot)
|
|
10637
|
+
: base;
|
|
8882
10638
|
if (!item.key || seen.has(item.key)) return;
|
|
8883
10639
|
seen.add(item.key);
|
|
8884
10640
|
items.push(item);
|
|
@@ -8888,8 +10644,6 @@ export default function workflowExtension(
|
|
|
8888
10644
|
if (!name) continue;
|
|
8889
10645
|
const classification = classifyWriteCall(name, call.args, config);
|
|
8890
10646
|
if (!classification.mutating) continue;
|
|
8891
|
-
// A granted target is not gated — it never reaches a dialog.
|
|
8892
|
-
if (isWriteCallAllowed(classification, config, cwd)) continue;
|
|
8893
10647
|
// The plan directory stays writable in every mode.
|
|
8894
10648
|
if (
|
|
8895
10649
|
classification.paths.length > 0 &&
|
|
@@ -8897,9 +10651,9 @@ export default function workflowExtension(
|
|
|
8897
10651
|
) {
|
|
8898
10652
|
continue;
|
|
8899
10653
|
}
|
|
8900
|
-
add(name, classification);
|
|
10654
|
+
add(name, classification, call.args);
|
|
8901
10655
|
}
|
|
8902
|
-
add(String(currentEvent?.toolName ?? ""), currentClassification);
|
|
10656
|
+
add(String(currentEvent?.toolName ?? ""), currentClassification, currentEvent?.input);
|
|
8903
10657
|
return items;
|
|
8904
10658
|
}
|
|
8905
10659
|
|
|
@@ -8944,13 +10698,16 @@ export default function workflowExtension(
|
|
|
8944
10698
|
}
|
|
8945
10699
|
|
|
8946
10700
|
/**
|
|
8947
|
-
* Write permission gate (workflow.writePolicy
|
|
10701
|
+
* Write permission gate (workflow.writePolicy + the shared folder floor).
|
|
8948
10702
|
*
|
|
8949
10703
|
* The Workflow extension's three modes are Plan → Build → Default. Plan
|
|
8950
|
-
* mode is read-only and Build mode is unrestricted
|
|
8951
|
-
* mode was ALSO unrestricted: `resolveSessionRole()`
|
|
8952
|
-
* there, so the tool_call gate has no branch to take and
|
|
8953
|
-
* files without asking. This gate is the missing permission
|
|
10704
|
+
* mode is read-only and Build mode is unrestricted *inside the permitted
|
|
10705
|
+
* folders*, but DEFAULT mode was ALSO unrestricted: `resolveSessionRole()`
|
|
10706
|
+
* returns undefined there, so the tool_call gate has no branch to take and
|
|
10707
|
+
* the agent edits files without asking. This gate is the missing permission
|
|
10708
|
+
* knob. The write MODE is Default-only; the folder floor applies in every
|
|
10709
|
+
* mode (Build included) and asks only for proven outside targets, sensitive
|
|
10710
|
+
* files, or an explicit target path that cannot be resolved.
|
|
8954
10711
|
*
|
|
8955
10712
|
* It is deliberately INDEPENDENT of the role gate and of the commit
|
|
8956
10713
|
* policy: those govern *what* a role may do, this governs *whether the
|
|
@@ -8970,24 +10727,24 @@ export default function workflowExtension(
|
|
|
8970
10727
|
cwd: string,
|
|
8971
10728
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
8972
10729
|
try {
|
|
8973
|
-
// Scope: the
|
|
8974
|
-
//
|
|
8975
|
-
//
|
|
8976
|
-
//
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
}) !== undefined
|
|
8984
|
-
) {
|
|
8985
|
-
return undefined;
|
|
8986
|
-
}
|
|
10730
|
+
// Scope: the write MODE is a Default-mode knob; the shared FOLDER floor
|
|
10731
|
+
// applies to every mode. Plan mode already restricts writes to
|
|
10732
|
+
// .pi/plans/ (inside the folder root); Build mode is the deliberate
|
|
10733
|
+
// execution path but must still respect the user's folder floor.
|
|
10734
|
+
const gateRole = resolveSessionRole({
|
|
10735
|
+
envRole: process.env[WORKFLOW_ROLE_ENV],
|
|
10736
|
+
workflowMode,
|
|
10737
|
+
});
|
|
10738
|
+
if (gateRole && gateRole !== "builder") return undefined;
|
|
10739
|
+
const defaultMode = workflowMode === null && gateRole === undefined;
|
|
8987
10740
|
|
|
8988
|
-
const
|
|
10741
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
10742
|
+
const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
|
|
8989
10743
|
const config = resolved.config;
|
|
8990
|
-
|
|
10744
|
+
const effectiveWriteMode = defaultMode ? config.mode : "allow";
|
|
10745
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
10746
|
+
trusted,
|
|
10747
|
+
}).config;
|
|
8991
10748
|
|
|
8992
10749
|
const classification = classifyWriteCall(
|
|
8993
10750
|
event.toolName,
|
|
@@ -9005,23 +10762,94 @@ export default function workflowExtension(
|
|
|
9005
10762
|
return undefined;
|
|
9006
10763
|
}
|
|
9007
10764
|
|
|
10765
|
+
const folderTargets = writeFolderTargetsFor(
|
|
10766
|
+
String(event.toolName ?? ""),
|
|
10767
|
+
event.input,
|
|
10768
|
+
classification,
|
|
10769
|
+
cwd,
|
|
10770
|
+
);
|
|
10771
|
+
for (const folderTarget of folderTargets) {
|
|
10772
|
+
const hit = matchSensitivePath(folderTarget.path, folderConfig, cwd);
|
|
10773
|
+
if (hit.sensitive) {
|
|
10774
|
+
return {
|
|
10775
|
+
block: true,
|
|
10776
|
+
reason:
|
|
10777
|
+
`Write policy: \`${folderTarget.path}\` is a sensitive file` +
|
|
10778
|
+
(hit.pattern ? ` (${hit.pattern})` : "") +
|
|
10779
|
+
". This cannot be overridden by a write grant or folder. " +
|
|
10780
|
+
"Do not retry; ask the user to edit the sensitive list in settings.json if this is intentional.",
|
|
10781
|
+
};
|
|
10782
|
+
}
|
|
10783
|
+
}
|
|
10784
|
+
|
|
10785
|
+
const folderEvaluation = evaluateFolderTargets(folderTargets, {
|
|
10786
|
+
config: folderConfig,
|
|
10787
|
+
grants: config.allowedPaths,
|
|
10788
|
+
cwd,
|
|
10789
|
+
action: "write",
|
|
10790
|
+
});
|
|
10791
|
+
const folderDecision = decideFolderGate({
|
|
10792
|
+
sensitive: false,
|
|
10793
|
+
outside: folderEvaluation.outside.length > 0,
|
|
10794
|
+
outsideAction: folderConfig.outside,
|
|
10795
|
+
// Only a concrete target path that cannot be canonicalised is folder
|
|
10796
|
+
// uncertainty. "Mutating but no discovered write target" is NOT: the
|
|
10797
|
+
// shell analyzer cannot see inside `npm run build`, `node script`,
|
|
10798
|
+
// `npx`, `sed`/`awk` programs or `powershell`, and treating that as
|
|
10799
|
+
// "possibly outside" made Build and writePolicy:allow prompt for
|
|
10800
|
+
// ordinary in-repo toolchain commands. The capability policy owns
|
|
10801
|
+
// that case — writePolicy:ask still asks for every mutating call —
|
|
10802
|
+
// and undiscoverable shell writes are the documented residual
|
|
10803
|
+
// (README "Not a sandbox").
|
|
10804
|
+
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10805
|
+
});
|
|
9008
10806
|
const allowed = isWriteCallAllowed(classification, config, cwd);
|
|
9009
10807
|
const decision = decideWriteGate({
|
|
9010
|
-
mode:
|
|
10808
|
+
mode: effectiveWriteMode,
|
|
9011
10809
|
mutating: true,
|
|
9012
10810
|
allowed,
|
|
10811
|
+
folder: folderDecision,
|
|
9013
10812
|
});
|
|
9014
10813
|
if (decision === "allow") return undefined;
|
|
9015
10814
|
|
|
10815
|
+
// Which policy actually produced this ask. A folder-sourced prompt must
|
|
10816
|
+
// not be labelled "Write policy" — that misattribution is what made the
|
|
10817
|
+
// in-repo toolchain regression look like a write-policy bug.
|
|
10818
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
10819
|
+
const folderAsk = folderDecision === "ask";
|
|
10820
|
+
const gateSource: WriteGateSource = folderAsk
|
|
10821
|
+
? effectiveWriteMode === "ask"
|
|
10822
|
+
? "mixed"
|
|
10823
|
+
: "folder"
|
|
10824
|
+
: "write";
|
|
10825
|
+
const gatePolicyLabel =
|
|
10826
|
+
gateSource === "folder"
|
|
10827
|
+
? "Folder policy"
|
|
10828
|
+
: gateSource === "mixed"
|
|
10829
|
+
? "Permission policy"
|
|
10830
|
+
: "Write policy";
|
|
10831
|
+
const gateOutside = gateSource === "folder" ? baseRoot : undefined;
|
|
10832
|
+
|
|
9016
10833
|
const target =
|
|
9017
|
-
|
|
9018
|
-
?
|
|
9019
|
-
:
|
|
10834
|
+
folderTargets.length > 0
|
|
10835
|
+
? folderTargets.map((item) => item.path).join(", ")
|
|
10836
|
+
: classification.paths.length > 0
|
|
10837
|
+
? classification.paths.join(", ")
|
|
10838
|
+
: (classification.key ?? String(event.toolName));
|
|
9020
10839
|
const why = classification.reason
|
|
9021
10840
|
? ` (${classification.reason})`
|
|
9022
10841
|
: "";
|
|
9023
10842
|
|
|
9024
10843
|
if (decision === "block") {
|
|
10844
|
+
if (folderDecision === "block" && effectiveWriteMode !== "never") {
|
|
10845
|
+
return {
|
|
10846
|
+
block: true,
|
|
10847
|
+
reason:
|
|
10848
|
+
`Folder policy is 'block' (workflow.folderPolicy): the target is outside the permitted folders. ` +
|
|
10849
|
+
"Do not retry. Ask the user to open the project at that folder, grant it, or change /folder-policy. " +
|
|
10850
|
+
`Target: ${target}`,
|
|
10851
|
+
};
|
|
10852
|
+
}
|
|
9025
10853
|
return {
|
|
9026
10854
|
block: true,
|
|
9027
10855
|
reason:
|
|
@@ -9047,11 +10875,18 @@ export default function workflowExtension(
|
|
|
9047
10875
|
// the answer applies to all of them.
|
|
9048
10876
|
const ui = ctx?.ui;
|
|
9049
10877
|
if (!ui || !ctx.hasUI) {
|
|
10878
|
+
const policyName =
|
|
10879
|
+
gateSource === "folder"
|
|
10880
|
+
? "Folder policy is 'ask' (workflow.folderPolicy)"
|
|
10881
|
+
: gateSource === "mixed"
|
|
10882
|
+
? "Folder and write policies are 'ask' (workflow.folderPolicy + workflow.writePolicy)"
|
|
10883
|
+
: "Write policy is 'ask' (workflow.writePolicy)";
|
|
9050
10884
|
return {
|
|
9051
10885
|
block: true,
|
|
9052
10886
|
reason:
|
|
9053
|
-
|
|
10887
|
+
`${policyName}: writing needs user approval${why}, but no interactive UI is available. ` +
|
|
9054
10888
|
"Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
|
|
10889
|
+
(gateSource === "folder" ? `Outside: ${baseRoot}. ` : "") +
|
|
9055
10890
|
`Target: ${target}`,
|
|
9056
10891
|
};
|
|
9057
10892
|
}
|
|
@@ -9068,12 +10903,23 @@ export default function workflowExtension(
|
|
|
9068
10903
|
writeGateBatchCalls = [];
|
|
9069
10904
|
writeGateBatchCallIds = new Set();
|
|
9070
10905
|
}
|
|
9071
|
-
const items = collectWriteGateItems(
|
|
9072
|
-
|
|
10906
|
+
const items = collectWriteGateItems(
|
|
10907
|
+
event,
|
|
10908
|
+
classification,
|
|
10909
|
+
config,
|
|
10910
|
+
folderConfig,
|
|
10911
|
+
cwd,
|
|
10912
|
+
baseRoot,
|
|
10913
|
+
);
|
|
10914
|
+
const currentBase = writeGateItemFromClassification(
|
|
9073
10915
|
String(event.toolName ?? ""),
|
|
9074
10916
|
classification,
|
|
9075
10917
|
cwd,
|
|
9076
10918
|
);
|
|
10919
|
+
const currentItem =
|
|
10920
|
+
folderEvaluation.outside.length > 0
|
|
10921
|
+
? withFolderGrant(currentBase, folderTargets, cwd, baseRoot)
|
|
10922
|
+
: currentBase;
|
|
9077
10923
|
|
|
9078
10924
|
// (a) The user denied this target earlier in THIS RUN → deny again,
|
|
9079
10925
|
// silently. The model gets the same sentence; the user is not asked a
|
|
@@ -9087,6 +10933,8 @@ export default function workflowExtension(
|
|
|
9087
10933
|
target,
|
|
9088
10934
|
why: classification.reason,
|
|
9089
10935
|
instruction: remembered.instruction,
|
|
10936
|
+
source: gateSource,
|
|
10937
|
+
outside: gateOutside,
|
|
9090
10938
|
}),
|
|
9091
10939
|
};
|
|
9092
10940
|
}
|
|
@@ -9104,6 +10952,8 @@ export default function workflowExtension(
|
|
|
9104
10952
|
target,
|
|
9105
10953
|
why: classification.reason,
|
|
9106
10954
|
instruction: answer.instruction,
|
|
10955
|
+
source: gateSource,
|
|
10956
|
+
outside: gateOutside,
|
|
9107
10957
|
}),
|
|
9108
10958
|
};
|
|
9109
10959
|
}
|
|
@@ -9112,7 +10962,7 @@ export default function workflowExtension(
|
|
|
9112
10962
|
const options = writeGateOptions(writeGateItemsKind(items));
|
|
9113
10963
|
let sel: unknown;
|
|
9114
10964
|
try {
|
|
9115
|
-
sel = await ui.select(writeGateTitle(items), options);
|
|
10965
|
+
sel = await ui.select(writeGateTitle(items, gateSource, baseRoot), options);
|
|
9116
10966
|
} catch (_e) {
|
|
9117
10967
|
void _e;
|
|
9118
10968
|
sel = undefined;
|
|
@@ -9134,14 +10984,23 @@ export default function workflowExtension(
|
|
|
9134
10984
|
for (const p of item.grantPaths ?? []) allowedPaths.push(p);
|
|
9135
10985
|
if (item.grantCommand) allowedCommands.push(item.grantCommand);
|
|
9136
10986
|
}
|
|
9137
|
-
const
|
|
10987
|
+
const grantScope = folderConfig.grantScope;
|
|
10988
|
+
if (grantScope === "project" && !trusted) {
|
|
10989
|
+
return {
|
|
10990
|
+
block: true,
|
|
10991
|
+
reason:
|
|
10992
|
+
`${gatePolicyLabel} 'ask': this project is not trusted, so a project-scoped grant would be ignored. ` +
|
|
10993
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
10994
|
+
};
|
|
10995
|
+
}
|
|
10996
|
+
const written = writeWritePolicyConfig(grantScope, cwd, getAgentDir(), {
|
|
9138
10997
|
allowedPaths,
|
|
9139
10998
|
allowedCommands,
|
|
9140
10999
|
});
|
|
9141
11000
|
if (written.ok) {
|
|
9142
11001
|
try {
|
|
9143
11002
|
(ctx as any).ui?.notify?.(
|
|
9144
|
-
|
|
11003
|
+
`${gatePolicyLabel}: always allowed ${writeGateTargetsLabel(items)}`,
|
|
9145
11004
|
"info",
|
|
9146
11005
|
);
|
|
9147
11006
|
} catch (_e) {
|
|
@@ -9155,99 +11014,444 @@ export default function workflowExtension(
|
|
|
9155
11014
|
return {
|
|
9156
11015
|
block: true,
|
|
9157
11016
|
reason:
|
|
9158
|
-
|
|
11017
|
+
`${gatePolicyLabel} 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
|
|
9159
11018
|
`Target: ${target}`,
|
|
9160
11019
|
};
|
|
9161
11020
|
}
|
|
9162
11021
|
|
|
9163
|
-
// ── Denial ──
|
|
9164
|
-
// "Deny with instructions…" lets the user say WHY in one sentence
|
|
9165
|
-
// ("only explore and propose a plan, do not edit files yet"), so the
|
|
9166
|
-
// model can change course instead of stopping cold. The sentence
|
|
9167
|
-
// travels through TWO channels:
|
|
9168
|
-
// 1. the block reason (the tool result the model reads right now), and
|
|
9169
|
-
// 2. a USER message (a steer turn the model must obey).
|
|
9170
|
-
// Anthropic shipped the comment-only version and then shipped the bug
|
|
9171
|
-
// where the model ignored it (anthropics/claude-code#89231, #52824):
|
|
9172
|
-
// a tool result is a fact the model may reinterpret, a user turn is an
|
|
9173
|
-
// instruction it must follow.
|
|
11022
|
+
// ── Denial ──
|
|
11023
|
+
// "Deny with instructions…" lets the user say WHY in one sentence
|
|
11024
|
+
// ("only explore and propose a plan, do not edit files yet"), so the
|
|
11025
|
+
// model can change course instead of stopping cold. The sentence
|
|
11026
|
+
// travels through TWO channels:
|
|
11027
|
+
// 1. the block reason (the tool result the model reads right now), and
|
|
11028
|
+
// 2. a USER message (a steer turn the model must obey).
|
|
11029
|
+
// Anthropic shipped the comment-only version and then shipped the bug
|
|
11030
|
+
// where the model ignored it (anthropics/claude-code#89231, #52824):
|
|
11031
|
+
// a tool result is a fact the model may reinterpret, a user turn is an
|
|
11032
|
+
// instruction it must follow.
|
|
11033
|
+
let instruction = "";
|
|
11034
|
+
if (choice === "deny-with-instructions") {
|
|
11035
|
+
const inputFn = (ui as any).input;
|
|
11036
|
+
if (typeof inputFn === "function") {
|
|
11037
|
+
let typed: unknown;
|
|
11038
|
+
try {
|
|
11039
|
+
typed = await inputFn.call(
|
|
11040
|
+
ui,
|
|
11041
|
+
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
11042
|
+
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
11043
|
+
);
|
|
11044
|
+
} catch (_e) {
|
|
11045
|
+
void _e;
|
|
11046
|
+
typed = undefined;
|
|
11047
|
+
}
|
|
11048
|
+
instruction = sanitizeWriteInstruction(typed);
|
|
11049
|
+
}
|
|
11050
|
+
// No input dialog, a cancelled dialog, or empty text → plain denial
|
|
11051
|
+
// below. A cancel is never an implicit allow.
|
|
11052
|
+
}
|
|
11053
|
+
|
|
11054
|
+
// The answer covers every gated write of this turn.
|
|
11055
|
+
writeGateTurnAnswer = { choice, instruction };
|
|
11056
|
+
// …and the denial is remembered for the REST OF THE RUN, under every
|
|
11057
|
+
// target of this turn: a later retry is auto-denied with the same
|
|
11058
|
+
// sentence instead of asking the user again.
|
|
11059
|
+
for (const item of items) {
|
|
11060
|
+
writeGateRunDenials.set(item.key, { instruction });
|
|
11061
|
+
}
|
|
11062
|
+
|
|
11063
|
+
const reason = buildWriteDenyReason({
|
|
11064
|
+
target: items.length > 1 ? writeGateTargetsLabel(items) : target,
|
|
11065
|
+
why: classification.reason,
|
|
11066
|
+
instruction,
|
|
11067
|
+
source: gateSource,
|
|
11068
|
+
outside: gateOutside,
|
|
11069
|
+
});
|
|
11070
|
+
|
|
11071
|
+
if (instruction) {
|
|
11072
|
+
// Channel 2: a user-role message. `steer` queues it while the agent
|
|
11073
|
+
// is running; it lands after the current tool batch, before the next
|
|
11074
|
+
// model call, as a genuine user turn. ONE per turn, not one per
|
|
11075
|
+
// blocked call.
|
|
11076
|
+
deliverWriteInstruction(items, instruction);
|
|
11077
|
+
// Audit trail — transcript only (appendEntry is never sent to the
|
|
11078
|
+
// LLM). One entry per turn: it records the user's decision, not each
|
|
11079
|
+
// call the decision was applied to.
|
|
11080
|
+
try {
|
|
11081
|
+
pi.appendEntry("workflow-write-denial", {
|
|
11082
|
+
tool: String(event.toolName ?? ""),
|
|
11083
|
+
target,
|
|
11084
|
+
instruction,
|
|
11085
|
+
at: Date.now(),
|
|
11086
|
+
targets: items.map((item) => item.target),
|
|
11087
|
+
});
|
|
11088
|
+
} catch (_e) {
|
|
11089
|
+
void _e;
|
|
11090
|
+
}
|
|
11091
|
+
}
|
|
11092
|
+
|
|
11093
|
+
return {
|
|
11094
|
+
block: true,
|
|
11095
|
+
reason,
|
|
11096
|
+
// NOTE: no `terminate` here. pi consults `terminate` only AFTER the
|
|
11097
|
+
// whole tool batch has run (`executeToolCallsParallel` prepares and
|
|
11098
|
+
// runs every call before `shouldTerminateToolBatch()`), so it cannot
|
|
11099
|
+
// suppress a sibling write in the same batch — it only decides
|
|
11100
|
+
// whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
|
|
11101
|
+
// would end the run with no model acknowledgement at all: a
|
|
11102
|
+
// behaviour change nobody asked for. The guided path gets its next
|
|
11103
|
+
// turn from the queued steer message instead.
|
|
11104
|
+
};
|
|
11105
|
+
} catch (e: unknown) {
|
|
11106
|
+
// Fail closed: a broken policy read must not become an open door.
|
|
11107
|
+
return {
|
|
11108
|
+
block: true,
|
|
11109
|
+
reason:
|
|
11110
|
+
`Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
|
|
11111
|
+
"Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
|
|
11112
|
+
};
|
|
11113
|
+
}
|
|
11114
|
+
}
|
|
11115
|
+
|
|
11116
|
+
function collectReadGateItems(
|
|
11117
|
+
currentEvent: any,
|
|
11118
|
+
currentClassification: ReturnType<typeof classifyReadCall>,
|
|
11119
|
+
config: ReadPolicyConfig,
|
|
11120
|
+
folderConfig: FolderPolicyConfig,
|
|
11121
|
+
cwd: string,
|
|
11122
|
+
lockedRoots?: readonly string[],
|
|
11123
|
+
baseRoot?: string,
|
|
11124
|
+
): ReadGateItem[] {
|
|
11125
|
+
const items: ReadGateItem[] = [];
|
|
11126
|
+
const seen = new Set<string>();
|
|
11127
|
+
const add = (
|
|
11128
|
+
name: string,
|
|
11129
|
+
classification: ReturnType<typeof classifyReadCall>,
|
|
11130
|
+
) => {
|
|
11131
|
+
if (classification.exempt) return;
|
|
11132
|
+
const targets = classification.targets;
|
|
11133
|
+
const evaluation = evaluateFolderTargets(targets, {
|
|
11134
|
+
config: folderConfig,
|
|
11135
|
+
grants: [...config.allowedPaths, ...readGateTurnGrantedRoots],
|
|
11136
|
+
cwd,
|
|
11137
|
+
lockedRoots,
|
|
11138
|
+
action: "read",
|
|
11139
|
+
});
|
|
11140
|
+
for (const target of evaluation.outside) {
|
|
11141
|
+
const item = readGateItemFromTarget(
|
|
11142
|
+
name,
|
|
11143
|
+
target,
|
|
11144
|
+
"outside the folder",
|
|
11145
|
+
baseRoot,
|
|
11146
|
+
);
|
|
11147
|
+
if (seen.has(item.key)) continue;
|
|
11148
|
+
seen.add(item.key);
|
|
11149
|
+
items.push(item);
|
|
11150
|
+
}
|
|
11151
|
+
if (evaluation.outside.length === 0) {
|
|
11152
|
+
for (const reason of classification.uncertain) {
|
|
11153
|
+
const key = `cmd:${reason}`;
|
|
11154
|
+
if (seen.has(key)) continue;
|
|
11155
|
+
seen.add(key);
|
|
11156
|
+
items.push({
|
|
11157
|
+
tool: name,
|
|
11158
|
+
target: { path: reason, kind: "file" },
|
|
11159
|
+
grantRoot: "",
|
|
11160
|
+
why: reason,
|
|
11161
|
+
key,
|
|
11162
|
+
});
|
|
11163
|
+
}
|
|
11164
|
+
}
|
|
11165
|
+
};
|
|
11166
|
+
for (const call of readGateBatchCalls) {
|
|
11167
|
+
const name = String(call.name ?? "");
|
|
11168
|
+
if (!name) continue;
|
|
11169
|
+
add(name, classifyReadCall(name, call.args, cwd, config));
|
|
11170
|
+
}
|
|
11171
|
+
add(
|
|
11172
|
+
String(currentEvent?.toolName ?? ""),
|
|
11173
|
+
currentClassification,
|
|
11174
|
+
);
|
|
11175
|
+
return items;
|
|
11176
|
+
}
|
|
11177
|
+
|
|
11178
|
+
function deliverReadInstruction(instruction: string, target: string): void {
|
|
11179
|
+
if (!instruction || readGateTurnSteered) return;
|
|
11180
|
+
const rt = reviewRuntime;
|
|
11181
|
+
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
11182
|
+
reviewEnabled: reviewEnabled(),
|
|
11183
|
+
reviewActive: rt?.isRoundActive() === true,
|
|
11184
|
+
hasReviewerSession: rt?.isAlive() === true,
|
|
11185
|
+
manualSessionActive: reviewSessionLive(),
|
|
11186
|
+
});
|
|
11187
|
+
if (!wouldRouteToReviewer) {
|
|
11188
|
+
try {
|
|
11189
|
+
(pi as any).sendUserMessage?.(
|
|
11190
|
+
buildReadGuidanceMessage({ target, instruction }),
|
|
11191
|
+
{ deliverAs: "steer" },
|
|
11192
|
+
);
|
|
11193
|
+
} catch (_e) {
|
|
11194
|
+
void _e;
|
|
11195
|
+
}
|
|
11196
|
+
}
|
|
11197
|
+
readGateTurnSteered = true;
|
|
11198
|
+
}
|
|
11199
|
+
|
|
11200
|
+
/**
|
|
11201
|
+
* Read permission gate. Applies in every workflow mode and to every role,
|
|
11202
|
+
* including Build/explorer/reviewer-adjacent calls. The shared folder floor
|
|
11203
|
+
* decides scope; the sensitive list always wins; the dialog is batched once
|
|
11204
|
+
* per assistant turn.
|
|
11205
|
+
*/
|
|
11206
|
+
async function readPolicyGate(
|
|
11207
|
+
event: any,
|
|
11208
|
+
ctx: any,
|
|
11209
|
+
cwd: string,
|
|
11210
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
11211
|
+
try {
|
|
11212
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
11213
|
+
const readResolved = readReadPolicyConfig(cwd, getAgentDir(), {
|
|
11214
|
+
trusted,
|
|
11215
|
+
});
|
|
11216
|
+
const folderResolved = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11217
|
+
trusted,
|
|
11218
|
+
});
|
|
11219
|
+
const config = readResolved.config;
|
|
11220
|
+
const folderConfig = folderResolved.config;
|
|
11221
|
+
const classification = classifyReadCall(
|
|
11222
|
+
String(event.toolName ?? ""),
|
|
11223
|
+
event.input,
|
|
11224
|
+
cwd,
|
|
11225
|
+
config,
|
|
11226
|
+
);
|
|
11227
|
+
if (classification.exempt) return undefined;
|
|
11228
|
+
if (
|
|
11229
|
+
classification.targets.length === 0 &&
|
|
11230
|
+
classification.uncertain.length === 0
|
|
11231
|
+
)
|
|
11232
|
+
return undefined;
|
|
11233
|
+
|
|
11234
|
+
const grants = [...config.allowedPaths, ...readGateTurnGrantedRoots];
|
|
11235
|
+
const evaluation = evaluateFolderTargets(classification.targets, {
|
|
11236
|
+
config: folderConfig,
|
|
11237
|
+
grants,
|
|
11238
|
+
cwd,
|
|
11239
|
+
lockedRoots: folderResolved.locked?.roots,
|
|
11240
|
+
action: "read",
|
|
11241
|
+
});
|
|
11242
|
+
if (evaluation.sensitive.length > 0) {
|
|
11243
|
+
const hit = evaluation.sensitive[0];
|
|
11244
|
+
return {
|
|
11245
|
+
block: true,
|
|
11246
|
+
reason: buildReadDenyReason({
|
|
11247
|
+
kind: "sensitive",
|
|
11248
|
+
target: hit.path,
|
|
11249
|
+
pattern: hit.pattern,
|
|
11250
|
+
}),
|
|
11251
|
+
};
|
|
11252
|
+
}
|
|
11253
|
+
|
|
11254
|
+
const outside = evaluation.outside;
|
|
11255
|
+
const uncertain = classification.uncertain;
|
|
11256
|
+
let decision = decideReadGate({
|
|
11257
|
+
sensitive: false,
|
|
11258
|
+
outside: outside.length > 0,
|
|
11259
|
+
outsideAction: folderConfig.outside,
|
|
11260
|
+
granted: false,
|
|
11261
|
+
uncertain:
|
|
11262
|
+
uncertain.length > 0 || evaluation.uncertain.length > 0,
|
|
11263
|
+
shell: config.shell,
|
|
11264
|
+
});
|
|
11265
|
+
|
|
11266
|
+
if (uncertain.length > 0 && config.shell === "strict") {
|
|
11267
|
+
const allowed = uncertain.every((reason) =>
|
|
11268
|
+
readGateTurnUncertainAllowed.has(reason),
|
|
11269
|
+
);
|
|
11270
|
+
if (allowed) decision = "allow";
|
|
11271
|
+
}
|
|
11272
|
+
if (decision === "allow") return undefined;
|
|
11273
|
+
if (decision === "block") {
|
|
11274
|
+
return {
|
|
11275
|
+
block: true,
|
|
11276
|
+
reason: buildReadDenyReason({
|
|
11277
|
+
kind: "blocked",
|
|
11278
|
+
target:
|
|
11279
|
+
outside.length > 0
|
|
11280
|
+
? outside.map((target) => target.path).join(", ")
|
|
11281
|
+
: uncertain[0] ?? String(event.toolName),
|
|
11282
|
+
outsideAction: folderConfig.outside,
|
|
11283
|
+
}),
|
|
11284
|
+
};
|
|
11285
|
+
}
|
|
11286
|
+
|
|
11287
|
+
const ui = ctx?.ui;
|
|
11288
|
+
if (!ui || !ctx.hasUI) {
|
|
11289
|
+
return {
|
|
11290
|
+
block: true,
|
|
11291
|
+
reason:
|
|
11292
|
+
"Read policy is 'ask' (workflow.folderPolicy): this read needs approval, but no interactive UI is available. " +
|
|
11293
|
+
"Do not retry; report what you need and let the user approve it.",
|
|
11294
|
+
};
|
|
11295
|
+
}
|
|
11296
|
+
|
|
11297
|
+
const callId = String(event.toolCallId ?? "");
|
|
11298
|
+
if (!readGateBatchCallIds.has(callId)) {
|
|
11299
|
+
readGateTurnAnswer = null;
|
|
11300
|
+
readGateTurnSteered = false;
|
|
11301
|
+
readGateBatchCalls = [];
|
|
11302
|
+
readGateBatchCallIds = new Set();
|
|
11303
|
+
readGateTurnGrantedRoots.clear();
|
|
11304
|
+
readGateTurnUncertainAllowed.clear();
|
|
11305
|
+
}
|
|
11306
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
11307
|
+
const items = collectReadGateItems(
|
|
11308
|
+
event,
|
|
11309
|
+
classification,
|
|
11310
|
+
config,
|
|
11311
|
+
folderConfig,
|
|
11312
|
+
cwd,
|
|
11313
|
+
folderResolved.locked?.roots,
|
|
11314
|
+
baseRoot,
|
|
11315
|
+
);
|
|
11316
|
+
const currentItems = items.filter((item) => {
|
|
11317
|
+
if (outside.length > 0)
|
|
11318
|
+
return outside.some((target) => item.key === readGateTargetKey(target));
|
|
11319
|
+
return uncertain.some((reason) => item.key === `cmd:${reason}`);
|
|
11320
|
+
});
|
|
11321
|
+
const currentItem = currentItems[0];
|
|
11322
|
+
const remembered = currentItem
|
|
11323
|
+
? readGateRunDenials.get(currentItem.key)
|
|
11324
|
+
: undefined;
|
|
11325
|
+
if (remembered) {
|
|
11326
|
+
return {
|
|
11327
|
+
block: true,
|
|
11328
|
+
reason: buildReadDenyReason({
|
|
11329
|
+
kind: "outside",
|
|
11330
|
+
target: readGateTargetsLabel(currentItems) || "the requested read",
|
|
11331
|
+
instruction: remembered.instruction,
|
|
11332
|
+
}),
|
|
11333
|
+
};
|
|
11334
|
+
}
|
|
11335
|
+
if (readGateTurnAnswer) {
|
|
11336
|
+
if (readGateTurnAnswer.choice === "deny") {
|
|
11337
|
+
return {
|
|
11338
|
+
block: true,
|
|
11339
|
+
reason: buildReadDenyReason({
|
|
11340
|
+
kind: "outside",
|
|
11341
|
+
target: readGateTargetsLabel(currentItems) || "the requested read",
|
|
11342
|
+
instruction: readGateTurnAnswer.instruction,
|
|
11343
|
+
}),
|
|
11344
|
+
};
|
|
11345
|
+
}
|
|
11346
|
+
return undefined;
|
|
11347
|
+
}
|
|
11348
|
+
|
|
11349
|
+
const options = readGateOptions(readGateItemsKind(items));
|
|
11350
|
+
let sel: unknown;
|
|
11351
|
+
try {
|
|
11352
|
+
sel = await ui.select(readGateTitle(items, baseRoot), options);
|
|
11353
|
+
} catch {
|
|
11354
|
+
sel = undefined;
|
|
11355
|
+
}
|
|
11356
|
+
const choice = parseReadGateChoice(sel);
|
|
11357
|
+
if (choice === "allow-once") {
|
|
11358
|
+
for (const item of items) if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
|
|
11359
|
+
for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
|
|
11360
|
+
readGateTurnAnswer = { choice: "allow-once", instruction: "" };
|
|
11361
|
+
return undefined;
|
|
11362
|
+
}
|
|
11363
|
+
if (choice === "always") {
|
|
11364
|
+
const roots = [
|
|
11365
|
+
...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
|
|
11366
|
+
];
|
|
11367
|
+
if (roots.length === 0) {
|
|
11368
|
+
for (const reason of uncertain)
|
|
11369
|
+
readGateTurnUncertainAllowed.add(reason);
|
|
11370
|
+
readGateTurnAnswer = { choice: "always", instruction: "" };
|
|
11371
|
+
return undefined;
|
|
11372
|
+
}
|
|
11373
|
+
const broad = roots.find((root) => isBroadGrantRoot(root));
|
|
11374
|
+
if (broad) {
|
|
11375
|
+
return {
|
|
11376
|
+
block: true,
|
|
11377
|
+
reason:
|
|
11378
|
+
`Read policy: \`${broad}\` is too broad to persist from a dialog. Choose Allow once, or use /folder-policy any deliberately.`,
|
|
11379
|
+
};
|
|
11380
|
+
}
|
|
11381
|
+
if (folderConfig.grantScope === "project" && !trusted) {
|
|
11382
|
+
return {
|
|
11383
|
+
block: true,
|
|
11384
|
+
reason:
|
|
11385
|
+
"Read policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
11386
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
11387
|
+
};
|
|
11388
|
+
}
|
|
11389
|
+
const written = writeReadPolicyConfig(
|
|
11390
|
+
folderConfig.grantScope,
|
|
11391
|
+
cwd,
|
|
11392
|
+
getAgentDir(),
|
|
11393
|
+
{ allowedPaths: roots },
|
|
11394
|
+
);
|
|
11395
|
+
if (!written.ok) {
|
|
11396
|
+
return {
|
|
11397
|
+
block: true,
|
|
11398
|
+
reason: `Read policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}).`,
|
|
11399
|
+
};
|
|
11400
|
+
}
|
|
11401
|
+
for (const root of roots) readGateTurnGrantedRoots.add(root);
|
|
11402
|
+
for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
|
|
11403
|
+
readGateTurnAnswer = { choice: "always", instruction: "" };
|
|
11404
|
+
return undefined;
|
|
11405
|
+
}
|
|
11406
|
+
|
|
9174
11407
|
let instruction = "";
|
|
9175
11408
|
if (choice === "deny-with-instructions") {
|
|
9176
11409
|
const inputFn = (ui as any).input;
|
|
9177
11410
|
if (typeof inputFn === "function") {
|
|
9178
|
-
let typed: unknown;
|
|
9179
11411
|
try {
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
11412
|
+
instruction = sanitizeReadInstruction(
|
|
11413
|
+
await inputFn.call(
|
|
11414
|
+
ui,
|
|
11415
|
+
READ_INSTRUCTION_PROMPT_TITLE,
|
|
11416
|
+
READ_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
11417
|
+
),
|
|
9184
11418
|
);
|
|
9185
|
-
} catch
|
|
9186
|
-
|
|
9187
|
-
typed = undefined;
|
|
11419
|
+
} catch {
|
|
11420
|
+
instruction = "";
|
|
9188
11421
|
}
|
|
9189
|
-
instruction = sanitizeWriteInstruction(typed);
|
|
9190
11422
|
}
|
|
9191
|
-
// No input dialog, a cancelled dialog, or empty text → plain denial
|
|
9192
|
-
// below. A cancel is never an implicit allow.
|
|
9193
|
-
}
|
|
9194
|
-
|
|
9195
|
-
// The answer covers every gated write of this turn.
|
|
9196
|
-
writeGateTurnAnswer = { choice, instruction };
|
|
9197
|
-
// …and the denial is remembered for the REST OF THE RUN, under every
|
|
9198
|
-
// target of this turn: a later retry is auto-denied with the same
|
|
9199
|
-
// sentence instead of asking the user again.
|
|
9200
|
-
for (const item of items) {
|
|
9201
|
-
writeGateRunDenials.set(item.key, { instruction });
|
|
9202
11423
|
}
|
|
9203
|
-
|
|
9204
|
-
const
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
try {
|
|
9220
|
-
pi.appendEntry("workflow-write-denial", {
|
|
9221
|
-
tool: String(event.toolName ?? ""),
|
|
9222
|
-
target,
|
|
9223
|
-
instruction,
|
|
9224
|
-
at: Date.now(),
|
|
9225
|
-
targets: items.map((item) => item.target),
|
|
9226
|
-
});
|
|
9227
|
-
} catch (_e) {
|
|
9228
|
-
void _e;
|
|
9229
|
-
}
|
|
11424
|
+
readGateTurnAnswer = { choice: "deny", instruction };
|
|
11425
|
+
for (const item of items) readGateRunDenials.set(item.key, { instruction });
|
|
11426
|
+
if (instruction)
|
|
11427
|
+
deliverReadInstruction(
|
|
11428
|
+
instruction,
|
|
11429
|
+
readGateTargetsLabel(items) || "the requested read",
|
|
11430
|
+
);
|
|
11431
|
+
try {
|
|
11432
|
+
pi.appendEntry("workflow-read-denial", {
|
|
11433
|
+
tool: String(event.toolName ?? ""),
|
|
11434
|
+
target: readGateTargetsLabel(items),
|
|
11435
|
+
instruction,
|
|
11436
|
+
at: Date.now(),
|
|
11437
|
+
});
|
|
11438
|
+
} catch (_e) {
|
|
11439
|
+
void _e;
|
|
9230
11440
|
}
|
|
9231
|
-
|
|
9232
11441
|
return {
|
|
9233
11442
|
block: true,
|
|
9234
|
-
reason
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
// whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
|
|
9240
|
-
// would end the run with no model acknowledgement at all: a
|
|
9241
|
-
// behaviour change nobody asked for. The guided path gets its next
|
|
9242
|
-
// turn from the queued steer message instead.
|
|
11443
|
+
reason: buildReadDenyReason({
|
|
11444
|
+
kind: "outside",
|
|
11445
|
+
target: readGateTargetsLabel(items) || "the requested read",
|
|
11446
|
+
instruction,
|
|
11447
|
+
}),
|
|
9243
11448
|
};
|
|
9244
|
-
} catch (
|
|
9245
|
-
// Fail closed: a broken policy read must not become an open door.
|
|
11449
|
+
} catch (error: unknown) {
|
|
9246
11450
|
return {
|
|
9247
11451
|
block: true,
|
|
9248
11452
|
reason:
|
|
9249
|
-
`
|
|
9250
|
-
"Do not retry
|
|
11453
|
+
`Read policy could not be evaluated (${error instanceof Error ? error.message : String(error)}), so the read was blocked. ` +
|
|
11454
|
+
"Do not retry; ask the user to fix /read-policy or /folder-policy.",
|
|
9251
11455
|
};
|
|
9252
11456
|
}
|
|
9253
11457
|
}
|
|
@@ -9385,15 +11589,19 @@ export default function workflowExtension(
|
|
|
9385
11589
|
reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
|
|
9386
11590
|
} as any;
|
|
9387
11591
|
}
|
|
9388
|
-
// ── Write permission gate (workflow.writePolicy
|
|
9389
|
-
//
|
|
9390
|
-
//
|
|
9391
|
-
//
|
|
9392
|
-
|
|
9393
|
-
if (workflowMode === null) {
|
|
11592
|
+
// ── Write permission gate (workflow.writePolicy + shared folder floor) ──
|
|
11593
|
+
// The write MODE is a Default-mode knob; the folder floor applies in every
|
|
11594
|
+
// mode. It runs after the role/commit rules and before the build-todo
|
|
11595
|
+
// guard so a folder/sensitive refusal wins.
|
|
11596
|
+
{
|
|
9394
11597
|
const writeBlock = await writePolicyGate(event, ctx, cwd);
|
|
9395
11598
|
if (writeBlock) return writeBlock as any;
|
|
9396
11599
|
}
|
|
11600
|
+
// ── Read permission gate (workflow.readPolicy + shared folder floor) ──
|
|
11601
|
+
{
|
|
11602
|
+
const readBlock = await readPolicyGate(event, ctx, cwd);
|
|
11603
|
+
if (readBlock) return readBlock as any;
|
|
11604
|
+
}
|
|
9397
11605
|
// In build mode, ensure todos exist before first real edit
|
|
9398
11606
|
if (
|
|
9399
11607
|
(event.toolName === "edit" || event.toolName === "write") &&
|
|
@@ -9455,7 +11663,73 @@ export default function workflowExtension(
|
|
|
9455
11663
|
seedCommittedStepRefs();
|
|
9456
11664
|
const completed = uncommittedDoneSteps();
|
|
9457
11665
|
const dirty = await isDirty(pi as any, cwd);
|
|
9458
|
-
const
|
|
11666
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
11667
|
+
const commitConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
11668
|
+
trusted,
|
|
11669
|
+
}).config;
|
|
11670
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11671
|
+
trusted,
|
|
11672
|
+
}).config;
|
|
11673
|
+
const repoRoot = detectRepoRoot(cwd) ?? cwd;
|
|
11674
|
+
const folder = evaluateFolderTargets(
|
|
11675
|
+
[{ path: repoRoot, kind: "dir" }],
|
|
11676
|
+
{
|
|
11677
|
+
config: folderConfig,
|
|
11678
|
+
grants: commitConfig.allowedPaths,
|
|
11679
|
+
cwd,
|
|
11680
|
+
action: "commit",
|
|
11681
|
+
},
|
|
11682
|
+
);
|
|
11683
|
+
if (folder.outside.length > 0) {
|
|
11684
|
+
ctx?.ui?.notify?.(
|
|
11685
|
+
`Per-step commit skipped: ${repoRoot} is outside the permitted folders (use /folder-policy or grant it first).`,
|
|
11686
|
+
"warning",
|
|
11687
|
+
);
|
|
11688
|
+
return;
|
|
11689
|
+
}
|
|
11690
|
+
const status = runGitZ(repoRoot, [
|
|
11691
|
+
"status",
|
|
11692
|
+
"--porcelain=v1",
|
|
11693
|
+
"-z",
|
|
11694
|
+
"--untracked-files=all",
|
|
11695
|
+
]);
|
|
11696
|
+
if (!status.ok) {
|
|
11697
|
+
ctx?.ui?.notify?.(
|
|
11698
|
+
`Per-step commit skipped: sensitive-file check could not run (${status.error ?? "git status failed"}).`,
|
|
11699
|
+
"warning",
|
|
11700
|
+
);
|
|
11701
|
+
return;
|
|
11702
|
+
}
|
|
11703
|
+
const sensitive = status.paths
|
|
11704
|
+
.map((p) => matchSensitivePath(p, folderConfig, repoRoot))
|
|
11705
|
+
.find((hit) => hit.sensitive);
|
|
11706
|
+
if (sensitive) {
|
|
11707
|
+
ctx?.ui?.notify?.(
|
|
11708
|
+
`Per-step commit skipped: ${sensitive.path} is sensitive. Remove it from the working tree/index or add a manual sensitiveExemptions entry.`,
|
|
11709
|
+
"warning",
|
|
11710
|
+
);
|
|
11711
|
+
return;
|
|
11712
|
+
}
|
|
11713
|
+
const statusFolder = evaluateFolderTargets(
|
|
11714
|
+
status.paths.map((p) => ({
|
|
11715
|
+
path: path.join(repoRoot, p),
|
|
11716
|
+
kind: "file" as const,
|
|
11717
|
+
})),
|
|
11718
|
+
{
|
|
11719
|
+
config: folderConfig,
|
|
11720
|
+
grants: commitConfig.allowedPaths,
|
|
11721
|
+
cwd,
|
|
11722
|
+
action: "commit",
|
|
11723
|
+
},
|
|
11724
|
+
);
|
|
11725
|
+
if (statusFolder.outside.length > 0 || statusFolder.uncertain.length > 0) {
|
|
11726
|
+
ctx?.ui?.notify?.(
|
|
11727
|
+
`Per-step commit skipped: staged paths leave the permitted folders or could not be verified. Review the working tree and commit manually.`,
|
|
11728
|
+
"warning",
|
|
11729
|
+
);
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
const mode = commitConfig.mode;
|
|
9459
11733
|
const decision = stepCommitDecision({
|
|
9460
11734
|
mode,
|
|
9461
11735
|
hasTodos: todoItems.length > 0,
|
|
@@ -9512,6 +11786,44 @@ export default function workflowExtension(
|
|
|
9512
11786
|
}
|
|
9513
11787
|
}
|
|
9514
11788
|
|
|
11789
|
+
// Output redaction is defence in depth for channels the tool gate cannot
|
|
11790
|
+
// fully see (grep --hidden, git diff/show, truncated bash output).
|
|
11791
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
11792
|
+
try {
|
|
11793
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
11794
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11795
|
+
trusted: (ctx as any)?.isProjectTrusted?.() === true,
|
|
11796
|
+
}).config;
|
|
11797
|
+
return redactToolResult(event as any, folderConfig, cwd) as any;
|
|
11798
|
+
} catch (error: unknown) {
|
|
11799
|
+
const tool = String((event as any)?.toolName ?? "");
|
|
11800
|
+
if (
|
|
11801
|
+
[
|
|
11802
|
+
"read",
|
|
11803
|
+
"read_symbol",
|
|
11804
|
+
"read_enclosing",
|
|
11805
|
+
"module_report",
|
|
11806
|
+
"grep",
|
|
11807
|
+
"find",
|
|
11808
|
+
"ls",
|
|
11809
|
+
"bash",
|
|
11810
|
+
"powershell",
|
|
11811
|
+
].includes(tool)
|
|
11812
|
+
) {
|
|
11813
|
+
return {
|
|
11814
|
+
content: [
|
|
11815
|
+
{
|
|
11816
|
+
type: "text",
|
|
11817
|
+
text: `[read policy redaction failed — output withheld: ${error instanceof Error ? error.message : String(error)}]`,
|
|
11818
|
+
},
|
|
11819
|
+
],
|
|
11820
|
+
structuredContent: undefined,
|
|
11821
|
+
} as any;
|
|
11822
|
+
}
|
|
11823
|
+
return undefined;
|
|
11824
|
+
}
|
|
11825
|
+
});
|
|
11826
|
+
|
|
9515
11827
|
pi.on("tool_result", async (_event, ctx) => {
|
|
9516
11828
|
try {
|
|
9517
11829
|
const leaf =
|
|
@@ -9564,6 +11876,10 @@ export default function workflowExtension(
|
|
|
9564
11876
|
writeGateBatchCallIds = callIds;
|
|
9565
11877
|
writeGateTurnAnswer = null;
|
|
9566
11878
|
writeGateTurnSteered = false;
|
|
11879
|
+
readGateBatchCalls = calls;
|
|
11880
|
+
readGateBatchCallIds = callIds;
|
|
11881
|
+
readGateTurnAnswer = null;
|
|
11882
|
+
readGateTurnSteered = false;
|
|
9567
11883
|
} catch (_e) {
|
|
9568
11884
|
void _e;
|
|
9569
11885
|
}
|
|
@@ -9577,6 +11893,12 @@ export default function workflowExtension(
|
|
|
9577
11893
|
writeGateTurnSteered = false;
|
|
9578
11894
|
writeGateBatchCalls = [];
|
|
9579
11895
|
writeGateBatchCallIds = new Set();
|
|
11896
|
+
readGateTurnAnswer = null;
|
|
11897
|
+
readGateTurnSteered = false;
|
|
11898
|
+
readGateBatchCalls = [];
|
|
11899
|
+
readGateBatchCallIds = new Set();
|
|
11900
|
+
readGateTurnGrantedRoots.clear();
|
|
11901
|
+
readGateTurnUncertainAllowed.clear();
|
|
9580
11902
|
// Seed the per-step commit ledger once per session: steps already done
|
|
9581
11903
|
// (restored sessions, re-executed plans) never trigger a spurious commit.
|
|
9582
11904
|
try {
|
|
@@ -9627,6 +11949,13 @@ export default function workflowExtension(
|
|
|
9627
11949
|
writeGateTurnSteered = false;
|
|
9628
11950
|
writeGateBatchCalls = [];
|
|
9629
11951
|
writeGateBatchCallIds = new Set();
|
|
11952
|
+
readGateRunDenials.clear();
|
|
11953
|
+
readGateTurnAnswer = null;
|
|
11954
|
+
readGateTurnSteered = false;
|
|
11955
|
+
readGateBatchCalls = [];
|
|
11956
|
+
readGateBatchCallIds = new Set();
|
|
11957
|
+
readGateTurnGrantedRoots.clear();
|
|
11958
|
+
readGateTurnUncertainAllowed.clear();
|
|
9630
11959
|
// keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
|
|
9631
11960
|
});
|
|
9632
11961
|
|
|
@@ -9675,12 +12004,47 @@ export default function workflowExtension(
|
|
|
9675
12004
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
9676
12005
|
currentSessionCtx = ctx as any;
|
|
9677
12006
|
ensurePlannotatorListeners();
|
|
12007
|
+
// Safety valve for a prompt that bypassed `input` (host queue, another
|
|
12008
|
+
// extension): never let a Review turn run concurrently with a plan/build
|
|
12009
|
+
// turn. Stop the reviewer first, then let the queued prompt proceed.
|
|
12010
|
+
if (reviewTurnOwns(reviewTurn) && reviewTurn !== "transition") {
|
|
12011
|
+
// Invalidate the parked handoff BEFORE settling its gate: otherwise the
|
|
12012
|
+
// runHandoff continuation can observe the old cycle token and render the
|
|
12013
|
+
// abandoned plan alongside the new prompt.
|
|
12014
|
+
reviewCycleId += 1;
|
|
12015
|
+
try {
|
|
12016
|
+
await suspendReview();
|
|
12017
|
+
} catch (_e) {
|
|
12018
|
+
void _e;
|
|
12019
|
+
}
|
|
12020
|
+
endReviewTurn();
|
|
12021
|
+
notifyReview(
|
|
12022
|
+
"Review stopped — a prompt was already queued; the plan was not reviewed.",
|
|
12023
|
+
"info",
|
|
12024
|
+
);
|
|
12025
|
+
}
|
|
9678
12026
|
// Fresh user prompt → reset the passive reminder cadence. Continuation runs
|
|
9679
12027
|
// (triggerTurn/steer/followUp) do not fire this event, so state survives them.
|
|
9680
12028
|
turnsSinceLastTodoWrite = 0;
|
|
9681
12029
|
turnsSinceLastReminder = 0;
|
|
9682
12030
|
mutatedSinceLastTodoWrite = false;
|
|
9683
12031
|
footerInjectedThisTurn = false;
|
|
12032
|
+
// A real user prompt means the user has moved on from a finished plan:
|
|
12033
|
+
// hide the one-line todo panel. `custom` runs (triggerTurn) must not do
|
|
12034
|
+
// this — they can be self-initiated while the user is still reading.
|
|
12035
|
+
if (
|
|
12036
|
+
event.messageType === "user" &&
|
|
12037
|
+
isTodoListSettled(todoItems) &&
|
|
12038
|
+
!todoPanelPromptAfterSettle
|
|
12039
|
+
) {
|
|
12040
|
+
todoPanelPromptAfterSettle = true;
|
|
12041
|
+
updateStatus(ctx);
|
|
12042
|
+
try {
|
|
12043
|
+
persistState();
|
|
12044
|
+
} catch (_e) {
|
|
12045
|
+
void _e;
|
|
12046
|
+
}
|
|
12047
|
+
}
|
|
9684
12048
|
// A fresh user prompt starts a new handoff/provenance run. It also
|
|
9685
12049
|
// supersedes any pending browser/inline decision immediately: the user
|
|
9686
12050
|
// moved on, so a stale tab must not approve the previous plan. Follow-up
|
|
@@ -9705,10 +12069,14 @@ export default function workflowExtension(
|
|
|
9705
12069
|
}
|
|
9706
12070
|
// Every fresh prompt is a new review cycle: reset the round budget, cancel
|
|
9707
12071
|
// an abandoned pause/gate, and tag the cycle so an old handoff cannot
|
|
9708
|
-
// render after this prompt.
|
|
12072
|
+
// render after this prompt. A feedback/refine continuation keeps the
|
|
12073
|
+
// manual cycle alive; a genuine user prompt ends it.
|
|
12074
|
+
const feedbackTurn = reviewFeedbackTurn;
|
|
12075
|
+
reviewFeedbackTurn = false;
|
|
9709
12076
|
try {
|
|
12077
|
+
if (!feedbackTurn) endReviewTurn();
|
|
9710
12078
|
await beginReviewCycle({
|
|
9711
|
-
reason: "prompt",
|
|
12079
|
+
reason: feedbackTurn ? "refine" : "prompt",
|
|
9712
12080
|
keepFindings: true,
|
|
9713
12081
|
planPath: currentPlanFile,
|
|
9714
12082
|
});
|
|
@@ -9895,6 +12263,25 @@ export default function workflowExtension(
|
|
|
9895
12263
|
/** Plan path/hash the currently rendered handoff prompt belongs to. */
|
|
9896
12264
|
let pendingHandoffPlanPath: string | null = null;
|
|
9897
12265
|
let pendingHandoffPlanHash: string | null = null;
|
|
12266
|
+
let pendingHandoffRequestId: string | null = null;
|
|
12267
|
+
let pendingHandoffSource: ReviewHandoffSource | null = null;
|
|
12268
|
+
let pendingHandoffCycleId = 0;
|
|
12269
|
+
let pendingHandoffReviewUrl: string | null = null;
|
|
12270
|
+
let pendingHandoffPlanTextLen = 0;
|
|
12271
|
+
/** The one in-flight `plannotator:request` waiter; settles exactly once. */
|
|
12272
|
+
let pendingPlanReviewRequest:
|
|
12273
|
+
| {
|
|
12274
|
+
requestId: string;
|
|
12275
|
+
settled: boolean;
|
|
12276
|
+
finish: (outcome: PlanReviewRequestOutcome) => void;
|
|
12277
|
+
}
|
|
12278
|
+
| undefined;
|
|
12279
|
+
/** The review id already recorded in the transcript (one entry per review). */
|
|
12280
|
+
let recordedHandoffReviewId: string | null = null;
|
|
12281
|
+
/** A manual `/review` owns the following feedback cycle (even when auto is off). */
|
|
12282
|
+
let manualReviewCycle = false;
|
|
12283
|
+
/** One reload recovery at a time (session_start may re-enter). */
|
|
12284
|
+
let recoveryInFlight: Promise<void> | null = null;
|
|
9898
12285
|
/** Last path announced by the suppressed-handoff notice (per run). */
|
|
9899
12286
|
let lastPlanReadyNoticeKey: string | null = null;
|
|
9900
12287
|
|
|
@@ -9919,18 +12306,88 @@ export default function workflowExtension(
|
|
|
9919
12306
|
* switch supersedes it; stale browser events must not approve the old plan.
|
|
9920
12307
|
* Returns true when persisted state changed so the caller can persist once.
|
|
9921
12308
|
*/
|
|
12309
|
+
/** One `/review-status` row for a browser handoff awaiting a decision. */
|
|
12310
|
+
function pendingHandoffStatusLine(): string {
|
|
12311
|
+
if (!awaitingDecision) return "";
|
|
12312
|
+
const age = lastHandoffAt
|
|
12313
|
+
? Math.max(0, Math.round((Date.now() - lastHandoffAt) / 1000))
|
|
12314
|
+
: 0;
|
|
12315
|
+
return ` handoff : browser pending — ${lastReviewId ?? "review id unknown"}${
|
|
12316
|
+
pendingHandoffReviewUrl ? ` @ ${pendingHandoffReviewUrl}` : ""
|
|
12317
|
+
} (${pendingHandoffSource ?? "auto"}, ${age}s)`;
|
|
12318
|
+
}
|
|
12319
|
+
|
|
9922
12320
|
function clearPendingHandoffDecision(): boolean {
|
|
9923
12321
|
const changed =
|
|
9924
12322
|
awaitingDecision === true ||
|
|
9925
12323
|
lastHandoffAt !== undefined ||
|
|
9926
12324
|
lastReviewId !== null ||
|
|
9927
12325
|
pendingHandoffPlanPath !== null ||
|
|
9928
|
-
pendingHandoffPlanHash !== null
|
|
12326
|
+
pendingHandoffPlanHash !== null ||
|
|
12327
|
+
pendingHandoffRequestId !== null ||
|
|
12328
|
+
pendingHandoffSource !== null ||
|
|
12329
|
+
pendingHandoffCycleId !== 0 ||
|
|
12330
|
+
pendingHandoffReviewUrl !== null;
|
|
9929
12331
|
awaitingDecision = false;
|
|
9930
12332
|
lastHandoffAt = undefined;
|
|
9931
12333
|
lastReviewId = null;
|
|
9932
12334
|
pendingHandoffPlanPath = null;
|
|
9933
12335
|
pendingHandoffPlanHash = null;
|
|
12336
|
+
pendingHandoffRequestId = null;
|
|
12337
|
+
pendingHandoffSource = null;
|
|
12338
|
+
pendingHandoffCycleId = 0;
|
|
12339
|
+
pendingHandoffReviewUrl = null;
|
|
12340
|
+
pendingHandoffPlanTextLen = 0;
|
|
12341
|
+
recordedHandoffReviewId = null;
|
|
12342
|
+
return changed;
|
|
12343
|
+
}
|
|
12344
|
+
|
|
12345
|
+
/**
|
|
12346
|
+
* Drop a gate that cannot belong to the round/plan about to start.
|
|
12347
|
+
*
|
|
12348
|
+
* A new automatic round, a manual `/review`, or a browser decision arriving
|
|
12349
|
+
* while another round runs all supersede the old gate. A duplicate event for
|
|
12350
|
+
* the same plan outside a new round must not clear a legitimate pending
|
|
12351
|
+
* prompt, so the pure `pendingHandoffIsStale` predicate decides.
|
|
12352
|
+
*/
|
|
12353
|
+
function supersedePendingHandoff(
|
|
12354
|
+
reason: string,
|
|
12355
|
+
opts: {
|
|
12356
|
+
force?: boolean;
|
|
12357
|
+
nextCycleId?: number;
|
|
12358
|
+
nextPlanHash?: string;
|
|
12359
|
+
} = {},
|
|
12360
|
+
): boolean {
|
|
12361
|
+
if (
|
|
12362
|
+
!pendingHandoffIsStale({
|
|
12363
|
+
awaiting: awaitingDecision,
|
|
12364
|
+
force: opts.force,
|
|
12365
|
+
pendingCycleId:
|
|
12366
|
+
pendingHandoffCycleId > 0 ? pendingHandoffCycleId : undefined,
|
|
12367
|
+
currentCycleId: opts.nextCycleId ?? reviewCycleId,
|
|
12368
|
+
pendingPlanHash: pendingHandoffPlanHash ?? undefined,
|
|
12369
|
+
nextPlanHash: opts.nextPlanHash,
|
|
12370
|
+
})
|
|
12371
|
+
) {
|
|
12372
|
+
return false;
|
|
12373
|
+
}
|
|
12374
|
+
const changed = clearPendingHandoffDecision();
|
|
12375
|
+
if (changed) {
|
|
12376
|
+
notifyReview(`Plan handoff superseded — ${reason}.`, "info");
|
|
12377
|
+
try {
|
|
12378
|
+
pi.appendEntry("workflow-handoff-superseded", {
|
|
12379
|
+
reason,
|
|
12380
|
+
at: Date.now(),
|
|
12381
|
+
});
|
|
12382
|
+
} catch (_e) {
|
|
12383
|
+
void _e;
|
|
12384
|
+
}
|
|
12385
|
+
try {
|
|
12386
|
+
persistState();
|
|
12387
|
+
} catch (_e) {
|
|
12388
|
+
void _e;
|
|
12389
|
+
}
|
|
12390
|
+
}
|
|
9934
12391
|
return changed;
|
|
9935
12392
|
}
|
|
9936
12393
|
|
|
@@ -10232,26 +12689,36 @@ export default function workflowExtension(
|
|
|
10232
12689
|
}
|
|
10233
12690
|
// Build completion
|
|
10234
12691
|
if (workflowMode === "build" && todoItems.length > 0) {
|
|
10235
|
-
const allDone = todoItems
|
|
10236
|
-
(t) => isDone(t) || itemStatus(t) === "cancelled",
|
|
10237
|
-
);
|
|
12692
|
+
const allDone = isTodoListSettled(todoItems);
|
|
10238
12693
|
if (allDone) {
|
|
10239
|
-
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10246
|
-
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
|
|
12694
|
+
// One card per list revision: a settled list stays settled across
|
|
12695
|
+
// turns, and re-sending the same card on every agent_end buried the
|
|
12696
|
+
// transcript in duplicates. A new/renamed list or changed statuses
|
|
12697
|
+
// produce a new signature and therefore a new card.
|
|
12698
|
+
const signature = todoCompletionSignature(todoItems);
|
|
12699
|
+
if (signature !== lastCompletedSignature) {
|
|
12700
|
+
lastCompletedSignature = signature;
|
|
12701
|
+
try {
|
|
12702
|
+
(pi as any).sendMessage?.(
|
|
12703
|
+
{
|
|
12704
|
+
customType: "workflow-complete",
|
|
12705
|
+
content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
|
|
12706
|
+
display: true,
|
|
12707
|
+
},
|
|
12708
|
+
{ triggerTurn: false },
|
|
12709
|
+
);
|
|
12710
|
+
} catch (_e) {
|
|
12711
|
+
void _e;
|
|
12712
|
+
}
|
|
10250
12713
|
}
|
|
10251
12714
|
// keep todos for history but stay in build
|
|
10252
12715
|
todoStale = false;
|
|
10253
12716
|
updateStatus(ctx as any);
|
|
10254
12717
|
persistState();
|
|
12718
|
+
} else if (lastCompletedSignature !== null) {
|
|
12719
|
+
// The list reopened (new step or a reverted status): allow the next
|
|
12720
|
+
// completion to emit its own card. Persisted on the next mutation.
|
|
12721
|
+
lastCompletedSignature = null;
|
|
10255
12722
|
}
|
|
10256
12723
|
return;
|
|
10257
12724
|
}
|
|
@@ -10462,17 +12929,21 @@ export default function workflowExtension(
|
|
|
10462
12929
|
void _e;
|
|
10463
12930
|
}
|
|
10464
12931
|
};
|
|
10465
|
-
|
|
12932
|
+
const reviewThisCycle = reviewEnabled() || manualReviewCycle;
|
|
12933
|
+
if (planTextForRender && reviewThisCycle) {
|
|
10466
12934
|
const authorPlan: string = planTextForRender;
|
|
10467
12935
|
reviewHandoffGate.reset();
|
|
10468
12936
|
reviewGatePhase = "auditing";
|
|
10469
12937
|
reviewGateCycleId = handoffCycle;
|
|
12938
|
+
// Review owns the turn before the reviewer boots. A plan handoff with
|
|
12939
|
+
// Review Mode off never reaches this block (no Review turn).
|
|
12940
|
+
beginReviewTurn();
|
|
10470
12941
|
try {
|
|
10471
12942
|
const reviewed = await runReviewStep(
|
|
10472
12943
|
authorPlan,
|
|
10473
12944
|
ctx as ExtensionContext,
|
|
10474
12945
|
(ctx as any).signal as AbortSignal | undefined,
|
|
10475
|
-
"auto",
|
|
12946
|
+
manualReviewCycle ? "manual" : "auto",
|
|
10476
12947
|
);
|
|
10477
12948
|
if (handoffCycle !== reviewCycleId) return;
|
|
10478
12949
|
const reviewedText = reviewed.planText;
|
|
@@ -10587,114 +13058,15 @@ export default function workflowExtension(
|
|
|
10587
13058
|
if (planTextForRender && hasUI && !awaitingDecision) {
|
|
10588
13059
|
// ── Plannotator webview branch (browser review) ──────────
|
|
10589
13060
|
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
|
|
13061
|
+
const handoff = await requestPlanReview({
|
|
13062
|
+
planText: planTextForRender,
|
|
13063
|
+
planPath,
|
|
13064
|
+
source: "auto",
|
|
13065
|
+
ctx: ctx as ExtensionContext,
|
|
13066
|
+
cycleId: handoffCycle,
|
|
13067
|
+
});
|
|
13068
|
+
if (handoff.ok) return;
|
|
13069
|
+
// The helper already cleared the gate; fall through to inline.
|
|
10698
13070
|
}
|
|
10699
13071
|
awaitingDecision = true;
|
|
10700
13072
|
lastHandoffAt = Date.now();
|