@dieulc/workflow 0.10.0 → 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/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,
@@ -187,6 +231,17 @@ import {
187
231
  setWorkflowStatus,
188
232
  WORKFLOW_STATUS_KEYS,
189
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";
190
245
  import {
191
246
  defaultProfileConfig,
192
247
  loadProfilesDetailed,
@@ -256,6 +311,9 @@ import {
256
311
  reviewModeLockKind,
257
312
  reviewModeLockNotice,
258
313
  reviewModeLockStatusLine,
314
+ reviewInputDecision,
315
+ reviewRoundOwnsTurn,
316
+ reviewTurnOwns,
259
317
  reviewNoticeText,
260
318
  reviewPauseIsCurrent,
261
319
  reviewResumeGateOutcome,
@@ -278,6 +336,7 @@ import {
278
336
  type ReviewHandoffPhase,
279
337
  type ReviewHandoffResult,
280
338
  type ReviewModeLockKind,
339
+ type ReviewTurnPhase,
281
340
  type ReviewState,
282
341
  type ReviewTranscriptOp,
283
342
  type ReviewTrigger,
@@ -449,8 +508,17 @@ async function runSingleAgent(
449
508
  shell: false,
450
509
  stdio: ["ignore", "pipe", "pipe"],
451
510
  // Carry the role into the child so its workflow extension gates bash
452
- // with the same policy the parent applies (see permissions.ts).
453
- env: subagentEnv(process.env, agent.role),
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
+ },
454
522
  },
455
523
  );
456
524
  // Track for cleanup on session_shutdown — prevents orphaned subagents
@@ -607,12 +675,33 @@ export default function workflowExtension(
607
675
  null;
608
676
  let writeGateTurnSteered = false;
609
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 }>();
610
689
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
611
690
  let turnsSinceLastTodoWrite = 0;
612
691
  let turnsSinceLastReminder = 0;
613
692
  let mutatedSinceLastTodoWrite = false;
614
693
  let lastRefMisses: { refs: string[] } | undefined;
615
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";
616
705
  let footerInjectedThisTurn = false;
617
706
  let lastStaleNoticeAt: number | undefined;
618
707
  // Path+content hash of the last plan-file reconciliation (skip no-op merges).
@@ -1189,83 +1278,81 @@ export default function workflowExtension(
1189
1278
  todoStale = false;
1190
1279
  }
1191
1280
 
1192
- /** Display ref for a todo row (stable canonical GLOBAL step). */
1193
- function todoLabel(it: TodoItem, index: number): string {
1194
- return todoRef(it) || String(it.step || index + 1);
1195
- }
1196
-
1197
1281
  /**
1198
- * Rows for the todo widget: group headers + labelled items, anchored on the
1199
- * first incomplete item (including its group header), capped for the terminal.
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).
1200
1286
  */
1201
- function buildTodoRows(
1202
- theme: any,
1203
- width: number,
1204
- mode: WorkflowMode | null,
1205
- ): string[] {
1206
- if (todoItems.length === 0) return [];
1207
- const MAX_WIDGET_LINES = 15;
1208
- const contentWidth = Math.max(1, width - 2);
1209
- type Row = { group?: string; item?: TodoItem; index: number };
1210
- const rows: Row[] = [];
1211
- let lastGroup: string | undefined;
1212
- todoItems.forEach((item, index) => {
1213
- if (item.group && item.group !== lastGroup) {
1214
- rows.push({ group: item.group, index });
1215
- lastGroup = item.group;
1216
- }
1217
- 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,
1218
1294
  });
1219
- const firstUndone = todoItems.findIndex(
1220
- (t) => !isDone(t) && itemStatus(t) !== "cancelled",
1221
- );
1222
- const anchorIndex = firstUndone < 0 ? 0 : firstUndone;
1223
- let startRow = rows.findIndex((r) => r.item && r.index === anchorIndex);
1224
- if (startRow < 0) startRow = 0;
1225
- if (startRow > 0 && rows[startRow - 1]?.group) startRow -= 1;
1226
- const visible = rows.slice(startRow, startRow + MAX_WIDGET_LINES);
1227
- const rendered = visible.map((row) => {
1228
- if (row.group) {
1229
- return truncateToWidth(
1230
- theme.fg("muted", `── ${row.group}`),
1231
- contentWidth,
1232
- "…",
1233
- );
1234
- }
1235
- const item = row.item as TodoItem;
1236
- const ref = todoLabel(item, row.index);
1237
- const status = itemStatus(item);
1238
- const isCurrent = row.index === anchorIndex && !isDone(item);
1239
- const prefix =
1240
- isCurrent || status === "in_progress"
1241
- ? theme.fg("accent", "▶ ")
1242
- : status === "completed"
1243
- ? theme.fg("success", "☑ ")
1244
- : status === "cancelled"
1245
- ? theme.fg("dim", "✖ ")
1246
- : theme.fg("dim", "☐ ");
1247
- const labelPart =
1248
- item.label !== undefined && String(item.label) !== ref
1249
- ? theme.fg("muted", ` (${item.label})`)
1250
- : "";
1251
- const body = `${ref}. ${item.text}${labelPart}`;
1252
- let styled: string;
1253
- if (isDone(item)) {
1254
- styled = theme.fg("muted", theme.strikethrough(body));
1255
- } else if (isCurrent && mode === "build") {
1256
- styled = theme.fg("accent", body);
1257
- } else if (mode === "plan" || mode === null) {
1258
- styled = theme.fg("muted", body);
1259
- } else {
1260
- 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;
1261
1314
  }
1262
- return truncateToWidth(`${prefix}${styled}`, contentWidth, "…");
1263
- });
1264
- const remaining = rows.length - (startRow + visible.length);
1265
- if (remaining > 0) {
1266
- rendered.push(theme.fg("muted", `… +${remaining} more`));
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;
1267
1346
  }
1268
- return rendered;
1347
+ todoPanelOverride = override;
1348
+ todoPanelOverrideKey = override === null ? null : todoListKey(todoItems);
1349
+ updateStatus(ctx);
1350
+ try {
1351
+ persistState();
1352
+ } catch (_e) {
1353
+ void _e;
1354
+ }
1355
+ if (note) ctx.ui.notify(note, "info");
1269
1356
  }
1270
1357
 
1271
1358
  function updateStatus(ctx: ExtensionContext) {
@@ -1280,6 +1367,7 @@ export default function workflowExtension(
1280
1367
  commitMode = readCommitPolicyConfig(
1281
1368
  (ctx as any).cwd ?? process.cwd(),
1282
1369
  getAgentDir(),
1370
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1283
1371
  ).config.mode;
1284
1372
  } catch {
1285
1373
  commitMode = "never";
@@ -1293,27 +1381,41 @@ export default function workflowExtension(
1293
1381
  writeMode = readWritePolicyConfig(
1294
1382
  (ctx as any).cwd ?? process.cwd(),
1295
1383
  getAgentDir(),
1384
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1296
1385
  ).config.mode;
1297
1386
  } catch {
1298
1387
  writeMode = "allow";
1299
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
+ }
1300
1399
  const segments = buildWorkflowStatusSegments({
1301
1400
  mode: workflowMode,
1302
- plannotatorActive,
1401
+ reviewPhase: reviewTurn,
1303
1402
  done: todoItems.filter((t) => isDone(t)).length,
1304
1403
  total: todoItems.length,
1305
- stale: todoStale,
1404
+ stale: todoStale && !isTodoListSettled(todoItems),
1306
1405
  profileLabel,
1307
1406
  roleLabel,
1308
1407
  commitMode,
1309
1408
  writeMode: workflowMode === null ? writeMode : null,
1409
+ folderMode,
1310
1410
  });
1311
1411
  const modeTone =
1312
- workflowMode === "build"
1412
+ reviewTurn !== "idle"
1313
1413
  ? "accent"
1314
- : workflowMode === "plan"
1315
- ? "warning"
1316
- : "muted";
1414
+ : workflowMode === "build"
1415
+ ? "accent"
1416
+ : workflowMode === "plan"
1417
+ ? "warning"
1418
+ : "muted";
1317
1419
  setWorkflowStatus(
1318
1420
  ctx.ui,
1319
1421
  WORKFLOW_STATUS_KEYS.mode,
@@ -1330,15 +1432,10 @@ export default function workflowExtension(
1330
1432
  ctx.ui.theme.fg("dim", segments.policy),
1331
1433
  );
1332
1434
  if (todoItems.length > 0) {
1333
- ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1334
- return {
1335
- render: (w: number) => buildTodoRows(theme, w, workflowMode),
1336
- invalidate: () => {},
1337
- dispose: () => {},
1338
- };
1339
- });
1435
+ refreshTodoPanel(ctx);
1340
1436
  } else {
1341
1437
  ctx.ui.setWidget("workflow-todos", undefined);
1438
+ todoPanelAppliedState = "hidden";
1342
1439
  }
1343
1440
  // One status owner: every footer refresh (mode switch, model change, round
1344
1441
  // phase, pause) also re-evaluates the review widget. This is what clears a
@@ -1415,6 +1512,15 @@ export default function workflowExtension(
1415
1512
  let activeReview:
1416
1513
  | { planText: string; round: number; controller: AbortController }
1417
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;
1418
1524
  /** Long-lived ESC listener covering a round and the paused window. */
1419
1525
  let detachReviewEscListener: (() => void) | undefined;
1420
1526
  /**
@@ -1466,9 +1572,47 @@ export default function workflowExtension(
1466
1572
  roundActive,
1467
1573
  paused,
1468
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",
1469
1594
  });
1470
1595
  }
1471
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
+
1472
1616
  /** Refuse a mode transition while a review round or pause owns the turn. */
1473
1617
  function blockModeSwitchDuringReview(
1474
1618
  ctx?: ExtensionContext,
@@ -2124,6 +2268,7 @@ export default function workflowExtension(
2124
2268
  // A gated audit becomes a paused gate (the handoff keeps waiting). A
2125
2269
  // manual review (idle gate) leaves the phase alone.
2126
2270
  if (reviewGatePhase === "auditing") reviewGatePhase = "paused";
2271
+ setReviewTurn("paused");
2127
2272
  refreshReviewPauseUi();
2128
2273
  return true;
2129
2274
  }
@@ -2335,6 +2480,7 @@ export default function workflowExtension(
2335
2480
  // follow `reviewState`; the spinner row needs an explicit message while
2336
2481
  // the child session streams off the main turn.
2337
2482
  setReviewWorkingMessage("🔍 Reviewing plan…");
2483
+ setReviewTurn("running");
2338
2484
  // Paint the live review line now, not after the child session's first
2339
2485
  // state event: a manual `/review` must look alive from the first tick.
2340
2486
  updateStatus(ctx as any);
@@ -2890,6 +3036,7 @@ export default function workflowExtension(
2890
3036
  flushReviewEntries(true);
2891
3037
  resetReviewStream();
2892
3038
  detachReviewEscListenerNow();
3039
+ endReviewTurn();
2893
3040
  try {
2894
3041
  currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
2895
3042
  } catch (_e) {
@@ -3015,6 +3162,15 @@ export default function workflowExtension(
3015
3162
 
3016
3163
  const scope = scopeFor("global");
3017
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
+ }
3018
3174
 
3019
3175
  const res = writeReviewModeConfig(scope, cwd, agentDir, {
3020
3176
  enabled: enable,
@@ -3066,7 +3222,9 @@ export default function workflowExtension(
3066
3222
  : "global";
3067
3223
 
3068
3224
  if (!sub || sub === "status") {
3069
- const resolved = readCommitPolicyConfig(cwd, agentDir);
3225
+ const resolved = readCommitPolicyConfig(cwd, agentDir, {
3226
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3227
+ });
3070
3228
  const paths = commitConfigPaths(cwd, agentDir);
3071
3229
  const where =
3072
3230
  resolved.sources.mode === "default"
@@ -3125,8 +3283,9 @@ export default function workflowExtension(
3125
3283
  });
3126
3284
 
3127
3285
  // ── /write-policy: view/set the write permission (workflow.writePolicy) ──
3128
- // Available in every mode: it only writes settings JSON; the gate itself
3129
- // lives in tool_call and applies in Default mode only.
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.
3130
3289
  pi.registerCommand("write-policy", {
3131
3290
  description:
3132
3291
  "View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
@@ -3144,7 +3303,9 @@ export default function workflowExtension(
3144
3303
  : "global";
3145
3304
 
3146
3305
  if (!sub || sub === "status") {
3147
- const resolved = readWritePolicyConfig(cwd, agentDir);
3306
+ const resolved = readWritePolicyConfig(cwd, agentDir, {
3307
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3308
+ });
3148
3309
  const paths = writePolicyConfigPaths(cwd, agentDir);
3149
3310
  const where =
3150
3311
  resolved.sources.mode === "default"
@@ -3153,7 +3314,8 @@ export default function workflowExtension(
3153
3314
  const lines = [
3154
3315
  `Write policy: ${resolved.config.mode} (${where})`,
3155
3316
  ` ${describeWritePolicyMode(resolved.config.mode)}`,
3156
- " Applies to Default mode only — Plan mode is read-only already, /build is the execution escape hatch.",
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.",
3157
3319
  ];
3158
3320
  if (resolved.config.allowedPaths.length > 0) {
3159
3321
  lines.push(
@@ -3227,6 +3389,133 @@ export default function workflowExtension(
3227
3389
  // gate: it reads the same analyzeCommand/explainCommandRefusal the gates
3228
3390
  // use, so the explanation cannot describe a policy the gate no longer
3229
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
+
3230
3519
  pi.registerCommand("permissions", {
3231
3520
  description:
3232
3521
  "Explain why a shell command would be allowed or refused: /permissions explain <command>",
@@ -3235,7 +3524,55 @@ export default function workflowExtension(
3235
3524
  const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
3236
3525
  // `/permissions explain <cmd>` and `/permissions <cmd>` both work.
3237
3526
  const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
3238
- ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
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");
3239
3576
  },
3240
3577
  getArgumentCompletions: (prefix: string) =>
3241
3578
  permissionsArgumentCompletions(prefix),
@@ -3506,6 +3843,9 @@ export default function workflowExtension(
3506
3843
  abortedReviewHash = undefined;
3507
3844
  pausedReview = undefined;
3508
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();
3509
3849
  const result = await runReviewStep(
3510
3850
  target.text,
3511
3851
  ctx as any,
@@ -3580,6 +3920,7 @@ export default function workflowExtension(
3580
3920
  // A manual pause (idle gate) is unchanged; the runtime/activeReview
3581
3921
  // flags carry that lock.
3582
3922
  reviewGatePhase = reviewGatePhaseOnResume(reviewGatePhase);
3923
+ setReviewTurn("running");
3583
3924
  // A stale quiet-abort marker must not silence this explicit resume.
3584
3925
  quietAbortHash = undefined;
3585
3926
  // If the plan file moved on while paused, review the new revision fresh
@@ -3669,6 +4010,46 @@ export default function workflowExtension(
3669
4010
  const active =
3670
4011
  activeReview !== undefined || rt?.isRoundActive() === true;
3671
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
+ }
3672
4053
  if (!active && !paused) {
3673
4054
  ctx.ui.notify("No review running or paused.", "info");
3674
4055
  return;
@@ -3680,6 +4061,10 @@ export default function workflowExtension(
3680
4061
  // abort lands; falls back to the author's draft when unknown).
3681
4062
  const abortedText =
3682
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();
3683
4068
  pausedReview = undefined;
3684
4069
  // An aborted revision is not auto-reviewed again until the plan changes.
3685
4070
  if (targetHash) abortedReviewHash = targetHash;
@@ -3702,9 +4087,15 @@ export default function workflowExtension(
3702
4087
  "Review stopped — the author's plan stands unreviewed.",
3703
4088
  "info",
3704
4089
  );
3705
- // A gated handoff was waiting on this round: hand off the author's
3706
- // plan through the normal renderer so the plan is always delivered.
3707
- settleReviewGate({ outcome: "unreviewed", planText: abortedText });
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");
3708
4099
  return;
3709
4100
  }
3710
4101
  flushReviewEntries(true);
@@ -3713,8 +4104,20 @@ export default function workflowExtension(
3713
4104
  "Paused review discarded — the author's plan stands.",
3714
4105
  "info",
3715
4106
  );
3716
- settleReviewGate({ outcome: "unreviewed", planText: abortedText });
3717
- // No round will run its finally, so clear the live review line here.
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");
3718
4121
  updateStatus(ctx as any);
3719
4122
  },
3720
4123
  });
@@ -3742,6 +4145,12 @@ export default function workflowExtension(
3742
4145
  */
3743
4146
  interface ModeSwitchOptions {
3744
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;
3745
4154
  }
3746
4155
 
3747
4156
  /** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
@@ -4177,6 +4586,12 @@ export default function workflowExtension(
4177
4586
  },
4178
4587
  defaultModel,
4179
4588
  lastModeSwitch,
4589
+ todoPanel: {
4590
+ override: todoPanelOverride,
4591
+ overrideKey: todoPanelOverrideKey,
4592
+ promptAfterSettle: todoPanelPromptAfterSettle,
4593
+ completedSignature: lastCompletedSignature,
4594
+ },
4180
4595
  } as any);
4181
4596
  lastPersistedPlanFile = currentPlanFile;
4182
4597
  }
@@ -4193,9 +4608,10 @@ export default function workflowExtension(
4193
4608
  return;
4194
4609
  }
4195
4610
  // A live review owns the turn: no mode transition until it lands, fails,
4196
- // pauses or is aborted. The post-review approve path enters Build through
4197
- // `enterBuildModeFromPlan`, which is no longer locked once the gate settles.
4198
- 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;
4199
4615
  // Leaving Default mode: remember its model so it can be restored on return.
4200
4616
  if (workflowMode === null) captureDefaultModel(ctx);
4201
4617
  workflowMode = "plan";
@@ -4414,11 +4830,19 @@ export default function workflowExtension(
4414
4830
  async function enterBuildModeFromPlan(
4415
4831
  ctx: ExtensionContext,
4416
4832
  planPath: string,
4417
- ) {
4418
- // Defense in depth: the inline/browser approve paths only run after the
4419
- // gated review resolves, but a stale approve must never switch tools while
4420
- // a round or pause still owns the turn.
4421
- if (blockModeSwitchDuringReview(ctx)) return;
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
+ }
4422
4846
  workflowMode = "build";
4423
4847
  syncLegacyFlags();
4424
4848
  // The plan is being handed to Build; this Plan cycle cannot hand off again.
@@ -4457,6 +4881,7 @@ export default function workflowExtension(
4457
4881
  : `Build mode: ${loaded.count} todos synced from plan (progress kept).`,
4458
4882
  "info",
4459
4883
  );
4884
+ return true;
4460
4885
  }
4461
4886
 
4462
4887
  // ── Plannotator bridge helpers ─────────────────────────────────
@@ -4560,6 +4985,9 @@ export default function workflowExtension(
4560
4985
  pendingHandoffReviewUrl = null;
4561
4986
  lastReviewId = null;
4562
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");
4563
4991
  persistState();
4564
4992
  const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
4565
4993
  const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
@@ -4723,6 +5151,7 @@ export default function workflowExtension(
4723
5151
  if (reason === "startup" || reason === "new" || !lastReviewId) {
4724
5152
  const changed = clearPendingHandoffDecision();
4725
5153
  if (changed) persistState();
5154
+ endReviewTurn();
4726
5155
  record("clear", { why: "no restorable review" });
4727
5156
  return;
4728
5157
  }
@@ -4739,6 +5168,7 @@ export default function workflowExtension(
4739
5168
  });
4740
5169
  if (decision.action === "keep") {
4741
5170
  if (pendingHandoffSource === "manual") manualReviewCycle = true;
5171
+ setReviewTurn("handoff");
4742
5172
  record("keep", {
4743
5173
  reviewId: lastReviewId,
4744
5174
  ...(pendingHandoffReviewUrl
@@ -4758,7 +5188,7 @@ export default function workflowExtension(
4758
5188
  approved: decision.approved,
4759
5189
  });
4760
5190
  if (decision.approved) {
4761
- handlePlannotatorApprove(
5191
+ await handlePlannotatorApprove(
4762
5192
  pendingHandoffPlanPath ?? currentPlanFile ?? "",
4763
5193
  currentFile ?? "",
4764
5194
  decision.feedback,
@@ -4773,6 +5203,7 @@ export default function workflowExtension(
4773
5203
  }
4774
5204
  const changed = clearPendingHandoffDecision();
4775
5205
  if (changed) persistState();
5206
+ endReviewTurn();
4776
5207
  record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
4777
5208
  notifyReview(
4778
5209
  `Previous browser review could not be recovered — ${decision.reason}.`,
@@ -4823,20 +5254,62 @@ export default function workflowExtension(
4823
5254
  return true;
4824
5255
  }
4825
5256
 
4826
- function handlePlannotatorApprove(
5257
+ /** One execute handoff: todo list + the Build-starting message. */
5258
+ function sendExecuteMessages(
5259
+ ctx: ExtensionContext,
5260
+ planPath: string,
5261
+ feedback?: string,
5262
+ ): void {
5263
+ try {
5264
+ const remainingList = formatTodoNumberedList(todoItems);
5265
+ const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
5266
+ const planTodoListMessage = {
5267
+ customType: "workflow-todo-list",
5268
+ content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
5269
+ display: true,
5270
+ };
5271
+ const firstTodo = todoItems[0];
5272
+ const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
5273
+ const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
5274
+ (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
5275
+ (pi as any).sendMessage?.(
5276
+ {
5277
+ customType: "workflow-plan-execute",
5278
+ content: execMessage,
5279
+ display: true,
5280
+ },
5281
+ { triggerTurn: true, deliverAs: "followUp" },
5282
+ );
5283
+ } catch (_e) {
5284
+ void _e;
5285
+ }
5286
+ try {
5287
+ pi.appendEntry("workflow-handoff-result", {
5288
+ at: Date.now(),
5289
+ choice: "execute",
5290
+ source: "plannotator",
5291
+ planPath,
5292
+ });
5293
+ } catch (_e) {
5294
+ void _e;
5295
+ }
5296
+ }
5297
+
5298
+ async function handlePlannotatorApprove(
4827
5299
  planFilePath: string,
4828
5300
  planContent: string,
4829
5301
  feedback: string | undefined,
4830
5302
  cwd: string | undefined,
4831
- ) {
5303
+ ): Promise<void> {
4832
5304
  const ctx = getActiveCtx();
4833
5305
  if (!ctx) return;
4834
5306
  // A manual `/review` handoff may be approved from Default/Build; an
4835
5307
  // automatic handoff only exists in Plan mode.
4836
5308
  if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
4837
- // A stale browser approve while a review runs must not clear the pending
4838
- // decision or send the execute messages: the plan is still being audited.
4839
- if (blockModeSwitchDuringReview(ctx as any)) {
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()) {
4840
5313
  supersedePendingHandoff(
4841
5314
  "a browser decision arrived while a new review round was running",
4842
5315
  { force: true },
@@ -4898,59 +5371,42 @@ export default function workflowExtension(
4898
5371
  } catch (_e) {
4899
5372
  void _e;
4900
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");
4901
5378
  if (pendingHandoffSource === "manual") manualReviewCycle = false;
4902
5379
  clearPendingHandoffDecision();
4903
- void enterBuildModeFromPlan(ctx as any, resolvedPlan)
4904
- .then(() => updateStatus(ctx as any))
4905
- .catch(() => {
4906
- try {
4907
- updateStatus(ctx as any);
4908
- } catch (_e) {
4909
- void _e;
4910
- }
4911
- });
5380
+ let enteredBuild = false;
4912
5381
  try {
4913
- const remainingList = formatTodoNumberedList(todoItems);
4914
- const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
4915
- const planTodoListMessage = {
4916
- customType: "workflow-todo-list",
4917
- content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
4918
- display: true,
4919
- };
4920
- const firstTodo = todoItems[0];
4921
- const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
4922
- const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
4923
- (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
4924
- (pi as any).sendMessage?.(
4925
- {
4926
- customType: "workflow-plan-execute",
4927
- content: execMessage,
4928
- display: true,
4929
- },
4930
- { triggerTurn: true, deliverAs: "followUp" },
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",
4931
5391
  );
4932
- } catch (_e) {
4933
- void _e;
4934
5392
  }
4935
- try {
4936
- pi.appendEntry("workflow-handoff-result", {
4937
- at: Date.now(),
4938
- choice: "execute",
4939
- source: "plannotator",
4940
- planPath: resolvedPlan,
4941
- });
4942
- } catch (_e) {
4943
- void _e;
5393
+ if (!enteredBuild) {
5394
+ setReviewTurn("handoff");
5395
+ updateStatus(ctx as any);
5396
+ return;
4944
5397
  }
5398
+ setReviewTurn("idle");
5399
+ updateStatus(ctx as any);
5400
+ sendExecuteMessages(ctx as any, resolvedPlan, feedback);
4945
5401
  }
4946
5402
 
4947
5403
  async function handlePlannotatorDeny(feedback: string) {
4948
5404
  const ctx = getActiveCtx();
4949
5405
  if (!ctx) return;
4950
- // Not a mode transition, but while a review owns the turn every browser
4951
- // decision is stale: injecting feedback here would mutate the plan under
4952
- // the reviewer. A legitimate deny only arrives after the gate resolves.
4953
- if (blockModeSwitchDuringReview(ctx as any)) {
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()) {
4954
5410
  supersedePendingHandoff(
4955
5411
  "a browser decision arrived while a new review round was running",
4956
5412
  { force: true },
@@ -4959,11 +5415,12 @@ export default function workflowExtension(
4959
5415
  }
4960
5416
  if (!awaitingDecision) return;
4961
5417
  const manualSource = pendingHandoffSource === "manual";
5418
+ setReviewTurn("transition");
4962
5419
  if (manualSource && workflowMode !== "plan") {
4963
5420
  // A manual review can be launched from Default/Build. Its feedback is a
4964
5421
  // plan revision, so switch to Plan before the follow-up turn starts.
4965
5422
  try {
4966
- await setPlanMode(ctx as any, { quiet: true });
5423
+ await setPlanMode(ctx as any, { quiet: true, fromReview: true });
4967
5424
  } catch (_e) {
4968
5425
  void _e;
4969
5426
  }
@@ -4977,6 +5434,11 @@ export default function workflowExtension(
4977
5434
  // outstanding findings are carried forward so the reviewer can confirm they
4978
5435
  // were actually addressed rather than re-raising them.
4979
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;
4980
5442
  persistState();
4981
5443
  try {
4982
5444
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -5010,6 +5472,91 @@ export default function workflowExtension(
5010
5472
  }
5011
5473
  }
5012
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
+
5013
5560
  function ensurePlannotatorListeners() {
5014
5561
  if (plannotatorListenersRegistered) return;
5015
5562
  try {
@@ -5017,16 +5564,17 @@ export default function workflowExtension(
5017
5564
  if (!bus || typeof bus.on !== "function") return;
5018
5565
  // plan-approved (external executionMode) — direct handoff
5019
5566
  bus.on(PLANNOTATOR_PLAN_APPROVED, (event: any) => {
5020
- try {
5021
- handlePlannotatorApprove(
5022
- event.planFilePath,
5023
- event.planContent,
5024
- event.feedback,
5025
- event.cwd,
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",
5026
5576
  );
5027
- } catch (_e) {
5028
- void _e;
5029
- }
5577
+ });
5030
5578
  });
5031
5579
  // review-result (plan-review shared API) — approve/deny from browser
5032
5580
  bus.on(PLANNOTATOR_REVIEW_RESULT, (event: any) => {
@@ -5046,12 +5594,17 @@ export default function workflowExtension(
5046
5594
  } catch (_e) {
5047
5595
  void _e;
5048
5596
  }
5049
- handlePlannotatorApprove(
5597
+ void handlePlannotatorApprove(
5050
5598
  planPath,
5051
5599
  content,
5052
5600
  event.feedback,
5053
5601
  (getActiveCtx() as any)?.cwd,
5054
- );
5602
+ ).catch((e: unknown) => {
5603
+ notifyReview(
5604
+ `Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
5605
+ "error",
5606
+ );
5607
+ });
5055
5608
  } else {
5056
5609
  void handlePlannotatorDeny(
5057
5610
  event.feedback || "Plan needs revision.",
@@ -5099,9 +5652,31 @@ export default function workflowExtension(
5099
5652
  if (event.outcome !== "closed") return;
5100
5653
  if (!lastReviewId || event.reviewId !== lastReviewId) return;
5101
5654
  if (!awaitingDecision) return;
5102
- supersedePendingHandoff(
5103
- "the browser review closed without a decision",
5104
- { force: true },
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
+ },
5105
5680
  );
5106
5681
  } catch (_e) {
5107
5682
  void _e;
@@ -5296,6 +5871,34 @@ export default function workflowExtension(
5296
5871
  void _e;
5297
5872
  }
5298
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
+
5299
5902
  // Tab cycles workflow modes (context-aware: not when an overlay is active).
5300
5903
  // WorkflowTabEditor gives Tab back to autocomplete while a suggestion list is
5301
5904
  // visible; this registration remains the no-list path. Single registration to
@@ -5559,7 +6162,10 @@ export default function workflowExtension(
5559
6162
  };
5560
6163
  todoItems.push(item);
5561
6164
  try {
5562
- if (currentSessionCtx) updateStatus(currentSessionCtx);
6165
+ if (currentSessionCtx) {
6166
+ updateStatus(currentSessionCtx);
6167
+ persistState();
6168
+ }
5563
6169
  } catch (_e) {
5564
6170
  void _e;
5565
6171
  }
@@ -5680,6 +6286,10 @@ export default function workflowExtension(
5680
6286
  const c = todoItems.length;
5681
6287
  todoItems = [];
5682
6288
  todoPlanPath = undefined;
6289
+ todoPanelOverride = null;
6290
+ todoPanelOverrideKey = null;
6291
+ todoPanelPromptAfterSettle = false;
6292
+ lastCompletedSignature = null;
5683
6293
  try {
5684
6294
  if (currentSessionCtx) {
5685
6295
  updateStatus(currentSessionCtx);
@@ -5711,19 +6321,119 @@ export default function workflowExtension(
5711
6321
  });
5712
6322
 
5713
6323
  pi.registerCommand("todos", {
5714
- description: "Show workflow todos",
5715
- handler: async (_args, ctx) => {
5716
- const list = todoItems.length
5717
- ? `Steps are numbered GLOBALLY 1..${todoItems.length}:
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}:
5718
6345
  ` +
5719
- todoItems
5720
- .map((t) => {
5721
- const g = t.group ? `[${t.group}] ` : "";
5722
- return `${g}${formatTodoLine(t)}`;
5723
- })
5724
- .join("\n")
5725
- : "No todos.";
5726
- ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
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
+ }));
5727
6437
  },
5728
6438
  });
5729
6439
 
@@ -8805,6 +9515,20 @@ export default function workflowExtension(
8805
9515
  // command reaches the reviewer now; the write-gate steer guard below stays
8806
9516
  // (a write instruction must never be steered into the reviewer).
8807
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
+ }
8808
9532
  if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
8809
9533
  const note = event.text.slice(5).trim();
8810
9534
  if (note) {
@@ -8900,12 +9624,27 @@ export default function workflowExtension(
8900
9624
  // A session boundary owns the session-scoped plan fallback; the restore
8901
9625
  // loop below re-seeds it from the persisted `planFile`.
8902
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";
8903
9635
  // A reloaded/forked session must never inherit a stale write denial.
8904
9636
  writeGateRunDenials.clear();
8905
9637
  writeGateTurnAnswer = null;
8906
9638
  writeGateTurnSteered = false;
8907
9639
  writeGateBatchCalls = [];
8908
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();
8909
9648
  // Roles are file-backed; load them up-front so every read path (status,
8910
9649
  // band label, reviewer runtime) sees the real config even in a fresh
8911
9650
  // session that has no persisted workflow entry yet. Profiles likewise.
@@ -8945,6 +9684,15 @@ export default function workflowExtension(
8945
9684
  }
8946
9685
  syncLegacyFlags();
8947
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
+ }
8948
9696
  todoPlanPath = d.planFile;
8949
9697
  toolsBeforePlanMode = d.toolsBeforePlanMode;
8950
9698
  // An entry without a planFile must not erase a value restored
@@ -9275,6 +10023,9 @@ export default function workflowExtension(
9275
10023
  // whether it survives; do not null it here, or a restored gate can only
9276
10024
  // suppress the next handoff.
9277
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";
9278
10029
  currentSessionCtx = ctx as any;
9279
10030
  // Give Tab back to autocomplete while a suggestion list is visible; the raw
9280
10031
  // `tab` shortcut keeps cycling modes otherwise.
@@ -9321,6 +10072,13 @@ export default function workflowExtension(
9321
10072
  writeGateTurnSteered = false;
9322
10073
  writeGateBatchCalls = [];
9323
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();
9324
10082
  // Kill any active explore subagents to prevent orphan leaks.
9325
10083
  // Without this, `pi --mode json` children spawned by runSingleAgent
9326
10084
  // (up to 4 concurrent) could survive if VS Code closes the terminal
@@ -9355,6 +10113,8 @@ export default function workflowExtension(
9355
10113
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
9356
10114
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
9357
10115
  ctx.ui.setWidget("workflow-todos", undefined);
10116
+ // The host dropped the widget; a later session_start re-registers it.
10117
+ todoPanelAppliedState = "unregistered";
9358
10118
  // Drop any /commit authorization or flow: a reload/quit must never leave
9359
10119
  // an exemption armed for whatever runs next.
9360
10120
  commitAuthorization = null;
@@ -9370,6 +10130,12 @@ export default function workflowExtension(
9370
10130
  todoItems = [];
9371
10131
  todoPlanPath = undefined;
9372
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";
9373
10139
  // A rewind is a branch boundary: no per-run write from the old branch
9374
10140
  // may authorize a handoff or a manual review on the new one.
9375
10141
  resetPlanRunProvenance();
@@ -9380,13 +10146,18 @@ export default function workflowExtension(
9380
10146
  try {
9381
10147
  const branch = ctx.sessionManager.getBranch();
9382
10148
  for (const e of branch as any[]) {
9383
- if (
9384
- e.type === "custom" &&
9385
- e.customType === "workflow" &&
9386
- Array.isArray(e.data?.todos)
9387
- ) {
9388
- todoItems = ensureTodoLabels(e.data.todos);
9389
- if (e.data.planFile) todoPlanPath = e.data.planFile;
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
+ }
9390
10161
  }
9391
10162
  if (
9392
10163
  e.type === "message" &&
@@ -9426,6 +10197,126 @@ export default function workflowExtension(
9426
10197
  * The decision itself is the pure `decideCommitGate`; this wrapper supplies
9427
10198
  * the runtime inputs and renders the block reasons / ask overlay.
9428
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
+
9429
10320
  async function commitPolicyGate(
9430
10321
  event: any,
9431
10322
  ctx: any,
@@ -9434,7 +10325,54 @@ export default function workflowExtension(
9434
10325
  const cmd = String(event?.input?.command ?? "");
9435
10326
  const hit = gitCommitPolicyHit(cmd);
9436
10327
  if (!hit) return undefined;
9437
- const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
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
+ }
9438
10376
  const now = Date.now();
9439
10377
  const decision = decideCommitGate({
9440
10378
  policy,
@@ -9442,6 +10380,8 @@ export default function workflowExtension(
9442
10380
  hasTodos: todoItems.length > 0,
9443
10381
  auth: commitAuthorization,
9444
10382
  now,
10383
+ folder: folderDecision,
10384
+ sensitive: Boolean(sensitiveHit && !("failed" in sensitiveHit)),
9445
10385
  });
9446
10386
  if (decision === "allow") {
9447
10387
  // Count a granted `git commit` against the authorization, but only when
@@ -9464,6 +10404,25 @@ export default function workflowExtension(
9464
10404
  }
9465
10405
 
9466
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
+ }
9467
10426
  return {
9468
10427
  block: true,
9469
10428
  reason:
@@ -9484,20 +10443,60 @@ export default function workflowExtension(
9484
10443
  return {
9485
10444
  block: true,
9486
10445
  reason:
9487
- `Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
10446
+ `${folderOutside ? "Folder policy is 'ask' (workflow.folderPolicy)" : "Commit policy is 'ask' (workflow.commitPolicy)"}: \`git ${hit}\` needs user approval, ` +
9488
10447
  "but no interactive UI is available. Leave the change uncommitted and let the user decide.",
9489
10448
  };
9490
10449
  }
10450
+ const commitGrant = folderOutside
10451
+ ? (outsideProjectRootForTarget({ path: repoRoot, kind: "dir" }, baseRoot) ??
10452
+ repoRoot)
10453
+ : repoRoot;
9491
10454
  let sel: unknown;
9492
10455
  try {
9493
10456
  sel = await ui.select(
9494
- `Commit policy 'ask': allow \`git ${hit}\`?`,
9495
- ["Allow once", "Deny"],
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"],
9496
10463
  );
9497
10464
  } catch {
9498
10465
  sel = undefined;
9499
10466
  }
9500
- if (String(sel ?? "").startsWith("Allow")) return undefined;
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
+ }
9501
10500
  return {
9502
10501
  block: true,
9503
10502
  reason:
@@ -9514,7 +10513,9 @@ export default function workflowExtension(
9514
10513
  function commitDirectiveLine(ctx: any): string {
9515
10514
  try {
9516
10515
  const cwd = ctx?.cwd ?? process.cwd();
9517
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
10516
+ const mode = readCommitPolicyConfig(cwd, getAgentDir(), {
10517
+ trusted: ctx?.isProjectTrusted?.() === true,
10518
+ }).config.mode;
9518
10519
  switch (mode) {
9519
10520
  case "never":
9520
10521
  return (
@@ -9550,16 +10551,90 @@ export default function workflowExtension(
9550
10551
  * part of the list (its arguments may have been rewritten by another
9551
10552
  * extension's tool_call handler after the assistant message was sealed).
9552
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
+
9553
10604
  function collectWriteGateItems(
9554
10605
  currentEvent: any,
9555
10606
  currentClassification: WriteCallClassification,
9556
10607
  config: WritePolicyConfig,
10608
+ folderConfig: FolderPolicyConfig,
9557
10609
  cwd: string,
10610
+ baseRoot: string,
9558
10611
  ): WriteGateItem[] {
9559
10612
  const items: WriteGateItem[] = [];
9560
10613
  const seen = new Set<string>();
9561
- const add = (name: string, classification: WriteCallClassification) => {
9562
- const item = writeGateItemFromClassification(name, classification, cwd);
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;
9563
10638
  if (!item.key || seen.has(item.key)) return;
9564
10639
  seen.add(item.key);
9565
10640
  items.push(item);
@@ -9569,8 +10644,6 @@ export default function workflowExtension(
9569
10644
  if (!name) continue;
9570
10645
  const classification = classifyWriteCall(name, call.args, config);
9571
10646
  if (!classification.mutating) continue;
9572
- // A granted target is not gated — it never reaches a dialog.
9573
- if (isWriteCallAllowed(classification, config, cwd)) continue;
9574
10647
  // The plan directory stays writable in every mode.
9575
10648
  if (
9576
10649
  classification.paths.length > 0 &&
@@ -9578,9 +10651,9 @@ export default function workflowExtension(
9578
10651
  ) {
9579
10652
  continue;
9580
10653
  }
9581
- add(name, classification);
10654
+ add(name, classification, call.args);
9582
10655
  }
9583
- add(String(currentEvent?.toolName ?? ""), currentClassification);
10656
+ add(String(currentEvent?.toolName ?? ""), currentClassification, currentEvent?.input);
9584
10657
  return items;
9585
10658
  }
9586
10659
 
@@ -9625,13 +10698,16 @@ export default function workflowExtension(
9625
10698
  }
9626
10699
 
9627
10700
  /**
9628
- * Write permission gate (workflow.writePolicy) — Default mode only.
10701
+ * Write permission gate (workflow.writePolicy + the shared folder floor).
9629
10702
  *
9630
10703
  * The Workflow extension's three modes are Plan → Build → Default. Plan
9631
- * mode is read-only and Build mode is unrestricted by design, but DEFAULT
9632
- * mode was ALSO unrestricted: `resolveSessionRole()` returns undefined
9633
- * there, so the tool_call gate has no branch to take and the agent edits
9634
- * files without asking. This gate is the missing permission knob.
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.
9635
10711
  *
9636
10712
  * It is deliberately INDEPENDENT of the role gate and of the commit
9637
10713
  * policy: those govern *what* a role may do, this governs *whether the
@@ -9651,24 +10727,24 @@ export default function workflowExtension(
9651
10727
  cwd: string,
9652
10728
  ): Promise<{ block: true; reason: string } | undefined> {
9653
10729
  try {
9654
- // Scope: the top-level Default-mode session only.
9655
- // - Plan mode has its own stricter rules (writes only to .pi/plans/).
9656
- // - Build mode is the deliberate "go ahead" execution escape hatch.
9657
- // - A spawned child carries PI_WORKFLOW_ROLE and is already read-only
9658
- // through the role gate (and has no UI to answer a prompt).
9659
- if (workflowMode !== null) return undefined;
9660
- if (
9661
- resolveSessionRole({
9662
- envRole: process.env[WORKFLOW_ROLE_ENV],
9663
- workflowMode,
9664
- }) !== undefined
9665
- ) {
9666
- return undefined;
9667
- }
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;
9668
10740
 
9669
- const resolved = readWritePolicyConfig(cwd, getAgentDir());
10741
+ const trusted = ctx?.isProjectTrusted?.() === true;
10742
+ const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
9670
10743
  const config = resolved.config;
9671
- if (config.mode === "allow") return undefined;
10744
+ const effectiveWriteMode = defaultMode ? config.mode : "allow";
10745
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
10746
+ trusted,
10747
+ }).config;
9672
10748
 
9673
10749
  const classification = classifyWriteCall(
9674
10750
  event.toolName,
@@ -9686,23 +10762,94 @@ export default function workflowExtension(
9686
10762
  return undefined;
9687
10763
  }
9688
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
+ });
9689
10806
  const allowed = isWriteCallAllowed(classification, config, cwd);
9690
10807
  const decision = decideWriteGate({
9691
- mode: config.mode,
10808
+ mode: effectiveWriteMode,
9692
10809
  mutating: true,
9693
10810
  allowed,
10811
+ folder: folderDecision,
9694
10812
  });
9695
10813
  if (decision === "allow") return undefined;
9696
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
+
9697
10833
  const target =
9698
- classification.paths.length > 0
9699
- ? classification.paths.join(", ")
9700
- : (classification.key ?? String(event.toolName));
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));
9701
10839
  const why = classification.reason
9702
10840
  ? ` (${classification.reason})`
9703
10841
  : "";
9704
10842
 
9705
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
+ }
9706
10853
  return {
9707
10854
  block: true,
9708
10855
  reason:
@@ -9728,11 +10875,18 @@ export default function workflowExtension(
9728
10875
  // the answer applies to all of them.
9729
10876
  const ui = ctx?.ui;
9730
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)";
9731
10884
  return {
9732
10885
  block: true,
9733
10886
  reason:
9734
- `Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
10887
+ `${policyName}: writing needs user approval${why}, but no interactive UI is available. ` +
9735
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}. ` : "") +
9736
10890
  `Target: ${target}`,
9737
10891
  };
9738
10892
  }
@@ -9749,12 +10903,23 @@ export default function workflowExtension(
9749
10903
  writeGateBatchCalls = [];
9750
10904
  writeGateBatchCallIds = new Set();
9751
10905
  }
9752
- const items = collectWriteGateItems(event, classification, config, cwd);
9753
- const currentItem = writeGateItemFromClassification(
10906
+ const items = collectWriteGateItems(
10907
+ event,
10908
+ classification,
10909
+ config,
10910
+ folderConfig,
10911
+ cwd,
10912
+ baseRoot,
10913
+ );
10914
+ const currentBase = writeGateItemFromClassification(
9754
10915
  String(event.toolName ?? ""),
9755
10916
  classification,
9756
10917
  cwd,
9757
10918
  );
10919
+ const currentItem =
10920
+ folderEvaluation.outside.length > 0
10921
+ ? withFolderGrant(currentBase, folderTargets, cwd, baseRoot)
10922
+ : currentBase;
9758
10923
 
9759
10924
  // (a) The user denied this target earlier in THIS RUN → deny again,
9760
10925
  // silently. The model gets the same sentence; the user is not asked a
@@ -9768,6 +10933,8 @@ export default function workflowExtension(
9768
10933
  target,
9769
10934
  why: classification.reason,
9770
10935
  instruction: remembered.instruction,
10936
+ source: gateSource,
10937
+ outside: gateOutside,
9771
10938
  }),
9772
10939
  };
9773
10940
  }
@@ -9785,6 +10952,8 @@ export default function workflowExtension(
9785
10952
  target,
9786
10953
  why: classification.reason,
9787
10954
  instruction: answer.instruction,
10955
+ source: gateSource,
10956
+ outside: gateOutside,
9788
10957
  }),
9789
10958
  };
9790
10959
  }
@@ -9793,7 +10962,7 @@ export default function workflowExtension(
9793
10962
  const options = writeGateOptions(writeGateItemsKind(items));
9794
10963
  let sel: unknown;
9795
10964
  try {
9796
- sel = await ui.select(writeGateTitle(items), options);
10965
+ sel = await ui.select(writeGateTitle(items, gateSource, baseRoot), options);
9797
10966
  } catch (_e) {
9798
10967
  void _e;
9799
10968
  sel = undefined;
@@ -9815,14 +10984,23 @@ export default function workflowExtension(
9815
10984
  for (const p of item.grantPaths ?? []) allowedPaths.push(p);
9816
10985
  if (item.grantCommand) allowedCommands.push(item.grantCommand);
9817
10986
  }
9818
- const written = writeWritePolicyConfig("global", cwd, getAgentDir(), {
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(), {
9819
10997
  allowedPaths,
9820
10998
  allowedCommands,
9821
10999
  });
9822
11000
  if (written.ok) {
9823
11001
  try {
9824
11002
  (ctx as any).ui?.notify?.(
9825
- `Write policy: always allowed ${writeGateTargetsLabel(items)}`,
11003
+ `${gatePolicyLabel}: always allowed ${writeGateTargetsLabel(items)}`,
9826
11004
  "info",
9827
11005
  );
9828
11006
  } catch (_e) {
@@ -9836,7 +11014,7 @@ export default function workflowExtension(
9836
11014
  return {
9837
11015
  block: true,
9838
11016
  reason:
9839
- `Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
11017
+ `${gatePolicyLabel} 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
9840
11018
  `Target: ${target}`,
9841
11019
  };
9842
11020
  }
@@ -9886,6 +11064,8 @@ export default function workflowExtension(
9886
11064
  target: items.length > 1 ? writeGateTargetsLabel(items) : target,
9887
11065
  why: classification.reason,
9888
11066
  instruction,
11067
+ source: gateSource,
11068
+ outside: gateOutside,
9889
11069
  });
9890
11070
 
9891
11071
  if (instruction) {
@@ -9933,6 +11113,349 @@ export default function workflowExtension(
9933
11113
  }
9934
11114
  }
9935
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
+
11407
+ let instruction = "";
11408
+ if (choice === "deny-with-instructions") {
11409
+ const inputFn = (ui as any).input;
11410
+ if (typeof inputFn === "function") {
11411
+ try {
11412
+ instruction = sanitizeReadInstruction(
11413
+ await inputFn.call(
11414
+ ui,
11415
+ READ_INSTRUCTION_PROMPT_TITLE,
11416
+ READ_INSTRUCTION_PROMPT_PLACEHOLDER,
11417
+ ),
11418
+ );
11419
+ } catch {
11420
+ instruction = "";
11421
+ }
11422
+ }
11423
+ }
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;
11440
+ }
11441
+ return {
11442
+ block: true,
11443
+ reason: buildReadDenyReason({
11444
+ kind: "outside",
11445
+ target: readGateTargetsLabel(items) || "the requested read",
11446
+ instruction,
11447
+ }),
11448
+ };
11449
+ } catch (error: unknown) {
11450
+ return {
11451
+ block: true,
11452
+ reason:
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.",
11455
+ };
11456
+ }
11457
+ }
11458
+
9936
11459
  // Block edits/writes + bash gating
9937
11460
  pi.on("tool_call", async (event, ctx) => {
9938
11461
  const cwd = (ctx as ExtensionContext).cwd;
@@ -10066,15 +11589,19 @@ export default function workflowExtension(
10066
11589
  reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
10067
11590
  } as any;
10068
11591
  }
10069
- // ── Write permission gate (workflow.writePolicy) ── Independent of the
10070
- // role gate and of the commit policy: it governs whether the USER
10071
- // permits a write at all. It only applies in Default mode — Plan has its
10072
- // own stricter rules and Build is the approved-execution escape hatch —
10073
- // and it runs last so it only sees calls nothing else claimed.
10074
- 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
+ {
10075
11597
  const writeBlock = await writePolicyGate(event, ctx, cwd);
10076
11598
  if (writeBlock) return writeBlock as any;
10077
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
+ }
10078
11605
  // In build mode, ensure todos exist before first real edit
10079
11606
  if (
10080
11607
  (event.toolName === "edit" || event.toolName === "write") &&
@@ -10136,7 +11663,73 @@ export default function workflowExtension(
10136
11663
  seedCommittedStepRefs();
10137
11664
  const completed = uncommittedDoneSteps();
10138
11665
  const dirty = await isDirty(pi as any, cwd);
10139
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
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;
10140
11733
  const decision = stepCommitDecision({
10141
11734
  mode,
10142
11735
  hasTodos: todoItems.length > 0,
@@ -10193,6 +11786,44 @@ export default function workflowExtension(
10193
11786
  }
10194
11787
  }
10195
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
+
10196
11827
  pi.on("tool_result", async (_event, ctx) => {
10197
11828
  try {
10198
11829
  const leaf =
@@ -10245,6 +11876,10 @@ export default function workflowExtension(
10245
11876
  writeGateBatchCallIds = callIds;
10246
11877
  writeGateTurnAnswer = null;
10247
11878
  writeGateTurnSteered = false;
11879
+ readGateBatchCalls = calls;
11880
+ readGateBatchCallIds = callIds;
11881
+ readGateTurnAnswer = null;
11882
+ readGateTurnSteered = false;
10248
11883
  } catch (_e) {
10249
11884
  void _e;
10250
11885
  }
@@ -10258,6 +11893,12 @@ export default function workflowExtension(
10258
11893
  writeGateTurnSteered = false;
10259
11894
  writeGateBatchCalls = [];
10260
11895
  writeGateBatchCallIds = new Set();
11896
+ readGateTurnAnswer = null;
11897
+ readGateTurnSteered = false;
11898
+ readGateBatchCalls = [];
11899
+ readGateBatchCallIds = new Set();
11900
+ readGateTurnGrantedRoots.clear();
11901
+ readGateTurnUncertainAllowed.clear();
10261
11902
  // Seed the per-step commit ledger once per session: steps already done
10262
11903
  // (restored sessions, re-executed plans) never trigger a spurious commit.
10263
11904
  try {
@@ -10308,6 +11949,13 @@ export default function workflowExtension(
10308
11949
  writeGateTurnSteered = false;
10309
11950
  writeGateBatchCalls = [];
10310
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();
10311
11959
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
10312
11960
  });
10313
11961
 
@@ -10356,12 +12004,47 @@ export default function workflowExtension(
10356
12004
  pi.on("before_agent_start", async (event, ctx) => {
10357
12005
  currentSessionCtx = ctx as any;
10358
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
+ }
10359
12026
  // Fresh user prompt → reset the passive reminder cadence. Continuation runs
10360
12027
  // (triggerTurn/steer/followUp) do not fire this event, so state survives them.
10361
12028
  turnsSinceLastTodoWrite = 0;
10362
12029
  turnsSinceLastReminder = 0;
10363
12030
  mutatedSinceLastTodoWrite = false;
10364
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
+ }
10365
12048
  // A fresh user prompt starts a new handoff/provenance run. It also
10366
12049
  // supersedes any pending browser/inline decision immediately: the user
10367
12050
  // moved on, so a stale tab must not approve the previous plan. Follow-up
@@ -10386,10 +12069,14 @@ export default function workflowExtension(
10386
12069
  }
10387
12070
  // Every fresh prompt is a new review cycle: reset the round budget, cancel
10388
12071
  // an abandoned pause/gate, and tag the cycle so an old handoff cannot
10389
- // 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;
10390
12076
  try {
12077
+ if (!feedbackTurn) endReviewTurn();
10391
12078
  await beginReviewCycle({
10392
- reason: "prompt",
12079
+ reason: feedbackTurn ? "refine" : "prompt",
10393
12080
  keepFindings: true,
10394
12081
  planPath: currentPlanFile,
10395
12082
  });
@@ -11002,26 +12689,36 @@ export default function workflowExtension(
11002
12689
  }
11003
12690
  // Build completion
11004
12691
  if (workflowMode === "build" && todoItems.length > 0) {
11005
- const allDone = todoItems.every(
11006
- (t) => isDone(t) || itemStatus(t) === "cancelled",
11007
- );
12692
+ const allDone = isTodoListSettled(todoItems);
11008
12693
  if (allDone) {
11009
- try {
11010
- (pi as any).sendMessage?.(
11011
- {
11012
- customType: "workflow-complete",
11013
- content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
11014
- display: true,
11015
- },
11016
- { triggerTurn: false },
11017
- );
11018
- } catch (_e) {
11019
- void _e;
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
+ }
11020
12713
  }
11021
12714
  // keep todos for history but stay in build
11022
12715
  todoStale = false;
11023
12716
  updateStatus(ctx as any);
11024
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;
11025
12722
  }
11026
12723
  return;
11027
12724
  }
@@ -11238,6 +12935,9 @@ export default function workflowExtension(
11238
12935
  reviewHandoffGate.reset();
11239
12936
  reviewGatePhase = "auditing";
11240
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();
11241
12941
  try {
11242
12942
  const reviewed = await runReviewStep(
11243
12943
  authorPlan,