@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/README.md +113 -15
- package/commit-policy.ts +82 -10
- package/edit-policy.ts +121 -45
- package/folder-policy.ts +2165 -0
- package/index.ts +2493 -270
- package/package.json +9 -2
- package/read-policy-redact.ts +359 -0
- package/read-policy-tools.ts +312 -0
- package/read-policy.ts +672 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +152 -52
- package/review-tools.ts +228 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1066 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +166 -0
- package/workflow-status.ts +22 -8
package/index.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
19
|
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
|
20
20
|
import type {
|
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
type EditorTheme,
|
|
42
42
|
type SelectItem,
|
|
43
43
|
wrapTextWithAnsi,
|
|
44
|
-
truncateToWidth,
|
|
45
44
|
} from "@earendil-works/pi-tui";
|
|
46
45
|
import { Type } from "typebox";
|
|
47
46
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -121,6 +120,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
|
-
|
|
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
|
-
*
|
|
1199
|
-
*
|
|
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
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1426
|
+
reviewTurn !== "idle"
|
|
1313
1427
|
? "accent"
|
|
1314
|
-
: workflowMode === "
|
|
1315
|
-
? "
|
|
1316
|
-
: "
|
|
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
|
|
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(
|
|
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
|
|
3129
|
-
//
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
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
|
-
|
|
3717
|
-
|
|
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.
|
|
4197
|
-
// `
|
|
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
|
-
|
|
4419
|
-
//
|
|
4420
|
-
//
|
|
4421
|
-
|
|
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
|
-
|
|
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
|
|
4838
|
-
// decision or send
|
|
4839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
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
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
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
|
-
//
|
|
4951
|
-
//
|
|
4952
|
-
//
|
|
4953
|
-
if (
|
|
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
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
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)
|
|
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:
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
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
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
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.
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
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
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9495
|
-
|
|
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
|
-
|
|
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()
|
|
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 = (
|
|
9562
|
-
|
|
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
|
|
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
|
|
9632
|
-
* mode was ALSO unrestricted: `resolveSessionRole()`
|
|
9633
|
-
* there, so the tool_call gate has no branch to take and
|
|
9634
|
-
* files without asking. This gate is the missing permission
|
|
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
|
|
9655
|
-
//
|
|
9656
|
-
//
|
|
9657
|
-
//
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9662
|
-
|
|
9663
|
-
|
|
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
|
|
11120
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
11121
|
+
const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
|
|
9670
11122
|
const config = resolved.config;
|
|
9671
|
-
|
|
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:
|
|
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
|
-
|
|
9699
|
-
?
|
|
9700
|
-
:
|
|
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
|
-
|
|
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(
|
|
9753
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9835
|
-
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
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
|
|
10070
|
-
//
|
|
10071
|
-
//
|
|
10072
|
-
//
|
|
10073
|
-
|
|
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
|
|
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
|
|
11006
|
-
(t) => isDone(t) || itemStatus(t) === "cancelled",
|
|
11007
|
-
);
|
|
13215
|
+
const allDone = isTodoListSettled(todoItems);
|
|
11008
13216
|
if (allDone) {
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
|
|
11018
|
-
|
|
11019
|
-
|
|
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,
|