@dieulc/workflow 0.10.0 → 0.11.1

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,60 @@ import {
121
120
  type PendingCommitFlow,
122
121
  type StepCommitCandidate,
123
122
  } from "./commit-policy.ts";
123
+ import {
124
+ baseFolderRoots,
125
+ buildFolderPolicyDeny,
126
+ decideFolderGate,
127
+ describeFolderEvaluation,
128
+ detectRepoRoot,
129
+ dialogGrantRootForTarget,
130
+ broadGrantRootReason,
131
+ isBroadGrantRoot,
132
+ evaluateFolderTargets,
133
+ evaluateBaseCoverage,
134
+ matchSensitivePath,
135
+ readFolderPolicyConfig,
136
+ writeFolderPolicyConfig,
137
+ revokeFolderPolicyListEntry,
138
+ folderPolicyArgumentCompletions,
139
+ folderPolicyConfigPaths,
140
+ EXCLUDE_USAGE,
141
+ grantRootForTarget,
142
+ isBroadExcludePattern,
143
+ isWorkflowSettingsPath,
144
+ normalizeExcludePattern,
145
+ parseExcludeArgs,
146
+ splitPolicyArgs,
147
+ DEFAULT_SENSITIVE_PATTERNS,
148
+ type FolderPolicyConfig,
149
+ type FolderTarget,
150
+ } from "./folder-policy.ts";
151
+ import { analyzeShellPaths } from "./shell-paths.ts";
152
+ import {
153
+ buildReadDenyReason,
154
+ buildReadGuidanceMessage,
155
+ decideReadGate,
156
+ parseReadGateChoice,
157
+ readGateItemFromTarget,
158
+ readGateItemsKind,
159
+ readGateOptions,
160
+ readGateTargetKey,
161
+ readGateTargetsLabel,
162
+ readGateTitle,
163
+ readReadPolicyConfig,
164
+ sanitizeReadInstruction,
165
+ writeReadPolicyConfig,
166
+ readPolicyArgumentCompletions,
167
+ readPolicyConfigPaths,
168
+ revokeReadPolicyPath,
169
+ describeReadPolicy,
170
+ READ_INSTRUCTION_PROMPT_PLACEHOLDER,
171
+ READ_INSTRUCTION_PROMPT_TITLE,
172
+ type ReadGateItem,
173
+ type ReadPolicyConfig,
174
+ } from "./read-policy.ts";
175
+ import { classifyReadCall } from "./read-policy-tools.ts";
176
+ import { redactToolResult } from "./read-policy-redact.ts";
124
177
  import {
125
178
  buildWriteDenyReason,
126
179
  buildWriteGuidanceMessage,
@@ -128,6 +181,7 @@ import {
128
181
  decideWriteGate,
129
182
  describeWritePolicyMode,
130
183
  isWriteCallAllowed,
184
+ normalizeWritePath,
131
185
  parseWriteGateChoice,
132
186
  readWritePolicyConfig,
133
187
  sanitizeWriteInstruction,
@@ -143,6 +197,7 @@ import {
143
197
  WRITE_INSTRUCTION_PROMPT_TITLE,
144
198
  type WriteGateChoice,
145
199
  type WriteGateItem,
200
+ type WriteGateSource,
146
201
  type WriteCallClassification,
147
202
  type WritePolicyConfig,
148
203
  type WritePolicyMode,
@@ -187,6 +242,17 @@ import {
187
242
  setWorkflowStatus,
188
243
  WORKFLOW_STATUS_KEYS,
189
244
  } from "./workflow-status.ts";
245
+ import {
246
+ buildTodoRows,
247
+ deriveTodoPanel,
248
+ isTodoListSettled,
249
+ readPersistedTodoPanel,
250
+ renderTodoSummary,
251
+ todoCompletionSignature,
252
+ todoListKey,
253
+ type TodoPanelOverride,
254
+ type TodoPanelState,
255
+ } from "./todo-panel.ts";
190
256
  import {
191
257
  defaultProfileConfig,
192
258
  loadProfilesDetailed,
@@ -256,6 +322,9 @@ import {
256
322
  reviewModeLockKind,
257
323
  reviewModeLockNotice,
258
324
  reviewModeLockStatusLine,
325
+ reviewInputDecision,
326
+ reviewRoundOwnsTurn,
327
+ reviewTurnOwns,
259
328
  reviewNoticeText,
260
329
  reviewPauseIsCurrent,
261
330
  reviewResumeGateOutcome,
@@ -278,6 +347,7 @@ import {
278
347
  type ReviewHandoffPhase,
279
348
  type ReviewHandoffResult,
280
349
  type ReviewModeLockKind,
350
+ type ReviewTurnPhase,
281
351
  type ReviewState,
282
352
  type ReviewTranscriptOp,
283
353
  type ReviewTrigger,
@@ -411,6 +481,7 @@ async function runSingleAgent(
411
481
  agent: AgentConfig,
412
482
  task: string,
413
483
  signal?: AbortSignal,
484
+ folderEnv?: Record<string, string>,
414
485
  ): Promise<{ messages: any[]; stderr: string; exitCode: number; usage: any }> {
415
486
  const tmpDir = await fs.promises.mkdtemp(
416
487
  path.join(os.tmpdir(), "pi-subagent-"),
@@ -449,8 +520,19 @@ async function runSingleAgent(
449
520
  shell: false,
450
521
  stdio: ["ignore", "pipe", "pipe"],
451
522
  // 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),
523
+ // with the same policy the parent applies (see permissions.ts), and
524
+ // lock the folder roots so the child cannot expand via a sibling
525
+ // project's settings.
526
+ env: {
527
+ ...subagentEnv(process.env, agent.role),
528
+ ...(folderEnv ?? {
529
+ PI_FOLDER_POLICY_LOCKED: "1",
530
+ PI_FOLDER_POLICY_ROOTS: JSON.stringify([
531
+ detectRepoRoot(cwd) ?? cwd,
532
+ ]),
533
+ PI_FOLDER_POLICY_SOURCE: "parent",
534
+ }),
535
+ },
454
536
  },
455
537
  );
456
538
  // Track for cleanup on session_shutdown — prevents orphaned subagents
@@ -607,12 +689,33 @@ export default function workflowExtension(
607
689
  null;
608
690
  let writeGateTurnSteered = false;
609
691
  const writeGateRunDenials = new Map<string, { instruction: string }>();
692
+ // ── Read-policy gate state (turn-scoped grants, run-scoped denials) ──
693
+ let readGateBatchCalls: { name: string; args: unknown }[] = [];
694
+ let readGateBatchCallIds = new Set<string>();
695
+ const readGateTurnGrantedRoots = new Set<string>();
696
+ const readGateTurnUncertainAllowed = new Set<string>();
697
+ let readGateTurnAnswer: {
698
+ choice: "allow-once" | "always" | "deny";
699
+ instruction: string;
700
+ } | null = null;
701
+ let readGateTurnSteered = false;
702
+ const readGateRunDenials = new Map<string, { instruction: string }>();
610
703
  // ── Reminder throttle + staleness tracking (passive enforcement layer) ──
611
704
  let turnsSinceLastTodoWrite = 0;
612
705
  let turnsSinceLastReminder = 0;
613
706
  let mutatedSinceLastTodoWrite = false;
614
707
  let lastRefMisses: { refs: string[] } | undefined;
615
708
  let todoStale = false;
709
+ // ── Todo panel presentation (pinned widget above the editor) ──
710
+ // The widget can never scroll with the transcript, so it collapses to one
711
+ // line when the plan settles and hides at the next user prompt. Overrides
712
+ // are scoped to `todoPanelOverrideKey` (ref/text/group identity), so status
713
+ // churn keeps the user's choice while a new/renamed list expires it.
714
+ let todoPanelOverride: TodoPanelOverride = null;
715
+ let todoPanelOverrideKey: string | null = null;
716
+ let todoPanelPromptAfterSettle = false;
717
+ let lastCompletedSignature: string | null = null;
718
+ let todoPanelAppliedState: TodoPanelState | "unregistered" = "unregistered";
616
719
  let footerInjectedThisTurn = false;
617
720
  let lastStaleNoticeAt: number | undefined;
618
721
  // Path+content hash of the last plan-file reconciliation (skip no-op merges).
@@ -1189,83 +1292,81 @@ export default function workflowExtension(
1189
1292
  todoStale = false;
1190
1293
  }
1191
1294
 
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
1295
  /**
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.
1296
+ * Reconcile the pinned todo widget with the derived presentation state.
1297
+ * Called from `updateStatus` after the footer writes; only re-registers the
1298
+ * component when the state changes (marking a step done repaints through
1299
+ * the host, so the widget is not disposed/rebuilt on every refresh).
1200
1300
  */
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 });
1301
+ function refreshTodoPanel(ctx: ExtensionContext) {
1302
+ const view = deriveTodoPanel({
1303
+ items: todoItems,
1304
+ override: todoPanelOverride,
1305
+ overrideKey: todoPanelOverrideKey,
1306
+ promptAfterSettle: todoPanelPromptAfterSettle,
1307
+ stale: todoStale,
1218
1308
  });
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;
1309
+ // A choice made for a different list must not hide a new task.
1310
+ if (todoPanelOverrideKey !== view.listKey) {
1311
+ todoPanelOverride = null;
1312
+ todoPanelOverrideKey = null;
1313
+ }
1314
+ if (!view.settled && todoPanelPromptAfterSettle) {
1315
+ todoPanelPromptAfterSettle = false;
1316
+ }
1317
+ // Reopened list: a later completion may emit its own card again.
1318
+ if (!view.settled && lastCompletedSignature !== null) {
1319
+ lastCompletedSignature = null;
1320
+ }
1321
+ if (view.settled && todoStale) todoStale = false;
1322
+ if (view.state === todoPanelAppliedState) return;
1323
+ todoPanelAppliedState = view.state;
1324
+ try {
1325
+ if (view.state === "hidden") {
1326
+ ctx.ui.setWidget("workflow-todos", undefined);
1327
+ return;
1261
1328
  }
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`));
1329
+ ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
1330
+ const render = (w: number) => {
1331
+ // Re-derive so the component always shows live items and the live
1332
+ // mode, even though it was registered for an earlier state.
1333
+ const live = deriveTodoPanel({
1334
+ items: todoItems,
1335
+ override: todoPanelOverride,
1336
+ overrideKey: todoPanelOverrideKey,
1337
+ promptAfterSettle: todoPanelPromptAfterSettle,
1338
+ stale: todoStale,
1339
+ });
1340
+ if (live.state === "hidden") return [];
1341
+ return live.state === "collapsed"
1342
+ ? [renderTodoSummary(theme, w, live)]
1343
+ : buildTodoRows(theme, w, todoItems, workflowMode);
1344
+ };
1345
+ return { render, invalidate: () => {}, dispose: () => {} };
1346
+ });
1347
+ } catch (_e) {
1348
+ void _e;
1349
+ }
1350
+ }
1351
+
1352
+ function setTodoPanelOverride(
1353
+ ctx: ExtensionContext,
1354
+ override: TodoPanelOverride,
1355
+ note?: string,
1356
+ ) {
1357
+ if (!todoItems.length && override === "expanded") {
1358
+ ctx.ui.notify("No todos.", "info");
1359
+ return;
1360
+ }
1361
+ todoPanelOverride = override;
1362
+ todoPanelOverrideKey = override === null ? null : todoListKey(todoItems);
1363
+ updateStatus(ctx);
1364
+ try {
1365
+ persistState();
1366
+ } catch (_e) {
1367
+ void _e;
1267
1368
  }
1268
- return rendered;
1369
+ if (note) ctx.ui.notify(note, "info");
1269
1370
  }
1270
1371
 
1271
1372
  function updateStatus(ctx: ExtensionContext) {
@@ -1280,6 +1381,7 @@ export default function workflowExtension(
1280
1381
  commitMode = readCommitPolicyConfig(
1281
1382
  (ctx as any).cwd ?? process.cwd(),
1282
1383
  getAgentDir(),
1384
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1283
1385
  ).config.mode;
1284
1386
  } catch {
1285
1387
  commitMode = "never";
@@ -1293,27 +1395,41 @@ export default function workflowExtension(
1293
1395
  writeMode = readWritePolicyConfig(
1294
1396
  (ctx as any).cwd ?? process.cwd(),
1295
1397
  getAgentDir(),
1398
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1296
1399
  ).config.mode;
1297
1400
  } catch {
1298
1401
  writeMode = "allow";
1299
1402
  }
1403
+ let folderMode: string | null = null;
1404
+ try {
1405
+ folderMode = readFolderPolicyConfig(
1406
+ (ctx as any).cwd ?? process.cwd(),
1407
+ getAgentDir(),
1408
+ { trusted: (ctx as any).isProjectTrusted?.() === true },
1409
+ ).config.mode;
1410
+ } catch {
1411
+ folderMode = null;
1412
+ }
1300
1413
  const segments = buildWorkflowStatusSegments({
1301
1414
  mode: workflowMode,
1302
- plannotatorActive,
1415
+ reviewPhase: reviewTurn,
1303
1416
  done: todoItems.filter((t) => isDone(t)).length,
1304
1417
  total: todoItems.length,
1305
- stale: todoStale,
1418
+ stale: todoStale && !isTodoListSettled(todoItems),
1306
1419
  profileLabel,
1307
1420
  roleLabel,
1308
1421
  commitMode,
1309
1422
  writeMode: workflowMode === null ? writeMode : null,
1423
+ folderMode,
1310
1424
  });
1311
1425
  const modeTone =
1312
- workflowMode === "build"
1426
+ reviewTurn !== "idle"
1313
1427
  ? "accent"
1314
- : workflowMode === "plan"
1315
- ? "warning"
1316
- : "muted";
1428
+ : workflowMode === "build"
1429
+ ? "accent"
1430
+ : workflowMode === "plan"
1431
+ ? "warning"
1432
+ : "muted";
1317
1433
  setWorkflowStatus(
1318
1434
  ctx.ui,
1319
1435
  WORKFLOW_STATUS_KEYS.mode,
@@ -1330,15 +1446,10 @@ export default function workflowExtension(
1330
1446
  ctx.ui.theme.fg("dim", segments.policy),
1331
1447
  );
1332
1448
  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
- });
1449
+ refreshTodoPanel(ctx);
1340
1450
  } else {
1341
1451
  ctx.ui.setWidget("workflow-todos", undefined);
1452
+ todoPanelAppliedState = "hidden";
1342
1453
  }
1343
1454
  // One status owner: every footer refresh (mode switch, model change, round
1344
1455
  // phase, pause) also re-evaluates the review widget. This is what clears a
@@ -1415,6 +1526,15 @@ export default function workflowExtension(
1415
1526
  let activeReview:
1416
1527
  | { planText: string; round: number; controller: AbortController }
1417
1528
  | undefined;
1529
+ /**
1530
+ * The exclusive Review turn (plan 2026-11-14). Unlike the old derived lock,
1531
+ * this also covers the browser/inline handoff, where no child session runs.
1532
+ * In-memory: a reload restores `handoff` from `awaitingDecision`, otherwise
1533
+ * idle (a running round cannot survive the process).
1534
+ */
1535
+ let reviewTurn: ReviewTurnPhase = "idle";
1536
+ /** The next plan turn is the feedback/refine continuation of a Deny. */
1537
+ let reviewFeedbackTurn = false;
1418
1538
  /** Long-lived ESC listener covering a round and the paused window. */
1419
1539
  let detachReviewEscListener: (() => void) | undefined;
1420
1540
  /**
@@ -1466,9 +1586,47 @@ export default function workflowExtension(
1466
1586
  roundActive,
1467
1587
  paused,
1468
1588
  gatePhase,
1589
+ handoff: reviewTurn === "handoff" || awaitingDecision,
1590
+ transition: reviewTurn === "transition",
1591
+ });
1592
+ }
1593
+
1594
+ /** True while a reviewer round or a real pause owns the turn (not handoff). */
1595
+ function reviewRoundInFlight(): boolean {
1596
+ let roundActive = false;
1597
+ try {
1598
+ roundActive = reviewRuntime?.isRoundActive() === true;
1599
+ } catch (_e) {
1600
+ void _e;
1601
+ }
1602
+ return reviewRoundOwnsTurn({
1603
+ roundInFlight: activeReview !== undefined,
1604
+ roundActive,
1605
+ paused: currentPausedReview() !== undefined,
1606
+ gatePhase:
1607
+ reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle",
1469
1608
  });
1470
1609
  }
1471
1610
 
1611
+ /** One status refresh for every review-turn transition. */
1612
+ function setReviewTurn(phase: ReviewTurnPhase): void {
1613
+ if (reviewTurn === phase) return;
1614
+ reviewTurn = phase;
1615
+ try {
1616
+ if (currentSessionCtx) updateStatus(currentSessionCtx);
1617
+ } catch (_e) {
1618
+ void _e;
1619
+ }
1620
+ }
1621
+
1622
+ function beginReviewTurn(): void {
1623
+ setReviewTurn("running");
1624
+ }
1625
+
1626
+ function endReviewTurn(): void {
1627
+ setReviewTurn("idle");
1628
+ }
1629
+
1472
1630
  /** Refuse a mode transition while a review round or pause owns the turn. */
1473
1631
  function blockModeSwitchDuringReview(
1474
1632
  ctx?: ExtensionContext,
@@ -1715,6 +1873,32 @@ export default function workflowExtension(
1715
1873
  return currentSessionCtx?.cwd ?? process.cwd();
1716
1874
  }
1717
1875
 
1876
+ /**
1877
+ * Environment for a spawned subagent: the role, the parent-approved roots
1878
+ * (repo root of the task folder plus the task folder itself) and the
1879
+ * parent's deny layer (sensitive extras + `excludePaths`). The child cannot
1880
+ * widen its own scope: it ignores project settings entirely.
1881
+ */
1882
+ function subagentFolderEnv(cwd: string): Record<string, string> {
1883
+ const parentCwd = (currentSessionCtx as any)?.cwd ?? cwd;
1884
+ const trusted = (currentSessionCtx as any)?.isProjectTrusted?.() === true;
1885
+ let deny = "";
1886
+ try {
1887
+ deny = buildFolderPolicyDeny(
1888
+ readFolderPolicyConfig(parentCwd, getAgentDir(), { trusted }).config,
1889
+ );
1890
+ } catch (_e) {
1891
+ void _e;
1892
+ }
1893
+ const roots = [...new Set([detectRepoRoot(cwd) ?? cwd, cwd])];
1894
+ return {
1895
+ PI_FOLDER_POLICY_LOCKED: "1",
1896
+ PI_FOLDER_POLICY_ROOTS: JSON.stringify(roots),
1897
+ PI_FOLDER_POLICY_SOURCE: "parent",
1898
+ PI_FOLDER_POLICY_DENY: deny,
1899
+ };
1900
+ }
1901
+
1718
1902
  /**
1719
1903
  * Current plan-file text (absolute or cwd-relative), or `undefined` when there
1720
1904
  * is no plan path, the file is gone, or it is unreadable. Never throws.
@@ -1979,7 +2163,13 @@ export default function workflowExtension(
1979
2163
  if (m) agent.model = `${m.provider}/${m.id}`;
1980
2164
  if (m?.thinking) agent.thinking = m.thinking;
1981
2165
 
1982
- const res = await runSingleAgent(cwd, agent, task, signal);
2166
+ const res = await runSingleAgent(
2167
+ cwd,
2168
+ agent,
2169
+ task,
2170
+ signal,
2171
+ subagentFolderEnv(cwd),
2172
+ );
1983
2173
  const out = getFinalOutput(res.messages);
1984
2174
  if (!out && res.exitCode !== 0) {
1985
2175
  throw new Error(
@@ -1999,6 +2189,20 @@ export default function workflowExtension(
1999
2189
  reviewRuntime = createReviewRuntime({
2000
2190
  cwd,
2001
2191
  agentDir,
2192
+ // The reviewer is an isolated session: hand it the parent's deny layer
2193
+ // (sensitive extras + excludePaths) and read grants so its guarded
2194
+ // read/grep/find/ls and review_bash cannot see an excluded tree.
2195
+ folderPolicy: () => {
2196
+ const cwdNow = reviewCwd();
2197
+ const trusted =
2198
+ (currentSessionCtx as any)?.isProjectTrusted?.() === true;
2199
+ return {
2200
+ config: readFolderPolicyConfig(cwdNow, getAgentDir(), { trusted })
2201
+ .config,
2202
+ grants: readReadPolicyConfig(cwdNow, getAgentDir(), { trusted }).config
2203
+ .allowedPaths,
2204
+ };
2205
+ },
2002
2206
  config: () => reviewConfigNow()?.config,
2003
2207
  reviewerModel: () => {
2004
2208
  // The round snapshot wins while set (see executeReviewRound): a
@@ -2124,6 +2328,7 @@ export default function workflowExtension(
2124
2328
  // A gated audit becomes a paused gate (the handoff keeps waiting). A
2125
2329
  // manual review (idle gate) leaves the phase alone.
2126
2330
  if (reviewGatePhase === "auditing") reviewGatePhase = "paused";
2331
+ setReviewTurn("paused");
2127
2332
  refreshReviewPauseUi();
2128
2333
  return true;
2129
2334
  }
@@ -2335,6 +2540,7 @@ export default function workflowExtension(
2335
2540
  // follow `reviewState`; the spinner row needs an explicit message while
2336
2541
  // the child session streams off the main turn.
2337
2542
  setReviewWorkingMessage("🔍 Reviewing plan…");
2543
+ setReviewTurn("running");
2338
2544
  // Paint the live review line now, not after the child session's first
2339
2545
  // state event: a manual `/review` must look alive from the first tick.
2340
2546
  updateStatus(ctx as any);
@@ -2890,6 +3096,7 @@ export default function workflowExtension(
2890
3096
  flushReviewEntries(true);
2891
3097
  resetReviewStream();
2892
3098
  detachReviewEscListenerNow();
3099
+ endReviewTurn();
2893
3100
  try {
2894
3101
  currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
2895
3102
  } catch (_e) {
@@ -3015,6 +3222,15 @@ export default function workflowExtension(
3015
3222
 
3016
3223
  const scope = scopeFor("global");
3017
3224
  const enable = sub === "on";
3225
+ // Disabling would tear down the review (and its pending handoff) behind
3226
+ // the lock. Abort is the explicit exit while Review owns the turn.
3227
+ if (!enable && reviewTurnOwns(reviewTurn)) {
3228
+ ctx.ui.notify(
3229
+ "Review is active — /review-abort first (or resume/approve in the review tab).",
3230
+ "info",
3231
+ );
3232
+ return;
3233
+ }
3018
3234
 
3019
3235
  const res = writeReviewModeConfig(scope, cwd, agentDir, {
3020
3236
  enabled: enable,
@@ -3066,7 +3282,9 @@ export default function workflowExtension(
3066
3282
  : "global";
3067
3283
 
3068
3284
  if (!sub || sub === "status") {
3069
- const resolved = readCommitPolicyConfig(cwd, agentDir);
3285
+ const resolved = readCommitPolicyConfig(cwd, agentDir, {
3286
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3287
+ });
3070
3288
  const paths = commitConfigPaths(cwd, agentDir);
3071
3289
  const where =
3072
3290
  resolved.sources.mode === "default"
@@ -3125,8 +3343,9 @@ export default function workflowExtension(
3125
3343
  });
3126
3344
 
3127
3345
  // ── /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.
3346
+ // Available in every mode: it only writes settings JSON. The write *mode*
3347
+ // applies in Default mode only; the shared folder floor applies in every
3348
+ // mode, Build included.
3130
3349
  pi.registerCommand("write-policy", {
3131
3350
  description:
3132
3351
  "View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
@@ -3144,7 +3363,9 @@ export default function workflowExtension(
3144
3363
  : "global";
3145
3364
 
3146
3365
  if (!sub || sub === "status") {
3147
- const resolved = readWritePolicyConfig(cwd, agentDir);
3366
+ const resolved = readWritePolicyConfig(cwd, agentDir, {
3367
+ trusted: (ctx as any).isProjectTrusted?.() === true,
3368
+ });
3148
3369
  const paths = writePolicyConfigPaths(cwd, agentDir);
3149
3370
  const where =
3150
3371
  resolved.sources.mode === "default"
@@ -3153,7 +3374,8 @@ export default function workflowExtension(
3153
3374
  const lines = [
3154
3375
  `Write policy: ${resolved.config.mode} (${where})`,
3155
3376
  ` ${describeWritePolicyMode(resolved.config.mode)}`,
3156
- " Applies to Default mode only — Plan mode is read-only already, /build is the execution escape hatch.",
3377
+ " The write mode applies to Default mode only; the shared folder floor applies in every mode.",
3378
+ " Plan mode is read-only already; /build is the execution escape hatch.",
3157
3379
  ];
3158
3380
  if (resolved.config.allowedPaths.length > 0) {
3159
3381
  lines.push(
@@ -3227,6 +3449,345 @@ export default function workflowExtension(
3227
3449
  // gate: it reads the same analyzeCommand/explainCommandRefusal the gates
3228
3450
  // use, so the explanation cannot describe a policy the gate no longer
3229
3451
  // enforces. Read-only, so it is safe in every mode.
3452
+ pi.registerCommand("folder-policy", {
3453
+ description: "Shared folder scope for read/write/commit: repo|cwd|parent|any, outside ask|block, sensitive list",
3454
+ handler: async (args, ctx) => {
3455
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3456
+ const agentDir = getAgentDir();
3457
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3458
+ const split = splitPolicyArgs(args);
3459
+ if (split.error) {
3460
+ ctx.ui.notify(`${split.error}\n${EXCLUDE_USAGE}`, "warning");
3461
+ return;
3462
+ }
3463
+ const tokens = split.tokens;
3464
+ const sub = (tokens[0] || "status").toLowerCase();
3465
+ const second = (tokens[1] || "").toLowerCase();
3466
+ // Legacy bare-token scope for the mode verbs. `exclude` parses its own
3467
+ // scope (flags only) because a standalone `project`/`global` word there
3468
+ // is a path, not a scope.
3469
+ const modeScope: "global" | "project" = tokens.includes("--project")
3470
+ ? "project"
3471
+ : tokens.includes("--global")
3472
+ ? "global"
3473
+ : tokens.includes("project")
3474
+ ? "project"
3475
+ : "global";
3476
+ const resolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
3477
+ const baseRoot = baseFolderRoots(resolved.config, cwd)[0] ?? cwd;
3478
+ // Per capability, with that capability's real grants: a read grant can
3479
+ // rescue reads while writes and commits stay refused at the base.
3480
+ const baseCoverage = (config: typeof resolved.config) =>
3481
+ evaluateBaseCoverage({
3482
+ config,
3483
+ cwd,
3484
+ lockedRoots: resolved.locked?.roots,
3485
+ grantsByCapability: {
3486
+ read: readReadPolicyConfig(cwd, agentDir, { trusted }).config
3487
+ .allowedPaths,
3488
+ write: readWritePolicyConfig(cwd, agentDir, { trusted }).config
3489
+ .allowedPaths,
3490
+ commit: readCommitPolicyConfig(cwd, agentDir, { trusted }).config
3491
+ .allowedPaths,
3492
+ },
3493
+ });
3494
+ if (sub === "status") {
3495
+ const paths = folderPolicyConfigPaths(cwd, agentDir);
3496
+ const lines = [
3497
+ `Folder policy: ${resolved.config.mode} (${resolved.sources.mode === "default" ? "default" : resolved.sources.mode + " settings"})`,
3498
+ ` outside: ${resolved.config.outside}; grantScope: ${resolved.config.grantScope}; templates: ${resolved.config.sensitiveTemplates}`,
3499
+ ` base: ${baseRoot}`,
3500
+ " unverifiable writes: not prompted (use /write-policy ask for strictness)",
3501
+ ` sensitive: ${DEFAULT_SENSITIVE_PATTERNS.length} default, ${resolved.config.sensitivePatterns.length} extra, ${resolved.config.sensitiveExemptions.length} exemption(s)`,
3502
+ ` excluded: ${resolved.config.excludePaths.length} rule(s)`,
3503
+ ...(resolved.config.excludePaths.length > 0
3504
+ ? [
3505
+ ...resolved.config.excludePaths.map((p) => ` ${p}`),
3506
+ " (an excluded folder itself is refused; a sub-folder needs an explicit grant)",
3507
+ ]
3508
+ : []),
3509
+ `Global: ${paths.global}`,
3510
+ `Project: ${paths.project}${resolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
3511
+ "Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive · exclude <path>|add <path>|revoke <path>|list [--global|--project] (quote paths with spaces)",
3512
+ ];
3513
+ const coverage = baseCoverage(resolved.config);
3514
+ const blocked = coverage.filter(
3515
+ (entry) => entry.excluded.length > 0 || entry.outside.length > 0,
3516
+ );
3517
+ const rescued = coverage.filter((entry) => entry.rescuedBy);
3518
+ if (blocked.length > 0) {
3519
+ const hit = blocked[0]?.excluded[0] ?? blocked[0]?.outside[0];
3520
+ ctx.ui.notify(
3521
+ [
3522
+ ...lines,
3523
+ "",
3524
+ `WARNING: the session folder itself is excluded (${hit?.path ?? baseRoot}).`,
3525
+ `Refused at the session folder: ${blocked.map((entry) => entry.capability).join(", ")}.`,
3526
+ ...(rescued.length > 0
3527
+ ? [
3528
+ `Still covered by a grant: ${rescued
3529
+ .map((entry) => `${entry.capability} (${entry.rescuedBy})`)
3530
+ .join(", ")}.`,
3531
+ ]
3532
+ : []),
3533
+ "Grant a sub-folder explicitly, or revoke with:",
3534
+ " /folder-policy exclude revoke <path>",
3535
+ ].join("\n"),
3536
+ "error",
3537
+ );
3538
+ return;
3539
+ }
3540
+ ctx.ui.notify(
3541
+ [
3542
+ ...lines,
3543
+ ...(rescued.length > 0
3544
+ ? [
3545
+ ` (the session folder is inside an exclusion, but ${rescued
3546
+ .map((entry) => entry.capability)
3547
+ .join(", ")} is covered by a grant)`,
3548
+ ]
3549
+ : []),
3550
+ ].join("\n"),
3551
+ "info",
3552
+ );
3553
+ return;
3554
+ }
3555
+ if (sub === "exclude") {
3556
+ const parsed = parseExcludeArgs(tokens.slice(1));
3557
+ if (!parsed.ok) {
3558
+ ctx.ui.notify(`${parsed.message}\n${EXCLUDE_USAGE}`, "warning");
3559
+ return;
3560
+ }
3561
+ const settingsPaths = folderPolicyConfigPaths(cwd, agentDir);
3562
+ const activeLines = (config: typeof resolved.config): string[] => [
3563
+ `Active exclusions (${config.excludePaths.length}):`,
3564
+ ...(config.excludePaths.length > 0
3565
+ ? config.excludePaths.map((rule) => ` ${rule}`)
3566
+ : [" (none)"]),
3567
+ ];
3568
+ if (parsed.action === "list") {
3569
+ ctx.ui.notify(
3570
+ [
3571
+ `Excluded paths (${resolved.config.excludePaths.length}):`,
3572
+ ...(resolved.config.excludePaths.length > 0
3573
+ ? resolved.config.excludePaths.map((rule) => ` ${rule}`)
3574
+ : [" (none)"]),
3575
+ ...(resolved.sources.projectIgnored
3576
+ ? ["Project settings are ignored: this project is not trusted."]
3577
+ : []),
3578
+ "The excluded path itself is refused (no dialog, no grant). A path below it",
3579
+ "is outside: it can be approved once, or always via a grant inside it.",
3580
+ "Sensitive files stay blocked even inside an approved sub-folder.",
3581
+ `Global: ${settingsPaths.global}`,
3582
+ `Project: ${settingsPaths.project}`,
3583
+ ].join("\n"),
3584
+ "info",
3585
+ );
3586
+ return;
3587
+ }
3588
+ const rawPattern = parsed.pattern ?? "";
3589
+ const trimmedInput = rawPattern.trim();
3590
+ if (parsed.action === "revoke") {
3591
+ const result = revokeFolderPolicyListEntry(
3592
+ parsed.scope,
3593
+ cwd,
3594
+ agentDir,
3595
+ "excludePaths",
3596
+ rawPattern,
3597
+ );
3598
+ if (!result.ok) {
3599
+ ctx.ui.notify(
3600
+ `Could not update the exclude list: ${result.error ?? "unknown error"}`,
3601
+ "error",
3602
+ );
3603
+ return;
3604
+ }
3605
+ const after = readFolderPolicyConfig(cwd, agentDir, { trusted });
3606
+ ctx.ui.notify(
3607
+ [
3608
+ result.removed
3609
+ ? `Removed (${parsed.scope}): ${trimmedInput}`
3610
+ : `Not in the exclude list (${parsed.scope}): ${trimmedInput}`,
3611
+ `Settings: ${result.path}`,
3612
+ ...activeLines(after.config),
3613
+ ].join("\n"),
3614
+ result.removed ? "info" : "warning",
3615
+ );
3616
+ updateStatus(ctx as any);
3617
+ return;
3618
+ }
3619
+ const pattern = normalizeExcludePattern(rawPattern);
3620
+ if (!pattern || pattern === "*" || pattern === "**" || pattern === "**/*") {
3621
+ ctx.ui.notify(
3622
+ "Refusing that exclude pattern: it would block every path. Give a path such as C:/ or ~ (quoting paths that contain spaces).",
3623
+ "warning",
3624
+ );
3625
+ return;
3626
+ }
3627
+ const written = writeFolderPolicyConfig(parsed.scope, cwd, agentDir, {
3628
+ excludePaths: [pattern],
3629
+ });
3630
+ if (!written.ok) {
3631
+ ctx.ui.notify(
3632
+ `Could not write the exclude list: ${written.error ?? "unknown error"}`,
3633
+ "error",
3634
+ );
3635
+ return;
3636
+ }
3637
+ const after = readFolderPolicyConfig(cwd, agentDir, { trusted });
3638
+ const coverage = baseCoverage(after.config);
3639
+ const blocked = coverage.filter(
3640
+ (entry) => entry.excluded.length > 0 || entry.outside.length > 0,
3641
+ );
3642
+ const rescued = coverage.filter((entry) => entry.rescuedBy);
3643
+ const notes: string[] = [];
3644
+ // A successful write is never an error. Only a rule that cripples the
3645
+ // session folder (and that no grant rescues) escalates.
3646
+ let level: "info" | "warning" | "error" = "info";
3647
+ if (blocked.length > 0) {
3648
+ notes.push(
3649
+ `WARNING: this also covers the session folder (${baseRoot}).`,
3650
+ `Refused at the session folder: ${blocked.map((entry) => entry.capability).join(", ")}.`,
3651
+ ...(rescued.length > 0
3652
+ ? [
3653
+ `Still covered by a grant: ${rescued
3654
+ .map((entry) => `${entry.capability} (${entry.rescuedBy})`)
3655
+ .join(", ")}.`,
3656
+ ]
3657
+ : []),
3658
+ "Grant a sub-folder explicitly, or revoke with:",
3659
+ ` /folder-policy exclude revoke ${pattern}`,
3660
+ );
3661
+ level = "error";
3662
+ } else if (rescued.length > 0) {
3663
+ notes.push(
3664
+ `Note: this also covers the session folder, but ${rescued
3665
+ .map((entry) => entry.capability)
3666
+ .join(", ")} is covered by an explicit grant.`,
3667
+ );
3668
+ level = "warning";
3669
+ } else if (isBroadExcludePattern(pattern)) {
3670
+ notes.push(
3671
+ `Note: ${pattern} is an OS/drive root or your home directory.`,
3672
+ "Nothing inside it is reachable until a sub-folder is approved once or",
3673
+ "granted explicitly; dialogs cannot grant a root or home in one click.",
3674
+ );
3675
+ level = "warning";
3676
+ }
3677
+ ctx.ui.notify(
3678
+ [
3679
+ `Excluded (${parsed.scope}): ${pattern}` +
3680
+ (trimmedInput && trimmedInput !== pattern
3681
+ ? ` (normalised from ${trimmedInput})`
3682
+ : ""),
3683
+ `Settings: ${written.path}`,
3684
+ ...activeLines(after.config),
3685
+ ...notes,
3686
+ ].join("\n"),
3687
+ level,
3688
+ );
3689
+ updateStatus(ctx as any);
3690
+ return;
3691
+ }
3692
+ if (sub === "sensitive") {
3693
+ ctx.ui.notify(
3694
+ [
3695
+ "Sensitive deny list (always blocked for read/write/commit):",
3696
+ ...DEFAULT_SENSITIVE_PATTERNS.map((p) => ` ${p}`),
3697
+ ...(resolved.config.sensitivePatterns.length
3698
+ ? ["Extra:", ...resolved.config.sensitivePatterns.map((p) => ` ${p}`)]
3699
+ : []),
3700
+ ...(resolved.config.sensitiveExemptions.length
3701
+ ? ["Exemptions (manual):", ...resolved.config.sensitiveExemptions.map((p) => ` ${p}`)]
3702
+ : []),
3703
+ ].join("\n"),
3704
+ "info",
3705
+ );
3706
+ return;
3707
+ }
3708
+ let patch: Record<string, unknown> | undefined;
3709
+ if (["repo", "cwd", "parent", "any"].includes(sub)) patch = { mode: sub };
3710
+ else if (sub === "outside" && ["ask", "block"].includes(second)) patch = { outside: second };
3711
+ else if (sub === "grant" && ["project", "global"].includes(second)) patch = { grantScope: second };
3712
+ else if (sub === "templates" && ["allow", "block"].includes(second)) patch = { sensitiveTemplates: second };
3713
+ if (!patch) {
3714
+ ctx.ui.notify("Usage: /folder-policy repo|cwd|parent|any · outside ask|block · grant project|global · templates allow|block · sensitive", "info");
3715
+ return;
3716
+ }
3717
+ const written = writeFolderPolicyConfig(modeScope, cwd, agentDir, patch as any);
3718
+ ctx.ui.notify(
3719
+ written.ok
3720
+ ? `Folder policy updated (${modeScope}): ${written.path}`
3721
+ : `Could not write folder policy: ${written.error ?? "unknown error"}`,
3722
+ written.ok ? "info" : "error",
3723
+ );
3724
+ updateStatus(ctx as any);
3725
+ },
3726
+ getArgumentCompletions: (prefix: string) => folderPolicyArgumentCompletions(prefix),
3727
+ });
3728
+
3729
+ pi.registerCommand("read-policy", {
3730
+ description: "Read permission: grants, shell paths|strict, guarded tools and the shared folder scope",
3731
+ handler: async (args, ctx) => {
3732
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3733
+ const agentDir = getAgentDir();
3734
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3735
+ const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
3736
+ const sub = (tokens[0] || "status").toLowerCase();
3737
+ const readResolved = readReadPolicyConfig(cwd, agentDir, { trusted });
3738
+ const folderResolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
3739
+ const paths = readPolicyConfigPaths(cwd, agentDir);
3740
+ if (sub === "status" || !sub) {
3741
+ ctx.ui.notify(
3742
+ [
3743
+ describeReadPolicy(readResolved.config, folderResolved.config),
3744
+ ` grants: ${readResolved.config.allowedPaths.join(", ") || "none"}`,
3745
+ ` guarded tools: ${readResolved.config.guardedTools.join(", ") || "none"}`,
3746
+ `Global: ${paths.global}`,
3747
+ `Project: ${paths.project}${readResolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
3748
+ "Usage: /read-policy allow <path> [global|project] · revoke <path> · shell paths|strict · status",
3749
+ ].join("\n"),
3750
+ "info",
3751
+ );
3752
+ return;
3753
+ }
3754
+ const scope: "global" | "project" = tokens.includes("project")
3755
+ ? "project"
3756
+ : "global";
3757
+ if (sub === "shell") {
3758
+ const value = (tokens[1] || "").toLowerCase();
3759
+ if (value !== "paths" && value !== "strict") {
3760
+ ctx.ui.notify("Usage: /read-policy shell paths|strict [global|project]", "info");
3761
+ return;
3762
+ }
3763
+ const written = writeReadPolicyConfig(scope, cwd, agentDir, { shell: value });
3764
+ ctx.ui.notify(written.ok ? `Read shell → ${value} (${scope})` : `Could not save: ${written.error}`, written.ok ? "info" : "error");
3765
+ updateStatus(ctx as any);
3766
+ return;
3767
+ }
3768
+ if (sub === "allow" || sub === "revoke") {
3769
+ const target = tokens.slice(1).find((t) => t !== "global" && t !== "project");
3770
+ if (!target) {
3771
+ ctx.ui.notify(`Usage: /read-policy ${sub} <path> [global|project]`, "info");
3772
+ return;
3773
+ }
3774
+ const result =
3775
+ sub === "allow"
3776
+ ? writeReadPolicyConfig(scope, cwd, agentDir, { allowedPaths: [target] })
3777
+ : revokeReadPolicyPath(scope, cwd, agentDir, target);
3778
+ ctx.ui.notify(
3779
+ result.ok
3780
+ ? `Read grant ${sub === "allow" ? "added" : "removed"} (${scope}): ${target}`
3781
+ : `Could not save read grant: ${result.error}`,
3782
+ result.ok ? "info" : "error",
3783
+ );
3784
+ return;
3785
+ }
3786
+ ctx.ui.notify("Usage: /read-policy status|allow <path>|revoke <path>|shell paths|strict", "info");
3787
+ },
3788
+ getArgumentCompletions: (prefix: string) => readPolicyArgumentCompletions(prefix),
3789
+ });
3790
+
3230
3791
  pi.registerCommand("permissions", {
3231
3792
  description:
3232
3793
  "Explain why a shell command would be allowed or refused: /permissions explain <command>",
@@ -3235,7 +3796,55 @@ export default function workflowExtension(
3235
3796
  const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
3236
3797
  // `/permissions explain <cmd>` and `/permissions <cmd>` both work.
3237
3798
  const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
3238
- ctx.ui.notify(describeCommandPolicy(command).join("\n"), "info");
3799
+ const lines = describeCommandPolicy(command);
3800
+ if (command.trim()) {
3801
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
3802
+ const trusted = (ctx as any).isProjectTrusted?.() === true;
3803
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
3804
+ trusted,
3805
+ }).config;
3806
+ const writeConfig = readWritePolicyConfig(cwd, getAgentDir(), {
3807
+ trusted,
3808
+ }).config;
3809
+ const readConfig = readReadPolicyConfig(cwd, getAgentDir(), {
3810
+ trusted,
3811
+ }).config;
3812
+ const analysis = analyzeShellPaths(command, cwd);
3813
+ const classification = classifyWriteCall(
3814
+ "bash",
3815
+ { command },
3816
+ writeConfig,
3817
+ );
3818
+ const writeTargets = writeFolderTargetsFor(
3819
+ "bash",
3820
+ { command },
3821
+ classification,
3822
+ cwd,
3823
+ );
3824
+ const evaluation = evaluateFolderTargets(
3825
+ [...analysis.reads, ...writeTargets],
3826
+ {
3827
+ config: folderConfig,
3828
+ grants: [...writeConfig.allowedPaths, ...readConfig.allowedPaths],
3829
+ cwd,
3830
+ action: "write",
3831
+ },
3832
+ );
3833
+ lines.push(
3834
+ "",
3835
+ ...describeFolderEvaluation(
3836
+ evaluation,
3837
+ detectRepoRoot(cwd) ?? cwd,
3838
+ ),
3839
+ );
3840
+ if (analysis.uncertain.length > 0) {
3841
+ lines.push(` shell analysis: ${analysis.uncertain.join("; ")}`);
3842
+ }
3843
+ lines.push(
3844
+ " unverifiable writes are not prompted under writePolicy allow/Build; use /write-policy ask for strictness",
3845
+ );
3846
+ }
3847
+ ctx.ui.notify(lines.join("\n"), "info");
3239
3848
  },
3240
3849
  getArgumentCompletions: (prefix: string) =>
3241
3850
  permissionsArgumentCompletions(prefix),
@@ -3506,6 +4115,9 @@ export default function workflowExtension(
3506
4115
  abortedReviewHash = undefined;
3507
4116
  pausedReview = undefined;
3508
4117
  reviewRound = 0;
4118
+ // Manual /review is a Review turn from the first tick: the mode status,
4119
+ // input gate and mode lock must be active before the child boots.
4120
+ beginReviewTurn();
3509
4121
  const result = await runReviewStep(
3510
4122
  target.text,
3511
4123
  ctx as any,
@@ -3580,6 +4192,7 @@ export default function workflowExtension(
3580
4192
  // A manual pause (idle gate) is unchanged; the runtime/activeReview
3581
4193
  // flags carry that lock.
3582
4194
  reviewGatePhase = reviewGatePhaseOnResume(reviewGatePhase);
4195
+ setReviewTurn("running");
3583
4196
  // A stale quiet-abort marker must not silence this explicit resume.
3584
4197
  quietAbortHash = undefined;
3585
4198
  // If the plan file moved on while paused, review the new revision fresh
@@ -3669,6 +4282,46 @@ export default function workflowExtension(
3669
4282
  const active =
3670
4283
  activeReview !== undefined || rt?.isRoundActive() === true;
3671
4284
  const paused = currentPausedReview();
4285
+ // A browser/inline decision can own the turn with no round and no pause.
4286
+ if (!active && !paused && awaitingDecision) {
4287
+ const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
4288
+ const planText =
4289
+ (planPath &&
4290
+ (() => {
4291
+ try {
4292
+ return fs.existsSync(planPath)
4293
+ ? fs.readFileSync(planPath, "utf8")
4294
+ : undefined;
4295
+ } catch {
4296
+ return undefined;
4297
+ }
4298
+ })()) ??
4299
+ "";
4300
+ if (pendingHandoffReviewUrl) {
4301
+ setReviewTurn("handoff");
4302
+ ctx.ui.notify(
4303
+ "Review skipped — approve or annotate in the review tab.",
4304
+ "info",
4305
+ );
4306
+ return;
4307
+ }
4308
+ if (planText && planPath) {
4309
+ const handed = await openManualHandoff(
4310
+ { planText, outcome: "aborted", paused: false },
4311
+ planPath,
4312
+ ctx as any,
4313
+ );
4314
+ if (!handed) setReviewTurn("handoff");
4315
+ return;
4316
+ }
4317
+ clearPendingHandoffDecision();
4318
+ setReviewTurn("idle");
4319
+ ctx.ui.notify(
4320
+ `Review skipped — the plan file is ${planPath || "unavailable"}.`,
4321
+ "info",
4322
+ );
4323
+ return;
4324
+ }
3672
4325
  if (!active && !paused) {
3673
4326
  ctx.ui.notify("No review running or paused.", "info");
3674
4327
  return;
@@ -3680,6 +4333,10 @@ export default function workflowExtension(
3680
4333
  // abort lands; falls back to the author's draft when unknown).
3681
4334
  const abortedText =
3682
4335
  paused?.planText ?? activeReview?.planText;
4336
+ const pausedPlanPath = currentPlanFile ?? "";
4337
+ // A parked automatic handoff owns its own presentation: settle the gate
4338
+ // and let `runHandoff` render, never present twice.
4339
+ const gated = reviewGatePhase !== "idle" && !reviewHandoffGate.settled();
3683
4340
  pausedReview = undefined;
3684
4341
  // An aborted revision is not auto-reviewed again until the plan changes.
3685
4342
  if (targetHash) abortedReviewHash = targetHash;
@@ -3702,9 +4359,15 @@ export default function workflowExtension(
3702
4359
  "Review stopped — the author's plan stands unreviewed.",
3703
4360
  "info",
3704
4361
  );
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 });
4362
+ if (gated) {
4363
+ setReviewTurn("handoff");
4364
+ settleReviewGate({ outcome: "unreviewed", planText: abortedText });
4365
+ return;
4366
+ }
4367
+ // A manual /review command is awaiting this round; it will present the
4368
+ // aborted plan through `openManualHandoff` (shouldHandoffManualResult
4369
+ // now includes aborted). Keep Review visible until then.
4370
+ setReviewTurn("handoff");
3708
4371
  return;
3709
4372
  }
3710
4373
  flushReviewEntries(true);
@@ -3713,8 +4376,20 @@ export default function workflowExtension(
3713
4376
  "Paused review discarded — the author's plan stands.",
3714
4377
  "info",
3715
4378
  );
3716
- settleReviewGate({ outcome: "unreviewed", planText: abortedText });
3717
- // No round will run its finally, so clear the live review line here.
4379
+ if (gated) {
4380
+ setReviewTurn("handoff");
4381
+ settleReviewGate({ outcome: "unreviewed", planText: abortedText });
4382
+ return;
4383
+ }
4384
+ if (abortedText && pausedPlanPath) {
4385
+ await openManualHandoff(
4386
+ { planText: abortedText, outcome: "aborted", paused: false },
4387
+ pausedPlanPath,
4388
+ ctx as any,
4389
+ );
4390
+ return;
4391
+ }
4392
+ setReviewTurn("idle");
3718
4393
  updateStatus(ctx as any);
3719
4394
  },
3720
4395
  });
@@ -3742,6 +4417,12 @@ export default function workflowExtension(
3742
4417
  */
3743
4418
  interface ModeSwitchOptions {
3744
4419
  quiet?: boolean;
4420
+ /**
4421
+ * Internal review-decision exit: the caller already validated the pending
4422
+ * decision and owns the `transition` state, so the full review lock must
4423
+ * not refuse it. Never set this from a user-facing mode switch.
4424
+ */
4425
+ fromReview?: boolean;
3745
4426
  }
3746
4427
 
3747
4428
  /** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
@@ -4177,6 +4858,12 @@ export default function workflowExtension(
4177
4858
  },
4178
4859
  defaultModel,
4179
4860
  lastModeSwitch,
4861
+ todoPanel: {
4862
+ override: todoPanelOverride,
4863
+ overrideKey: todoPanelOverrideKey,
4864
+ promptAfterSettle: todoPanelPromptAfterSettle,
4865
+ completedSignature: lastCompletedSignature,
4866
+ },
4180
4867
  } as any);
4181
4868
  lastPersistedPlanFile = currentPlanFile;
4182
4869
  }
@@ -4193,9 +4880,10 @@ export default function workflowExtension(
4193
4880
  return;
4194
4881
  }
4195
4882
  // 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;
4883
+ // pauses or is aborted. A validated browser/inline decision passes
4884
+ // `fromReview` because it is the exit, not a competing mode switch.
4885
+ if (opts?.fromReview !== true && blockModeSwitchDuringReview(ctx)) return;
4886
+ if (opts?.fromReview === true && reviewRoundInFlight()) return;
4199
4887
  // Leaving Default mode: remember its model so it can be restored on return.
4200
4888
  if (workflowMode === null) captureDefaultModel(ctx);
4201
4889
  workflowMode = "plan";
@@ -4414,11 +5102,19 @@ export default function workflowExtension(
4414
5102
  async function enterBuildModeFromPlan(
4415
5103
  ctx: ExtensionContext,
4416
5104
  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;
5105
+ opts?: { fromReview?: boolean },
5106
+ ): Promise<boolean> {
5107
+ // Defense in depth: a stale approve must never switch tools while a real
5108
+ // reviewer round/pause owns the turn. The handoff itself is consumed by
5109
+ // the caller (fromReview), so only `reviewRoundInFlight()` can refuse it.
5110
+ if (opts?.fromReview === true) {
5111
+ if (reviewRoundInFlight()) {
5112
+ ctx.ui.notify(reviewModeLockNotice("running"), "info");
5113
+ return false;
5114
+ }
5115
+ } else if (blockModeSwitchDuringReview(ctx)) {
5116
+ return false;
5117
+ }
4422
5118
  workflowMode = "build";
4423
5119
  syncLegacyFlags();
4424
5120
  // The plan is being handed to Build; this Plan cycle cannot hand off again.
@@ -4457,6 +5153,7 @@ export default function workflowExtension(
4457
5153
  : `Build mode: ${loaded.count} todos synced from plan (progress kept).`,
4458
5154
  "info",
4459
5155
  );
5156
+ return true;
4460
5157
  }
4461
5158
 
4462
5159
  // ── Plannotator bridge helpers ─────────────────────────────────
@@ -4560,6 +5257,9 @@ export default function workflowExtension(
4560
5257
  pendingHandoffReviewUrl = null;
4561
5258
  lastReviewId = null;
4562
5259
  recordedHandoffReviewId = null;
5260
+ // Keep the Review mode visible while the browser/inline decision owns the
5261
+ // turn. The reviewer has finished, but prompts and modes stay locked.
5262
+ setReviewTurn("handoff");
4563
5263
  persistState();
4564
5264
  const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
4565
5265
  const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
@@ -4723,6 +5423,7 @@ export default function workflowExtension(
4723
5423
  if (reason === "startup" || reason === "new" || !lastReviewId) {
4724
5424
  const changed = clearPendingHandoffDecision();
4725
5425
  if (changed) persistState();
5426
+ endReviewTurn();
4726
5427
  record("clear", { why: "no restorable review" });
4727
5428
  return;
4728
5429
  }
@@ -4739,6 +5440,7 @@ export default function workflowExtension(
4739
5440
  });
4740
5441
  if (decision.action === "keep") {
4741
5442
  if (pendingHandoffSource === "manual") manualReviewCycle = true;
5443
+ setReviewTurn("handoff");
4742
5444
  record("keep", {
4743
5445
  reviewId: lastReviewId,
4744
5446
  ...(pendingHandoffReviewUrl
@@ -4758,7 +5460,7 @@ export default function workflowExtension(
4758
5460
  approved: decision.approved,
4759
5461
  });
4760
5462
  if (decision.approved) {
4761
- handlePlannotatorApprove(
5463
+ await handlePlannotatorApprove(
4762
5464
  pendingHandoffPlanPath ?? currentPlanFile ?? "",
4763
5465
  currentFile ?? "",
4764
5466
  decision.feedback,
@@ -4773,6 +5475,7 @@ export default function workflowExtension(
4773
5475
  }
4774
5476
  const changed = clearPendingHandoffDecision();
4775
5477
  if (changed) persistState();
5478
+ endReviewTurn();
4776
5479
  record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
4777
5480
  notifyReview(
4778
5481
  `Previous browser review could not be recovered — ${decision.reason}.`,
@@ -4823,20 +5526,62 @@ export default function workflowExtension(
4823
5526
  return true;
4824
5527
  }
4825
5528
 
4826
- function handlePlannotatorApprove(
5529
+ /** One execute handoff: todo list + the Build-starting message. */
5530
+ function sendExecuteMessages(
5531
+ ctx: ExtensionContext,
5532
+ planPath: string,
5533
+ feedback?: string,
5534
+ ): void {
5535
+ try {
5536
+ const remainingList = formatTodoNumberedList(todoItems);
5537
+ const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
5538
+ const planTodoListMessage = {
5539
+ customType: "workflow-todo-list",
5540
+ content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
5541
+ display: true,
5542
+ };
5543
+ const firstTodo = todoItems[0];
5544
+ const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
5545
+ 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).`;
5546
+ (pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
5547
+ (pi as any).sendMessage?.(
5548
+ {
5549
+ customType: "workflow-plan-execute",
5550
+ content: execMessage,
5551
+ display: true,
5552
+ },
5553
+ { triggerTurn: true, deliverAs: "followUp" },
5554
+ );
5555
+ } catch (_e) {
5556
+ void _e;
5557
+ }
5558
+ try {
5559
+ pi.appendEntry("workflow-handoff-result", {
5560
+ at: Date.now(),
5561
+ choice: "execute",
5562
+ source: "plannotator",
5563
+ planPath,
5564
+ });
5565
+ } catch (_e) {
5566
+ void _e;
5567
+ }
5568
+ }
5569
+
5570
+ async function handlePlannotatorApprove(
4827
5571
  planFilePath: string,
4828
5572
  planContent: string,
4829
5573
  feedback: string | undefined,
4830
5574
  cwd: string | undefined,
4831
- ) {
5575
+ ): Promise<void> {
4832
5576
  const ctx = getActiveCtx();
4833
5577
  if (!ctx) return;
4834
5578
  // A manual `/review` handoff may be approved from Default/Build; an
4835
5579
  // automatic handoff only exists in Plan mode.
4836
5580
  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)) {
5581
+ // A stale browser approve while a reviewer round/pause runs must not clear
5582
+ // the pending decision or send execute. The handoff itself is deliberately
5583
+ // NOT part of this predicate: it is the decision being consumed here.
5584
+ if (reviewRoundInFlight()) {
4840
5585
  supersedePendingHandoff(
4841
5586
  "a browser decision arrived while a new review round was running",
4842
5587
  { force: true },
@@ -4898,59 +5643,42 @@ export default function workflowExtension(
4898
5643
  } catch (_e) {
4899
5644
  void _e;
4900
5645
  }
5646
+ // Atomic decision exit: stop the reviewer and apply Build BEFORE any
5647
+ // follow-up message. `transition` keeps input/mode gates closed while the
5648
+ // async mode switch runs, so a queued prompt cannot race it.
5649
+ setReviewTurn("transition");
4901
5650
  if (pendingHandoffSource === "manual") manualReviewCycle = false;
4902
5651
  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
- });
5652
+ let enteredBuild = false;
4912
5653
  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" },
5654
+ enteredBuild = await enterBuildModeFromPlan(
5655
+ ctx as any,
5656
+ resolvedPlan,
5657
+ { fromReview: true },
5658
+ );
5659
+ } catch (e: any) {
5660
+ ctx.ui.notify(
5661
+ `Could not enter Build mode (${String(e?.message ?? e)}); the plan was not executed.`,
5662
+ "warning",
4931
5663
  );
4932
- } catch (_e) {
4933
- void _e;
4934
5664
  }
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;
5665
+ if (!enteredBuild) {
5666
+ setReviewTurn("handoff");
5667
+ updateStatus(ctx as any);
5668
+ return;
4944
5669
  }
5670
+ setReviewTurn("idle");
5671
+ updateStatus(ctx as any);
5672
+ sendExecuteMessages(ctx as any, resolvedPlan, feedback);
4945
5673
  }
4946
5674
 
4947
5675
  async function handlePlannotatorDeny(feedback: string) {
4948
5676
  const ctx = getActiveCtx();
4949
5677
  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)) {
5678
+ // A stale deny while a new reviewer round/pause runs must not inject
5679
+ // feedback under the reviewer. The pending handoff (if any) is the decision
5680
+ // being consumed, so only the round predicate can refuse it.
5681
+ if (reviewRoundInFlight()) {
4954
5682
  supersedePendingHandoff(
4955
5683
  "a browser decision arrived while a new review round was running",
4956
5684
  { force: true },
@@ -4959,11 +5687,12 @@ export default function workflowExtension(
4959
5687
  }
4960
5688
  if (!awaitingDecision) return;
4961
5689
  const manualSource = pendingHandoffSource === "manual";
5690
+ setReviewTurn("transition");
4962
5691
  if (manualSource && workflowMode !== "plan") {
4963
5692
  // A manual review can be launched from Default/Build. Its feedback is a
4964
5693
  // plan revision, so switch to Plan before the follow-up turn starts.
4965
5694
  try {
4966
- await setPlanMode(ctx as any, { quiet: true });
5695
+ await setPlanMode(ctx as any, { quiet: true, fromReview: true });
4967
5696
  } catch (_e) {
4968
5697
  void _e;
4969
5698
  }
@@ -4977,6 +5706,11 @@ export default function workflowExtension(
4977
5706
  // outstanding findings are carried forward so the reviewer can confirm they
4978
5707
  // were actually addressed rather than re-raising them.
4979
5708
  await beginReviewCycle({ reason: "deny", keepFindings: true });
5709
+ setReviewTurn("idle");
5710
+ // The feedback turn is not a fresh user prompt: keeping the refine reason
5711
+ // preserves manualReviewCycle, so a manual review with automatic Review
5712
+ // Mode off still re-reviews the revision.
5713
+ reviewFeedbackTurn = true;
4980
5714
  persistState();
4981
5715
  try {
4982
5716
  const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
@@ -5010,6 +5744,91 @@ export default function workflowExtension(
5010
5744
  }
5011
5745
  }
5012
5746
 
5747
+ /**
5748
+ * The browser review closed without a decision. The user must still get a
5749
+ * handoff: present a compact inline Approve/Revise choice with the author's
5750
+ * plan. Reopening the browser here could loop on every close, so this path is
5751
+ * deliberately inline-only. Never leaves Review stuck: cancel/idle clears it.
5752
+ */
5753
+ async function presentClosedHandoff(
5754
+ ctx: ExtensionContext,
5755
+ planPath: string,
5756
+ planText: string,
5757
+ ): Promise<void> {
5758
+ clearPendingHandoffDecision();
5759
+ setReviewTurn("handoff");
5760
+ if (!planText || !planPath) {
5761
+ endReviewTurn();
5762
+ ctx.ui.notify(
5763
+ `Plan review closed — the plan file is ${planPath || "unavailable"}.`,
5764
+ "info",
5765
+ );
5766
+ return;
5767
+ }
5768
+ if ((ctx as any).hasUI !== true) {
5769
+ try {
5770
+ (pi as any).sendMessage?.(
5771
+ {
5772
+ customType: "workflow-handoff-fallback",
5773
+ 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.`,
5774
+ display: true,
5775
+ },
5776
+ { triggerTurn: false },
5777
+ );
5778
+ } catch (_e) {
5779
+ void _e;
5780
+ }
5781
+ endReviewTurn();
5782
+ return;
5783
+ }
5784
+ const execute = "Execute the plan in Build Mode";
5785
+ const refine = "Refine the plan in Plan Mode";
5786
+ let choice: string | undefined;
5787
+ try {
5788
+ choice = await (ctx as any).ui.select(
5789
+ "Plan review closed — choose next step",
5790
+ [execute, refine],
5791
+ );
5792
+ } catch {
5793
+ choice = undefined;
5794
+ }
5795
+ if (choice === execute) {
5796
+ setReviewTurn("transition");
5797
+ const ok = await enterBuildModeFromPlan(ctx as any, planPath, {
5798
+ fromReview: true,
5799
+ });
5800
+ if (!ok) {
5801
+ setReviewTurn("handoff");
5802
+ return;
5803
+ }
5804
+ setReviewTurn("idle");
5805
+ sendExecuteMessages(ctx, planPath);
5806
+ return;
5807
+ }
5808
+ if (choice === refine) {
5809
+ setReviewTurn("transition");
5810
+ if (workflowMode !== "plan") {
5811
+ try {
5812
+ await setPlanMode(ctx as any, { quiet: true, fromReview: true });
5813
+ } catch (_e) {
5814
+ void _e;
5815
+ }
5816
+ }
5817
+ await beginReviewCycle({ reason: "refine", keepFindings: true });
5818
+ setReviewTurn("idle");
5819
+ ctx.ui.notify(
5820
+ "Plan review closed — staying in Plan mode. Tell me what to change.",
5821
+ "info",
5822
+ );
5823
+ return;
5824
+ }
5825
+ endReviewTurn();
5826
+ ctx.ui.notify(
5827
+ `Plan review closed — the plan is at ${planPath}.`,
5828
+ "info",
5829
+ );
5830
+ }
5831
+
5013
5832
  function ensurePlannotatorListeners() {
5014
5833
  if (plannotatorListenersRegistered) return;
5015
5834
  try {
@@ -5017,16 +5836,17 @@ export default function workflowExtension(
5017
5836
  if (!bus || typeof bus.on !== "function") return;
5018
5837
  // plan-approved (external executionMode) — direct handoff
5019
5838
  bus.on(PLANNOTATOR_PLAN_APPROVED, (event: any) => {
5020
- try {
5021
- handlePlannotatorApprove(
5022
- event.planFilePath,
5023
- event.planContent,
5024
- event.feedback,
5025
- event.cwd,
5839
+ void handlePlannotatorApprove(
5840
+ event?.planFilePath,
5841
+ event?.planContent,
5842
+ event?.feedback,
5843
+ event?.cwd,
5844
+ ).catch((e: unknown) => {
5845
+ notifyReview(
5846
+ `Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
5847
+ "error",
5026
5848
  );
5027
- } catch (_e) {
5028
- void _e;
5029
- }
5849
+ });
5030
5850
  });
5031
5851
  // review-result (plan-review shared API) — approve/deny from browser
5032
5852
  bus.on(PLANNOTATOR_REVIEW_RESULT, (event: any) => {
@@ -5046,12 +5866,17 @@ export default function workflowExtension(
5046
5866
  } catch (_e) {
5047
5867
  void _e;
5048
5868
  }
5049
- handlePlannotatorApprove(
5869
+ void handlePlannotatorApprove(
5050
5870
  planPath,
5051
5871
  content,
5052
5872
  event.feedback,
5053
5873
  (getActiveCtx() as any)?.cwd,
5054
- );
5874
+ ).catch((e: unknown) => {
5875
+ notifyReview(
5876
+ `Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
5877
+ "error",
5878
+ );
5879
+ });
5055
5880
  } else {
5056
5881
  void handlePlannotatorDeny(
5057
5882
  event.feedback || "Plan needs revision.",
@@ -5099,9 +5924,31 @@ export default function workflowExtension(
5099
5924
  if (event.outcome !== "closed") return;
5100
5925
  if (!lastReviewId || event.reviewId !== lastReviewId) return;
5101
5926
  if (!awaitingDecision) return;
5102
- supersedePendingHandoff(
5103
- "the browser review closed without a decision",
5104
- { force: true },
5927
+ const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
5928
+ let planText = "";
5929
+ try {
5930
+ if (planPath && fs.existsSync(planPath)) {
5931
+ planText = fs.readFileSync(planPath, "utf8");
5932
+ }
5933
+ } catch (_e) {
5934
+ void _e;
5935
+ }
5936
+ const ctx = getActiveCtx();
5937
+ if (!ctx) {
5938
+ supersedePendingHandoff(
5939
+ "the browser review closed without a decision",
5940
+ { force: true },
5941
+ );
5942
+ return;
5943
+ }
5944
+ void presentClosedHandoff(ctx as any, planPath, planText).catch(
5945
+ (e: unknown) => {
5946
+ notifyReview(
5947
+ `Plan handoff after review close failed: ${e instanceof Error ? e.message : String(e)}`,
5948
+ "warning",
5949
+ );
5950
+ endReviewTurn();
5951
+ },
5105
5952
  );
5106
5953
  } catch (_e) {
5107
5954
  void _e;
@@ -5296,6 +6143,34 @@ export default function workflowExtension(
5296
6143
  void _e;
5297
6144
  }
5298
6145
 
6146
+ // Todo panel toggle: expanded ↔ collapsed, and hidden → expanded.
6147
+ // Ctrl+Alt+T is the Linux default for opening a terminal, so this uses O.
6148
+ try {
6149
+ pi.registerShortcut(Key.ctrlAlt("o"), {
6150
+ description: "Toggle the workflow todo panel",
6151
+ handler: async (ctx: any) => {
6152
+ if (overlayDepth > 0) return;
6153
+ if (todoItems.length === 0) {
6154
+ ctx.ui.notify("No todos.", "info");
6155
+ return;
6156
+ }
6157
+ const view = deriveTodoPanel({
6158
+ items: todoItems,
6159
+ override: todoPanelOverride,
6160
+ overrideKey: todoPanelOverrideKey,
6161
+ promptAfterSettle: todoPanelPromptAfterSettle,
6162
+ stale: todoStale,
6163
+ });
6164
+ setTodoPanelOverride(
6165
+ ctx as any,
6166
+ view.state === "expanded" ? "collapsed" : "expanded",
6167
+ );
6168
+ },
6169
+ });
6170
+ } catch (_e) {
6171
+ void _e;
6172
+ }
6173
+
5299
6174
  // Tab cycles workflow modes (context-aware: not when an overlay is active).
5300
6175
  // WorkflowTabEditor gives Tab back to autocomplete while a suggestion list is
5301
6176
  // visible; this registration remains the no-list path. Single registration to
@@ -5559,7 +6434,10 @@ export default function workflowExtension(
5559
6434
  };
5560
6435
  todoItems.push(item);
5561
6436
  try {
5562
- if (currentSessionCtx) updateStatus(currentSessionCtx);
6437
+ if (currentSessionCtx) {
6438
+ updateStatus(currentSessionCtx);
6439
+ persistState();
6440
+ }
5563
6441
  } catch (_e) {
5564
6442
  void _e;
5565
6443
  }
@@ -5680,6 +6558,10 @@ export default function workflowExtension(
5680
6558
  const c = todoItems.length;
5681
6559
  todoItems = [];
5682
6560
  todoPlanPath = undefined;
6561
+ todoPanelOverride = null;
6562
+ todoPanelOverrideKey = null;
6563
+ todoPanelPromptAfterSettle = false;
6564
+ lastCompletedSignature = null;
5683
6565
  try {
5684
6566
  if (currentSessionCtx) {
5685
6567
  updateStatus(currentSessionCtx);
@@ -5711,19 +6593,119 @@ export default function workflowExtension(
5711
6593
  });
5712
6594
 
5713
6595
  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}:
6596
+ description:
6597
+ "Show or control the todo panel: /todos [show|expand|collapse|hide|auto|clear]",
6598
+ handler: async (args, ctx) => {
6599
+ const tokens = String(args || "")
6600
+ .trim()
6601
+ .split(/\s+/)
6602
+ .filter(Boolean);
6603
+ const sub = (tokens[0] || "").toLowerCase();
6604
+ const usage = [
6605
+ "Usage: /todos [show|expand|collapse|hide|auto|clear]",
6606
+ " (no arg) list every step with global refs",
6607
+ " expand show the full pinned panel",
6608
+ " collapse keep one summary line",
6609
+ " hide remove the pinned panel (footer keeps progress)",
6610
+ " auto follow the automatic collapse/hide sequence",
6611
+ " clear delete the todo list (asks first when UI is available)",
6612
+ ].join("\n");
6613
+
6614
+ if (!sub) {
6615
+ const list = todoItems.length
6616
+ ? `Steps are numbered GLOBALLY 1..${todoItems.length}:
5718
6617
  ` +
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");
6618
+ todoItems
6619
+ .map((t) => {
6620
+ const g = t.group ? `[${t.group}] ` : "";
6621
+ return `${g}${formatTodoLine(t)}`;
6622
+ })
6623
+ .join("\n")
6624
+ : "No todos.";
6625
+ ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
6626
+ return;
6627
+ }
6628
+
6629
+ if (sub === "show" || sub === "expand") {
6630
+ setTodoPanelOverride(ctx, "expanded");
6631
+ return;
6632
+ }
6633
+ if (sub === "collapse") {
6634
+ setTodoPanelOverride(ctx, "collapsed");
6635
+ return;
6636
+ }
6637
+ if (sub === "hide") {
6638
+ setTodoPanelOverride(ctx, "hidden");
6639
+ return;
6640
+ }
6641
+ if (sub === "auto") {
6642
+ setTodoPanelOverride(ctx, null);
6643
+ return;
6644
+ }
6645
+ if (sub === "clear") {
6646
+ const count = todoItems.length;
6647
+ if (count === 0) {
6648
+ ctx.ui.notify("No todos.", "info");
6649
+ return;
6650
+ }
6651
+ // Headless confirm() resolves false and would silently turn this into
6652
+ // a no-op; the command is explicit, so only ask when there is UI.
6653
+ const confirmed =
6654
+ (ctx as any).hasUI === true
6655
+ ? await ctx.ui.confirm(
6656
+ "Clear todos?",
6657
+ "This resets the list. In Build mode, edits are blocked until a list exists again.",
6658
+ )
6659
+ : true;
6660
+ if (!confirmed) {
6661
+ ctx.ui.notify("Clear cancelled.", "info");
6662
+ return;
6663
+ }
6664
+ todoItems = [];
6665
+ todoPlanPath = undefined;
6666
+ todoPanelOverride = null;
6667
+ todoPanelOverrideKey = null;
6668
+ todoPanelPromptAfterSettle = false;
6669
+ lastCompletedSignature = null;
6670
+ updateStatus(ctx);
6671
+ try {
6672
+ persistState();
6673
+ } catch (_e) {
6674
+ void _e;
6675
+ }
6676
+ onTodoWrite();
6677
+ ctx.ui.notify(
6678
+ `Cleared ${count} todos. In Build mode, edits are blocked until a list exists again (approve a plan or add a step).`,
6679
+ "info",
6680
+ );
6681
+ return;
6682
+ }
6683
+ if (sub === "help") {
6684
+ ctx.ui.notify(usage, "info");
6685
+ return;
6686
+ }
6687
+ ctx.ui.notify(`Unknown /todos action: ${sub}\n${usage}`, "info");
6688
+ },
6689
+ getArgumentCompletions: (prefix: string) => {
6690
+ const raw = String(prefix ?? "");
6691
+ if (/\s/.test(raw.trim())) return null;
6692
+ const lower = raw.trim().toLowerCase();
6693
+ const verbs = [
6694
+ { name: "show", description: "Show the full pinned panel" },
6695
+ { name: "expand", description: "Show the full pinned panel" },
6696
+ { name: "collapse", description: "Keep one summary line" },
6697
+ { name: "hide", description: "Remove the pinned panel" },
6698
+ { name: "auto", description: "Automatic collapse/hide sequence" },
6699
+ { name: "clear", description: "Delete the todo list" },
6700
+ { name: "help", description: "Show usage" },
6701
+ ];
6702
+ const matching = verbs.filter((v) => v.name.startsWith(lower));
6703
+ if (matching.length === 0) return null;
6704
+ return matching.map((v) => ({
6705
+ value: `${v.name} `,
6706
+ label: v.name,
6707
+ description: v.description,
6708
+ }));
5727
6709
  },
5728
6710
  });
5729
6711
 
@@ -8275,6 +9257,7 @@ export default function workflowExtension(
8275
9257
  ag.role ? ag : { ...ag, role: "explorer" },
8276
9258
  job.task,
8277
9259
  signal,
9260
+ subagentFolderEnv(job.cwd ?? cwd),
8278
9261
  );
8279
9262
  if (signal?.aborted) {
8280
9263
  results[idx] = {
@@ -8805,6 +9788,20 @@ export default function workflowExtension(
8805
9788
  // command reaches the reviewer now; the write-gate steer guard below stays
8806
9789
  // (a write instruction must never be steered into the reviewer).
8807
9790
  pi.on("input", async (event, _ctx) => {
9791
+ // Review owns the turn: only the explicit controls and model/profile
9792
+ // changes may become a prompt. This is the terminal-Pi half of the lock;
9793
+ // the Desktop store refuses prose before it reaches this hook.
9794
+ if (reviewTurnOwns(reviewTurn)) {
9795
+ const decision = reviewInputDecision(
9796
+ typeof event.text === "string" ? event.text : "",
9797
+ reviewTurn,
9798
+ );
9799
+ if (decision.action === "block") {
9800
+ notifyReview(decision.notice, "info");
9801
+ return { action: "handled" } as any;
9802
+ }
9803
+ return { action: "continue" } as any;
9804
+ }
8808
9805
  if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
8809
9806
  const note = event.text.slice(5).trim();
8810
9807
  if (note) {
@@ -8900,12 +9897,27 @@ export default function workflowExtension(
8900
9897
  // A session boundary owns the session-scoped plan fallback; the restore
8901
9898
  // loop below re-seeds it from the persisted `planFile`.
8902
9899
  resetSessionPlanProvenance();
9900
+ // The branch is the source of truth for the panel's presentation. The
9901
+ // host has cleared every extension widget on a session switch/reload, so
9902
+ // the reconciler must register it again from the restored state.
9903
+ todoPanelOverride = null;
9904
+ todoPanelOverrideKey = null;
9905
+ todoPanelPromptAfterSettle = false;
9906
+ lastCompletedSignature = null;
9907
+ todoPanelAppliedState = "unregistered";
8903
9908
  // A reloaded/forked session must never inherit a stale write denial.
8904
9909
  writeGateRunDenials.clear();
8905
9910
  writeGateTurnAnswer = null;
8906
9911
  writeGateTurnSteered = false;
8907
9912
  writeGateBatchCalls = [];
8908
9913
  writeGateBatchCallIds = new Set();
9914
+ readGateRunDenials.clear();
9915
+ readGateTurnAnswer = null;
9916
+ readGateTurnSteered = false;
9917
+ readGateBatchCalls = [];
9918
+ readGateBatchCallIds = new Set();
9919
+ readGateTurnGrantedRoots.clear();
9920
+ readGateTurnUncertainAllowed.clear();
8909
9921
  // Roles are file-backed; load them up-front so every read path (status,
8910
9922
  // band label, reviewer runtime) sees the real config even in a fresh
8911
9923
  // session that has no persisted workflow entry yet. Profiles likewise.
@@ -8945,6 +9957,15 @@ export default function workflowExtension(
8945
9957
  }
8946
9958
  syncLegacyFlags();
8947
9959
  if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
9960
+ // Entries written before this feature have no `todoPanel`; they
9961
+ // must not erase a value restored from an earlier entry.
9962
+ if (d.todoPanel && typeof d.todoPanel === "object") {
9963
+ const panel = readPersistedTodoPanel(d);
9964
+ todoPanelOverride = panel.override;
9965
+ todoPanelOverrideKey = panel.overrideKey;
9966
+ todoPanelPromptAfterSettle = panel.promptAfterSettle;
9967
+ lastCompletedSignature = panel.completedSignature;
9968
+ }
8948
9969
  todoPlanPath = d.planFile;
8949
9970
  toolsBeforePlanMode = d.toolsBeforePlanMode;
8950
9971
  // An entry without a planFile must not erase a value restored
@@ -9275,6 +10296,9 @@ export default function workflowExtension(
9275
10296
  // whether it survives; do not null it here, or a restored gate can only
9276
10297
  // suppress the next handoff.
9277
10298
  if (!awaitingDecision) clearPendingHandoffDecision();
10299
+ // A reload cannot preserve a running/paused reviewer child. Only a
10300
+ // persisted pending decision survives, and it owns the turn as `handoff`.
10301
+ reviewTurn = awaitingDecision ? "handoff" : "idle";
9278
10302
  currentSessionCtx = ctx as any;
9279
10303
  // Give Tab back to autocomplete while a suggestion list is visible; the raw
9280
10304
  // `tab` shortcut keeps cycling modes otherwise.
@@ -9321,6 +10345,13 @@ export default function workflowExtension(
9321
10345
  writeGateTurnSteered = false;
9322
10346
  writeGateBatchCalls = [];
9323
10347
  writeGateBatchCallIds = new Set();
10348
+ readGateRunDenials.clear();
10349
+ readGateTurnAnswer = null;
10350
+ readGateTurnSteered = false;
10351
+ readGateBatchCalls = [];
10352
+ readGateBatchCallIds = new Set();
10353
+ readGateTurnGrantedRoots.clear();
10354
+ readGateTurnUncertainAllowed.clear();
9324
10355
  // Kill any active explore subagents to prevent orphan leaks.
9325
10356
  // Without this, `pi --mode json` children spawned by runSingleAgent
9326
10357
  // (up to 4 concurrent) could survive if VS Code closes the terminal
@@ -9355,6 +10386,8 @@ export default function workflowExtension(
9355
10386
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
9356
10387
  ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
9357
10388
  ctx.ui.setWidget("workflow-todos", undefined);
10389
+ // The host dropped the widget; a later session_start re-registers it.
10390
+ todoPanelAppliedState = "unregistered";
9358
10391
  // Drop any /commit authorization or flow: a reload/quit must never leave
9359
10392
  // an exemption armed for whatever runs next.
9360
10393
  commitAuthorization = null;
@@ -9370,6 +10403,12 @@ export default function workflowExtension(
9370
10403
  todoItems = [];
9371
10404
  todoPlanPath = undefined;
9372
10405
  btwNotes = [];
10406
+ // A rewind is a branch boundary: the panel choice belongs to the branch.
10407
+ todoPanelOverride = null;
10408
+ todoPanelOverrideKey = null;
10409
+ todoPanelPromptAfterSettle = false;
10410
+ lastCompletedSignature = null;
10411
+ todoPanelAppliedState = "unregistered";
9373
10412
  // A rewind is a branch boundary: no per-run write from the old branch
9374
10413
  // may authorize a handoff or a manual review on the new one.
9375
10414
  resetPlanRunProvenance();
@@ -9380,13 +10419,18 @@ export default function workflowExtension(
9380
10419
  try {
9381
10420
  const branch = ctx.sessionManager.getBranch();
9382
10421
  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;
10422
+ if (e.type === "custom" && e.customType === "workflow") {
10423
+ if (Array.isArray(e.data?.todos)) {
10424
+ todoItems = ensureTodoLabels(e.data.todos);
10425
+ if (e.data.planFile) todoPlanPath = e.data.planFile;
10426
+ }
10427
+ if (e.data?.todoPanel && typeof e.data.todoPanel === "object") {
10428
+ const panel = readPersistedTodoPanel(e.data);
10429
+ todoPanelOverride = panel.override;
10430
+ todoPanelOverrideKey = panel.overrideKey;
10431
+ todoPanelPromptAfterSettle = panel.promptAfterSettle;
10432
+ lastCompletedSignature = panel.completedSignature;
10433
+ }
9390
10434
  }
9391
10435
  if (
9392
10436
  e.type === "message" &&
@@ -9426,15 +10470,226 @@ export default function workflowExtension(
9426
10470
  * The decision itself is the pure `decideCommitGate`; this wrapper supplies
9427
10471
  * the runtime inputs and renders the block reasons / ask overlay.
9428
10472
  */
9429
- async function commitPolicyGate(
9430
- event: any,
9431
- ctx: any,
10473
+ function runGitZ(
10474
+ repoRoot: string,
10475
+ args: string[],
10476
+ ): { ok: boolean; paths: string[]; error?: string } {
10477
+ // The Pi host's PATH is not the shell's PATH (Windows especially). Honour
10478
+ // an explicit override so a desktop install without git on PATH can still
10479
+ // run the commit gate's synchronous checks.
10480
+ const gitBinary = process.env.PI_GIT_BIN?.trim() || "git";
10481
+ try {
10482
+ const res = spawnSync(gitBinary, ["-C", repoRoot, ...args], {
10483
+ encoding: "utf8",
10484
+ timeout: 5000,
10485
+ windowsHide: true,
10486
+ maxBuffer: 8 * 1024 * 1024,
10487
+ });
10488
+ if (res.error) {
10489
+ const code = (res.error as NodeJS.ErrnoException).code;
10490
+ const missing =
10491
+ code === "ENOENT" || /\bENOENT\b/.test(res.error.message);
10492
+ return {
10493
+ ok: false,
10494
+ paths: [],
10495
+ error: missing
10496
+ ? `git executable not found (${gitBinary}); set PI_GIT_BIN to the git binary`
10497
+ : res.error.message,
10498
+ };
10499
+ }
10500
+ if (res.status !== 0)
10501
+ return {
10502
+ ok: false,
10503
+ paths: [],
10504
+ error: String(res.stderr ?? "").trim() || `git exited ${res.status}`,
10505
+ };
10506
+ const paths = String(res.stdout ?? "")
10507
+ .split("\0")
10508
+ .map((entry) => entry.replace(/\r?\n$/, ""))
10509
+ .map((entry) => entry.replace(/^..\s+/, ""))
10510
+ .map((entry) => entry.split(" -> ").pop() ?? entry)
10511
+ .filter(Boolean);
10512
+ return { ok: true, paths };
10513
+ } catch (error: unknown) {
10514
+ return {
10515
+ ok: false,
10516
+ paths: [],
10517
+ error: error instanceof Error ? error.message : String(error),
10518
+ };
10519
+ }
10520
+ }
10521
+
10522
+ function resolveCommitRoot(
10523
+ candidate: string,
10524
+ cwd: string,
10525
+ ): { root: string; uncertain: boolean } {
10526
+ const res = runGitZ(candidate, ["rev-parse", "--show-toplevel"]);
10527
+ if (res.ok && res.paths[0]) {
10528
+ return {
10529
+ root: normalizeWritePath(res.paths[0], candidate),
10530
+ uncertain: false,
10531
+ };
10532
+ }
10533
+ const detected = detectRepoRoot(candidate) ?? detectRepoRoot(cwd);
10534
+ return detected
10535
+ ? { root: detected, uncertain: false }
10536
+ : { root: candidate, uncertain: true };
10537
+ }
10538
+
10539
+ interface CommitPathScan {
10540
+ /** Explicit operands first, then index/working-tree paths. */
10541
+ paths: string[];
10542
+ failed?: string;
10543
+ }
10544
+
10545
+ /**
10546
+ * One git scan per `git add|commit`, shared by the sensitive and the
10547
+ * exclusion checks (they must not disagree about which paths are involved,
10548
+ * and running git twice doubled the timeout exposure).
10549
+ */
10550
+ function scanCommitPaths(
10551
+ scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
10552
+ ): CommitPathScan {
10553
+ const paths: string[] = [];
10554
+ const push = (values: readonly string[]) => {
10555
+ for (const value of values) if (!paths.includes(value)) paths.push(value);
10556
+ };
10557
+ push(scope.explicitPaths);
10558
+ if (scope.hit === "add" && scope.allPaths) {
10559
+ const status = runGitZ(scope.repoRoot, [
10560
+ "status",
10561
+ "--porcelain=v1",
10562
+ "-z",
10563
+ "--untracked-files=all",
10564
+ ]);
10565
+ if (!status.ok)
10566
+ return { paths, failed: status.error ?? "git status failed" };
10567
+ push(status.paths);
10568
+ }
10569
+ if (scope.hit === "commit") {
10570
+ const staged = runGitZ(scope.repoRoot, [
10571
+ "diff",
10572
+ "--cached",
10573
+ "--name-only",
10574
+ "-z",
10575
+ ]);
10576
+ if (!staged.ok)
10577
+ return { paths, failed: staged.error ?? "git diff --cached failed" };
10578
+ push(staged.paths);
10579
+ if (scope.allPaths) {
10580
+ const status = runGitZ(scope.repoRoot, [
10581
+ "status",
10582
+ "--porcelain=v1",
10583
+ "-z",
10584
+ "--untracked-files=all",
10585
+ ]);
10586
+ if (!status.ok)
10587
+ return { paths, failed: status.error ?? "git status failed" };
10588
+ push(status.paths);
10589
+ }
10590
+ }
10591
+ return { paths };
10592
+ }
10593
+
10594
+ /** First sensitive path in a scan, in scan order. */
10595
+ function commitSensitiveHit(
10596
+ scan: CommitPathScan,
10597
+ scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
10598
+ folderConfig: FolderPolicyConfig,
10599
+ ): { path: string; pattern: string } | undefined {
10600
+ for (const p of scan.paths) {
10601
+ const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
10602
+ if (hit.sensitive)
10603
+ return { path: p, pattern: hit.pattern ?? "sensitive" };
10604
+ }
10605
+ return undefined;
10606
+ }
10607
+
10608
+ async function commitPolicyGate(
10609
+ event: any,
10610
+ ctx: any,
9432
10611
  cwd: string,
9433
10612
  ): Promise<{ block: true; reason: string } | undefined> {
9434
10613
  const cmd = String(event?.input?.command ?? "");
9435
10614
  const hit = gitCommitPolicyHit(cmd);
9436
10615
  if (!hit) return undefined;
9437
- const policy = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
10616
+ const trusted = ctx?.isProjectTrusted?.() === true;
10617
+ const policyConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
10618
+ trusted,
10619
+ }).config;
10620
+ const policy = policyConfig.mode;
10621
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
10622
+ trusted,
10623
+ }).config;
10624
+ const git = analyzeShellPaths(cmd, cwd).git;
10625
+ const gitDir = /\bGIT_DIR=([^\s;&|]+)/.exec(cmd)?.[1];
10626
+ const candidateRoot = gitDir
10627
+ ? normalizeWritePath(gitDir.replace(/^['"]|['"]$/g, ""), cwd)
10628
+ : (git?.repoRoot ?? normalizeWritePath(cwd, cwd));
10629
+ const resolvedRoot = resolveCommitRoot(candidateRoot, cwd);
10630
+ const repoRoot = resolvedRoot.root;
10631
+ const gitScope = git ? { ...git, repoRoot } : undefined;
10632
+ const folderEvaluation = evaluateFolderTargets(
10633
+ [{ path: repoRoot, kind: "dir" }],
10634
+ {
10635
+ config: folderConfig,
10636
+ grants: policyConfig.allowedPaths,
10637
+ cwd,
10638
+ action: "commit",
10639
+ },
10640
+ );
10641
+ if (resolvedRoot.uncertain)
10642
+ folderEvaluation.uncertain.push(repoRoot);
10643
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
10644
+ // decideFolderGate already turns uncertainty into ask (outside: ask) or
10645
+ // block (outside: block); there is no "allow" case left to escalate.
10646
+ const scan = gitScope ? scanCommitPaths(gitScope) : undefined;
10647
+ if (scan?.failed) {
10648
+ return {
10649
+ block: true,
10650
+ reason:
10651
+ `Commit policy: the sensitive-file/exclusion check could not run (${scan.failed}). ` +
10652
+ "Refusing to commit unverified. Do not retry; ask the user to commit manually or fix git access.",
10653
+ };
10654
+ }
10655
+ const sensitiveHit =
10656
+ scan && gitScope ? commitSensitiveHit(scan, gitScope, folderConfig) : undefined;
10657
+ // The exclusion rule is applied to the same scanned paths with the same
10658
+ // rescue semantics as the folder floor. Git paths are repository-relative.
10659
+ const pathEvaluation =
10660
+ scan && scan.paths.length > 0
10661
+ ? evaluateFolderTargets(
10662
+ scan.paths.map((p) => ({
10663
+ path: path.isAbsolute(p) ? p : path.join(repoRoot, p),
10664
+ kind: "file" as const,
10665
+ })),
10666
+ {
10667
+ config: folderConfig,
10668
+ grants: policyConfig.allowedPaths,
10669
+ cwd,
10670
+ action: "commit",
10671
+ },
10672
+ )
10673
+ : undefined;
10674
+ const excludedHit =
10675
+ folderEvaluation.excluded[0] ?? pathEvaluation?.excluded[0];
10676
+ // A staged path below an excluded folder is "outside" (the commit does not
10677
+ // name it, so the floor decides ask/block for the whole commit). Only
10678
+ // exclusion-caused outside targets carry `excludeRoot`; an ordinary
10679
+ // outside repository keeps its existing attribution below.
10680
+ const outsidePathTarget = pathEvaluation?.outside.find(
10681
+ (target) => target.excludeRoot !== undefined,
10682
+ );
10683
+ const pathOutside = outsidePathTarget !== undefined;
10684
+ const folderOutside =
10685
+ folderEvaluation.outside.length > 0 || pathOutside;
10686
+ const folderDecision = decideFolderGate({
10687
+ sensitive: false,
10688
+ excluded: folderEvaluation.excluded.length > 0,
10689
+ outside: folderOutside,
10690
+ outsideAction: folderConfig.outside,
10691
+ uncertain: folderEvaluation.uncertain.length > 0,
10692
+ });
9438
10693
  const now = Date.now();
9439
10694
  const decision = decideCommitGate({
9440
10695
  policy,
@@ -9442,6 +10697,9 @@ export default function workflowExtension(
9442
10697
  hasTodos: todoItems.length > 0,
9443
10698
  auth: commitAuthorization,
9444
10699
  now,
10700
+ folder: folderDecision,
10701
+ sensitive: Boolean(sensitiveHit),
10702
+ excluded: Boolean(excludedHit),
9445
10703
  });
9446
10704
  if (decision === "allow") {
9447
10705
  // Count a granted `git commit` against the authorization, but only when
@@ -9464,6 +10722,36 @@ export default function workflowExtension(
9464
10722
  }
9465
10723
 
9466
10724
  if (decision === "block") {
10725
+ if (excludedHit) {
10726
+ return {
10727
+ block: true,
10728
+ reason:
10729
+ `Folder policy: \`${excludedHit.path}\` is excluded by \`workflow.folderPolicy.excludePaths\` (${excludedHit.pattern}). ` +
10730
+ "Committing an excluded path is refused even when the commit mode is 'allow'. " +
10731
+ "Do not retry. Remove it from the index, or ask the user to revoke the rule with `/folder-policy exclude revoke`.",
10732
+ };
10733
+ }
10734
+ if (sensitiveHit) {
10735
+ return {
10736
+ block: true,
10737
+ reason:
10738
+ `Commit policy: \`${sensitiveHit.path}\` is a sensitive file (${sensitiveHit.pattern}). ` +
10739
+ "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.",
10740
+ };
10741
+ }
10742
+ if (folderDecision === "block" && policy !== "never") {
10743
+ return {
10744
+ block: true,
10745
+ reason: pathOutside
10746
+ ? `Folder policy is 'block' (workflow.folderPolicy): the staged path \`${outsidePathTarget?.path}\` is inside an excluded folder. ` +
10747
+ "Committing it is refused even when the commit mode is 'allow'. Do not retry; unstage it, or ask the user to revoke the rule with `/folder-policy exclude revoke`."
10748
+ : `Folder policy is 'block' (workflow.folderPolicy): \`git ${hit}\` targets ${repoRoot}, ` +
10749
+ (folderOutside
10750
+ ? `which is outside the permitted folders (${baseRoot}). `
10751
+ : "which could not be verified against the permitted folders. ") +
10752
+ "Do not retry. Ask the user to grant that repository folder or change /folder-policy.",
10753
+ };
10754
+ }
9467
10755
  return {
9468
10756
  block: true,
9469
10757
  reason:
@@ -9484,20 +10772,106 @@ export default function workflowExtension(
9484
10772
  return {
9485
10773
  block: true,
9486
10774
  reason:
9487
- `Commit policy is 'ask' (workflow.commitPolicy): \`git ${hit}\` needs user approval, ` +
10775
+ `${folderOutside ? "Folder policy is 'ask' (workflow.folderPolicy)" : "Commit policy is 'ask' (workflow.commitPolicy)"}: \`git ${hit}\` needs user approval, ` +
9488
10776
  "but no interactive UI is available. Leave the change uncommitted and let the user decide.",
9489
10777
  };
9490
10778
  }
10779
+ const repoGrant = dialogGrantRootForTarget(
10780
+ { path: repoRoot, kind: "dir" },
10781
+ baseRoot,
10782
+ cwd,
10783
+ );
10784
+ // When the ask is caused by a staged path inside an excluded folder, the
10785
+ // grant must sit inside the exclusion too (a repo-root grant is above it).
10786
+ const rescueGrant =
10787
+ pathOutside && outsidePathTarget
10788
+ ? grantRootForTarget(outsidePathTarget)
10789
+ : undefined;
10790
+ const persistGrant = rescueGrant ?? repoGrant.root;
10791
+ const persistBroad = broadGrantRootReason(persistGrant, cwd);
10792
+ const commitScopeOk = folderConfig.grantScope === "global" || trusted;
10793
+ const persistable = !persistBroad && commitScopeOk;
10794
+ const commitRemedyScope = commitScopeOk ? folderConfig.grantScope : "global";
10795
+ const commitGrantBlockedReason =
10796
+ folderOutside && !persistable
10797
+ ? persistBroad
10798
+ ? `${persistBroad}; add it to workflow.commitPolicy.allowedPaths in the ${commitRemedyScope} settings (/commit-policy status shows the path)`
10799
+ : "this project is not trusted; add it to workflow.commitPolicy.allowedPaths in the global settings (/commit-policy status shows the path)"
10800
+ : undefined;
9491
10801
  let sel: unknown;
9492
10802
  try {
9493
10803
  sel = await ui.select(
9494
- `Commit policy 'ask': allow \`git ${hit}\`?`,
9495
- ["Allow once", "Deny"],
10804
+ `${folderOutside ? "Folder" : "Commit"} policy 'ask': allow \`git ${hit}\`?` +
10805
+ `\nRepository: ${repoRoot}` +
10806
+ (rescueGrant ? `\nExcluded staged path: ${outsidePathTarget?.path}` : "") +
10807
+ (folderOutside ? `\nOutside: ${baseRoot}` : "") +
10808
+ (commitGrantBlockedReason
10809
+ ? `\nGrant not available: ${commitGrantBlockedReason}`
10810
+ : ""),
10811
+ folderOutside && persistable
10812
+ ? ["Allow once", "Always allow this repository folder", "Deny"]
10813
+ : ["Allow once", "Deny"],
9496
10814
  );
9497
10815
  } catch {
9498
10816
  sel = undefined;
9499
10817
  }
9500
- if (String(sel ?? "").startsWith("Allow")) return undefined;
10818
+ const answer = String(sel ?? "");
10819
+ if (answer.startsWith("Always allow")) {
10820
+ // The dialog omits "Always" when persistence is impossible; a stale or
10821
+ // forced answer still must not turn an approval into a denial.
10822
+ const allowOnceWithWarning = (message: string) => {
10823
+ try {
10824
+ (ctx as any).ui?.notify?.(message, "warning");
10825
+ } catch (_e) {
10826
+ void _e;
10827
+ }
10828
+ return undefined;
10829
+ };
10830
+ if (persistBroad) {
10831
+ return allowOnceWithWarning(
10832
+ `Folder policy: ${persistBroad} cannot be granted from a dialog. ` +
10833
+ "This commit was allowed once, but no grant was saved. " +
10834
+ `Add it to workflow.commitPolicy.allowedPaths in the ${commitRemedyScope} settings (/commit-policy status shows the path).`,
10835
+ );
10836
+ }
10837
+ if (folderConfig.grantScope === "project" && !trusted) {
10838
+ return allowOnceWithWarning(
10839
+ "Folder policy: this project is not trusted, so a project-scoped grant would be ignored. " +
10840
+ "This commit was allowed once, but no grant was saved. " +
10841
+ "Add it to workflow.commitPolicy.allowedPaths in the global settings, or trust the project.",
10842
+ );
10843
+ }
10844
+ const written = writeCommitPolicyConfig(
10845
+ folderConfig.grantScope,
10846
+ cwd,
10847
+ getAgentDir(),
10848
+ { allowedPaths: [persistGrant] },
10849
+ );
10850
+ if (!written.ok) {
10851
+ return allowOnceWithWarning(
10852
+ `Folder policy: could not save the commit folder grant (${written.error ?? "unknown error"}). ` +
10853
+ "This commit was allowed once, but no grant was saved.",
10854
+ );
10855
+ }
10856
+ try {
10857
+ (ctx as any).ui?.notify?.(
10858
+ `Commit policy: always allowed ${persistGrant}`,
10859
+ "info",
10860
+ );
10861
+ } catch (_e) {
10862
+ void _e;
10863
+ }
10864
+ return undefined;
10865
+ }
10866
+ if (answer.startsWith("Allow")) return undefined;
10867
+ if (folderOutside) {
10868
+ return {
10869
+ block: true,
10870
+ reason:
10871
+ `Folder policy 'ask': the user declined \`git ${hit}\` for ${repoRoot}, which is outside ${baseRoot}. ` +
10872
+ "Do not retry — leave the changes uncommitted for the user.",
10873
+ };
10874
+ }
9501
10875
  return {
9502
10876
  block: true,
9503
10877
  reason:
@@ -9514,7 +10888,9 @@ export default function workflowExtension(
9514
10888
  function commitDirectiveLine(ctx: any): string {
9515
10889
  try {
9516
10890
  const cwd = ctx?.cwd ?? process.cwd();
9517
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
10891
+ const mode = readCommitPolicyConfig(cwd, getAgentDir(), {
10892
+ trusted: ctx?.isProjectTrusted?.() === true,
10893
+ }).config.mode;
9518
10894
  switch (mode) {
9519
10895
  case "never":
9520
10896
  return (
@@ -9550,16 +10926,94 @@ export default function workflowExtension(
9550
10926
  * part of the list (its arguments may have been rewritten by another
9551
10927
  * extension's tool_call handler after the assistant message was sealed).
9552
10928
  */
10929
+ function writeFolderTargetsFor(
10930
+ toolName: string,
10931
+ input: any,
10932
+ classification: WriteCallClassification,
10933
+ cwd: string,
10934
+ ): FolderTarget[] {
10935
+ if (classification.paths.length > 0) {
10936
+ return classification.paths.map((p) => ({ path: p, kind: "file" as const }));
10937
+ }
10938
+ if (toolName === "bash" || toolName === "powershell") {
10939
+ const command = String(input?.command ?? "");
10940
+ return analyzeShellPaths(command, cwd, {
10941
+ family: toolName === "powershell" ? "powershell" : "posix",
10942
+ }).writes;
10943
+ }
10944
+ return [];
10945
+ }
10946
+
10947
+ function withFolderGrant(
10948
+ item: WriteGateItem,
10949
+ targets: readonly FolderTarget[],
10950
+ cwd: string,
10951
+ baseRoot: string,
10952
+ ): WriteGateItem {
10953
+ const grantEntries = targets
10954
+ .map((target) => {
10955
+ const exact = normalizeWritePath(target.path, cwd);
10956
+ // The outside-project grouping, the exclusion rescue and the
10957
+ // broad-root fallback live in one shared helper so read, write and
10958
+ // commit cannot disagree about what "Always allow" persists.
10959
+ const { root } = dialogGrantRootForTarget(target, baseRoot, cwd);
10960
+ // A dir/repo target (or a glob's static prefix) is granted as a
10961
+ // folder; so is a containing folder computed for an outside file. A
10962
+ // file directly in the common parent is granted as that file alone.
10963
+ const folder = target.kind !== "file" || root !== exact;
10964
+ return { path: root, folder };
10965
+ })
10966
+ .filter((entry) => entry.path.length > 0);
10967
+ if (grantEntries.length === 0) return item;
10968
+ const unique = [...new Set(grantEntries.map((entry) => entry.path))].sort();
10969
+ const exclusionReason = targets.find((target) => target.reason)?.reason;
10970
+ return {
10971
+ ...item,
10972
+ target: unique.join(", "),
10973
+ why: exclusionReason ?? item.why,
10974
+ grantPaths: [...new Set([...(item.grantPaths ?? []), ...unique])],
10975
+ grantKind: grantEntries.every((entry) => entry.folder)
10976
+ ? "folder"
10977
+ : "file",
10978
+ grantCommand: undefined,
10979
+ key: `path:${unique.join("|")}`,
10980
+ };
10981
+ }
10982
+
9553
10983
  function collectWriteGateItems(
9554
10984
  currentEvent: any,
9555
10985
  currentClassification: WriteCallClassification,
9556
10986
  config: WritePolicyConfig,
10987
+ folderConfig: FolderPolicyConfig,
9557
10988
  cwd: string,
10989
+ baseRoot: string,
9558
10990
  ): WriteGateItem[] {
9559
10991
  const items: WriteGateItem[] = [];
9560
10992
  const seen = new Set<string>();
9561
- const add = (name: string, classification: WriteCallClassification) => {
9562
- const item = writeGateItemFromClassification(name, classification, cwd);
10993
+ const add = (
10994
+ name: string,
10995
+ classification: WriteCallClassification,
10996
+ input: any,
10997
+ ) => {
10998
+ const targets = writeFolderTargetsFor(
10999
+ name,
11000
+ input,
11001
+ classification,
11002
+ cwd,
11003
+ );
11004
+ const evaluation = evaluateFolderTargets(targets, {
11005
+ config: folderConfig,
11006
+ grants: config.allowedPaths,
11007
+ cwd,
11008
+ action: "write",
11009
+ });
11010
+ const needsFolder = evaluation.outside.length > 0;
11011
+ if (!needsFolder && isWriteCallAllowed(classification, config, cwd))
11012
+ return;
11013
+ const base = writeGateItemFromClassification(name, classification, cwd);
11014
+ const item = needsFolder
11015
+ ? withFolderGrant(base, targets, cwd, baseRoot)
11016
+ : base;
9563
11017
  if (!item.key || seen.has(item.key)) return;
9564
11018
  seen.add(item.key);
9565
11019
  items.push(item);
@@ -9569,8 +11023,6 @@ export default function workflowExtension(
9569
11023
  if (!name) continue;
9570
11024
  const classification = classifyWriteCall(name, call.args, config);
9571
11025
  if (!classification.mutating) continue;
9572
- // A granted target is not gated — it never reaches a dialog.
9573
- if (isWriteCallAllowed(classification, config, cwd)) continue;
9574
11026
  // The plan directory stays writable in every mode.
9575
11027
  if (
9576
11028
  classification.paths.length > 0 &&
@@ -9578,9 +11030,9 @@ export default function workflowExtension(
9578
11030
  ) {
9579
11031
  continue;
9580
11032
  }
9581
- add(name, classification);
11033
+ add(name, classification, call.args);
9582
11034
  }
9583
- add(String(currentEvent?.toolName ?? ""), currentClassification);
11035
+ add(String(currentEvent?.toolName ?? ""), currentClassification, currentEvent?.input);
9584
11036
  return items;
9585
11037
  }
9586
11038
 
@@ -9625,13 +11077,16 @@ export default function workflowExtension(
9625
11077
  }
9626
11078
 
9627
11079
  /**
9628
- * Write permission gate (workflow.writePolicy) — Default mode only.
11080
+ * Write permission gate (workflow.writePolicy + the shared folder floor).
9629
11081
  *
9630
11082
  * 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.
11083
+ * mode is read-only and Build mode is unrestricted *inside the permitted
11084
+ * folders*, but DEFAULT mode was ALSO unrestricted: `resolveSessionRole()`
11085
+ * returns undefined there, so the tool_call gate has no branch to take and
11086
+ * the agent edits files without asking. This gate is the missing permission
11087
+ * knob. The write MODE is Default-only; the folder floor applies in every
11088
+ * mode (Build included) and asks only for proven outside targets, sensitive
11089
+ * files, or an explicit target path that cannot be resolved.
9635
11090
  *
9636
11091
  * It is deliberately INDEPENDENT of the role gate and of the commit
9637
11092
  * policy: those govern *what* a role may do, this governs *whether the
@@ -9651,24 +11106,24 @@ export default function workflowExtension(
9651
11106
  cwd: string,
9652
11107
  ): Promise<{ block: true; reason: string } | undefined> {
9653
11108
  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
- }
11109
+ // Scope: the write MODE is a Default-mode knob; the shared FOLDER floor
11110
+ // applies to every mode. Plan mode already restricts writes to
11111
+ // .pi/plans/ (inside the folder root); Build mode is the deliberate
11112
+ // execution path but must still respect the user's folder floor.
11113
+ const gateRole = resolveSessionRole({
11114
+ envRole: process.env[WORKFLOW_ROLE_ENV],
11115
+ workflowMode,
11116
+ });
11117
+ if (gateRole && gateRole !== "builder") return undefined;
11118
+ const defaultMode = workflowMode === null && gateRole === undefined;
9668
11119
 
9669
- const resolved = readWritePolicyConfig(cwd, getAgentDir());
11120
+ const trusted = ctx?.isProjectTrusted?.() === true;
11121
+ const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
9670
11122
  const config = resolved.config;
9671
- if (config.mode === "allow") return undefined;
11123
+ const effectiveWriteMode = defaultMode ? config.mode : "allow";
11124
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
11125
+ trusted,
11126
+ }).config;
9672
11127
 
9673
11128
  const classification = classifyWriteCall(
9674
11129
  event.toolName,
@@ -9686,23 +11141,123 @@ export default function workflowExtension(
9686
11141
  return undefined;
9687
11142
  }
9688
11143
 
11144
+ const folderTargets = writeFolderTargetsFor(
11145
+ String(event.toolName ?? ""),
11146
+ event.input,
11147
+ classification,
11148
+ cwd,
11149
+ );
11150
+ // Workflow settings are user-owned: an agent that could rewrite them
11151
+ // could delete its own exclusions. Applies in every mode, including
11152
+ // Build, and is never softened by a grant. Slash commands write through
11153
+ // fs and are unaffected.
11154
+ const settingsTarget = folderTargets.find((folderTarget) =>
11155
+ isWorkflowSettingsPath(folderTarget.path, cwd, getAgentDir()),
11156
+ );
11157
+ if (settingsTarget) {
11158
+ return {
11159
+ block: true,
11160
+ reason:
11161
+ `Write policy: \`${settingsTarget.path}\` is the workflow settings file, which is user-owned. ` +
11162
+ "The agent may not rewrite permissions or the exclude list, even with a write grant or in Build mode. " +
11163
+ "Do not retry. Ask the user to run the matching slash command (/folder-policy, /read-policy, /write-policy, /commit-policy) or edit the file themselves.",
11164
+ };
11165
+ }
11166
+ for (const folderTarget of folderTargets) {
11167
+ const hit = matchSensitivePath(folderTarget.path, folderConfig, cwd);
11168
+ if (hit.sensitive) {
11169
+ return {
11170
+ block: true,
11171
+ reason:
11172
+ `Write policy: \`${folderTarget.path}\` is a sensitive file` +
11173
+ (hit.pattern ? ` (${hit.pattern})` : "") +
11174
+ ". This cannot be overridden by a write grant or folder. " +
11175
+ "Do not retry; ask the user to edit the sensitive list in settings.json if this is intentional.",
11176
+ };
11177
+ }
11178
+ }
11179
+
11180
+ const folderEvaluation = evaluateFolderTargets(folderTargets, {
11181
+ config: folderConfig,
11182
+ grants: config.allowedPaths,
11183
+ cwd,
11184
+ action: "write",
11185
+ });
11186
+ const folderDecision = decideFolderGate({
11187
+ sensitive: false,
11188
+ excluded: folderEvaluation.excluded.length > 0,
11189
+ outside: folderEvaluation.outside.length > 0,
11190
+ outsideAction: folderConfig.outside,
11191
+ // Only a concrete target path that cannot be canonicalised is folder
11192
+ // uncertainty. "Mutating but no discovered write target" is NOT: the
11193
+ // shell analyzer cannot see inside `npm run build`, `node script`,
11194
+ // `npx`, `sed`/`awk` programs or `powershell`, and treating that as
11195
+ // "possibly outside" made Build and writePolicy:allow prompt for
11196
+ // ordinary in-repo toolchain commands. The capability policy owns
11197
+ // that case — writePolicy:ask still asks for every mutating call —
11198
+ // and undiscoverable shell writes are the documented residual
11199
+ // (README "Not a sandbox").
11200
+ uncertain: folderEvaluation.uncertain.length > 0,
11201
+ });
9689
11202
  const allowed = isWriteCallAllowed(classification, config, cwd);
9690
11203
  const decision = decideWriteGate({
9691
- mode: config.mode,
11204
+ mode: effectiveWriteMode,
9692
11205
  mutating: true,
9693
11206
  allowed,
11207
+ folder: folderDecision,
9694
11208
  });
9695
11209
  if (decision === "allow") return undefined;
9696
11210
 
11211
+ // Which policy actually produced this ask. A folder-sourced prompt must
11212
+ // not be labelled "Write policy" — that misattribution is what made the
11213
+ // in-repo toolchain regression look like a write-policy bug.
11214
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
11215
+ const folderAsk = folderDecision === "ask";
11216
+ const gateSource: WriteGateSource = folderAsk
11217
+ ? effectiveWriteMode === "ask"
11218
+ ? "mixed"
11219
+ : "folder"
11220
+ : "write";
11221
+ const gatePolicyLabel =
11222
+ gateSource === "folder"
11223
+ ? "Folder policy"
11224
+ : gateSource === "mixed"
11225
+ ? "Permission policy"
11226
+ : "Write policy";
11227
+ const gateOutside = gateSource === "folder" ? baseRoot : undefined;
11228
+
9697
11229
  const target =
9698
- classification.paths.length > 0
9699
- ? classification.paths.join(", ")
9700
- : (classification.key ?? String(event.toolName));
11230
+ folderTargets.length > 0
11231
+ ? folderTargets.map((item) => item.path).join(", ")
11232
+ : classification.paths.length > 0
11233
+ ? classification.paths.join(", ")
11234
+ : (classification.key ?? String(event.toolName));
9701
11235
  const why = classification.reason
9702
11236
  ? ` (${classification.reason})`
9703
11237
  : "";
9704
11238
 
9705
11239
  if (decision === "block") {
11240
+ // An excluded folder (or the excluded root itself) is the most
11241
+ // specific reason: name the rule and the revoke command.
11242
+ if (folderEvaluation.excluded.length > 0) {
11243
+ const hit = folderEvaluation.excluded[0];
11244
+ return {
11245
+ block: true,
11246
+ reason:
11247
+ `Folder policy: \`${hit.path}\` is excluded by \`workflow.folderPolicy.excludePaths\` (${hit.pattern}). ` +
11248
+ "Writing to an excluded folder is refused in every mode, including Build, and cannot be overridden by a write grant. " +
11249
+ `Do not retry. A specific sub-folder can still be requested; ask the user to revoke the rule with \`/folder-policy exclude revoke\`. Target: ${target}`,
11250
+ };
11251
+ }
11252
+ if (folderDecision === "block" && effectiveWriteMode !== "never") {
11253
+ return {
11254
+ block: true,
11255
+ reason:
11256
+ `Folder policy is 'block' (workflow.folderPolicy): the target is outside the permitted folders. ` +
11257
+ "Do not retry. Ask the user to open the project at that folder, grant it, or change /folder-policy. " +
11258
+ `Target: ${target}`,
11259
+ };
11260
+ }
9706
11261
  return {
9707
11262
  block: true,
9708
11263
  reason:
@@ -9728,11 +11283,18 @@ export default function workflowExtension(
9728
11283
  // the answer applies to all of them.
9729
11284
  const ui = ctx?.ui;
9730
11285
  if (!ui || !ctx.hasUI) {
11286
+ const policyName =
11287
+ gateSource === "folder"
11288
+ ? "Folder policy is 'ask' (workflow.folderPolicy)"
11289
+ : gateSource === "mixed"
11290
+ ? "Folder and write policies are 'ask' (workflow.folderPolicy + workflow.writePolicy)"
11291
+ : "Write policy is 'ask' (workflow.writePolicy)";
9731
11292
  return {
9732
11293
  block: true,
9733
11294
  reason:
9734
- `Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
11295
+ `${policyName}: writing needs user approval${why}, but no interactive UI is available. ` +
9735
11296
  "Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
11297
+ (gateSource === "folder" ? `Outside: ${baseRoot}. ` : "") +
9736
11298
  `Target: ${target}`,
9737
11299
  };
9738
11300
  }
@@ -9749,12 +11311,23 @@ export default function workflowExtension(
9749
11311
  writeGateBatchCalls = [];
9750
11312
  writeGateBatchCallIds = new Set();
9751
11313
  }
9752
- const items = collectWriteGateItems(event, classification, config, cwd);
9753
- const currentItem = writeGateItemFromClassification(
11314
+ const items = collectWriteGateItems(
11315
+ event,
11316
+ classification,
11317
+ config,
11318
+ folderConfig,
11319
+ cwd,
11320
+ baseRoot,
11321
+ );
11322
+ const currentBase = writeGateItemFromClassification(
9754
11323
  String(event.toolName ?? ""),
9755
11324
  classification,
9756
11325
  cwd,
9757
11326
  );
11327
+ const currentItem =
11328
+ folderEvaluation.outside.length > 0
11329
+ ? withFolderGrant(currentBase, folderTargets, cwd, baseRoot)
11330
+ : currentBase;
9758
11331
 
9759
11332
  // (a) The user denied this target earlier in THIS RUN → deny again,
9760
11333
  // silently. The model gets the same sentence; the user is not asked a
@@ -9768,6 +11341,8 @@ export default function workflowExtension(
9768
11341
  target,
9769
11342
  why: classification.reason,
9770
11343
  instruction: remembered.instruction,
11344
+ source: gateSource,
11345
+ outside: gateOutside,
9771
11346
  }),
9772
11347
  };
9773
11348
  }
@@ -9785,15 +11360,37 @@ export default function workflowExtension(
9785
11360
  target,
9786
11361
  why: classification.reason,
9787
11362
  instruction: answer.instruction,
11363
+ source: gateSource,
11364
+ outside: gateOutside,
9788
11365
  }),
9789
11366
  };
9790
11367
  }
9791
11368
 
9792
11369
  // (c) The first gated call of the turn → ask ONCE.
9793
- const options = writeGateOptions(writeGateItemsKind(items));
11370
+ const grantPaths = [
11371
+ ...new Set(items.flatMap((item) => item.grantPaths ?? [])),
11372
+ ];
11373
+ const broad = grantPaths.find((root) => isBroadGrantRoot(root, cwd));
11374
+ const broadReason = broad ? broadGrantRootReason(broad, cwd) : undefined;
11375
+ const scopeOk = folderConfig.grantScope === "global" || trusted;
11376
+ const persistable = !broad && scopeOk;
11377
+ const remedyScope = scopeOk ? folderConfig.grantScope : "global";
11378
+ const writeGrantBlockedReason = !persistable
11379
+ ? broad
11380
+ ? `${broadReason ?? "a protected root"}; add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path)`
11381
+ : "this project is not trusted; add it to workflow.writePolicy.allowedPaths in the global settings (/write-policy status shows the path)"
11382
+ : undefined;
11383
+ const options = writeGateOptions(writeGateItemsKind(items), {
11384
+ allowAlways: persistable,
11385
+ });
9794
11386
  let sel: unknown;
9795
11387
  try {
9796
- sel = await ui.select(writeGateTitle(items), options);
11388
+ sel = await ui.select(
11389
+ writeGateTitle(items, gateSource, baseRoot, {
11390
+ grantBlockedReason: writeGrantBlockedReason,
11391
+ }),
11392
+ options,
11393
+ );
9797
11394
  } catch (_e) {
9798
11395
  void _e;
9799
11396
  sel = undefined;
@@ -9815,14 +11412,40 @@ export default function workflowExtension(
9815
11412
  for (const p of item.grantPaths ?? []) allowedPaths.push(p);
9816
11413
  if (item.grantCommand) allowedCommands.push(item.grantCommand);
9817
11414
  }
9818
- const written = writeWritePolicyConfig("global", cwd, getAgentDir(), {
11415
+ // The dialog omits "Always" when persistence is impossible, but a
11416
+ // stale/forced answer must never turn an approval into a denial.
11417
+ const allowOnceWithWarning = (message: string) => {
11418
+ try {
11419
+ (ctx as any).ui?.notify?.(message, "warning");
11420
+ } catch (_e) {
11421
+ void _e;
11422
+ }
11423
+ writeGateTurnAnswer = { choice: "allow-once", instruction: "" };
11424
+ return undefined;
11425
+ };
11426
+ if (broad) {
11427
+ return allowOnceWithWarning(
11428
+ `${gatePolicyLabel}: ${broadReason ?? "a protected root"} cannot be granted from a dialog. ` +
11429
+ "This write was allowed once, but no grant was saved. " +
11430
+ `Add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path).`,
11431
+ );
11432
+ }
11433
+ const grantScope = folderConfig.grantScope;
11434
+ if (grantScope === "project" && !trusted) {
11435
+ return allowOnceWithWarning(
11436
+ `${gatePolicyLabel}: this project is not trusted, so a project-scoped grant would be ignored. ` +
11437
+ "This write was allowed once, but no grant was saved. " +
11438
+ "Add it to workflow.writePolicy.allowedPaths in the global settings, or trust the project.",
11439
+ );
11440
+ }
11441
+ const written = writeWritePolicyConfig(grantScope, cwd, getAgentDir(), {
9819
11442
  allowedPaths,
9820
11443
  allowedCommands,
9821
11444
  });
9822
11445
  if (written.ok) {
9823
11446
  try {
9824
11447
  (ctx as any).ui?.notify?.(
9825
- `Write policy: always allowed ${writeGateTargetsLabel(items)}`,
11448
+ `${gatePolicyLabel}: always allowed ${writeGateTargetsLabel(items)}`,
9826
11449
  "info",
9827
11450
  );
9828
11451
  } catch (_e) {
@@ -9831,14 +11454,11 @@ export default function workflowExtension(
9831
11454
  writeGateTurnAnswer = { choice, instruction: "" };
9832
11455
  return undefined;
9833
11456
  }
9834
- // The grant could not be persisted — do not silently allow a write
9835
- // the user asked to remember. Fall through to the denial below.
9836
- return {
9837
- block: true,
9838
- reason:
9839
- `Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
9840
- `Target: ${target}`,
9841
- };
11457
+ return allowOnceWithWarning(
11458
+ `${gatePolicyLabel}: could not save the permission grant (${written.error ?? "unknown error"}). ` +
11459
+ "This write was allowed once, but no grant was saved. " +
11460
+ `Add it to workflow.writePolicy.allowedPaths in the ${remedyScope} settings (/write-policy status shows the path).`,
11461
+ );
9842
11462
  }
9843
11463
 
9844
11464
  // ── Denial ──
@@ -9886,6 +11506,8 @@ export default function workflowExtension(
9886
11506
  target: items.length > 1 ? writeGateTargetsLabel(items) : target,
9887
11507
  why: classification.reason,
9888
11508
  instruction,
11509
+ source: gateSource,
11510
+ outside: gateOutside,
9889
11511
  });
9890
11512
 
9891
11513
  if (instruction) {
@@ -9933,6 +11555,403 @@ export default function workflowExtension(
9933
11555
  }
9934
11556
  }
9935
11557
 
11558
+ function collectReadGateItems(
11559
+ currentEvent: any,
11560
+ currentClassification: ReturnType<typeof classifyReadCall>,
11561
+ config: ReadPolicyConfig,
11562
+ folderConfig: FolderPolicyConfig,
11563
+ cwd: string,
11564
+ lockedRoots?: readonly string[],
11565
+ baseRoot?: string,
11566
+ ): ReadGateItem[] {
11567
+ const items: ReadGateItem[] = [];
11568
+ const seen = new Set<string>();
11569
+ const add = (
11570
+ name: string,
11571
+ classification: ReturnType<typeof classifyReadCall>,
11572
+ ) => {
11573
+ if (classification.exempt) return;
11574
+ const targets = classification.targets;
11575
+ const evaluation = evaluateFolderTargets(targets, {
11576
+ config: folderConfig,
11577
+ grants: [...config.allowedPaths, ...readGateTurnGrantedRoots],
11578
+ cwd,
11579
+ lockedRoots,
11580
+ action: "read",
11581
+ });
11582
+ for (const target of evaluation.outside) {
11583
+ const item = readGateItemFromTarget(
11584
+ name,
11585
+ target,
11586
+ target.reason ?? "outside the folder",
11587
+ cwd,
11588
+ baseRoot,
11589
+ );
11590
+ if (seen.has(item.key)) continue;
11591
+ seen.add(item.key);
11592
+ items.push(item);
11593
+ }
11594
+ if (evaluation.outside.length === 0) {
11595
+ for (const reason of classification.uncertain) {
11596
+ const key = `cmd:${reason}`;
11597
+ if (seen.has(key)) continue;
11598
+ seen.add(key);
11599
+ items.push({
11600
+ tool: name,
11601
+ target: { path: reason, kind: "file" },
11602
+ grantRoot: "",
11603
+ why: reason,
11604
+ key,
11605
+ });
11606
+ }
11607
+ }
11608
+ };
11609
+ for (const call of readGateBatchCalls) {
11610
+ const name = String(call.name ?? "");
11611
+ if (!name) continue;
11612
+ add(name, classifyReadCall(name, call.args, cwd, config));
11613
+ }
11614
+ add(
11615
+ String(currentEvent?.toolName ?? ""),
11616
+ currentClassification,
11617
+ );
11618
+ return items;
11619
+ }
11620
+
11621
+ function deliverReadInstruction(instruction: string, target: string): void {
11622
+ if (!instruction || readGateTurnSteered) return;
11623
+ const rt = reviewRuntime;
11624
+ const wouldRouteToReviewer = shouldRouteInputToReviewer({
11625
+ reviewEnabled: reviewEnabled(),
11626
+ reviewActive: rt?.isRoundActive() === true,
11627
+ hasReviewerSession: rt?.isAlive() === true,
11628
+ manualSessionActive: reviewSessionLive(),
11629
+ });
11630
+ if (!wouldRouteToReviewer) {
11631
+ try {
11632
+ (pi as any).sendUserMessage?.(
11633
+ buildReadGuidanceMessage({ target, instruction }),
11634
+ { deliverAs: "steer" },
11635
+ );
11636
+ } catch (_e) {
11637
+ void _e;
11638
+ }
11639
+ }
11640
+ readGateTurnSteered = true;
11641
+ }
11642
+
11643
+ /**
11644
+ * Read permission gate. Applies in every workflow mode and to every role,
11645
+ * including Build/explorer/reviewer-adjacent calls. The shared folder floor
11646
+ * decides scope; the sensitive list always wins; the dialog is batched once
11647
+ * per assistant turn.
11648
+ */
11649
+ async function readPolicyGate(
11650
+ event: any,
11651
+ ctx: any,
11652
+ cwd: string,
11653
+ ): Promise<{ block: true; reason: string } | undefined> {
11654
+ try {
11655
+ const trusted = ctx?.isProjectTrusted?.() === true;
11656
+ const readResolved = readReadPolicyConfig(cwd, getAgentDir(), {
11657
+ trusted,
11658
+ });
11659
+ const folderResolved = readFolderPolicyConfig(cwd, getAgentDir(), {
11660
+ trusted,
11661
+ });
11662
+ const config = readResolved.config;
11663
+ const folderConfig = folderResolved.config;
11664
+ const classification = classifyReadCall(
11665
+ String(event.toolName ?? ""),
11666
+ event.input,
11667
+ cwd,
11668
+ config,
11669
+ );
11670
+ if (classification.exempt) return undefined;
11671
+ if (
11672
+ classification.targets.length === 0 &&
11673
+ classification.uncertain.length === 0
11674
+ )
11675
+ return undefined;
11676
+
11677
+ const grants = [...config.allowedPaths, ...readGateTurnGrantedRoots];
11678
+ const evaluation = evaluateFolderTargets(classification.targets, {
11679
+ config: folderConfig,
11680
+ grants,
11681
+ cwd,
11682
+ lockedRoots: folderResolved.locked?.roots,
11683
+ action: "read",
11684
+ });
11685
+ if (evaluation.sensitive.length > 0) {
11686
+ const hit = evaluation.sensitive[0];
11687
+ return {
11688
+ block: true,
11689
+ reason: buildReadDenyReason({
11690
+ kind: "sensitive",
11691
+ target: hit.path,
11692
+ pattern: hit.pattern,
11693
+ }),
11694
+ };
11695
+ }
11696
+
11697
+ // An excluded folder is refused outright: no dialog, no grant.
11698
+ if (evaluation.excluded.length > 0) {
11699
+ const hit = evaluation.excluded[0];
11700
+ return {
11701
+ block: true,
11702
+ reason: buildReadDenyReason({
11703
+ kind: "excluded",
11704
+ target: hit.path,
11705
+ pattern: hit.pattern,
11706
+ }),
11707
+ };
11708
+ }
11709
+
11710
+ const outside = evaluation.outside;
11711
+ const uncertain = classification.uncertain;
11712
+ let decision = decideReadGate({
11713
+ sensitive: false,
11714
+ excluded: evaluation.excluded.length > 0,
11715
+ outside: outside.length > 0,
11716
+ outsideAction: folderConfig.outside,
11717
+ granted: false,
11718
+ uncertain:
11719
+ uncertain.length > 0 || evaluation.uncertain.length > 0,
11720
+ shell: config.shell,
11721
+ });
11722
+
11723
+ if (uncertain.length > 0 && config.shell === "strict") {
11724
+ const allowed = uncertain.every((reason) =>
11725
+ readGateTurnUncertainAllowed.has(reason),
11726
+ );
11727
+ if (allowed) decision = "allow";
11728
+ }
11729
+ if (decision === "allow") return undefined;
11730
+ if (decision === "block") {
11731
+ return {
11732
+ block: true,
11733
+ reason: buildReadDenyReason({
11734
+ kind: "blocked",
11735
+ target:
11736
+ outside.length > 0
11737
+ ? outside.map((target) => target.path).join(", ")
11738
+ : uncertain[0] ?? String(event.toolName),
11739
+ outsideAction: folderConfig.outside,
11740
+ }),
11741
+ };
11742
+ }
11743
+
11744
+ const ui = ctx?.ui;
11745
+ if (!ui || !ctx.hasUI) {
11746
+ return {
11747
+ block: true,
11748
+ reason:
11749
+ "Read policy is 'ask' (workflow.folderPolicy): this read needs approval, but no interactive UI is available. " +
11750
+ "Do not retry; report what you need and let the user approve it.",
11751
+ };
11752
+ }
11753
+
11754
+ const callId = String(event.toolCallId ?? "");
11755
+ if (!readGateBatchCallIds.has(callId)) {
11756
+ readGateTurnAnswer = null;
11757
+ readGateTurnSteered = false;
11758
+ readGateBatchCalls = [];
11759
+ readGateBatchCallIds = new Set();
11760
+ readGateTurnGrantedRoots.clear();
11761
+ readGateTurnUncertainAllowed.clear();
11762
+ }
11763
+ const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
11764
+ const items = collectReadGateItems(
11765
+ event,
11766
+ classification,
11767
+ config,
11768
+ folderConfig,
11769
+ cwd,
11770
+ folderResolved.locked?.roots,
11771
+ baseRoot,
11772
+ );
11773
+ const currentItems = items.filter((item) => {
11774
+ if (outside.length > 0)
11775
+ return outside.some((target) => item.key === readGateTargetKey(target));
11776
+ return uncertain.some((reason) => item.key === `cmd:${reason}`);
11777
+ });
11778
+ const currentItem = currentItems[0];
11779
+ const remembered = currentItem
11780
+ ? readGateRunDenials.get(currentItem.key)
11781
+ : undefined;
11782
+ if (remembered) {
11783
+ return {
11784
+ block: true,
11785
+ reason: buildReadDenyReason({
11786
+ kind: "outside",
11787
+ target: readGateTargetsLabel(currentItems) || "the requested read",
11788
+ instruction: remembered.instruction,
11789
+ }),
11790
+ };
11791
+ }
11792
+ if (readGateTurnAnswer) {
11793
+ if (readGateTurnAnswer.choice === "deny") {
11794
+ return {
11795
+ block: true,
11796
+ reason: buildReadDenyReason({
11797
+ kind: "outside",
11798
+ target: readGateTargetsLabel(currentItems) || "the requested read",
11799
+ instruction: readGateTurnAnswer.instruction,
11800
+ }),
11801
+ };
11802
+ }
11803
+ return undefined;
11804
+ }
11805
+
11806
+ const roots = [
11807
+ ...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
11808
+ ];
11809
+ const broad = roots.find((root) => isBroadGrantRoot(root, cwd));
11810
+ const broadReason = broad ? broadGrantRootReason(broad, cwd) : undefined;
11811
+ const scopeOk = folderConfig.grantScope === "global" || trusted;
11812
+ const persistable = roots.length > 0 && !broad && scopeOk;
11813
+ const exactTarget = items[0]?.target.path ?? roots[0] ?? "";
11814
+ const remedyScope = scopeOk ? folderConfig.grantScope : "global";
11815
+ const grantBlockedReason =
11816
+ !persistable && roots.length > 0
11817
+ ? broad
11818
+ ? `${broadReason ?? "a protected root"}; persist the exact target with /read-policy allow ${exactTarget} ${remedyScope}`
11819
+ : `this project is not trusted; persist with /read-policy allow ${exactTarget} global`
11820
+ : undefined;
11821
+ const options = readGateOptions(readGateItemsKind(items), {
11822
+ allowAlways: persistable || roots.length === 0,
11823
+ });
11824
+ let sel: unknown;
11825
+ try {
11826
+ sel = await ui.select(
11827
+ readGateTitle(items, baseRoot, { grantBlockedReason }),
11828
+ options,
11829
+ );
11830
+ } catch {
11831
+ sel = undefined;
11832
+ }
11833
+ const choice = parseReadGateChoice(sel);
11834
+ if (choice === "allow-once") {
11835
+ for (const item of items) if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
11836
+ for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
11837
+ readGateTurnAnswer = { choice: "allow-once", instruction: "" };
11838
+ return undefined;
11839
+ }
11840
+ if (choice === "always") {
11841
+ if (roots.length === 0) {
11842
+ for (const reason of uncertain)
11843
+ readGateTurnUncertainAllowed.add(reason);
11844
+ readGateTurnAnswer = { choice: "always", instruction: "" };
11845
+ return undefined;
11846
+ }
11847
+ // The dialog omits "Always" when persistence is impossible, but a
11848
+ // stale/forced answer must never turn an approval into a denial.
11849
+ const allowOnceWithWarning = (message: string) => {
11850
+ try {
11851
+ (ctx as any).ui?.notify?.(message, "warning");
11852
+ } catch (_e) {
11853
+ void _e;
11854
+ }
11855
+ for (const item of items)
11856
+ if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
11857
+ for (const reason of uncertain)
11858
+ readGateTurnUncertainAllowed.add(reason);
11859
+ readGateTurnAnswer = { choice: "allow-once", instruction: "" };
11860
+ return undefined;
11861
+ };
11862
+ if (broad) {
11863
+ return allowOnceWithWarning(
11864
+ `Read policy: ${broadReason ?? "a protected root"} cannot be granted from a dialog. ` +
11865
+ "This read was allowed once, but no grant was saved. " +
11866
+ `Persist it deliberately with /read-policy allow ${exactTarget} ${remedyScope}.`,
11867
+ );
11868
+ }
11869
+ if (folderConfig.grantScope === "project" && !trusted) {
11870
+ return allowOnceWithWarning(
11871
+ "Read policy: this project is not trusted, so a project-scoped grant would be ignored. " +
11872
+ "This read was allowed once, but no grant was saved. " +
11873
+ `Persist it with /read-policy allow ${exactTarget} global, or trust the project.`,
11874
+ );
11875
+ }
11876
+ const written = writeReadPolicyConfig(
11877
+ folderConfig.grantScope,
11878
+ cwd,
11879
+ getAgentDir(),
11880
+ { allowedPaths: roots },
11881
+ );
11882
+ if (!written.ok) {
11883
+ return allowOnceWithWarning(
11884
+ `Read policy: could not save the permission grant (${written.error ?? "unknown error"}). ` +
11885
+ "This read was allowed once, but no grant was saved. " +
11886
+ `Persist it with /read-policy allow ${exactTarget} ${remedyScope}.`,
11887
+ );
11888
+ }
11889
+ try {
11890
+ (ctx as any).ui?.notify?.(
11891
+ `Read policy: always allowed ${roots.join(", ")}`,
11892
+ "info",
11893
+ );
11894
+ } catch (_e) {
11895
+ void _e;
11896
+ }
11897
+ for (const root of roots) readGateTurnGrantedRoots.add(root);
11898
+ for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
11899
+ readGateTurnAnswer = { choice: "always", instruction: "" };
11900
+ return undefined;
11901
+ }
11902
+
11903
+ let instruction = "";
11904
+ if (choice === "deny-with-instructions") {
11905
+ const inputFn = (ui as any).input;
11906
+ if (typeof inputFn === "function") {
11907
+ try {
11908
+ instruction = sanitizeReadInstruction(
11909
+ await inputFn.call(
11910
+ ui,
11911
+ READ_INSTRUCTION_PROMPT_TITLE,
11912
+ READ_INSTRUCTION_PROMPT_PLACEHOLDER,
11913
+ ),
11914
+ );
11915
+ } catch {
11916
+ instruction = "";
11917
+ }
11918
+ }
11919
+ }
11920
+ readGateTurnAnswer = { choice: "deny", instruction };
11921
+ for (const item of items) readGateRunDenials.set(item.key, { instruction });
11922
+ if (instruction)
11923
+ deliverReadInstruction(
11924
+ instruction,
11925
+ readGateTargetsLabel(items) || "the requested read",
11926
+ );
11927
+ try {
11928
+ pi.appendEntry("workflow-read-denial", {
11929
+ tool: String(event.toolName ?? ""),
11930
+ target: readGateTargetsLabel(items),
11931
+ instruction,
11932
+ at: Date.now(),
11933
+ });
11934
+ } catch (_e) {
11935
+ void _e;
11936
+ }
11937
+ return {
11938
+ block: true,
11939
+ reason: buildReadDenyReason({
11940
+ kind: "outside",
11941
+ target: readGateTargetsLabel(items) || "the requested read",
11942
+ instruction,
11943
+ }),
11944
+ };
11945
+ } catch (error: unknown) {
11946
+ return {
11947
+ block: true,
11948
+ reason:
11949
+ `Read policy could not be evaluated (${error instanceof Error ? error.message : String(error)}), so the read was blocked. ` +
11950
+ "Do not retry; ask the user to fix /read-policy or /folder-policy.",
11951
+ };
11952
+ }
11953
+ }
11954
+
9936
11955
  // Block edits/writes + bash gating
9937
11956
  pi.on("tool_call", async (event, ctx) => {
9938
11957
  const cwd = (ctx as ExtensionContext).cwd;
@@ -10066,15 +12085,19 @@ export default function workflowExtension(
10066
12085
  reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
10067
12086
  } as any;
10068
12087
  }
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) {
12088
+ // ── Write permission gate (workflow.writePolicy + shared folder floor) ──
12089
+ // The write MODE is a Default-mode knob; the folder floor applies in every
12090
+ // mode. It runs after the role/commit rules and before the build-todo
12091
+ // guard so a folder/sensitive refusal wins.
12092
+ {
10075
12093
  const writeBlock = await writePolicyGate(event, ctx, cwd);
10076
12094
  if (writeBlock) return writeBlock as any;
10077
12095
  }
12096
+ // ── Read permission gate (workflow.readPolicy + shared folder floor) ──
12097
+ {
12098
+ const readBlock = await readPolicyGate(event, ctx, cwd);
12099
+ if (readBlock) return readBlock as any;
12100
+ }
10078
12101
  // In build mode, ensure todos exist before first real edit
10079
12102
  if (
10080
12103
  (event.toolName === "edit" || event.toolName === "write") &&
@@ -10136,7 +12159,91 @@ export default function workflowExtension(
10136
12159
  seedCommittedStepRefs();
10137
12160
  const completed = uncommittedDoneSteps();
10138
12161
  const dirty = await isDirty(pi as any, cwd);
10139
- const mode = readCommitPolicyConfig(cwd, getAgentDir()).config.mode;
12162
+ const trusted = ctx?.isProjectTrusted?.() === true;
12163
+ const commitConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
12164
+ trusted,
12165
+ }).config;
12166
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
12167
+ trusted,
12168
+ }).config;
12169
+ const repoRoot = detectRepoRoot(cwd) ?? cwd;
12170
+ const folder = evaluateFolderTargets(
12171
+ [{ path: repoRoot, kind: "dir" }],
12172
+ {
12173
+ config: folderConfig,
12174
+ grants: commitConfig.allowedPaths,
12175
+ cwd,
12176
+ action: "commit",
12177
+ },
12178
+ );
12179
+ if (folder.excluded.length > 0) {
12180
+ const hit = folder.excluded[0];
12181
+ ctx?.ui?.notify?.(
12182
+ `Per-step commit skipped: ${repoRoot} is excluded by workflow.folderPolicy.excludePaths (${hit.pattern}). Revoke it with /folder-policy exclude revoke or commit manually.`,
12183
+ "warning",
12184
+ );
12185
+ return;
12186
+ }
12187
+ if (folder.outside.length > 0) {
12188
+ ctx?.ui?.notify?.(
12189
+ `Per-step commit skipped: ${repoRoot} is outside the permitted folders (use /folder-policy or grant it first).`,
12190
+ "warning",
12191
+ );
12192
+ return;
12193
+ }
12194
+ const status = runGitZ(repoRoot, [
12195
+ "status",
12196
+ "--porcelain=v1",
12197
+ "-z",
12198
+ "--untracked-files=all",
12199
+ ]);
12200
+ if (!status.ok) {
12201
+ ctx?.ui?.notify?.(
12202
+ `Per-step commit skipped: sensitive-file check could not run (${status.error ?? "git status failed"}).`,
12203
+ "warning",
12204
+ );
12205
+ return;
12206
+ }
12207
+ const sensitive = status.paths
12208
+ .map((p) => matchSensitivePath(p, folderConfig, repoRoot))
12209
+ .find((hit) => hit.sensitive);
12210
+ if (sensitive) {
12211
+ ctx?.ui?.notify?.(
12212
+ `Per-step commit skipped: ${sensitive.path} is sensitive. Remove it from the working tree/index or add a manual sensitiveExemptions entry.`,
12213
+ "warning",
12214
+ );
12215
+ return;
12216
+ }
12217
+ const statusFolder = evaluateFolderTargets(
12218
+ status.paths.map((p) => ({
12219
+ path: path.join(repoRoot, p),
12220
+ kind: "file" as const,
12221
+ })),
12222
+ {
12223
+ config: folderConfig,
12224
+ grants: commitConfig.allowedPaths,
12225
+ cwd,
12226
+ action: "commit",
12227
+ },
12228
+ );
12229
+ if (
12230
+ statusFolder.excluded.length > 0
12231
+ ) {
12232
+ const hit = statusFolder.excluded[0];
12233
+ ctx?.ui?.notify?.(
12234
+ `Per-step commit skipped: ${hit.path} is excluded by workflow.folderPolicy.excludePaths (${hit.pattern}). Revoke it with /folder-policy exclude revoke or commit manually.`,
12235
+ "warning",
12236
+ );
12237
+ return;
12238
+ }
12239
+ if (statusFolder.outside.length > 0 || statusFolder.uncertain.length > 0) {
12240
+ ctx?.ui?.notify?.(
12241
+ `Per-step commit skipped: staged paths leave the permitted folders or could not be verified. Review the working tree and commit manually.`,
12242
+ "warning",
12243
+ );
12244
+ return;
12245
+ }
12246
+ const mode = commitConfig.mode;
10140
12247
  const decision = stepCommitDecision({
10141
12248
  mode,
10142
12249
  hasTodos: todoItems.length > 0,
@@ -10193,6 +12300,53 @@ export default function workflowExtension(
10193
12300
  }
10194
12301
  }
10195
12302
 
12303
+ // Output redaction is defence in depth for channels the tool gate cannot
12304
+ // fully see (grep --hidden, git diff/show, truncated bash output).
12305
+ pi.on("tool_result", async (event, ctx) => {
12306
+ try {
12307
+ const cwd = (ctx as any)?.cwd ?? process.cwd();
12308
+ const trusted = (ctx as any)?.isProjectTrusted?.() === true;
12309
+ const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
12310
+ trusted,
12311
+ }).config;
12312
+ // Roots that can re-open an excluded path: the session floor plus the
12313
+ // read grants (an approved sub-folder must stay readable).
12314
+ const readGrants = readReadPolicyConfig(cwd, getAgentDir(), {
12315
+ trusted,
12316
+ }).config.allowedPaths;
12317
+ return redactToolResult(event as any, folderConfig, cwd, {
12318
+ base: baseFolderRoots(folderConfig, cwd),
12319
+ grants: [...readGrants, ...readGateTurnGrantedRoots],
12320
+ }) as any;
12321
+ } catch (error: unknown) {
12322
+ const tool = String((event as any)?.toolName ?? "");
12323
+ if (
12324
+ [
12325
+ "read",
12326
+ "read_symbol",
12327
+ "read_enclosing",
12328
+ "module_report",
12329
+ "grep",
12330
+ "find",
12331
+ "ls",
12332
+ "bash",
12333
+ "powershell",
12334
+ ].includes(tool)
12335
+ ) {
12336
+ return {
12337
+ content: [
12338
+ {
12339
+ type: "text",
12340
+ text: `[read policy redaction failed — output withheld: ${error instanceof Error ? error.message : String(error)}]`,
12341
+ },
12342
+ ],
12343
+ structuredContent: undefined,
12344
+ } as any;
12345
+ }
12346
+ return undefined;
12347
+ }
12348
+ });
12349
+
10196
12350
  pi.on("tool_result", async (_event, ctx) => {
10197
12351
  try {
10198
12352
  const leaf =
@@ -10245,6 +12399,10 @@ export default function workflowExtension(
10245
12399
  writeGateBatchCallIds = callIds;
10246
12400
  writeGateTurnAnswer = null;
10247
12401
  writeGateTurnSteered = false;
12402
+ readGateBatchCalls = calls;
12403
+ readGateBatchCallIds = callIds;
12404
+ readGateTurnAnswer = null;
12405
+ readGateTurnSteered = false;
10248
12406
  } catch (_e) {
10249
12407
  void _e;
10250
12408
  }
@@ -10258,6 +12416,12 @@ export default function workflowExtension(
10258
12416
  writeGateTurnSteered = false;
10259
12417
  writeGateBatchCalls = [];
10260
12418
  writeGateBatchCallIds = new Set();
12419
+ readGateTurnAnswer = null;
12420
+ readGateTurnSteered = false;
12421
+ readGateBatchCalls = [];
12422
+ readGateBatchCallIds = new Set();
12423
+ readGateTurnGrantedRoots.clear();
12424
+ readGateTurnUncertainAllowed.clear();
10261
12425
  // Seed the per-step commit ledger once per session: steps already done
10262
12426
  // (restored sessions, re-executed plans) never trigger a spurious commit.
10263
12427
  try {
@@ -10308,6 +12472,13 @@ export default function workflowExtension(
10308
12472
  writeGateTurnSteered = false;
10309
12473
  writeGateBatchCalls = [];
10310
12474
  writeGateBatchCallIds = new Set();
12475
+ readGateRunDenials.clear();
12476
+ readGateTurnAnswer = null;
12477
+ readGateTurnSteered = false;
12478
+ readGateBatchCalls = [];
12479
+ readGateBatchCallIds = new Set();
12480
+ readGateTurnGrantedRoots.clear();
12481
+ readGateTurnUncertainAllowed.clear();
10311
12482
  // keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
10312
12483
  });
10313
12484
 
@@ -10356,12 +12527,47 @@ export default function workflowExtension(
10356
12527
  pi.on("before_agent_start", async (event, ctx) => {
10357
12528
  currentSessionCtx = ctx as any;
10358
12529
  ensurePlannotatorListeners();
12530
+ // Safety valve for a prompt that bypassed `input` (host queue, another
12531
+ // extension): never let a Review turn run concurrently with a plan/build
12532
+ // turn. Stop the reviewer first, then let the queued prompt proceed.
12533
+ if (reviewTurnOwns(reviewTurn) && reviewTurn !== "transition") {
12534
+ // Invalidate the parked handoff BEFORE settling its gate: otherwise the
12535
+ // runHandoff continuation can observe the old cycle token and render the
12536
+ // abandoned plan alongside the new prompt.
12537
+ reviewCycleId += 1;
12538
+ try {
12539
+ await suspendReview();
12540
+ } catch (_e) {
12541
+ void _e;
12542
+ }
12543
+ endReviewTurn();
12544
+ notifyReview(
12545
+ "Review stopped — a prompt was already queued; the plan was not reviewed.",
12546
+ "info",
12547
+ );
12548
+ }
10359
12549
  // Fresh user prompt → reset the passive reminder cadence. Continuation runs
10360
12550
  // (triggerTurn/steer/followUp) do not fire this event, so state survives them.
10361
12551
  turnsSinceLastTodoWrite = 0;
10362
12552
  turnsSinceLastReminder = 0;
10363
12553
  mutatedSinceLastTodoWrite = false;
10364
12554
  footerInjectedThisTurn = false;
12555
+ // A real user prompt means the user has moved on from a finished plan:
12556
+ // hide the one-line todo panel. `custom` runs (triggerTurn) must not do
12557
+ // this — they can be self-initiated while the user is still reading.
12558
+ if (
12559
+ event.messageType === "user" &&
12560
+ isTodoListSettled(todoItems) &&
12561
+ !todoPanelPromptAfterSettle
12562
+ ) {
12563
+ todoPanelPromptAfterSettle = true;
12564
+ updateStatus(ctx);
12565
+ try {
12566
+ persistState();
12567
+ } catch (_e) {
12568
+ void _e;
12569
+ }
12570
+ }
10365
12571
  // A fresh user prompt starts a new handoff/provenance run. It also
10366
12572
  // supersedes any pending browser/inline decision immediately: the user
10367
12573
  // moved on, so a stale tab must not approve the previous plan. Follow-up
@@ -10386,10 +12592,14 @@ export default function workflowExtension(
10386
12592
  }
10387
12593
  // Every fresh prompt is a new review cycle: reset the round budget, cancel
10388
12594
  // an abandoned pause/gate, and tag the cycle so an old handoff cannot
10389
- // render after this prompt.
12595
+ // render after this prompt. A feedback/refine continuation keeps the
12596
+ // manual cycle alive; a genuine user prompt ends it.
12597
+ const feedbackTurn = reviewFeedbackTurn;
12598
+ reviewFeedbackTurn = false;
10390
12599
  try {
12600
+ if (!feedbackTurn) endReviewTurn();
10391
12601
  await beginReviewCycle({
10392
- reason: "prompt",
12602
+ reason: feedbackTurn ? "refine" : "prompt",
10393
12603
  keepFindings: true,
10394
12604
  planPath: currentPlanFile,
10395
12605
  });
@@ -11002,26 +13212,36 @@ export default function workflowExtension(
11002
13212
  }
11003
13213
  // Build completion
11004
13214
  if (workflowMode === "build" && todoItems.length > 0) {
11005
- const allDone = todoItems.every(
11006
- (t) => isDone(t) || itemStatus(t) === "cancelled",
11007
- );
13215
+ const allDone = isTodoListSettled(todoItems);
11008
13216
  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;
13217
+ // One card per list revision: a settled list stays settled across
13218
+ // turns, and re-sending the same card on every agent_end buried the
13219
+ // transcript in duplicates. A new/renamed list or changed statuses
13220
+ // produce a new signature and therefore a new card.
13221
+ const signature = todoCompletionSignature(todoItems);
13222
+ if (signature !== lastCompletedSignature) {
13223
+ lastCompletedSignature = signature;
13224
+ try {
13225
+ (pi as any).sendMessage?.(
13226
+ {
13227
+ customType: "workflow-complete",
13228
+ content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
13229
+ display: true,
13230
+ },
13231
+ { triggerTurn: false },
13232
+ );
13233
+ } catch (_e) {
13234
+ void _e;
13235
+ }
11020
13236
  }
11021
13237
  // keep todos for history but stay in build
11022
13238
  todoStale = false;
11023
13239
  updateStatus(ctx as any);
11024
13240
  persistState();
13241
+ } else if (lastCompletedSignature !== null) {
13242
+ // The list reopened (new step or a reverted status): allow the next
13243
+ // completion to emit its own card. Persisted on the next mutation.
13244
+ lastCompletedSignature = null;
11025
13245
  }
11026
13246
  return;
11027
13247
  }
@@ -11238,6 +13458,9 @@ export default function workflowExtension(
11238
13458
  reviewHandoffGate.reset();
11239
13459
  reviewGatePhase = "auditing";
11240
13460
  reviewGateCycleId = handoffCycle;
13461
+ // Review owns the turn before the reviewer boots. A plan handoff with
13462
+ // Review Mode off never reaches this block (no Review turn).
13463
+ beginReviewTurn();
11241
13464
  try {
11242
13465
  const reviewed = await runReviewStep(
11243
13466
  authorPlan,