@dieulc/workflow 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -14
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +1958 -258
- package/package.json +9 -2
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +142 -52
- package/review-tools.ts +169 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
package/index.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
19
|
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
|
20
20
|
import type {
|
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
type EditorTheme,
|
|
42
42
|
type SelectItem,
|
|
43
43
|
wrapTextWithAnsi,
|
|
44
|
-
truncateToWidth,
|
|
45
44
|
} from "@earendil-works/pi-tui";
|
|
46
45
|
import { Type } from "typebox";
|
|
47
46
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -121,6 +120,49 @@ import {
|
|
|
121
120
|
type PendingCommitFlow,
|
|
122
121
|
type StepCommitCandidate,
|
|
123
122
|
} from "./commit-policy.ts";
|
|
123
|
+
import {
|
|
124
|
+
baseFolderRoots,
|
|
125
|
+
decideFolderGate,
|
|
126
|
+
describeFolderEvaluation,
|
|
127
|
+
detectRepoRoot,
|
|
128
|
+
isBroadGrantRoot,
|
|
129
|
+
outsideProjectRootForTarget,
|
|
130
|
+
evaluateFolderTargets,
|
|
131
|
+
matchSensitivePath,
|
|
132
|
+
readFolderPolicyConfig,
|
|
133
|
+
writeFolderPolicyConfig,
|
|
134
|
+
folderPolicyArgumentCompletions,
|
|
135
|
+
folderPolicyConfigPaths,
|
|
136
|
+
DEFAULT_SENSITIVE_PATTERNS,
|
|
137
|
+
type FolderPolicyConfig,
|
|
138
|
+
type FolderTarget,
|
|
139
|
+
} from "./folder-policy.ts";
|
|
140
|
+
import { analyzeShellPaths } from "./shell-paths.ts";
|
|
141
|
+
import {
|
|
142
|
+
buildReadDenyReason,
|
|
143
|
+
buildReadGuidanceMessage,
|
|
144
|
+
decideReadGate,
|
|
145
|
+
parseReadGateChoice,
|
|
146
|
+
readGateItemFromTarget,
|
|
147
|
+
readGateItemsKind,
|
|
148
|
+
readGateOptions,
|
|
149
|
+
readGateTargetKey,
|
|
150
|
+
readGateTargetsLabel,
|
|
151
|
+
readGateTitle,
|
|
152
|
+
readReadPolicyConfig,
|
|
153
|
+
sanitizeReadInstruction,
|
|
154
|
+
writeReadPolicyConfig,
|
|
155
|
+
readPolicyArgumentCompletions,
|
|
156
|
+
readPolicyConfigPaths,
|
|
157
|
+
revokeReadPolicyPath,
|
|
158
|
+
describeReadPolicy,
|
|
159
|
+
READ_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
160
|
+
READ_INSTRUCTION_PROMPT_TITLE,
|
|
161
|
+
type ReadGateItem,
|
|
162
|
+
type ReadPolicyConfig,
|
|
163
|
+
} from "./read-policy.ts";
|
|
164
|
+
import { classifyReadCall } from "./read-policy-tools.ts";
|
|
165
|
+
import { redactToolResult } from "./read-policy-redact.ts";
|
|
124
166
|
import {
|
|
125
167
|
buildWriteDenyReason,
|
|
126
168
|
buildWriteGuidanceMessage,
|
|
@@ -128,6 +170,7 @@ import {
|
|
|
128
170
|
decideWriteGate,
|
|
129
171
|
describeWritePolicyMode,
|
|
130
172
|
isWriteCallAllowed,
|
|
173
|
+
normalizeWritePath,
|
|
131
174
|
parseWriteGateChoice,
|
|
132
175
|
readWritePolicyConfig,
|
|
133
176
|
sanitizeWriteInstruction,
|
|
@@ -143,6 +186,7 @@ import {
|
|
|
143
186
|
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
144
187
|
type WriteGateChoice,
|
|
145
188
|
type WriteGateItem,
|
|
189
|
+
type WriteGateSource,
|
|
146
190
|
type WriteCallClassification,
|
|
147
191
|
type WritePolicyConfig,
|
|
148
192
|
type WritePolicyMode,
|
|
@@ -187,6 +231,17 @@ import {
|
|
|
187
231
|
setWorkflowStatus,
|
|
188
232
|
WORKFLOW_STATUS_KEYS,
|
|
189
233
|
} from "./workflow-status.ts";
|
|
234
|
+
import {
|
|
235
|
+
buildTodoRows,
|
|
236
|
+
deriveTodoPanel,
|
|
237
|
+
isTodoListSettled,
|
|
238
|
+
readPersistedTodoPanel,
|
|
239
|
+
renderTodoSummary,
|
|
240
|
+
todoCompletionSignature,
|
|
241
|
+
todoListKey,
|
|
242
|
+
type TodoPanelOverride,
|
|
243
|
+
type TodoPanelState,
|
|
244
|
+
} from "./todo-panel.ts";
|
|
190
245
|
import {
|
|
191
246
|
defaultProfileConfig,
|
|
192
247
|
loadProfilesDetailed,
|
|
@@ -256,6 +311,9 @@ import {
|
|
|
256
311
|
reviewModeLockKind,
|
|
257
312
|
reviewModeLockNotice,
|
|
258
313
|
reviewModeLockStatusLine,
|
|
314
|
+
reviewInputDecision,
|
|
315
|
+
reviewRoundOwnsTurn,
|
|
316
|
+
reviewTurnOwns,
|
|
259
317
|
reviewNoticeText,
|
|
260
318
|
reviewPauseIsCurrent,
|
|
261
319
|
reviewResumeGateOutcome,
|
|
@@ -278,6 +336,7 @@ import {
|
|
|
278
336
|
type ReviewHandoffPhase,
|
|
279
337
|
type ReviewHandoffResult,
|
|
280
338
|
type ReviewModeLockKind,
|
|
339
|
+
type ReviewTurnPhase,
|
|
281
340
|
type ReviewState,
|
|
282
341
|
type ReviewTranscriptOp,
|
|
283
342
|
type ReviewTrigger,
|
|
@@ -449,8 +508,17 @@ async function runSingleAgent(
|
|
|
449
508
|
shell: false,
|
|
450
509
|
stdio: ["ignore", "pipe", "pipe"],
|
|
451
510
|
// Carry the role into the child so its workflow extension gates bash
|
|
452
|
-
// with the same policy the parent applies (see permissions.ts)
|
|
453
|
-
|
|
511
|
+
// with the same policy the parent applies (see permissions.ts), and
|
|
512
|
+
// lock the folder roots so the child cannot expand via a sibling
|
|
513
|
+
// project's settings.
|
|
514
|
+
env: {
|
|
515
|
+
...subagentEnv(process.env, agent.role),
|
|
516
|
+
PI_FOLDER_POLICY_LOCKED: "1",
|
|
517
|
+
PI_FOLDER_POLICY_ROOTS: JSON.stringify([
|
|
518
|
+
detectRepoRoot(cwd) ?? cwd,
|
|
519
|
+
]),
|
|
520
|
+
PI_FOLDER_POLICY_SOURCE: "parent",
|
|
521
|
+
},
|
|
454
522
|
},
|
|
455
523
|
);
|
|
456
524
|
// Track for cleanup on session_shutdown — prevents orphaned subagents
|
|
@@ -607,12 +675,33 @@ export default function workflowExtension(
|
|
|
607
675
|
null;
|
|
608
676
|
let writeGateTurnSteered = false;
|
|
609
677
|
const writeGateRunDenials = new Map<string, { instruction: string }>();
|
|
678
|
+
// ── Read-policy gate state (turn-scoped grants, run-scoped denials) ──
|
|
679
|
+
let readGateBatchCalls: { name: string; args: unknown }[] = [];
|
|
680
|
+
let readGateBatchCallIds = new Set<string>();
|
|
681
|
+
const readGateTurnGrantedRoots = new Set<string>();
|
|
682
|
+
const readGateTurnUncertainAllowed = new Set<string>();
|
|
683
|
+
let readGateTurnAnswer: {
|
|
684
|
+
choice: "allow-once" | "always" | "deny";
|
|
685
|
+
instruction: string;
|
|
686
|
+
} | null = null;
|
|
687
|
+
let readGateTurnSteered = false;
|
|
688
|
+
const readGateRunDenials = new Map<string, { instruction: string }>();
|
|
610
689
|
// ── Reminder throttle + staleness tracking (passive enforcement layer) ──
|
|
611
690
|
let turnsSinceLastTodoWrite = 0;
|
|
612
691
|
let turnsSinceLastReminder = 0;
|
|
613
692
|
let mutatedSinceLastTodoWrite = false;
|
|
614
693
|
let lastRefMisses: { refs: string[] } | undefined;
|
|
615
694
|
let todoStale = false;
|
|
695
|
+
// ── Todo panel presentation (pinned widget above the editor) ──
|
|
696
|
+
// The widget can never scroll with the transcript, so it collapses to one
|
|
697
|
+
// line when the plan settles and hides at the next user prompt. Overrides
|
|
698
|
+
// are scoped to `todoPanelOverrideKey` (ref/text/group identity), so status
|
|
699
|
+
// churn keeps the user's choice while a new/renamed list expires it.
|
|
700
|
+
let todoPanelOverride: TodoPanelOverride = null;
|
|
701
|
+
let todoPanelOverrideKey: string | null = null;
|
|
702
|
+
let todoPanelPromptAfterSettle = false;
|
|
703
|
+
let lastCompletedSignature: string | null = null;
|
|
704
|
+
let todoPanelAppliedState: TodoPanelState | "unregistered" = "unregistered";
|
|
616
705
|
let footerInjectedThisTurn = false;
|
|
617
706
|
let lastStaleNoticeAt: number | undefined;
|
|
618
707
|
// Path+content hash of the last plan-file reconciliation (skip no-op merges).
|
|
@@ -1189,83 +1278,81 @@ export default function workflowExtension(
|
|
|
1189
1278
|
todoStale = false;
|
|
1190
1279
|
}
|
|
1191
1280
|
|
|
1192
|
-
/** Display ref for a todo row (stable canonical GLOBAL step). */
|
|
1193
|
-
function todoLabel(it: TodoItem, index: number): string {
|
|
1194
|
-
return todoRef(it) || String(it.step || index + 1);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
1281
|
/**
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1282
|
+
* Reconcile the pinned todo widget with the derived presentation state.
|
|
1283
|
+
* Called from `updateStatus` after the footer writes; only re-registers the
|
|
1284
|
+
* component when the state changes (marking a step done repaints through
|
|
1285
|
+
* the host, so the widget is not disposed/rebuilt on every refresh).
|
|
1200
1286
|
*/
|
|
1201
|
-
function
|
|
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 });
|
|
1287
|
+
function refreshTodoPanel(ctx: ExtensionContext) {
|
|
1288
|
+
const view = deriveTodoPanel({
|
|
1289
|
+
items: todoItems,
|
|
1290
|
+
override: todoPanelOverride,
|
|
1291
|
+
overrideKey: todoPanelOverrideKey,
|
|
1292
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
1293
|
+
stale: todoStale,
|
|
1218
1294
|
});
|
|
1219
|
-
|
|
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;
|
|
1295
|
+
// A choice made for a different list must not hide a new task.
|
|
1296
|
+
if (todoPanelOverrideKey !== view.listKey) {
|
|
1297
|
+
todoPanelOverride = null;
|
|
1298
|
+
todoPanelOverrideKey = null;
|
|
1299
|
+
}
|
|
1300
|
+
if (!view.settled && todoPanelPromptAfterSettle) {
|
|
1301
|
+
todoPanelPromptAfterSettle = false;
|
|
1302
|
+
}
|
|
1303
|
+
// Reopened list: a later completion may emit its own card again.
|
|
1304
|
+
if (!view.settled && lastCompletedSignature !== null) {
|
|
1305
|
+
lastCompletedSignature = null;
|
|
1306
|
+
}
|
|
1307
|
+
if (view.settled && todoStale) todoStale = false;
|
|
1308
|
+
if (view.state === todoPanelAppliedState) return;
|
|
1309
|
+
todoPanelAppliedState = view.state;
|
|
1310
|
+
try {
|
|
1311
|
+
if (view.state === "hidden") {
|
|
1312
|
+
ctx.ui.setWidget("workflow-todos", undefined);
|
|
1313
|
+
return;
|
|
1261
1314
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1315
|
+
ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
|
|
1316
|
+
const render = (w: number) => {
|
|
1317
|
+
// Re-derive so the component always shows live items and the live
|
|
1318
|
+
// mode, even though it was registered for an earlier state.
|
|
1319
|
+
const live = deriveTodoPanel({
|
|
1320
|
+
items: todoItems,
|
|
1321
|
+
override: todoPanelOverride,
|
|
1322
|
+
overrideKey: todoPanelOverrideKey,
|
|
1323
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
1324
|
+
stale: todoStale,
|
|
1325
|
+
});
|
|
1326
|
+
if (live.state === "hidden") return [];
|
|
1327
|
+
return live.state === "collapsed"
|
|
1328
|
+
? [renderTodoSummary(theme, w, live)]
|
|
1329
|
+
: buildTodoRows(theme, w, todoItems, workflowMode);
|
|
1330
|
+
};
|
|
1331
|
+
return { render, invalidate: () => {}, dispose: () => {} };
|
|
1332
|
+
});
|
|
1333
|
+
} catch (_e) {
|
|
1334
|
+
void _e;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function setTodoPanelOverride(
|
|
1339
|
+
ctx: ExtensionContext,
|
|
1340
|
+
override: TodoPanelOverride,
|
|
1341
|
+
note?: string,
|
|
1342
|
+
) {
|
|
1343
|
+
if (!todoItems.length && override === "expanded") {
|
|
1344
|
+
ctx.ui.notify("No todos.", "info");
|
|
1345
|
+
return;
|
|
1267
1346
|
}
|
|
1268
|
-
|
|
1347
|
+
todoPanelOverride = override;
|
|
1348
|
+
todoPanelOverrideKey = override === null ? null : todoListKey(todoItems);
|
|
1349
|
+
updateStatus(ctx);
|
|
1350
|
+
try {
|
|
1351
|
+
persistState();
|
|
1352
|
+
} catch (_e) {
|
|
1353
|
+
void _e;
|
|
1354
|
+
}
|
|
1355
|
+
if (note) ctx.ui.notify(note, "info");
|
|
1269
1356
|
}
|
|
1270
1357
|
|
|
1271
1358
|
function updateStatus(ctx: ExtensionContext) {
|
|
@@ -1280,6 +1367,7 @@ export default function workflowExtension(
|
|
|
1280
1367
|
commitMode = readCommitPolicyConfig(
|
|
1281
1368
|
(ctx as any).cwd ?? process.cwd(),
|
|
1282
1369
|
getAgentDir(),
|
|
1370
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1283
1371
|
).config.mode;
|
|
1284
1372
|
} catch {
|
|
1285
1373
|
commitMode = "never";
|
|
@@ -1293,27 +1381,41 @@ export default function workflowExtension(
|
|
|
1293
1381
|
writeMode = readWritePolicyConfig(
|
|
1294
1382
|
(ctx as any).cwd ?? process.cwd(),
|
|
1295
1383
|
getAgentDir(),
|
|
1384
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1296
1385
|
).config.mode;
|
|
1297
1386
|
} catch {
|
|
1298
1387
|
writeMode = "allow";
|
|
1299
1388
|
}
|
|
1389
|
+
let folderMode: string | null = null;
|
|
1390
|
+
try {
|
|
1391
|
+
folderMode = readFolderPolicyConfig(
|
|
1392
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
1393
|
+
getAgentDir(),
|
|
1394
|
+
{ trusted: (ctx as any).isProjectTrusted?.() === true },
|
|
1395
|
+
).config.mode;
|
|
1396
|
+
} catch {
|
|
1397
|
+
folderMode = null;
|
|
1398
|
+
}
|
|
1300
1399
|
const segments = buildWorkflowStatusSegments({
|
|
1301
1400
|
mode: workflowMode,
|
|
1302
|
-
|
|
1401
|
+
reviewPhase: reviewTurn,
|
|
1303
1402
|
done: todoItems.filter((t) => isDone(t)).length,
|
|
1304
1403
|
total: todoItems.length,
|
|
1305
|
-
stale: todoStale,
|
|
1404
|
+
stale: todoStale && !isTodoListSettled(todoItems),
|
|
1306
1405
|
profileLabel,
|
|
1307
1406
|
roleLabel,
|
|
1308
1407
|
commitMode,
|
|
1309
1408
|
writeMode: workflowMode === null ? writeMode : null,
|
|
1409
|
+
folderMode,
|
|
1310
1410
|
});
|
|
1311
1411
|
const modeTone =
|
|
1312
|
-
|
|
1412
|
+
reviewTurn !== "idle"
|
|
1313
1413
|
? "accent"
|
|
1314
|
-
: workflowMode === "
|
|
1315
|
-
? "
|
|
1316
|
-
: "
|
|
1414
|
+
: workflowMode === "build"
|
|
1415
|
+
? "accent"
|
|
1416
|
+
: workflowMode === "plan"
|
|
1417
|
+
? "warning"
|
|
1418
|
+
: "muted";
|
|
1317
1419
|
setWorkflowStatus(
|
|
1318
1420
|
ctx.ui,
|
|
1319
1421
|
WORKFLOW_STATUS_KEYS.mode,
|
|
@@ -1330,15 +1432,10 @@ export default function workflowExtension(
|
|
|
1330
1432
|
ctx.ui.theme.fg("dim", segments.policy),
|
|
1331
1433
|
);
|
|
1332
1434
|
if (todoItems.length > 0) {
|
|
1333
|
-
ctx
|
|
1334
|
-
return {
|
|
1335
|
-
render: (w: number) => buildTodoRows(theme, w, workflowMode),
|
|
1336
|
-
invalidate: () => {},
|
|
1337
|
-
dispose: () => {},
|
|
1338
|
-
};
|
|
1339
|
-
});
|
|
1435
|
+
refreshTodoPanel(ctx);
|
|
1340
1436
|
} else {
|
|
1341
1437
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
1438
|
+
todoPanelAppliedState = "hidden";
|
|
1342
1439
|
}
|
|
1343
1440
|
// One status owner: every footer refresh (mode switch, model change, round
|
|
1344
1441
|
// phase, pause) also re-evaluates the review widget. This is what clears a
|
|
@@ -1415,6 +1512,15 @@ export default function workflowExtension(
|
|
|
1415
1512
|
let activeReview:
|
|
1416
1513
|
| { planText: string; round: number; controller: AbortController }
|
|
1417
1514
|
| undefined;
|
|
1515
|
+
/**
|
|
1516
|
+
* The exclusive Review turn (plan 2026-11-14). Unlike the old derived lock,
|
|
1517
|
+
* this also covers the browser/inline handoff, where no child session runs.
|
|
1518
|
+
* In-memory: a reload restores `handoff` from `awaitingDecision`, otherwise
|
|
1519
|
+
* idle (a running round cannot survive the process).
|
|
1520
|
+
*/
|
|
1521
|
+
let reviewTurn: ReviewTurnPhase = "idle";
|
|
1522
|
+
/** The next plan turn is the feedback/refine continuation of a Deny. */
|
|
1523
|
+
let reviewFeedbackTurn = false;
|
|
1418
1524
|
/** Long-lived ESC listener covering a round and the paused window. */
|
|
1419
1525
|
let detachReviewEscListener: (() => void) | undefined;
|
|
1420
1526
|
/**
|
|
@@ -1466,9 +1572,47 @@ export default function workflowExtension(
|
|
|
1466
1572
|
roundActive,
|
|
1467
1573
|
paused,
|
|
1468
1574
|
gatePhase,
|
|
1575
|
+
handoff: reviewTurn === "handoff" || awaitingDecision,
|
|
1576
|
+
transition: reviewTurn === "transition",
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** True while a reviewer round or a real pause owns the turn (not handoff). */
|
|
1581
|
+
function reviewRoundInFlight(): boolean {
|
|
1582
|
+
let roundActive = false;
|
|
1583
|
+
try {
|
|
1584
|
+
roundActive = reviewRuntime?.isRoundActive() === true;
|
|
1585
|
+
} catch (_e) {
|
|
1586
|
+
void _e;
|
|
1587
|
+
}
|
|
1588
|
+
return reviewRoundOwnsTurn({
|
|
1589
|
+
roundInFlight: activeReview !== undefined,
|
|
1590
|
+
roundActive,
|
|
1591
|
+
paused: currentPausedReview() !== undefined,
|
|
1592
|
+
gatePhase:
|
|
1593
|
+
reviewGateCycleId === reviewCycleId ? reviewGatePhase : "idle",
|
|
1469
1594
|
});
|
|
1470
1595
|
}
|
|
1471
1596
|
|
|
1597
|
+
/** One status refresh for every review-turn transition. */
|
|
1598
|
+
function setReviewTurn(phase: ReviewTurnPhase): void {
|
|
1599
|
+
if (reviewTurn === phase) return;
|
|
1600
|
+
reviewTurn = phase;
|
|
1601
|
+
try {
|
|
1602
|
+
if (currentSessionCtx) updateStatus(currentSessionCtx);
|
|
1603
|
+
} catch (_e) {
|
|
1604
|
+
void _e;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function beginReviewTurn(): void {
|
|
1609
|
+
setReviewTurn("running");
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function endReviewTurn(): void {
|
|
1613
|
+
setReviewTurn("idle");
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1472
1616
|
/** Refuse a mode transition while a review round or pause owns the turn. */
|
|
1473
1617
|
function blockModeSwitchDuringReview(
|
|
1474
1618
|
ctx?: ExtensionContext,
|
|
@@ -2124,6 +2268,7 @@ export default function workflowExtension(
|
|
|
2124
2268
|
// A gated audit becomes a paused gate (the handoff keeps waiting). A
|
|
2125
2269
|
// manual review (idle gate) leaves the phase alone.
|
|
2126
2270
|
if (reviewGatePhase === "auditing") reviewGatePhase = "paused";
|
|
2271
|
+
setReviewTurn("paused");
|
|
2127
2272
|
refreshReviewPauseUi();
|
|
2128
2273
|
return true;
|
|
2129
2274
|
}
|
|
@@ -2335,6 +2480,7 @@ export default function workflowExtension(
|
|
|
2335
2480
|
// follow `reviewState`; the spinner row needs an explicit message while
|
|
2336
2481
|
// the child session streams off the main turn.
|
|
2337
2482
|
setReviewWorkingMessage("🔍 Reviewing plan…");
|
|
2483
|
+
setReviewTurn("running");
|
|
2338
2484
|
// Paint the live review line now, not after the child session's first
|
|
2339
2485
|
// state event: a manual `/review` must look alive from the first tick.
|
|
2340
2486
|
updateStatus(ctx as any);
|
|
@@ -2890,6 +3036,7 @@ export default function workflowExtension(
|
|
|
2890
3036
|
flushReviewEntries(true);
|
|
2891
3037
|
resetReviewStream();
|
|
2892
3038
|
detachReviewEscListenerNow();
|
|
3039
|
+
endReviewTurn();
|
|
2893
3040
|
try {
|
|
2894
3041
|
currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
2895
3042
|
} catch (_e) {
|
|
@@ -3015,6 +3162,15 @@ export default function workflowExtension(
|
|
|
3015
3162
|
|
|
3016
3163
|
const scope = scopeFor("global");
|
|
3017
3164
|
const enable = sub === "on";
|
|
3165
|
+
// Disabling would tear down the review (and its pending handoff) behind
|
|
3166
|
+
// the lock. Abort is the explicit exit while Review owns the turn.
|
|
3167
|
+
if (!enable && reviewTurnOwns(reviewTurn)) {
|
|
3168
|
+
ctx.ui.notify(
|
|
3169
|
+
"Review is active — /review-abort first (or resume/approve in the review tab).",
|
|
3170
|
+
"info",
|
|
3171
|
+
);
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3018
3174
|
|
|
3019
3175
|
const res = writeReviewModeConfig(scope, cwd, agentDir, {
|
|
3020
3176
|
enabled: enable,
|
|
@@ -3066,7 +3222,9 @@ export default function workflowExtension(
|
|
|
3066
3222
|
: "global";
|
|
3067
3223
|
|
|
3068
3224
|
if (!sub || sub === "status") {
|
|
3069
|
-
const resolved = readCommitPolicyConfig(cwd, agentDir
|
|
3225
|
+
const resolved = readCommitPolicyConfig(cwd, agentDir, {
|
|
3226
|
+
trusted: (ctx as any).isProjectTrusted?.() === true,
|
|
3227
|
+
});
|
|
3070
3228
|
const paths = commitConfigPaths(cwd, agentDir);
|
|
3071
3229
|
const where =
|
|
3072
3230
|
resolved.sources.mode === "default"
|
|
@@ -3125,8 +3283,9 @@ export default function workflowExtension(
|
|
|
3125
3283
|
});
|
|
3126
3284
|
|
|
3127
3285
|
// ── /write-policy: view/set the write permission (workflow.writePolicy) ──
|
|
3128
|
-
// Available in every mode: it only writes settings JSON
|
|
3129
|
-
//
|
|
3286
|
+
// Available in every mode: it only writes settings JSON. The write *mode*
|
|
3287
|
+
// applies in Default mode only; the shared folder floor applies in every
|
|
3288
|
+
// mode, Build included.
|
|
3130
3289
|
pi.registerCommand("write-policy", {
|
|
3131
3290
|
description:
|
|
3132
3291
|
"View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
|
|
@@ -3144,7 +3303,9 @@ export default function workflowExtension(
|
|
|
3144
3303
|
: "global";
|
|
3145
3304
|
|
|
3146
3305
|
if (!sub || sub === "status") {
|
|
3147
|
-
const resolved = readWritePolicyConfig(cwd, agentDir
|
|
3306
|
+
const resolved = readWritePolicyConfig(cwd, agentDir, {
|
|
3307
|
+
trusted: (ctx as any).isProjectTrusted?.() === true,
|
|
3308
|
+
});
|
|
3148
3309
|
const paths = writePolicyConfigPaths(cwd, agentDir);
|
|
3149
3310
|
const where =
|
|
3150
3311
|
resolved.sources.mode === "default"
|
|
@@ -3153,7 +3314,8 @@ export default function workflowExtension(
|
|
|
3153
3314
|
const lines = [
|
|
3154
3315
|
`Write policy: ${resolved.config.mode} (${where})`,
|
|
3155
3316
|
` ${describeWritePolicyMode(resolved.config.mode)}`,
|
|
3156
|
-
"
|
|
3317
|
+
" The write mode applies to Default mode only; the shared folder floor applies in every mode.",
|
|
3318
|
+
" Plan mode is read-only already; /build is the execution escape hatch.",
|
|
3157
3319
|
];
|
|
3158
3320
|
if (resolved.config.allowedPaths.length > 0) {
|
|
3159
3321
|
lines.push(
|
|
@@ -3227,6 +3389,133 @@ export default function workflowExtension(
|
|
|
3227
3389
|
// gate: it reads the same analyzeCommand/explainCommandRefusal the gates
|
|
3228
3390
|
// use, so the explanation cannot describe a policy the gate no longer
|
|
3229
3391
|
// enforces. Read-only, so it is safe in every mode.
|
|
3392
|
+
pi.registerCommand("folder-policy", {
|
|
3393
|
+
description: "Shared folder scope for read/write/commit: repo|cwd|parent|any, outside ask|block, sensitive list",
|
|
3394
|
+
handler: async (args, ctx) => {
|
|
3395
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3396
|
+
const agentDir = getAgentDir();
|
|
3397
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3398
|
+
const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
3399
|
+
const sub = (tokens[0] || "status").toLowerCase();
|
|
3400
|
+
const second = (tokens[1] || "").toLowerCase();
|
|
3401
|
+
const scope: "global" | "project" = tokens.includes("project")
|
|
3402
|
+
? "project"
|
|
3403
|
+
: "global";
|
|
3404
|
+
const resolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3405
|
+
if (sub === "status") {
|
|
3406
|
+
const paths = folderPolicyConfigPaths(cwd, agentDir);
|
|
3407
|
+
const lines = [
|
|
3408
|
+
`Folder policy: ${resolved.config.mode} (${resolved.sources.mode === "default" ? "default" : resolved.sources.mode + " settings"})`,
|
|
3409
|
+
` outside: ${resolved.config.outside}; grantScope: ${resolved.config.grantScope}; templates: ${resolved.config.sensitiveTemplates}`,
|
|
3410
|
+
` base: ${detectRepoRoot(cwd) ?? cwd}`,
|
|
3411
|
+
" unverifiable writes: not prompted (use /write-policy ask for strictness)",
|
|
3412
|
+
` sensitive: ${DEFAULT_SENSITIVE_PATTERNS.length} default, ${resolved.config.sensitivePatterns.length} extra, ${resolved.config.sensitiveExemptions.length} exemption(s)`,
|
|
3413
|
+
`Global: ${paths.global}`,
|
|
3414
|
+
`Project: ${paths.project}${resolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
|
|
3415
|
+
"Usage: /folder-policy repo|cwd|parent|any [global|project] · outside ask|block · grant project|global · templates allow|block · sensitive",
|
|
3416
|
+
];
|
|
3417
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
if (sub === "sensitive") {
|
|
3421
|
+
ctx.ui.notify(
|
|
3422
|
+
[
|
|
3423
|
+
"Sensitive deny list (always blocked for read/write/commit):",
|
|
3424
|
+
...DEFAULT_SENSITIVE_PATTERNS.map((p) => ` ${p}`),
|
|
3425
|
+
...(resolved.config.sensitivePatterns.length
|
|
3426
|
+
? ["Extra:", ...resolved.config.sensitivePatterns.map((p) => ` ${p}`)]
|
|
3427
|
+
: []),
|
|
3428
|
+
...(resolved.config.sensitiveExemptions.length
|
|
3429
|
+
? ["Exemptions (manual):", ...resolved.config.sensitiveExemptions.map((p) => ` ${p}`)]
|
|
3430
|
+
: []),
|
|
3431
|
+
].join("\n"),
|
|
3432
|
+
"info",
|
|
3433
|
+
);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
let patch: Record<string, unknown> | undefined;
|
|
3437
|
+
if (["repo", "cwd", "parent", "any"].includes(sub)) patch = { mode: sub };
|
|
3438
|
+
else if (sub === "outside" && ["ask", "block"].includes(second)) patch = { outside: second };
|
|
3439
|
+
else if (sub === "grant" && ["project", "global"].includes(second)) patch = { grantScope: second };
|
|
3440
|
+
else if (sub === "templates" && ["allow", "block"].includes(second)) patch = { sensitiveTemplates: second };
|
|
3441
|
+
if (!patch) {
|
|
3442
|
+
ctx.ui.notify("Usage: /folder-policy repo|cwd|parent|any · outside ask|block · grant project|global · templates allow|block · sensitive", "info");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
const written = writeFolderPolicyConfig(scope, cwd, agentDir, patch as any);
|
|
3446
|
+
ctx.ui.notify(
|
|
3447
|
+
written.ok
|
|
3448
|
+
? `Folder policy updated (${scope}): ${written.path}`
|
|
3449
|
+
: `Could not write folder policy: ${written.error ?? "unknown error"}`,
|
|
3450
|
+
written.ok ? "info" : "error",
|
|
3451
|
+
);
|
|
3452
|
+
updateStatus(ctx as any);
|
|
3453
|
+
},
|
|
3454
|
+
getArgumentCompletions: (prefix: string) => folderPolicyArgumentCompletions(prefix),
|
|
3455
|
+
});
|
|
3456
|
+
|
|
3457
|
+
pi.registerCommand("read-policy", {
|
|
3458
|
+
description: "Read permission: grants, shell paths|strict, guarded tools and the shared folder scope",
|
|
3459
|
+
handler: async (args, ctx) => {
|
|
3460
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3461
|
+
const agentDir = getAgentDir();
|
|
3462
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3463
|
+
const tokens = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
3464
|
+
const sub = (tokens[0] || "status").toLowerCase();
|
|
3465
|
+
const readResolved = readReadPolicyConfig(cwd, agentDir, { trusted });
|
|
3466
|
+
const folderResolved = readFolderPolicyConfig(cwd, agentDir, { trusted });
|
|
3467
|
+
const paths = readPolicyConfigPaths(cwd, agentDir);
|
|
3468
|
+
if (sub === "status" || !sub) {
|
|
3469
|
+
ctx.ui.notify(
|
|
3470
|
+
[
|
|
3471
|
+
describeReadPolicy(readResolved.config, folderResolved.config),
|
|
3472
|
+
` grants: ${readResolved.config.allowedPaths.join(", ") || "none"}`,
|
|
3473
|
+
` guarded tools: ${readResolved.config.guardedTools.join(", ") || "none"}`,
|
|
3474
|
+
`Global: ${paths.global}`,
|
|
3475
|
+
`Project: ${paths.project}${readResolved.sources.projectIgnored ? " (ignored: project not trusted)" : ""}`,
|
|
3476
|
+
"Usage: /read-policy allow <path> [global|project] · revoke <path> · shell paths|strict · status",
|
|
3477
|
+
].join("\n"),
|
|
3478
|
+
"info",
|
|
3479
|
+
);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
const scope: "global" | "project" = tokens.includes("project")
|
|
3483
|
+
? "project"
|
|
3484
|
+
: "global";
|
|
3485
|
+
if (sub === "shell") {
|
|
3486
|
+
const value = (tokens[1] || "").toLowerCase();
|
|
3487
|
+
if (value !== "paths" && value !== "strict") {
|
|
3488
|
+
ctx.ui.notify("Usage: /read-policy shell paths|strict [global|project]", "info");
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
const written = writeReadPolicyConfig(scope, cwd, agentDir, { shell: value });
|
|
3492
|
+
ctx.ui.notify(written.ok ? `Read shell → ${value} (${scope})` : `Could not save: ${written.error}`, written.ok ? "info" : "error");
|
|
3493
|
+
updateStatus(ctx as any);
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
if (sub === "allow" || sub === "revoke") {
|
|
3497
|
+
const target = tokens.slice(1).find((t) => t !== "global" && t !== "project");
|
|
3498
|
+
if (!target) {
|
|
3499
|
+
ctx.ui.notify(`Usage: /read-policy ${sub} <path> [global|project]`, "info");
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const result =
|
|
3503
|
+
sub === "allow"
|
|
3504
|
+
? writeReadPolicyConfig(scope, cwd, agentDir, { allowedPaths: [target] })
|
|
3505
|
+
: revokeReadPolicyPath(scope, cwd, agentDir, target);
|
|
3506
|
+
ctx.ui.notify(
|
|
3507
|
+
result.ok
|
|
3508
|
+
? `Read grant ${sub === "allow" ? "added" : "removed"} (${scope}): ${target}`
|
|
3509
|
+
: `Could not save read grant: ${result.error}`,
|
|
3510
|
+
result.ok ? "info" : "error",
|
|
3511
|
+
);
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
ctx.ui.notify("Usage: /read-policy status|allow <path>|revoke <path>|shell paths|strict", "info");
|
|
3515
|
+
},
|
|
3516
|
+
getArgumentCompletions: (prefix: string) => readPolicyArgumentCompletions(prefix),
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3230
3519
|
pi.registerCommand("permissions", {
|
|
3231
3520
|
description:
|
|
3232
3521
|
"Explain why a shell command would be allowed or refused: /permissions explain <command>",
|
|
@@ -3235,7 +3524,55 @@ export default function workflowExtension(
|
|
|
3235
3524
|
const first = raw.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
3236
3525
|
// `/permissions explain <cmd>` and `/permissions <cmd>` both work.
|
|
3237
3526
|
const command = first === "explain" ? raw.slice("explain".length).trim() : raw;
|
|
3238
|
-
|
|
3527
|
+
const lines = describeCommandPolicy(command);
|
|
3528
|
+
if (command.trim()) {
|
|
3529
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3530
|
+
const trusted = (ctx as any).isProjectTrusted?.() === true;
|
|
3531
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
3532
|
+
trusted,
|
|
3533
|
+
}).config;
|
|
3534
|
+
const writeConfig = readWritePolicyConfig(cwd, getAgentDir(), {
|
|
3535
|
+
trusted,
|
|
3536
|
+
}).config;
|
|
3537
|
+
const readConfig = readReadPolicyConfig(cwd, getAgentDir(), {
|
|
3538
|
+
trusted,
|
|
3539
|
+
}).config;
|
|
3540
|
+
const analysis = analyzeShellPaths(command, cwd);
|
|
3541
|
+
const classification = classifyWriteCall(
|
|
3542
|
+
"bash",
|
|
3543
|
+
{ command },
|
|
3544
|
+
writeConfig,
|
|
3545
|
+
);
|
|
3546
|
+
const writeTargets = writeFolderTargetsFor(
|
|
3547
|
+
"bash",
|
|
3548
|
+
{ command },
|
|
3549
|
+
classification,
|
|
3550
|
+
cwd,
|
|
3551
|
+
);
|
|
3552
|
+
const evaluation = evaluateFolderTargets(
|
|
3553
|
+
[...analysis.reads, ...writeTargets],
|
|
3554
|
+
{
|
|
3555
|
+
config: folderConfig,
|
|
3556
|
+
grants: [...writeConfig.allowedPaths, ...readConfig.allowedPaths],
|
|
3557
|
+
cwd,
|
|
3558
|
+
action: "write",
|
|
3559
|
+
},
|
|
3560
|
+
);
|
|
3561
|
+
lines.push(
|
|
3562
|
+
"",
|
|
3563
|
+
...describeFolderEvaluation(
|
|
3564
|
+
evaluation,
|
|
3565
|
+
detectRepoRoot(cwd) ?? cwd,
|
|
3566
|
+
),
|
|
3567
|
+
);
|
|
3568
|
+
if (analysis.uncertain.length > 0) {
|
|
3569
|
+
lines.push(` shell analysis: ${analysis.uncertain.join("; ")}`);
|
|
3570
|
+
}
|
|
3571
|
+
lines.push(
|
|
3572
|
+
" unverifiable writes are not prompted under writePolicy allow/Build; use /write-policy ask for strictness",
|
|
3573
|
+
);
|
|
3574
|
+
}
|
|
3575
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3239
3576
|
},
|
|
3240
3577
|
getArgumentCompletions: (prefix: string) =>
|
|
3241
3578
|
permissionsArgumentCompletions(prefix),
|
|
@@ -3506,6 +3843,9 @@ export default function workflowExtension(
|
|
|
3506
3843
|
abortedReviewHash = undefined;
|
|
3507
3844
|
pausedReview = undefined;
|
|
3508
3845
|
reviewRound = 0;
|
|
3846
|
+
// Manual /review is a Review turn from the first tick: the mode status,
|
|
3847
|
+
// input gate and mode lock must be active before the child boots.
|
|
3848
|
+
beginReviewTurn();
|
|
3509
3849
|
const result = await runReviewStep(
|
|
3510
3850
|
target.text,
|
|
3511
3851
|
ctx as any,
|
|
@@ -3580,6 +3920,7 @@ export default function workflowExtension(
|
|
|
3580
3920
|
// A manual pause (idle gate) is unchanged; the runtime/activeReview
|
|
3581
3921
|
// flags carry that lock.
|
|
3582
3922
|
reviewGatePhase = reviewGatePhaseOnResume(reviewGatePhase);
|
|
3923
|
+
setReviewTurn("running");
|
|
3583
3924
|
// A stale quiet-abort marker must not silence this explicit resume.
|
|
3584
3925
|
quietAbortHash = undefined;
|
|
3585
3926
|
// If the plan file moved on while paused, review the new revision fresh
|
|
@@ -3669,6 +4010,46 @@ export default function workflowExtension(
|
|
|
3669
4010
|
const active =
|
|
3670
4011
|
activeReview !== undefined || rt?.isRoundActive() === true;
|
|
3671
4012
|
const paused = currentPausedReview();
|
|
4013
|
+
// A browser/inline decision can own the turn with no round and no pause.
|
|
4014
|
+
if (!active && !paused && awaitingDecision) {
|
|
4015
|
+
const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
|
|
4016
|
+
const planText =
|
|
4017
|
+
(planPath &&
|
|
4018
|
+
(() => {
|
|
4019
|
+
try {
|
|
4020
|
+
return fs.existsSync(planPath)
|
|
4021
|
+
? fs.readFileSync(planPath, "utf8")
|
|
4022
|
+
: undefined;
|
|
4023
|
+
} catch {
|
|
4024
|
+
return undefined;
|
|
4025
|
+
}
|
|
4026
|
+
})()) ??
|
|
4027
|
+
"";
|
|
4028
|
+
if (pendingHandoffReviewUrl) {
|
|
4029
|
+
setReviewTurn("handoff");
|
|
4030
|
+
ctx.ui.notify(
|
|
4031
|
+
"Review skipped — approve or annotate in the review tab.",
|
|
4032
|
+
"info",
|
|
4033
|
+
);
|
|
4034
|
+
return;
|
|
4035
|
+
}
|
|
4036
|
+
if (planText && planPath) {
|
|
4037
|
+
const handed = await openManualHandoff(
|
|
4038
|
+
{ planText, outcome: "aborted", paused: false },
|
|
4039
|
+
planPath,
|
|
4040
|
+
ctx as any,
|
|
4041
|
+
);
|
|
4042
|
+
if (!handed) setReviewTurn("handoff");
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
clearPendingHandoffDecision();
|
|
4046
|
+
setReviewTurn("idle");
|
|
4047
|
+
ctx.ui.notify(
|
|
4048
|
+
`Review skipped — the plan file is ${planPath || "unavailable"}.`,
|
|
4049
|
+
"info",
|
|
4050
|
+
);
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
3672
4053
|
if (!active && !paused) {
|
|
3673
4054
|
ctx.ui.notify("No review running or paused.", "info");
|
|
3674
4055
|
return;
|
|
@@ -3680,6 +4061,10 @@ export default function workflowExtension(
|
|
|
3680
4061
|
// abort lands; falls back to the author's draft when unknown).
|
|
3681
4062
|
const abortedText =
|
|
3682
4063
|
paused?.planText ?? activeReview?.planText;
|
|
4064
|
+
const pausedPlanPath = currentPlanFile ?? "";
|
|
4065
|
+
// A parked automatic handoff owns its own presentation: settle the gate
|
|
4066
|
+
// and let `runHandoff` render, never present twice.
|
|
4067
|
+
const gated = reviewGatePhase !== "idle" && !reviewHandoffGate.settled();
|
|
3683
4068
|
pausedReview = undefined;
|
|
3684
4069
|
// An aborted revision is not auto-reviewed again until the plan changes.
|
|
3685
4070
|
if (targetHash) abortedReviewHash = targetHash;
|
|
@@ -3702,9 +4087,15 @@ export default function workflowExtension(
|
|
|
3702
4087
|
"Review stopped — the author's plan stands unreviewed.",
|
|
3703
4088
|
"info",
|
|
3704
4089
|
);
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
4090
|
+
if (gated) {
|
|
4091
|
+
setReviewTurn("handoff");
|
|
4092
|
+
settleReviewGate({ outcome: "unreviewed", planText: abortedText });
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
// A manual /review command is awaiting this round; it will present the
|
|
4096
|
+
// aborted plan through `openManualHandoff` (shouldHandoffManualResult
|
|
4097
|
+
// now includes aborted). Keep Review visible until then.
|
|
4098
|
+
setReviewTurn("handoff");
|
|
3708
4099
|
return;
|
|
3709
4100
|
}
|
|
3710
4101
|
flushReviewEntries(true);
|
|
@@ -3713,8 +4104,20 @@ export default function workflowExtension(
|
|
|
3713
4104
|
"Paused review discarded — the author's plan stands.",
|
|
3714
4105
|
"info",
|
|
3715
4106
|
);
|
|
3716
|
-
|
|
3717
|
-
|
|
4107
|
+
if (gated) {
|
|
4108
|
+
setReviewTurn("handoff");
|
|
4109
|
+
settleReviewGate({ outcome: "unreviewed", planText: abortedText });
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
if (abortedText && pausedPlanPath) {
|
|
4113
|
+
await openManualHandoff(
|
|
4114
|
+
{ planText: abortedText, outcome: "aborted", paused: false },
|
|
4115
|
+
pausedPlanPath,
|
|
4116
|
+
ctx as any,
|
|
4117
|
+
);
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
setReviewTurn("idle");
|
|
3718
4121
|
updateStatus(ctx as any);
|
|
3719
4122
|
},
|
|
3720
4123
|
});
|
|
@@ -3742,6 +4145,12 @@ export default function workflowExtension(
|
|
|
3742
4145
|
*/
|
|
3743
4146
|
interface ModeSwitchOptions {
|
|
3744
4147
|
quiet?: boolean;
|
|
4148
|
+
/**
|
|
4149
|
+
* Internal review-decision exit: the caller already validated the pending
|
|
4150
|
+
* decision and owns the `transition` state, so the full review lock must
|
|
4151
|
+
* not refuse it. Never set this from a user-facing mode switch.
|
|
4152
|
+
*/
|
|
4153
|
+
fromReview?: boolean;
|
|
3745
4154
|
}
|
|
3746
4155
|
|
|
3747
4156
|
/** Pure helper for tests: resolve which RoleModel plan/build will switch to. */
|
|
@@ -4177,6 +4586,12 @@ export default function workflowExtension(
|
|
|
4177
4586
|
},
|
|
4178
4587
|
defaultModel,
|
|
4179
4588
|
lastModeSwitch,
|
|
4589
|
+
todoPanel: {
|
|
4590
|
+
override: todoPanelOverride,
|
|
4591
|
+
overrideKey: todoPanelOverrideKey,
|
|
4592
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
4593
|
+
completedSignature: lastCompletedSignature,
|
|
4594
|
+
},
|
|
4180
4595
|
} as any);
|
|
4181
4596
|
lastPersistedPlanFile = currentPlanFile;
|
|
4182
4597
|
}
|
|
@@ -4193,9 +4608,10 @@ export default function workflowExtension(
|
|
|
4193
4608
|
return;
|
|
4194
4609
|
}
|
|
4195
4610
|
// A live review owns the turn: no mode transition until it lands, fails,
|
|
4196
|
-
// pauses or is aborted.
|
|
4197
|
-
// `
|
|
4198
|
-
if (blockModeSwitchDuringReview(ctx)) return;
|
|
4611
|
+
// pauses or is aborted. A validated browser/inline decision passes
|
|
4612
|
+
// `fromReview` because it is the exit, not a competing mode switch.
|
|
4613
|
+
if (opts?.fromReview !== true && blockModeSwitchDuringReview(ctx)) return;
|
|
4614
|
+
if (opts?.fromReview === true && reviewRoundInFlight()) return;
|
|
4199
4615
|
// Leaving Default mode: remember its model so it can be restored on return.
|
|
4200
4616
|
if (workflowMode === null) captureDefaultModel(ctx);
|
|
4201
4617
|
workflowMode = "plan";
|
|
@@ -4414,11 +4830,19 @@ export default function workflowExtension(
|
|
|
4414
4830
|
async function enterBuildModeFromPlan(
|
|
4415
4831
|
ctx: ExtensionContext,
|
|
4416
4832
|
planPath: string,
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
//
|
|
4420
|
-
//
|
|
4421
|
-
|
|
4833
|
+
opts?: { fromReview?: boolean },
|
|
4834
|
+
): Promise<boolean> {
|
|
4835
|
+
// Defense in depth: a stale approve must never switch tools while a real
|
|
4836
|
+
// reviewer round/pause owns the turn. The handoff itself is consumed by
|
|
4837
|
+
// the caller (fromReview), so only `reviewRoundInFlight()` can refuse it.
|
|
4838
|
+
if (opts?.fromReview === true) {
|
|
4839
|
+
if (reviewRoundInFlight()) {
|
|
4840
|
+
ctx.ui.notify(reviewModeLockNotice("running"), "info");
|
|
4841
|
+
return false;
|
|
4842
|
+
}
|
|
4843
|
+
} else if (blockModeSwitchDuringReview(ctx)) {
|
|
4844
|
+
return false;
|
|
4845
|
+
}
|
|
4422
4846
|
workflowMode = "build";
|
|
4423
4847
|
syncLegacyFlags();
|
|
4424
4848
|
// The plan is being handed to Build; this Plan cycle cannot hand off again.
|
|
@@ -4457,6 +4881,7 @@ export default function workflowExtension(
|
|
|
4457
4881
|
: `Build mode: ${loaded.count} todos synced from plan (progress kept).`,
|
|
4458
4882
|
"info",
|
|
4459
4883
|
);
|
|
4884
|
+
return true;
|
|
4460
4885
|
}
|
|
4461
4886
|
|
|
4462
4887
|
// ── Plannotator bridge helpers ─────────────────────────────────
|
|
@@ -4560,6 +4985,9 @@ export default function workflowExtension(
|
|
|
4560
4985
|
pendingHandoffReviewUrl = null;
|
|
4561
4986
|
lastReviewId = null;
|
|
4562
4987
|
recordedHandoffReviewId = null;
|
|
4988
|
+
// Keep the Review mode visible while the browser/inline decision owns the
|
|
4989
|
+
// turn. The reviewer has finished, but prompts and modes stay locked.
|
|
4990
|
+
setReviewTurn("handoff");
|
|
4563
4991
|
persistState();
|
|
4564
4992
|
const timeoutMs = Math.max(50, deps.plannotatorRequestTimeoutMs ?? 15_000);
|
|
4565
4993
|
const response = await new Promise<PlanReviewRequestOutcome>((resolve) => {
|
|
@@ -4723,6 +5151,7 @@ export default function workflowExtension(
|
|
|
4723
5151
|
if (reason === "startup" || reason === "new" || !lastReviewId) {
|
|
4724
5152
|
const changed = clearPendingHandoffDecision();
|
|
4725
5153
|
if (changed) persistState();
|
|
5154
|
+
endReviewTurn();
|
|
4726
5155
|
record("clear", { why: "no restorable review" });
|
|
4727
5156
|
return;
|
|
4728
5157
|
}
|
|
@@ -4739,6 +5168,7 @@ export default function workflowExtension(
|
|
|
4739
5168
|
});
|
|
4740
5169
|
if (decision.action === "keep") {
|
|
4741
5170
|
if (pendingHandoffSource === "manual") manualReviewCycle = true;
|
|
5171
|
+
setReviewTurn("handoff");
|
|
4742
5172
|
record("keep", {
|
|
4743
5173
|
reviewId: lastReviewId,
|
|
4744
5174
|
...(pendingHandoffReviewUrl
|
|
@@ -4758,7 +5188,7 @@ export default function workflowExtension(
|
|
|
4758
5188
|
approved: decision.approved,
|
|
4759
5189
|
});
|
|
4760
5190
|
if (decision.approved) {
|
|
4761
|
-
handlePlannotatorApprove(
|
|
5191
|
+
await handlePlannotatorApprove(
|
|
4762
5192
|
pendingHandoffPlanPath ?? currentPlanFile ?? "",
|
|
4763
5193
|
currentFile ?? "",
|
|
4764
5194
|
decision.feedback,
|
|
@@ -4773,6 +5203,7 @@ export default function workflowExtension(
|
|
|
4773
5203
|
}
|
|
4774
5204
|
const changed = clearPendingHandoffDecision();
|
|
4775
5205
|
if (changed) persistState();
|
|
5206
|
+
endReviewTurn();
|
|
4776
5207
|
record("clear", { why: decision.reason, reviewId: lastReviewId ?? "" });
|
|
4777
5208
|
notifyReview(
|
|
4778
5209
|
`Previous browser review could not be recovered — ${decision.reason}.`,
|
|
@@ -4823,20 +5254,62 @@ export default function workflowExtension(
|
|
|
4823
5254
|
return true;
|
|
4824
5255
|
}
|
|
4825
5256
|
|
|
4826
|
-
|
|
5257
|
+
/** One execute handoff: todo list + the Build-starting message. */
|
|
5258
|
+
function sendExecuteMessages(
|
|
5259
|
+
ctx: ExtensionContext,
|
|
5260
|
+
planPath: string,
|
|
5261
|
+
feedback?: string,
|
|
5262
|
+
): void {
|
|
5263
|
+
try {
|
|
5264
|
+
const remainingList = formatTodoNumberedList(todoItems);
|
|
5265
|
+
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
5266
|
+
const planTodoListMessage = {
|
|
5267
|
+
customType: "workflow-todo-list",
|
|
5268
|
+
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
|
5269
|
+
display: true,
|
|
5270
|
+
};
|
|
5271
|
+
const firstTodo = todoItems[0];
|
|
5272
|
+
const feedbackBlock = feedback ? `\n\nFeedback: ${feedback}` : "";
|
|
5273
|
+
const execMessage = `Execute the plan.\n\nRemaining steps:\n${remainingList}\n\nStart with: ${firstTodo ? firstTodo.text : (todoItems[0]?.text ?? "first step")}${feedbackBlock}${commitDirectiveLine(ctx)}\nAfter completing a step, include a [DONE:<ref>] tag in your response (use the numbers shown above).`;
|
|
5274
|
+
(pi as any).sendMessage?.(planTodoListMessage, { deliverAs: "followUp" });
|
|
5275
|
+
(pi as any).sendMessage?.(
|
|
5276
|
+
{
|
|
5277
|
+
customType: "workflow-plan-execute",
|
|
5278
|
+
content: execMessage,
|
|
5279
|
+
display: true,
|
|
5280
|
+
},
|
|
5281
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
5282
|
+
);
|
|
5283
|
+
} catch (_e) {
|
|
5284
|
+
void _e;
|
|
5285
|
+
}
|
|
5286
|
+
try {
|
|
5287
|
+
pi.appendEntry("workflow-handoff-result", {
|
|
5288
|
+
at: Date.now(),
|
|
5289
|
+
choice: "execute",
|
|
5290
|
+
source: "plannotator",
|
|
5291
|
+
planPath,
|
|
5292
|
+
});
|
|
5293
|
+
} catch (_e) {
|
|
5294
|
+
void _e;
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
async function handlePlannotatorApprove(
|
|
4827
5299
|
planFilePath: string,
|
|
4828
5300
|
planContent: string,
|
|
4829
5301
|
feedback: string | undefined,
|
|
4830
5302
|
cwd: string | undefined,
|
|
4831
|
-
) {
|
|
5303
|
+
): Promise<void> {
|
|
4832
5304
|
const ctx = getActiveCtx();
|
|
4833
5305
|
if (!ctx) return;
|
|
4834
5306
|
// A manual `/review` handoff may be approved from Default/Build; an
|
|
4835
5307
|
// automatic handoff only exists in Plan mode.
|
|
4836
5308
|
if (workflowMode !== "plan" && pendingHandoffSource !== "manual") return;
|
|
4837
|
-
// A stale browser approve while a
|
|
4838
|
-
// decision or send
|
|
4839
|
-
|
|
5309
|
+
// A stale browser approve while a reviewer round/pause runs must not clear
|
|
5310
|
+
// the pending decision or send execute. The handoff itself is deliberately
|
|
5311
|
+
// NOT part of this predicate: it is the decision being consumed here.
|
|
5312
|
+
if (reviewRoundInFlight()) {
|
|
4840
5313
|
supersedePendingHandoff(
|
|
4841
5314
|
"a browser decision arrived while a new review round was running",
|
|
4842
5315
|
{ force: true },
|
|
@@ -4898,59 +5371,42 @@ export default function workflowExtension(
|
|
|
4898
5371
|
} catch (_e) {
|
|
4899
5372
|
void _e;
|
|
4900
5373
|
}
|
|
5374
|
+
// Atomic decision exit: stop the reviewer and apply Build BEFORE any
|
|
5375
|
+
// follow-up message. `transition` keeps input/mode gates closed while the
|
|
5376
|
+
// async mode switch runs, so a queued prompt cannot race it.
|
|
5377
|
+
setReviewTurn("transition");
|
|
4901
5378
|
if (pendingHandoffSource === "manual") manualReviewCycle = false;
|
|
4902
5379
|
clearPendingHandoffDecision();
|
|
4903
|
-
|
|
4904
|
-
.then(() => updateStatus(ctx as any))
|
|
4905
|
-
.catch(() => {
|
|
4906
|
-
try {
|
|
4907
|
-
updateStatus(ctx as any);
|
|
4908
|
-
} catch (_e) {
|
|
4909
|
-
void _e;
|
|
4910
|
-
}
|
|
4911
|
-
});
|
|
5380
|
+
let enteredBuild = false;
|
|
4912
5381
|
try {
|
|
4913
|
-
|
|
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" },
|
|
5382
|
+
enteredBuild = await enterBuildModeFromPlan(
|
|
5383
|
+
ctx as any,
|
|
5384
|
+
resolvedPlan,
|
|
5385
|
+
{ fromReview: true },
|
|
5386
|
+
);
|
|
5387
|
+
} catch (e: any) {
|
|
5388
|
+
ctx.ui.notify(
|
|
5389
|
+
`Could not enter Build mode (${String(e?.message ?? e)}); the plan was not executed.`,
|
|
5390
|
+
"warning",
|
|
4931
5391
|
);
|
|
4932
|
-
} catch (_e) {
|
|
4933
|
-
void _e;
|
|
4934
5392
|
}
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
source: "plannotator",
|
|
4940
|
-
planPath: resolvedPlan,
|
|
4941
|
-
});
|
|
4942
|
-
} catch (_e) {
|
|
4943
|
-
void _e;
|
|
5393
|
+
if (!enteredBuild) {
|
|
5394
|
+
setReviewTurn("handoff");
|
|
5395
|
+
updateStatus(ctx as any);
|
|
5396
|
+
return;
|
|
4944
5397
|
}
|
|
5398
|
+
setReviewTurn("idle");
|
|
5399
|
+
updateStatus(ctx as any);
|
|
5400
|
+
sendExecuteMessages(ctx as any, resolvedPlan, feedback);
|
|
4945
5401
|
}
|
|
4946
5402
|
|
|
4947
5403
|
async function handlePlannotatorDeny(feedback: string) {
|
|
4948
5404
|
const ctx = getActiveCtx();
|
|
4949
5405
|
if (!ctx) return;
|
|
4950
|
-
//
|
|
4951
|
-
//
|
|
4952
|
-
//
|
|
4953
|
-
if (
|
|
5406
|
+
// A stale deny while a new reviewer round/pause runs must not inject
|
|
5407
|
+
// feedback under the reviewer. The pending handoff (if any) is the decision
|
|
5408
|
+
// being consumed, so only the round predicate can refuse it.
|
|
5409
|
+
if (reviewRoundInFlight()) {
|
|
4954
5410
|
supersedePendingHandoff(
|
|
4955
5411
|
"a browser decision arrived while a new review round was running",
|
|
4956
5412
|
{ force: true },
|
|
@@ -4959,11 +5415,12 @@ export default function workflowExtension(
|
|
|
4959
5415
|
}
|
|
4960
5416
|
if (!awaitingDecision) return;
|
|
4961
5417
|
const manualSource = pendingHandoffSource === "manual";
|
|
5418
|
+
setReviewTurn("transition");
|
|
4962
5419
|
if (manualSource && workflowMode !== "plan") {
|
|
4963
5420
|
// A manual review can be launched from Default/Build. Its feedback is a
|
|
4964
5421
|
// plan revision, so switch to Plan before the follow-up turn starts.
|
|
4965
5422
|
try {
|
|
4966
|
-
await setPlanMode(ctx as any, { quiet: true });
|
|
5423
|
+
await setPlanMode(ctx as any, { quiet: true, fromReview: true });
|
|
4967
5424
|
} catch (_e) {
|
|
4968
5425
|
void _e;
|
|
4969
5426
|
}
|
|
@@ -4977,6 +5434,11 @@ export default function workflowExtension(
|
|
|
4977
5434
|
// outstanding findings are carried forward so the reviewer can confirm they
|
|
4978
5435
|
// were actually addressed rather than re-raising them.
|
|
4979
5436
|
await beginReviewCycle({ reason: "deny", keepFindings: true });
|
|
5437
|
+
setReviewTurn("idle");
|
|
5438
|
+
// The feedback turn is not a fresh user prompt: keeping the refine reason
|
|
5439
|
+
// preserves manualReviewCycle, so a manual review with automatic Review
|
|
5440
|
+
// Mode off still re-reviews the revision.
|
|
5441
|
+
reviewFeedbackTurn = true;
|
|
4980
5442
|
persistState();
|
|
4981
5443
|
try {
|
|
4982
5444
|
const todoListText = formatTodoNumberedList(todoItems, { glyph: true });
|
|
@@ -5010,6 +5472,91 @@ export default function workflowExtension(
|
|
|
5010
5472
|
}
|
|
5011
5473
|
}
|
|
5012
5474
|
|
|
5475
|
+
/**
|
|
5476
|
+
* The browser review closed without a decision. The user must still get a
|
|
5477
|
+
* handoff: present a compact inline Approve/Revise choice with the author's
|
|
5478
|
+
* plan. Reopening the browser here could loop on every close, so this path is
|
|
5479
|
+
* deliberately inline-only. Never leaves Review stuck: cancel/idle clears it.
|
|
5480
|
+
*/
|
|
5481
|
+
async function presentClosedHandoff(
|
|
5482
|
+
ctx: ExtensionContext,
|
|
5483
|
+
planPath: string,
|
|
5484
|
+
planText: string,
|
|
5485
|
+
): Promise<void> {
|
|
5486
|
+
clearPendingHandoffDecision();
|
|
5487
|
+
setReviewTurn("handoff");
|
|
5488
|
+
if (!planText || !planPath) {
|
|
5489
|
+
endReviewTurn();
|
|
5490
|
+
ctx.ui.notify(
|
|
5491
|
+
`Plan review closed — the plan file is ${planPath || "unavailable"}.`,
|
|
5492
|
+
"info",
|
|
5493
|
+
);
|
|
5494
|
+
return;
|
|
5495
|
+
}
|
|
5496
|
+
if ((ctx as any).hasUI !== true) {
|
|
5497
|
+
try {
|
|
5498
|
+
(pi as any).sendMessage?.(
|
|
5499
|
+
{
|
|
5500
|
+
customType: "workflow-handoff-fallback",
|
|
5501
|
+
content: `**Plan ready**\n\nPlan review closed without a decision. The plan is at ${planPath}.\n\nType "Execute the plan" to build, or tell me what to refine.`,
|
|
5502
|
+
display: true,
|
|
5503
|
+
},
|
|
5504
|
+
{ triggerTurn: false },
|
|
5505
|
+
);
|
|
5506
|
+
} catch (_e) {
|
|
5507
|
+
void _e;
|
|
5508
|
+
}
|
|
5509
|
+
endReviewTurn();
|
|
5510
|
+
return;
|
|
5511
|
+
}
|
|
5512
|
+
const execute = "Execute the plan in Build Mode";
|
|
5513
|
+
const refine = "Refine the plan in Plan Mode";
|
|
5514
|
+
let choice: string | undefined;
|
|
5515
|
+
try {
|
|
5516
|
+
choice = await (ctx as any).ui.select(
|
|
5517
|
+
"Plan review closed — choose next step",
|
|
5518
|
+
[execute, refine],
|
|
5519
|
+
);
|
|
5520
|
+
} catch {
|
|
5521
|
+
choice = undefined;
|
|
5522
|
+
}
|
|
5523
|
+
if (choice === execute) {
|
|
5524
|
+
setReviewTurn("transition");
|
|
5525
|
+
const ok = await enterBuildModeFromPlan(ctx as any, planPath, {
|
|
5526
|
+
fromReview: true,
|
|
5527
|
+
});
|
|
5528
|
+
if (!ok) {
|
|
5529
|
+
setReviewTurn("handoff");
|
|
5530
|
+
return;
|
|
5531
|
+
}
|
|
5532
|
+
setReviewTurn("idle");
|
|
5533
|
+
sendExecuteMessages(ctx, planPath);
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
if (choice === refine) {
|
|
5537
|
+
setReviewTurn("transition");
|
|
5538
|
+
if (workflowMode !== "plan") {
|
|
5539
|
+
try {
|
|
5540
|
+
await setPlanMode(ctx as any, { quiet: true, fromReview: true });
|
|
5541
|
+
} catch (_e) {
|
|
5542
|
+
void _e;
|
|
5543
|
+
}
|
|
5544
|
+
}
|
|
5545
|
+
await beginReviewCycle({ reason: "refine", keepFindings: true });
|
|
5546
|
+
setReviewTurn("idle");
|
|
5547
|
+
ctx.ui.notify(
|
|
5548
|
+
"Plan review closed — staying in Plan mode. Tell me what to change.",
|
|
5549
|
+
"info",
|
|
5550
|
+
);
|
|
5551
|
+
return;
|
|
5552
|
+
}
|
|
5553
|
+
endReviewTurn();
|
|
5554
|
+
ctx.ui.notify(
|
|
5555
|
+
`Plan review closed — the plan is at ${planPath}.`,
|
|
5556
|
+
"info",
|
|
5557
|
+
);
|
|
5558
|
+
}
|
|
5559
|
+
|
|
5013
5560
|
function ensurePlannotatorListeners() {
|
|
5014
5561
|
if (plannotatorListenersRegistered) return;
|
|
5015
5562
|
try {
|
|
@@ -5017,16 +5564,17 @@ export default function workflowExtension(
|
|
|
5017
5564
|
if (!bus || typeof bus.on !== "function") return;
|
|
5018
5565
|
// plan-approved (external executionMode) — direct handoff
|
|
5019
5566
|
bus.on(PLANNOTATOR_PLAN_APPROVED, (event: any) => {
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5567
|
+
void handlePlannotatorApprove(
|
|
5568
|
+
event?.planFilePath,
|
|
5569
|
+
event?.planContent,
|
|
5570
|
+
event?.feedback,
|
|
5571
|
+
event?.cwd,
|
|
5572
|
+
).catch((e: unknown) => {
|
|
5573
|
+
notifyReview(
|
|
5574
|
+
`Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5575
|
+
"error",
|
|
5026
5576
|
);
|
|
5027
|
-
}
|
|
5028
|
-
void _e;
|
|
5029
|
-
}
|
|
5577
|
+
});
|
|
5030
5578
|
});
|
|
5031
5579
|
// review-result (plan-review shared API) — approve/deny from browser
|
|
5032
5580
|
bus.on(PLANNOTATOR_REVIEW_RESULT, (event: any) => {
|
|
@@ -5046,12 +5594,17 @@ export default function workflowExtension(
|
|
|
5046
5594
|
} catch (_e) {
|
|
5047
5595
|
void _e;
|
|
5048
5596
|
}
|
|
5049
|
-
handlePlannotatorApprove(
|
|
5597
|
+
void handlePlannotatorApprove(
|
|
5050
5598
|
planPath,
|
|
5051
5599
|
content,
|
|
5052
5600
|
event.feedback,
|
|
5053
5601
|
(getActiveCtx() as any)?.cwd,
|
|
5054
|
-
)
|
|
5602
|
+
).catch((e: unknown) => {
|
|
5603
|
+
notifyReview(
|
|
5604
|
+
`Plan approval failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5605
|
+
"error",
|
|
5606
|
+
);
|
|
5607
|
+
});
|
|
5055
5608
|
} else {
|
|
5056
5609
|
void handlePlannotatorDeny(
|
|
5057
5610
|
event.feedback || "Plan needs revision.",
|
|
@@ -5099,9 +5652,31 @@ export default function workflowExtension(
|
|
|
5099
5652
|
if (event.outcome !== "closed") return;
|
|
5100
5653
|
if (!lastReviewId || event.reviewId !== lastReviewId) return;
|
|
5101
5654
|
if (!awaitingDecision) return;
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5655
|
+
const planPath = pendingHandoffPlanPath ?? currentPlanFile ?? "";
|
|
5656
|
+
let planText = "";
|
|
5657
|
+
try {
|
|
5658
|
+
if (planPath && fs.existsSync(planPath)) {
|
|
5659
|
+
planText = fs.readFileSync(planPath, "utf8");
|
|
5660
|
+
}
|
|
5661
|
+
} catch (_e) {
|
|
5662
|
+
void _e;
|
|
5663
|
+
}
|
|
5664
|
+
const ctx = getActiveCtx();
|
|
5665
|
+
if (!ctx) {
|
|
5666
|
+
supersedePendingHandoff(
|
|
5667
|
+
"the browser review closed without a decision",
|
|
5668
|
+
{ force: true },
|
|
5669
|
+
);
|
|
5670
|
+
return;
|
|
5671
|
+
}
|
|
5672
|
+
void presentClosedHandoff(ctx as any, planPath, planText).catch(
|
|
5673
|
+
(e: unknown) => {
|
|
5674
|
+
notifyReview(
|
|
5675
|
+
`Plan handoff after review close failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
5676
|
+
"warning",
|
|
5677
|
+
);
|
|
5678
|
+
endReviewTurn();
|
|
5679
|
+
},
|
|
5105
5680
|
);
|
|
5106
5681
|
} catch (_e) {
|
|
5107
5682
|
void _e;
|
|
@@ -5296,6 +5871,34 @@ export default function workflowExtension(
|
|
|
5296
5871
|
void _e;
|
|
5297
5872
|
}
|
|
5298
5873
|
|
|
5874
|
+
// Todo panel toggle: expanded ↔ collapsed, and hidden → expanded.
|
|
5875
|
+
// Ctrl+Alt+T is the Linux default for opening a terminal, so this uses O.
|
|
5876
|
+
try {
|
|
5877
|
+
pi.registerShortcut(Key.ctrlAlt("o"), {
|
|
5878
|
+
description: "Toggle the workflow todo panel",
|
|
5879
|
+
handler: async (ctx: any) => {
|
|
5880
|
+
if (overlayDepth > 0) return;
|
|
5881
|
+
if (todoItems.length === 0) {
|
|
5882
|
+
ctx.ui.notify("No todos.", "info");
|
|
5883
|
+
return;
|
|
5884
|
+
}
|
|
5885
|
+
const view = deriveTodoPanel({
|
|
5886
|
+
items: todoItems,
|
|
5887
|
+
override: todoPanelOverride,
|
|
5888
|
+
overrideKey: todoPanelOverrideKey,
|
|
5889
|
+
promptAfterSettle: todoPanelPromptAfterSettle,
|
|
5890
|
+
stale: todoStale,
|
|
5891
|
+
});
|
|
5892
|
+
setTodoPanelOverride(
|
|
5893
|
+
ctx as any,
|
|
5894
|
+
view.state === "expanded" ? "collapsed" : "expanded",
|
|
5895
|
+
);
|
|
5896
|
+
},
|
|
5897
|
+
});
|
|
5898
|
+
} catch (_e) {
|
|
5899
|
+
void _e;
|
|
5900
|
+
}
|
|
5901
|
+
|
|
5299
5902
|
// Tab cycles workflow modes (context-aware: not when an overlay is active).
|
|
5300
5903
|
// WorkflowTabEditor gives Tab back to autocomplete while a suggestion list is
|
|
5301
5904
|
// visible; this registration remains the no-list path. Single registration to
|
|
@@ -5559,7 +6162,10 @@ export default function workflowExtension(
|
|
|
5559
6162
|
};
|
|
5560
6163
|
todoItems.push(item);
|
|
5561
6164
|
try {
|
|
5562
|
-
if (currentSessionCtx)
|
|
6165
|
+
if (currentSessionCtx) {
|
|
6166
|
+
updateStatus(currentSessionCtx);
|
|
6167
|
+
persistState();
|
|
6168
|
+
}
|
|
5563
6169
|
} catch (_e) {
|
|
5564
6170
|
void _e;
|
|
5565
6171
|
}
|
|
@@ -5680,6 +6286,10 @@ export default function workflowExtension(
|
|
|
5680
6286
|
const c = todoItems.length;
|
|
5681
6287
|
todoItems = [];
|
|
5682
6288
|
todoPlanPath = undefined;
|
|
6289
|
+
todoPanelOverride = null;
|
|
6290
|
+
todoPanelOverrideKey = null;
|
|
6291
|
+
todoPanelPromptAfterSettle = false;
|
|
6292
|
+
lastCompletedSignature = null;
|
|
5683
6293
|
try {
|
|
5684
6294
|
if (currentSessionCtx) {
|
|
5685
6295
|
updateStatus(currentSessionCtx);
|
|
@@ -5711,19 +6321,119 @@ export default function workflowExtension(
|
|
|
5711
6321
|
});
|
|
5712
6322
|
|
|
5713
6323
|
pi.registerCommand("todos", {
|
|
5714
|
-
description:
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
6324
|
+
description:
|
|
6325
|
+
"Show or control the todo panel: /todos [show|expand|collapse|hide|auto|clear]",
|
|
6326
|
+
handler: async (args, ctx) => {
|
|
6327
|
+
const tokens = String(args || "")
|
|
6328
|
+
.trim()
|
|
6329
|
+
.split(/\s+/)
|
|
6330
|
+
.filter(Boolean);
|
|
6331
|
+
const sub = (tokens[0] || "").toLowerCase();
|
|
6332
|
+
const usage = [
|
|
6333
|
+
"Usage: /todos [show|expand|collapse|hide|auto|clear]",
|
|
6334
|
+
" (no arg) list every step with global refs",
|
|
6335
|
+
" expand show the full pinned panel",
|
|
6336
|
+
" collapse keep one summary line",
|
|
6337
|
+
" hide remove the pinned panel (footer keeps progress)",
|
|
6338
|
+
" auto follow the automatic collapse/hide sequence",
|
|
6339
|
+
" clear delete the todo list (asks first when UI is available)",
|
|
6340
|
+
].join("\n");
|
|
6341
|
+
|
|
6342
|
+
if (!sub) {
|
|
6343
|
+
const list = todoItems.length
|
|
6344
|
+
? `Steps are numbered GLOBALLY 1..${todoItems.length}:
|
|
5718
6345
|
` +
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
6346
|
+
todoItems
|
|
6347
|
+
.map((t) => {
|
|
6348
|
+
const g = t.group ? `[${t.group}] ` : "";
|
|
6349
|
+
return `${g}${formatTodoLine(t)}`;
|
|
6350
|
+
})
|
|
6351
|
+
.join("\n")
|
|
6352
|
+
: "No todos.";
|
|
6353
|
+
ctx.ui.notify(`Todos (${todoItems.length}):\n${list}`, "info");
|
|
6354
|
+
return;
|
|
6355
|
+
}
|
|
6356
|
+
|
|
6357
|
+
if (sub === "show" || sub === "expand") {
|
|
6358
|
+
setTodoPanelOverride(ctx, "expanded");
|
|
6359
|
+
return;
|
|
6360
|
+
}
|
|
6361
|
+
if (sub === "collapse") {
|
|
6362
|
+
setTodoPanelOverride(ctx, "collapsed");
|
|
6363
|
+
return;
|
|
6364
|
+
}
|
|
6365
|
+
if (sub === "hide") {
|
|
6366
|
+
setTodoPanelOverride(ctx, "hidden");
|
|
6367
|
+
return;
|
|
6368
|
+
}
|
|
6369
|
+
if (sub === "auto") {
|
|
6370
|
+
setTodoPanelOverride(ctx, null);
|
|
6371
|
+
return;
|
|
6372
|
+
}
|
|
6373
|
+
if (sub === "clear") {
|
|
6374
|
+
const count = todoItems.length;
|
|
6375
|
+
if (count === 0) {
|
|
6376
|
+
ctx.ui.notify("No todos.", "info");
|
|
6377
|
+
return;
|
|
6378
|
+
}
|
|
6379
|
+
// Headless confirm() resolves false and would silently turn this into
|
|
6380
|
+
// a no-op; the command is explicit, so only ask when there is UI.
|
|
6381
|
+
const confirmed =
|
|
6382
|
+
(ctx as any).hasUI === true
|
|
6383
|
+
? await ctx.ui.confirm(
|
|
6384
|
+
"Clear todos?",
|
|
6385
|
+
"This resets the list. In Build mode, edits are blocked until a list exists again.",
|
|
6386
|
+
)
|
|
6387
|
+
: true;
|
|
6388
|
+
if (!confirmed) {
|
|
6389
|
+
ctx.ui.notify("Clear cancelled.", "info");
|
|
6390
|
+
return;
|
|
6391
|
+
}
|
|
6392
|
+
todoItems = [];
|
|
6393
|
+
todoPlanPath = undefined;
|
|
6394
|
+
todoPanelOverride = null;
|
|
6395
|
+
todoPanelOverrideKey = null;
|
|
6396
|
+
todoPanelPromptAfterSettle = false;
|
|
6397
|
+
lastCompletedSignature = null;
|
|
6398
|
+
updateStatus(ctx);
|
|
6399
|
+
try {
|
|
6400
|
+
persistState();
|
|
6401
|
+
} catch (_e) {
|
|
6402
|
+
void _e;
|
|
6403
|
+
}
|
|
6404
|
+
onTodoWrite();
|
|
6405
|
+
ctx.ui.notify(
|
|
6406
|
+
`Cleared ${count} todos. In Build mode, edits are blocked until a list exists again (approve a plan or add a step).`,
|
|
6407
|
+
"info",
|
|
6408
|
+
);
|
|
6409
|
+
return;
|
|
6410
|
+
}
|
|
6411
|
+
if (sub === "help") {
|
|
6412
|
+
ctx.ui.notify(usage, "info");
|
|
6413
|
+
return;
|
|
6414
|
+
}
|
|
6415
|
+
ctx.ui.notify(`Unknown /todos action: ${sub}\n${usage}`, "info");
|
|
6416
|
+
},
|
|
6417
|
+
getArgumentCompletions: (prefix: string) => {
|
|
6418
|
+
const raw = String(prefix ?? "");
|
|
6419
|
+
if (/\s/.test(raw.trim())) return null;
|
|
6420
|
+
const lower = raw.trim().toLowerCase();
|
|
6421
|
+
const verbs = [
|
|
6422
|
+
{ name: "show", description: "Show the full pinned panel" },
|
|
6423
|
+
{ name: "expand", description: "Show the full pinned panel" },
|
|
6424
|
+
{ name: "collapse", description: "Keep one summary line" },
|
|
6425
|
+
{ name: "hide", description: "Remove the pinned panel" },
|
|
6426
|
+
{ name: "auto", description: "Automatic collapse/hide sequence" },
|
|
6427
|
+
{ name: "clear", description: "Delete the todo list" },
|
|
6428
|
+
{ name: "help", description: "Show usage" },
|
|
6429
|
+
];
|
|
6430
|
+
const matching = verbs.filter((v) => v.name.startsWith(lower));
|
|
6431
|
+
if (matching.length === 0) return null;
|
|
6432
|
+
return matching.map((v) => ({
|
|
6433
|
+
value: `${v.name} `,
|
|
6434
|
+
label: v.name,
|
|
6435
|
+
description: v.description,
|
|
6436
|
+
}));
|
|
5727
6437
|
},
|
|
5728
6438
|
});
|
|
5729
6439
|
|
|
@@ -8805,6 +9515,20 @@ export default function workflowExtension(
|
|
|
8805
9515
|
// command reaches the reviewer now; the write-gate steer guard below stays
|
|
8806
9516
|
// (a write instruction must never be steered into the reviewer).
|
|
8807
9517
|
pi.on("input", async (event, _ctx) => {
|
|
9518
|
+
// Review owns the turn: only the explicit controls and model/profile
|
|
9519
|
+
// changes may become a prompt. This is the terminal-Pi half of the lock;
|
|
9520
|
+
// the Desktop store refuses prose before it reaches this hook.
|
|
9521
|
+
if (reviewTurnOwns(reviewTurn)) {
|
|
9522
|
+
const decision = reviewInputDecision(
|
|
9523
|
+
typeof event.text === "string" ? event.text : "",
|
|
9524
|
+
reviewTurn,
|
|
9525
|
+
);
|
|
9526
|
+
if (decision.action === "block") {
|
|
9527
|
+
notifyReview(decision.notice, "info");
|
|
9528
|
+
return { action: "handled" } as any;
|
|
9529
|
+
}
|
|
9530
|
+
return { action: "continue" } as any;
|
|
9531
|
+
}
|
|
8808
9532
|
if (typeof event.text === "string" && event.text.startsWith("/btw ")) {
|
|
8809
9533
|
const note = event.text.slice(5).trim();
|
|
8810
9534
|
if (note) {
|
|
@@ -8900,12 +9624,27 @@ export default function workflowExtension(
|
|
|
8900
9624
|
// A session boundary owns the session-scoped plan fallback; the restore
|
|
8901
9625
|
// loop below re-seeds it from the persisted `planFile`.
|
|
8902
9626
|
resetSessionPlanProvenance();
|
|
9627
|
+
// The branch is the source of truth for the panel's presentation. The
|
|
9628
|
+
// host has cleared every extension widget on a session switch/reload, so
|
|
9629
|
+
// the reconciler must register it again from the restored state.
|
|
9630
|
+
todoPanelOverride = null;
|
|
9631
|
+
todoPanelOverrideKey = null;
|
|
9632
|
+
todoPanelPromptAfterSettle = false;
|
|
9633
|
+
lastCompletedSignature = null;
|
|
9634
|
+
todoPanelAppliedState = "unregistered";
|
|
8903
9635
|
// A reloaded/forked session must never inherit a stale write denial.
|
|
8904
9636
|
writeGateRunDenials.clear();
|
|
8905
9637
|
writeGateTurnAnswer = null;
|
|
8906
9638
|
writeGateTurnSteered = false;
|
|
8907
9639
|
writeGateBatchCalls = [];
|
|
8908
9640
|
writeGateBatchCallIds = new Set();
|
|
9641
|
+
readGateRunDenials.clear();
|
|
9642
|
+
readGateTurnAnswer = null;
|
|
9643
|
+
readGateTurnSteered = false;
|
|
9644
|
+
readGateBatchCalls = [];
|
|
9645
|
+
readGateBatchCallIds = new Set();
|
|
9646
|
+
readGateTurnGrantedRoots.clear();
|
|
9647
|
+
readGateTurnUncertainAllowed.clear();
|
|
8909
9648
|
// Roles are file-backed; load them up-front so every read path (status,
|
|
8910
9649
|
// band label, reviewer runtime) sees the real config even in a fresh
|
|
8911
9650
|
// session that has no persisted workflow entry yet. Profiles likewise.
|
|
@@ -8945,6 +9684,15 @@ export default function workflowExtension(
|
|
|
8945
9684
|
}
|
|
8946
9685
|
syncLegacyFlags();
|
|
8947
9686
|
if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
|
|
9687
|
+
// Entries written before this feature have no `todoPanel`; they
|
|
9688
|
+
// must not erase a value restored from an earlier entry.
|
|
9689
|
+
if (d.todoPanel && typeof d.todoPanel === "object") {
|
|
9690
|
+
const panel = readPersistedTodoPanel(d);
|
|
9691
|
+
todoPanelOverride = panel.override;
|
|
9692
|
+
todoPanelOverrideKey = panel.overrideKey;
|
|
9693
|
+
todoPanelPromptAfterSettle = panel.promptAfterSettle;
|
|
9694
|
+
lastCompletedSignature = panel.completedSignature;
|
|
9695
|
+
}
|
|
8948
9696
|
todoPlanPath = d.planFile;
|
|
8949
9697
|
toolsBeforePlanMode = d.toolsBeforePlanMode;
|
|
8950
9698
|
// An entry without a planFile must not erase a value restored
|
|
@@ -9275,6 +10023,9 @@ export default function workflowExtension(
|
|
|
9275
10023
|
// whether it survives; do not null it here, or a restored gate can only
|
|
9276
10024
|
// suppress the next handoff.
|
|
9277
10025
|
if (!awaitingDecision) clearPendingHandoffDecision();
|
|
10026
|
+
// A reload cannot preserve a running/paused reviewer child. Only a
|
|
10027
|
+
// persisted pending decision survives, and it owns the turn as `handoff`.
|
|
10028
|
+
reviewTurn = awaitingDecision ? "handoff" : "idle";
|
|
9278
10029
|
currentSessionCtx = ctx as any;
|
|
9279
10030
|
// Give Tab back to autocomplete while a suggestion list is visible; the raw
|
|
9280
10031
|
// `tab` shortcut keeps cycling modes otherwise.
|
|
@@ -9321,6 +10072,13 @@ export default function workflowExtension(
|
|
|
9321
10072
|
writeGateTurnSteered = false;
|
|
9322
10073
|
writeGateBatchCalls = [];
|
|
9323
10074
|
writeGateBatchCallIds = new Set();
|
|
10075
|
+
readGateRunDenials.clear();
|
|
10076
|
+
readGateTurnAnswer = null;
|
|
10077
|
+
readGateTurnSteered = false;
|
|
10078
|
+
readGateBatchCalls = [];
|
|
10079
|
+
readGateBatchCallIds = new Set();
|
|
10080
|
+
readGateTurnGrantedRoots.clear();
|
|
10081
|
+
readGateTurnUncertainAllowed.clear();
|
|
9324
10082
|
// Kill any active explore subagents to prevent orphan leaks.
|
|
9325
10083
|
// Without this, `pi --mode json` children spawned by runSingleAgent
|
|
9326
10084
|
// (up to 4 concurrent) could survive if VS Code closes the terminal
|
|
@@ -9355,6 +10113,8 @@ export default function workflowExtension(
|
|
|
9355
10113
|
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
|
|
9356
10114
|
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
9357
10115
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
10116
|
+
// The host dropped the widget; a later session_start re-registers it.
|
|
10117
|
+
todoPanelAppliedState = "unregistered";
|
|
9358
10118
|
// Drop any /commit authorization or flow: a reload/quit must never leave
|
|
9359
10119
|
// an exemption armed for whatever runs next.
|
|
9360
10120
|
commitAuthorization = null;
|
|
@@ -9370,6 +10130,12 @@ export default function workflowExtension(
|
|
|
9370
10130
|
todoItems = [];
|
|
9371
10131
|
todoPlanPath = undefined;
|
|
9372
10132
|
btwNotes = [];
|
|
10133
|
+
// A rewind is a branch boundary: the panel choice belongs to the branch.
|
|
10134
|
+
todoPanelOverride = null;
|
|
10135
|
+
todoPanelOverrideKey = null;
|
|
10136
|
+
todoPanelPromptAfterSettle = false;
|
|
10137
|
+
lastCompletedSignature = null;
|
|
10138
|
+
todoPanelAppliedState = "unregistered";
|
|
9373
10139
|
// A rewind is a branch boundary: no per-run write from the old branch
|
|
9374
10140
|
// may authorize a handoff or a manual review on the new one.
|
|
9375
10141
|
resetPlanRunProvenance();
|
|
@@ -9380,13 +10146,18 @@ export default function workflowExtension(
|
|
|
9380
10146
|
try {
|
|
9381
10147
|
const branch = ctx.sessionManager.getBranch();
|
|
9382
10148
|
for (const e of branch as any[]) {
|
|
9383
|
-
if (
|
|
9384
|
-
e.
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
10149
|
+
if (e.type === "custom" && e.customType === "workflow") {
|
|
10150
|
+
if (Array.isArray(e.data?.todos)) {
|
|
10151
|
+
todoItems = ensureTodoLabels(e.data.todos);
|
|
10152
|
+
if (e.data.planFile) todoPlanPath = e.data.planFile;
|
|
10153
|
+
}
|
|
10154
|
+
if (e.data?.todoPanel && typeof e.data.todoPanel === "object") {
|
|
10155
|
+
const panel = readPersistedTodoPanel(e.data);
|
|
10156
|
+
todoPanelOverride = panel.override;
|
|
10157
|
+
todoPanelOverrideKey = panel.overrideKey;
|
|
10158
|
+
todoPanelPromptAfterSettle = panel.promptAfterSettle;
|
|
10159
|
+
lastCompletedSignature = panel.completedSignature;
|
|
10160
|
+
}
|
|
9390
10161
|
}
|
|
9391
10162
|
if (
|
|
9392
10163
|
e.type === "message" &&
|
|
@@ -9426,6 +10197,126 @@ export default function workflowExtension(
|
|
|
9426
10197
|
* The decision itself is the pure `decideCommitGate`; this wrapper supplies
|
|
9427
10198
|
* the runtime inputs and renders the block reasons / ask overlay.
|
|
9428
10199
|
*/
|
|
10200
|
+
function runGitZ(
|
|
10201
|
+
repoRoot: string,
|
|
10202
|
+
args: string[],
|
|
10203
|
+
): { ok: boolean; paths: string[]; error?: string } {
|
|
10204
|
+
// The Pi host's PATH is not the shell's PATH (Windows especially). Honour
|
|
10205
|
+
// an explicit override so a desktop install without git on PATH can still
|
|
10206
|
+
// run the commit gate's synchronous checks.
|
|
10207
|
+
const gitBinary = process.env.PI_GIT_BIN?.trim() || "git";
|
|
10208
|
+
try {
|
|
10209
|
+
const res = spawnSync(gitBinary, ["-C", repoRoot, ...args], {
|
|
10210
|
+
encoding: "utf8",
|
|
10211
|
+
timeout: 5000,
|
|
10212
|
+
windowsHide: true,
|
|
10213
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
10214
|
+
});
|
|
10215
|
+
if (res.error) {
|
|
10216
|
+
const code = (res.error as NodeJS.ErrnoException).code;
|
|
10217
|
+
const missing =
|
|
10218
|
+
code === "ENOENT" || /\bENOENT\b/.test(res.error.message);
|
|
10219
|
+
return {
|
|
10220
|
+
ok: false,
|
|
10221
|
+
paths: [],
|
|
10222
|
+
error: missing
|
|
10223
|
+
? `git executable not found (${gitBinary}); set PI_GIT_BIN to the git binary`
|
|
10224
|
+
: res.error.message,
|
|
10225
|
+
};
|
|
10226
|
+
}
|
|
10227
|
+
if (res.status !== 0)
|
|
10228
|
+
return {
|
|
10229
|
+
ok: false,
|
|
10230
|
+
paths: [],
|
|
10231
|
+
error: String(res.stderr ?? "").trim() || `git exited ${res.status}`,
|
|
10232
|
+
};
|
|
10233
|
+
const paths = String(res.stdout ?? "")
|
|
10234
|
+
.split("\0")
|
|
10235
|
+
.map((entry) => entry.replace(/\r?\n$/, ""))
|
|
10236
|
+
.map((entry) => entry.replace(/^..\s+/, ""))
|
|
10237
|
+
.map((entry) => entry.split(" -> ").pop() ?? entry)
|
|
10238
|
+
.filter(Boolean);
|
|
10239
|
+
return { ok: true, paths };
|
|
10240
|
+
} catch (error: unknown) {
|
|
10241
|
+
return {
|
|
10242
|
+
ok: false,
|
|
10243
|
+
paths: [],
|
|
10244
|
+
error: error instanceof Error ? error.message : String(error),
|
|
10245
|
+
};
|
|
10246
|
+
}
|
|
10247
|
+
}
|
|
10248
|
+
|
|
10249
|
+
function resolveCommitRoot(
|
|
10250
|
+
candidate: string,
|
|
10251
|
+
cwd: string,
|
|
10252
|
+
): { root: string; uncertain: boolean } {
|
|
10253
|
+
const res = runGitZ(candidate, ["rev-parse", "--show-toplevel"]);
|
|
10254
|
+
if (res.ok && res.paths[0]) {
|
|
10255
|
+
return {
|
|
10256
|
+
root: normalizeWritePath(res.paths[0], candidate),
|
|
10257
|
+
uncertain: false,
|
|
10258
|
+
};
|
|
10259
|
+
}
|
|
10260
|
+
const detected = detectRepoRoot(candidate) ?? detectRepoRoot(cwd);
|
|
10261
|
+
return detected
|
|
10262
|
+
? { root: detected, uncertain: false }
|
|
10263
|
+
: { root: candidate, uncertain: true };
|
|
10264
|
+
}
|
|
10265
|
+
|
|
10266
|
+
function commitSensitiveHit(
|
|
10267
|
+
scope: NonNullable<ReturnType<typeof analyzeShellPaths>["git"]>,
|
|
10268
|
+
folderConfig: FolderPolicyConfig,
|
|
10269
|
+
): { path: string; pattern: string } | { failed: string } | undefined {
|
|
10270
|
+
const check = (paths: readonly string[]) => {
|
|
10271
|
+
for (const p of paths) {
|
|
10272
|
+
const hit = matchSensitivePath(p, folderConfig, scope.repoRoot);
|
|
10273
|
+
if (hit.sensitive)
|
|
10274
|
+
return { path: p, pattern: hit.pattern ?? "sensitive" };
|
|
10275
|
+
}
|
|
10276
|
+
return undefined;
|
|
10277
|
+
};
|
|
10278
|
+
const explicit = check(scope.explicitPaths);
|
|
10279
|
+
if (explicit) return explicit;
|
|
10280
|
+
|
|
10281
|
+
if (scope.hit === "add" && scope.allPaths) {
|
|
10282
|
+
const status = runGitZ(scope.repoRoot, [
|
|
10283
|
+
"status",
|
|
10284
|
+
"--porcelain=v1",
|
|
10285
|
+
"-z",
|
|
10286
|
+
"--untracked-files=all",
|
|
10287
|
+
]);
|
|
10288
|
+
if (!status.ok)
|
|
10289
|
+
return { failed: status.error ?? "git status failed" };
|
|
10290
|
+
const hit = check(status.paths);
|
|
10291
|
+
if (hit) return hit;
|
|
10292
|
+
}
|
|
10293
|
+
if (scope.hit === "commit") {
|
|
10294
|
+
const staged = runGitZ(scope.repoRoot, [
|
|
10295
|
+
"diff",
|
|
10296
|
+
"--cached",
|
|
10297
|
+
"--name-only",
|
|
10298
|
+
"-z",
|
|
10299
|
+
]);
|
|
10300
|
+
if (!staged.ok)
|
|
10301
|
+
return { failed: staged.error ?? "git diff --cached failed" };
|
|
10302
|
+
const hit = check(staged.paths);
|
|
10303
|
+
if (hit) return hit;
|
|
10304
|
+
if (scope.allPaths) {
|
|
10305
|
+
const status = runGitZ(scope.repoRoot, [
|
|
10306
|
+
"status",
|
|
10307
|
+
"--porcelain=v1",
|
|
10308
|
+
"-z",
|
|
10309
|
+
"--untracked-files=all",
|
|
10310
|
+
]);
|
|
10311
|
+
if (!status.ok)
|
|
10312
|
+
return { failed: status.error ?? "git status failed" };
|
|
10313
|
+
const modified = check(status.paths);
|
|
10314
|
+
if (modified) return modified;
|
|
10315
|
+
}
|
|
10316
|
+
}
|
|
10317
|
+
return undefined;
|
|
10318
|
+
}
|
|
10319
|
+
|
|
9429
10320
|
async function commitPolicyGate(
|
|
9430
10321
|
event: any,
|
|
9431
10322
|
ctx: any,
|
|
@@ -9434,7 +10325,54 @@ export default function workflowExtension(
|
|
|
9434
10325
|
const cmd = String(event?.input?.command ?? "");
|
|
9435
10326
|
const hit = gitCommitPolicyHit(cmd);
|
|
9436
10327
|
if (!hit) return undefined;
|
|
9437
|
-
const
|
|
10328
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
10329
|
+
const policyConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
10330
|
+
trusted,
|
|
10331
|
+
}).config;
|
|
10332
|
+
const policy = policyConfig.mode;
|
|
10333
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
10334
|
+
trusted,
|
|
10335
|
+
}).config;
|
|
10336
|
+
const git = analyzeShellPaths(cmd, cwd).git;
|
|
10337
|
+
const gitDir = /\bGIT_DIR=([^\s;&|]+)/.exec(cmd)?.[1];
|
|
10338
|
+
const candidateRoot = gitDir
|
|
10339
|
+
? normalizeWritePath(gitDir.replace(/^['"]|['"]$/g, ""), cwd)
|
|
10340
|
+
: (git?.repoRoot ?? normalizeWritePath(cwd, cwd));
|
|
10341
|
+
const resolvedRoot = resolveCommitRoot(candidateRoot, cwd);
|
|
10342
|
+
const repoRoot = resolvedRoot.root;
|
|
10343
|
+
const gitScope = git ? { ...git, repoRoot } : undefined;
|
|
10344
|
+
const folderEvaluation = evaluateFolderTargets(
|
|
10345
|
+
[{ path: repoRoot, kind: "dir" }],
|
|
10346
|
+
{
|
|
10347
|
+
config: folderConfig,
|
|
10348
|
+
grants: policyConfig.allowedPaths,
|
|
10349
|
+
cwd,
|
|
10350
|
+
action: "commit",
|
|
10351
|
+
},
|
|
10352
|
+
);
|
|
10353
|
+
if (resolvedRoot.uncertain)
|
|
10354
|
+
folderEvaluation.uncertain.push(repoRoot);
|
|
10355
|
+
const folderOutside = folderEvaluation.outside.length > 0;
|
|
10356
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
10357
|
+
const folderDecision = decideFolderGate({
|
|
10358
|
+
sensitive: false,
|
|
10359
|
+
outside: folderEvaluation.outside.length > 0,
|
|
10360
|
+
outsideAction: folderConfig.outside,
|
|
10361
|
+
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10362
|
+
});
|
|
10363
|
+
// decideFolderGate already turns uncertainty into ask (outside: ask) or
|
|
10364
|
+
// block (outside: block); there is no "allow" case left to escalate.
|
|
10365
|
+
const sensitiveHit = gitScope
|
|
10366
|
+
? commitSensitiveHit(gitScope, folderConfig)
|
|
10367
|
+
: undefined;
|
|
10368
|
+
if (sensitiveHit && "failed" in sensitiveHit) {
|
|
10369
|
+
return {
|
|
10370
|
+
block: true,
|
|
10371
|
+
reason:
|
|
10372
|
+
`Commit policy: the sensitive-file check could not run (${sensitiveHit.failed}). ` +
|
|
10373
|
+
"Refusing to commit unverified. Do not retry; ask the user to commit manually or fix git access.",
|
|
10374
|
+
};
|
|
10375
|
+
}
|
|
9438
10376
|
const now = Date.now();
|
|
9439
10377
|
const decision = decideCommitGate({
|
|
9440
10378
|
policy,
|
|
@@ -9442,6 +10380,8 @@ export default function workflowExtension(
|
|
|
9442
10380
|
hasTodos: todoItems.length > 0,
|
|
9443
10381
|
auth: commitAuthorization,
|
|
9444
10382
|
now,
|
|
10383
|
+
folder: folderDecision,
|
|
10384
|
+
sensitive: Boolean(sensitiveHit && !("failed" in sensitiveHit)),
|
|
9445
10385
|
});
|
|
9446
10386
|
if (decision === "allow") {
|
|
9447
10387
|
// Count a granted `git commit` against the authorization, but only when
|
|
@@ -9464,6 +10404,25 @@ export default function workflowExtension(
|
|
|
9464
10404
|
}
|
|
9465
10405
|
|
|
9466
10406
|
if (decision === "block") {
|
|
10407
|
+
if (sensitiveHit && !("failed" in sensitiveHit)) {
|
|
10408
|
+
return {
|
|
10409
|
+
block: true,
|
|
10410
|
+
reason:
|
|
10411
|
+
`Commit policy: \`${sensitiveHit.path}\` is a sensitive file (${sensitiveHit.pattern}). ` +
|
|
10412
|
+
"Committing it is blocked even when the commit mode is 'allow'. Do not retry; remove it from the index, or ask the user to add a manual sensitiveExemptions entry.",
|
|
10413
|
+
};
|
|
10414
|
+
}
|
|
10415
|
+
if (folderDecision === "block" && policy !== "never") {
|
|
10416
|
+
return {
|
|
10417
|
+
block: true,
|
|
10418
|
+
reason:
|
|
10419
|
+
`Folder policy is 'block' (workflow.folderPolicy): \`git ${hit}\` targets ${repoRoot}, ` +
|
|
10420
|
+
(folderOutside
|
|
10421
|
+
? `which is outside the permitted folders (${baseRoot}). `
|
|
10422
|
+
: "which could not be verified against the permitted folders. ") +
|
|
10423
|
+
"Do not retry. Ask the user to grant that repository folder or change /folder-policy.",
|
|
10424
|
+
};
|
|
10425
|
+
}
|
|
9467
10426
|
return {
|
|
9468
10427
|
block: true,
|
|
9469
10428
|
reason:
|
|
@@ -9484,20 +10443,60 @@ export default function workflowExtension(
|
|
|
9484
10443
|
return {
|
|
9485
10444
|
block: true,
|
|
9486
10445
|
reason:
|
|
9487
|
-
|
|
10446
|
+
`${folderOutside ? "Folder policy is 'ask' (workflow.folderPolicy)" : "Commit policy is 'ask' (workflow.commitPolicy)"}: \`git ${hit}\` needs user approval, ` +
|
|
9488
10447
|
"but no interactive UI is available. Leave the change uncommitted and let the user decide.",
|
|
9489
10448
|
};
|
|
9490
10449
|
}
|
|
10450
|
+
const commitGrant = folderOutside
|
|
10451
|
+
? (outsideProjectRootForTarget({ path: repoRoot, kind: "dir" }, baseRoot) ??
|
|
10452
|
+
repoRoot)
|
|
10453
|
+
: repoRoot;
|
|
9491
10454
|
let sel: unknown;
|
|
9492
10455
|
try {
|
|
9493
10456
|
sel = await ui.select(
|
|
9494
|
-
|
|
9495
|
-
|
|
10457
|
+
`${folderOutside ? "Folder" : "Commit"} policy 'ask': allow \`git ${hit}\`?` +
|
|
10458
|
+
`\nRepository: ${repoRoot}` +
|
|
10459
|
+
(folderOutside ? `\nOutside: ${baseRoot}` : ""),
|
|
10460
|
+
folderOutside
|
|
10461
|
+
? ["Allow once", "Always allow this repository folder", "Deny"]
|
|
10462
|
+
: ["Allow once", "Deny"],
|
|
9496
10463
|
);
|
|
9497
10464
|
} catch {
|
|
9498
10465
|
sel = undefined;
|
|
9499
10466
|
}
|
|
9500
|
-
|
|
10467
|
+
const answer = String(sel ?? "");
|
|
10468
|
+
if (answer.startsWith("Always allow")) {
|
|
10469
|
+
if (folderConfig.grantScope === "project" && !trusted) {
|
|
10470
|
+
return {
|
|
10471
|
+
block: true,
|
|
10472
|
+
reason:
|
|
10473
|
+
"Folder policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
10474
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
10475
|
+
};
|
|
10476
|
+
}
|
|
10477
|
+
const written = writeCommitPolicyConfig(
|
|
10478
|
+
folderConfig.grantScope,
|
|
10479
|
+
cwd,
|
|
10480
|
+
getAgentDir(),
|
|
10481
|
+
{ allowedPaths: [commitGrant] },
|
|
10482
|
+
);
|
|
10483
|
+
if (!written.ok) {
|
|
10484
|
+
return {
|
|
10485
|
+
block: true,
|
|
10486
|
+
reason: `Could not save the commit folder grant (${written.error ?? "unknown error"}); the commit was not allowed.`,
|
|
10487
|
+
};
|
|
10488
|
+
}
|
|
10489
|
+
return undefined;
|
|
10490
|
+
}
|
|
10491
|
+
if (answer.startsWith("Allow")) return undefined;
|
|
10492
|
+
if (folderOutside) {
|
|
10493
|
+
return {
|
|
10494
|
+
block: true,
|
|
10495
|
+
reason:
|
|
10496
|
+
`Folder policy 'ask': the user declined \`git ${hit}\` for ${repoRoot}, which is outside ${baseRoot}. ` +
|
|
10497
|
+
"Do not retry — leave the changes uncommitted for the user.",
|
|
10498
|
+
};
|
|
10499
|
+
}
|
|
9501
10500
|
return {
|
|
9502
10501
|
block: true,
|
|
9503
10502
|
reason:
|
|
@@ -9514,7 +10513,9 @@ export default function workflowExtension(
|
|
|
9514
10513
|
function commitDirectiveLine(ctx: any): string {
|
|
9515
10514
|
try {
|
|
9516
10515
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
9517
|
-
const mode = readCommitPolicyConfig(cwd, getAgentDir()
|
|
10516
|
+
const mode = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
10517
|
+
trusted: ctx?.isProjectTrusted?.() === true,
|
|
10518
|
+
}).config.mode;
|
|
9518
10519
|
switch (mode) {
|
|
9519
10520
|
case "never":
|
|
9520
10521
|
return (
|
|
@@ -9550,16 +10551,90 @@ export default function workflowExtension(
|
|
|
9550
10551
|
* part of the list (its arguments may have been rewritten by another
|
|
9551
10552
|
* extension's tool_call handler after the assistant message was sealed).
|
|
9552
10553
|
*/
|
|
10554
|
+
function writeFolderTargetsFor(
|
|
10555
|
+
toolName: string,
|
|
10556
|
+
input: any,
|
|
10557
|
+
classification: WriteCallClassification,
|
|
10558
|
+
cwd: string,
|
|
10559
|
+
): FolderTarget[] {
|
|
10560
|
+
if (classification.paths.length > 0) {
|
|
10561
|
+
return classification.paths.map((p) => ({ path: p, kind: "file" as const }));
|
|
10562
|
+
}
|
|
10563
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
10564
|
+
const command = String(input?.command ?? "");
|
|
10565
|
+
return analyzeShellPaths(command, cwd).writes;
|
|
10566
|
+
}
|
|
10567
|
+
return [];
|
|
10568
|
+
}
|
|
10569
|
+
|
|
10570
|
+
function withFolderGrant(
|
|
10571
|
+
item: WriteGateItem,
|
|
10572
|
+
targets: readonly FolderTarget[],
|
|
10573
|
+
cwd: string,
|
|
10574
|
+
baseRoot: string,
|
|
10575
|
+
): WriteGateItem {
|
|
10576
|
+
const grantEntries = targets
|
|
10577
|
+
.map((target) => {
|
|
10578
|
+
const exact = normalizeWritePath(target.path, cwd);
|
|
10579
|
+
const outsideRoot = outsideProjectRootForTarget(target, baseRoot);
|
|
10580
|
+
const path = outsideRoot ?? exact;
|
|
10581
|
+
// A dir/repo target (or a glob's static prefix) is granted as a
|
|
10582
|
+
// folder; so is a containing folder computed for an outside file. A
|
|
10583
|
+
// file directly in the common parent is granted as that file alone.
|
|
10584
|
+
const folder =
|
|
10585
|
+
target.kind !== "file" ||
|
|
10586
|
+
(outsideRoot !== undefined && outsideRoot !== exact);
|
|
10587
|
+
return { path, folder };
|
|
10588
|
+
})
|
|
10589
|
+
.filter((entry) => entry.path.length > 0);
|
|
10590
|
+
if (grantEntries.length === 0) return item;
|
|
10591
|
+
const unique = [...new Set(grantEntries.map((entry) => entry.path))].sort();
|
|
10592
|
+
return {
|
|
10593
|
+
...item,
|
|
10594
|
+
target: unique.join(", "),
|
|
10595
|
+
grantPaths: [...new Set([...(item.grantPaths ?? []), ...unique])],
|
|
10596
|
+
grantKind: grantEntries.every((entry) => entry.folder)
|
|
10597
|
+
? "folder"
|
|
10598
|
+
: "file",
|
|
10599
|
+
grantCommand: undefined,
|
|
10600
|
+
key: `path:${unique.join("|")}`,
|
|
10601
|
+
};
|
|
10602
|
+
}
|
|
10603
|
+
|
|
9553
10604
|
function collectWriteGateItems(
|
|
9554
10605
|
currentEvent: any,
|
|
9555
10606
|
currentClassification: WriteCallClassification,
|
|
9556
10607
|
config: WritePolicyConfig,
|
|
10608
|
+
folderConfig: FolderPolicyConfig,
|
|
9557
10609
|
cwd: string,
|
|
10610
|
+
baseRoot: string,
|
|
9558
10611
|
): WriteGateItem[] {
|
|
9559
10612
|
const items: WriteGateItem[] = [];
|
|
9560
10613
|
const seen = new Set<string>();
|
|
9561
|
-
const add = (
|
|
9562
|
-
|
|
10614
|
+
const add = (
|
|
10615
|
+
name: string,
|
|
10616
|
+
classification: WriteCallClassification,
|
|
10617
|
+
input: any,
|
|
10618
|
+
) => {
|
|
10619
|
+
const targets = writeFolderTargetsFor(
|
|
10620
|
+
name,
|
|
10621
|
+
input,
|
|
10622
|
+
classification,
|
|
10623
|
+
cwd,
|
|
10624
|
+
);
|
|
10625
|
+
const evaluation = evaluateFolderTargets(targets, {
|
|
10626
|
+
config: folderConfig,
|
|
10627
|
+
grants: config.allowedPaths,
|
|
10628
|
+
cwd,
|
|
10629
|
+
action: "write",
|
|
10630
|
+
});
|
|
10631
|
+
const needsFolder = evaluation.outside.length > 0;
|
|
10632
|
+
if (!needsFolder && isWriteCallAllowed(classification, config, cwd))
|
|
10633
|
+
return;
|
|
10634
|
+
const base = writeGateItemFromClassification(name, classification, cwd);
|
|
10635
|
+
const item = needsFolder
|
|
10636
|
+
? withFolderGrant(base, targets, cwd, baseRoot)
|
|
10637
|
+
: base;
|
|
9563
10638
|
if (!item.key || seen.has(item.key)) return;
|
|
9564
10639
|
seen.add(item.key);
|
|
9565
10640
|
items.push(item);
|
|
@@ -9569,8 +10644,6 @@ export default function workflowExtension(
|
|
|
9569
10644
|
if (!name) continue;
|
|
9570
10645
|
const classification = classifyWriteCall(name, call.args, config);
|
|
9571
10646
|
if (!classification.mutating) continue;
|
|
9572
|
-
// A granted target is not gated — it never reaches a dialog.
|
|
9573
|
-
if (isWriteCallAllowed(classification, config, cwd)) continue;
|
|
9574
10647
|
// The plan directory stays writable in every mode.
|
|
9575
10648
|
if (
|
|
9576
10649
|
classification.paths.length > 0 &&
|
|
@@ -9578,9 +10651,9 @@ export default function workflowExtension(
|
|
|
9578
10651
|
) {
|
|
9579
10652
|
continue;
|
|
9580
10653
|
}
|
|
9581
|
-
add(name, classification);
|
|
10654
|
+
add(name, classification, call.args);
|
|
9582
10655
|
}
|
|
9583
|
-
add(String(currentEvent?.toolName ?? ""), currentClassification);
|
|
10656
|
+
add(String(currentEvent?.toolName ?? ""), currentClassification, currentEvent?.input);
|
|
9584
10657
|
return items;
|
|
9585
10658
|
}
|
|
9586
10659
|
|
|
@@ -9625,13 +10698,16 @@ export default function workflowExtension(
|
|
|
9625
10698
|
}
|
|
9626
10699
|
|
|
9627
10700
|
/**
|
|
9628
|
-
* Write permission gate (workflow.writePolicy
|
|
10701
|
+
* Write permission gate (workflow.writePolicy + the shared folder floor).
|
|
9629
10702
|
*
|
|
9630
10703
|
* The Workflow extension's three modes are Plan → Build → Default. Plan
|
|
9631
|
-
* mode is read-only and Build mode is unrestricted
|
|
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
|
|
10704
|
+
* mode is read-only and Build mode is unrestricted *inside the permitted
|
|
10705
|
+
* folders*, but DEFAULT mode was ALSO unrestricted: `resolveSessionRole()`
|
|
10706
|
+
* returns undefined there, so the tool_call gate has no branch to take and
|
|
10707
|
+
* the agent edits files without asking. This gate is the missing permission
|
|
10708
|
+
* knob. The write MODE is Default-only; the folder floor applies in every
|
|
10709
|
+
* mode (Build included) and asks only for proven outside targets, sensitive
|
|
10710
|
+
* files, or an explicit target path that cannot be resolved.
|
|
9635
10711
|
*
|
|
9636
10712
|
* It is deliberately INDEPENDENT of the role gate and of the commit
|
|
9637
10713
|
* policy: those govern *what* a role may do, this governs *whether the
|
|
@@ -9651,24 +10727,24 @@ export default function workflowExtension(
|
|
|
9651
10727
|
cwd: string,
|
|
9652
10728
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
9653
10729
|
try {
|
|
9654
|
-
// Scope: the
|
|
9655
|
-
//
|
|
9656
|
-
//
|
|
9657
|
-
//
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
}) !== undefined
|
|
9665
|
-
) {
|
|
9666
|
-
return undefined;
|
|
9667
|
-
}
|
|
10730
|
+
// Scope: the write MODE is a Default-mode knob; the shared FOLDER floor
|
|
10731
|
+
// applies to every mode. Plan mode already restricts writes to
|
|
10732
|
+
// .pi/plans/ (inside the folder root); Build mode is the deliberate
|
|
10733
|
+
// execution path but must still respect the user's folder floor.
|
|
10734
|
+
const gateRole = resolveSessionRole({
|
|
10735
|
+
envRole: process.env[WORKFLOW_ROLE_ENV],
|
|
10736
|
+
workflowMode,
|
|
10737
|
+
});
|
|
10738
|
+
if (gateRole && gateRole !== "builder") return undefined;
|
|
10739
|
+
const defaultMode = workflowMode === null && gateRole === undefined;
|
|
9668
10740
|
|
|
9669
|
-
const
|
|
10741
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
10742
|
+
const resolved = readWritePolicyConfig(cwd, getAgentDir(), { trusted });
|
|
9670
10743
|
const config = resolved.config;
|
|
9671
|
-
|
|
10744
|
+
const effectiveWriteMode = defaultMode ? config.mode : "allow";
|
|
10745
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
10746
|
+
trusted,
|
|
10747
|
+
}).config;
|
|
9672
10748
|
|
|
9673
10749
|
const classification = classifyWriteCall(
|
|
9674
10750
|
event.toolName,
|
|
@@ -9686,23 +10762,94 @@ export default function workflowExtension(
|
|
|
9686
10762
|
return undefined;
|
|
9687
10763
|
}
|
|
9688
10764
|
|
|
10765
|
+
const folderTargets = writeFolderTargetsFor(
|
|
10766
|
+
String(event.toolName ?? ""),
|
|
10767
|
+
event.input,
|
|
10768
|
+
classification,
|
|
10769
|
+
cwd,
|
|
10770
|
+
);
|
|
10771
|
+
for (const folderTarget of folderTargets) {
|
|
10772
|
+
const hit = matchSensitivePath(folderTarget.path, folderConfig, cwd);
|
|
10773
|
+
if (hit.sensitive) {
|
|
10774
|
+
return {
|
|
10775
|
+
block: true,
|
|
10776
|
+
reason:
|
|
10777
|
+
`Write policy: \`${folderTarget.path}\` is a sensitive file` +
|
|
10778
|
+
(hit.pattern ? ` (${hit.pattern})` : "") +
|
|
10779
|
+
". This cannot be overridden by a write grant or folder. " +
|
|
10780
|
+
"Do not retry; ask the user to edit the sensitive list in settings.json if this is intentional.",
|
|
10781
|
+
};
|
|
10782
|
+
}
|
|
10783
|
+
}
|
|
10784
|
+
|
|
10785
|
+
const folderEvaluation = evaluateFolderTargets(folderTargets, {
|
|
10786
|
+
config: folderConfig,
|
|
10787
|
+
grants: config.allowedPaths,
|
|
10788
|
+
cwd,
|
|
10789
|
+
action: "write",
|
|
10790
|
+
});
|
|
10791
|
+
const folderDecision = decideFolderGate({
|
|
10792
|
+
sensitive: false,
|
|
10793
|
+
outside: folderEvaluation.outside.length > 0,
|
|
10794
|
+
outsideAction: folderConfig.outside,
|
|
10795
|
+
// Only a concrete target path that cannot be canonicalised is folder
|
|
10796
|
+
// uncertainty. "Mutating but no discovered write target" is NOT: the
|
|
10797
|
+
// shell analyzer cannot see inside `npm run build`, `node script`,
|
|
10798
|
+
// `npx`, `sed`/`awk` programs or `powershell`, and treating that as
|
|
10799
|
+
// "possibly outside" made Build and writePolicy:allow prompt for
|
|
10800
|
+
// ordinary in-repo toolchain commands. The capability policy owns
|
|
10801
|
+
// that case — writePolicy:ask still asks for every mutating call —
|
|
10802
|
+
// and undiscoverable shell writes are the documented residual
|
|
10803
|
+
// (README "Not a sandbox").
|
|
10804
|
+
uncertain: folderEvaluation.uncertain.length > 0,
|
|
10805
|
+
});
|
|
9689
10806
|
const allowed = isWriteCallAllowed(classification, config, cwd);
|
|
9690
10807
|
const decision = decideWriteGate({
|
|
9691
|
-
mode:
|
|
10808
|
+
mode: effectiveWriteMode,
|
|
9692
10809
|
mutating: true,
|
|
9693
10810
|
allowed,
|
|
10811
|
+
folder: folderDecision,
|
|
9694
10812
|
});
|
|
9695
10813
|
if (decision === "allow") return undefined;
|
|
9696
10814
|
|
|
10815
|
+
// Which policy actually produced this ask. A folder-sourced prompt must
|
|
10816
|
+
// not be labelled "Write policy" — that misattribution is what made the
|
|
10817
|
+
// in-repo toolchain regression look like a write-policy bug.
|
|
10818
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
10819
|
+
const folderAsk = folderDecision === "ask";
|
|
10820
|
+
const gateSource: WriteGateSource = folderAsk
|
|
10821
|
+
? effectiveWriteMode === "ask"
|
|
10822
|
+
? "mixed"
|
|
10823
|
+
: "folder"
|
|
10824
|
+
: "write";
|
|
10825
|
+
const gatePolicyLabel =
|
|
10826
|
+
gateSource === "folder"
|
|
10827
|
+
? "Folder policy"
|
|
10828
|
+
: gateSource === "mixed"
|
|
10829
|
+
? "Permission policy"
|
|
10830
|
+
: "Write policy";
|
|
10831
|
+
const gateOutside = gateSource === "folder" ? baseRoot : undefined;
|
|
10832
|
+
|
|
9697
10833
|
const target =
|
|
9698
|
-
|
|
9699
|
-
?
|
|
9700
|
-
:
|
|
10834
|
+
folderTargets.length > 0
|
|
10835
|
+
? folderTargets.map((item) => item.path).join(", ")
|
|
10836
|
+
: classification.paths.length > 0
|
|
10837
|
+
? classification.paths.join(", ")
|
|
10838
|
+
: (classification.key ?? String(event.toolName));
|
|
9701
10839
|
const why = classification.reason
|
|
9702
10840
|
? ` (${classification.reason})`
|
|
9703
10841
|
: "";
|
|
9704
10842
|
|
|
9705
10843
|
if (decision === "block") {
|
|
10844
|
+
if (folderDecision === "block" && effectiveWriteMode !== "never") {
|
|
10845
|
+
return {
|
|
10846
|
+
block: true,
|
|
10847
|
+
reason:
|
|
10848
|
+
`Folder policy is 'block' (workflow.folderPolicy): the target is outside the permitted folders. ` +
|
|
10849
|
+
"Do not retry. Ask the user to open the project at that folder, grant it, or change /folder-policy. " +
|
|
10850
|
+
`Target: ${target}`,
|
|
10851
|
+
};
|
|
10852
|
+
}
|
|
9706
10853
|
return {
|
|
9707
10854
|
block: true,
|
|
9708
10855
|
reason:
|
|
@@ -9728,11 +10875,18 @@ export default function workflowExtension(
|
|
|
9728
10875
|
// the answer applies to all of them.
|
|
9729
10876
|
const ui = ctx?.ui;
|
|
9730
10877
|
if (!ui || !ctx.hasUI) {
|
|
10878
|
+
const policyName =
|
|
10879
|
+
gateSource === "folder"
|
|
10880
|
+
? "Folder policy is 'ask' (workflow.folderPolicy)"
|
|
10881
|
+
: gateSource === "mixed"
|
|
10882
|
+
? "Folder and write policies are 'ask' (workflow.folderPolicy + workflow.writePolicy)"
|
|
10883
|
+
: "Write policy is 'ask' (workflow.writePolicy)";
|
|
9731
10884
|
return {
|
|
9732
10885
|
block: true,
|
|
9733
10886
|
reason:
|
|
9734
|
-
|
|
10887
|
+
`${policyName}: writing needs user approval${why}, but no interactive UI is available. ` +
|
|
9735
10888
|
"Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
|
|
10889
|
+
(gateSource === "folder" ? `Outside: ${baseRoot}. ` : "") +
|
|
9736
10890
|
`Target: ${target}`,
|
|
9737
10891
|
};
|
|
9738
10892
|
}
|
|
@@ -9749,12 +10903,23 @@ export default function workflowExtension(
|
|
|
9749
10903
|
writeGateBatchCalls = [];
|
|
9750
10904
|
writeGateBatchCallIds = new Set();
|
|
9751
10905
|
}
|
|
9752
|
-
const items = collectWriteGateItems(
|
|
9753
|
-
|
|
10906
|
+
const items = collectWriteGateItems(
|
|
10907
|
+
event,
|
|
10908
|
+
classification,
|
|
10909
|
+
config,
|
|
10910
|
+
folderConfig,
|
|
10911
|
+
cwd,
|
|
10912
|
+
baseRoot,
|
|
10913
|
+
);
|
|
10914
|
+
const currentBase = writeGateItemFromClassification(
|
|
9754
10915
|
String(event.toolName ?? ""),
|
|
9755
10916
|
classification,
|
|
9756
10917
|
cwd,
|
|
9757
10918
|
);
|
|
10919
|
+
const currentItem =
|
|
10920
|
+
folderEvaluation.outside.length > 0
|
|
10921
|
+
? withFolderGrant(currentBase, folderTargets, cwd, baseRoot)
|
|
10922
|
+
: currentBase;
|
|
9758
10923
|
|
|
9759
10924
|
// (a) The user denied this target earlier in THIS RUN → deny again,
|
|
9760
10925
|
// silently. The model gets the same sentence; the user is not asked a
|
|
@@ -9768,6 +10933,8 @@ export default function workflowExtension(
|
|
|
9768
10933
|
target,
|
|
9769
10934
|
why: classification.reason,
|
|
9770
10935
|
instruction: remembered.instruction,
|
|
10936
|
+
source: gateSource,
|
|
10937
|
+
outside: gateOutside,
|
|
9771
10938
|
}),
|
|
9772
10939
|
};
|
|
9773
10940
|
}
|
|
@@ -9785,6 +10952,8 @@ export default function workflowExtension(
|
|
|
9785
10952
|
target,
|
|
9786
10953
|
why: classification.reason,
|
|
9787
10954
|
instruction: answer.instruction,
|
|
10955
|
+
source: gateSource,
|
|
10956
|
+
outside: gateOutside,
|
|
9788
10957
|
}),
|
|
9789
10958
|
};
|
|
9790
10959
|
}
|
|
@@ -9793,7 +10962,7 @@ export default function workflowExtension(
|
|
|
9793
10962
|
const options = writeGateOptions(writeGateItemsKind(items));
|
|
9794
10963
|
let sel: unknown;
|
|
9795
10964
|
try {
|
|
9796
|
-
sel = await ui.select(writeGateTitle(items), options);
|
|
10965
|
+
sel = await ui.select(writeGateTitle(items, gateSource, baseRoot), options);
|
|
9797
10966
|
} catch (_e) {
|
|
9798
10967
|
void _e;
|
|
9799
10968
|
sel = undefined;
|
|
@@ -9815,14 +10984,23 @@ export default function workflowExtension(
|
|
|
9815
10984
|
for (const p of item.grantPaths ?? []) allowedPaths.push(p);
|
|
9816
10985
|
if (item.grantCommand) allowedCommands.push(item.grantCommand);
|
|
9817
10986
|
}
|
|
9818
|
-
const
|
|
10987
|
+
const grantScope = folderConfig.grantScope;
|
|
10988
|
+
if (grantScope === "project" && !trusted) {
|
|
10989
|
+
return {
|
|
10990
|
+
block: true,
|
|
10991
|
+
reason:
|
|
10992
|
+
`${gatePolicyLabel} 'ask': this project is not trusted, so a project-scoped grant would be ignored. ` +
|
|
10993
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
10994
|
+
};
|
|
10995
|
+
}
|
|
10996
|
+
const written = writeWritePolicyConfig(grantScope, cwd, getAgentDir(), {
|
|
9819
10997
|
allowedPaths,
|
|
9820
10998
|
allowedCommands,
|
|
9821
10999
|
});
|
|
9822
11000
|
if (written.ok) {
|
|
9823
11001
|
try {
|
|
9824
11002
|
(ctx as any).ui?.notify?.(
|
|
9825
|
-
|
|
11003
|
+
`${gatePolicyLabel}: always allowed ${writeGateTargetsLabel(items)}`,
|
|
9826
11004
|
"info",
|
|
9827
11005
|
);
|
|
9828
11006
|
} catch (_e) {
|
|
@@ -9836,7 +11014,7 @@ export default function workflowExtension(
|
|
|
9836
11014
|
return {
|
|
9837
11015
|
block: true,
|
|
9838
11016
|
reason:
|
|
9839
|
-
|
|
11017
|
+
`${gatePolicyLabel} 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
|
|
9840
11018
|
`Target: ${target}`,
|
|
9841
11019
|
};
|
|
9842
11020
|
}
|
|
@@ -9886,6 +11064,8 @@ export default function workflowExtension(
|
|
|
9886
11064
|
target: items.length > 1 ? writeGateTargetsLabel(items) : target,
|
|
9887
11065
|
why: classification.reason,
|
|
9888
11066
|
instruction,
|
|
11067
|
+
source: gateSource,
|
|
11068
|
+
outside: gateOutside,
|
|
9889
11069
|
});
|
|
9890
11070
|
|
|
9891
11071
|
if (instruction) {
|
|
@@ -9933,6 +11113,349 @@ export default function workflowExtension(
|
|
|
9933
11113
|
}
|
|
9934
11114
|
}
|
|
9935
11115
|
|
|
11116
|
+
function collectReadGateItems(
|
|
11117
|
+
currentEvent: any,
|
|
11118
|
+
currentClassification: ReturnType<typeof classifyReadCall>,
|
|
11119
|
+
config: ReadPolicyConfig,
|
|
11120
|
+
folderConfig: FolderPolicyConfig,
|
|
11121
|
+
cwd: string,
|
|
11122
|
+
lockedRoots?: readonly string[],
|
|
11123
|
+
baseRoot?: string,
|
|
11124
|
+
): ReadGateItem[] {
|
|
11125
|
+
const items: ReadGateItem[] = [];
|
|
11126
|
+
const seen = new Set<string>();
|
|
11127
|
+
const add = (
|
|
11128
|
+
name: string,
|
|
11129
|
+
classification: ReturnType<typeof classifyReadCall>,
|
|
11130
|
+
) => {
|
|
11131
|
+
if (classification.exempt) return;
|
|
11132
|
+
const targets = classification.targets;
|
|
11133
|
+
const evaluation = evaluateFolderTargets(targets, {
|
|
11134
|
+
config: folderConfig,
|
|
11135
|
+
grants: [...config.allowedPaths, ...readGateTurnGrantedRoots],
|
|
11136
|
+
cwd,
|
|
11137
|
+
lockedRoots,
|
|
11138
|
+
action: "read",
|
|
11139
|
+
});
|
|
11140
|
+
for (const target of evaluation.outside) {
|
|
11141
|
+
const item = readGateItemFromTarget(
|
|
11142
|
+
name,
|
|
11143
|
+
target,
|
|
11144
|
+
"outside the folder",
|
|
11145
|
+
baseRoot,
|
|
11146
|
+
);
|
|
11147
|
+
if (seen.has(item.key)) continue;
|
|
11148
|
+
seen.add(item.key);
|
|
11149
|
+
items.push(item);
|
|
11150
|
+
}
|
|
11151
|
+
if (evaluation.outside.length === 0) {
|
|
11152
|
+
for (const reason of classification.uncertain) {
|
|
11153
|
+
const key = `cmd:${reason}`;
|
|
11154
|
+
if (seen.has(key)) continue;
|
|
11155
|
+
seen.add(key);
|
|
11156
|
+
items.push({
|
|
11157
|
+
tool: name,
|
|
11158
|
+
target: { path: reason, kind: "file" },
|
|
11159
|
+
grantRoot: "",
|
|
11160
|
+
why: reason,
|
|
11161
|
+
key,
|
|
11162
|
+
});
|
|
11163
|
+
}
|
|
11164
|
+
}
|
|
11165
|
+
};
|
|
11166
|
+
for (const call of readGateBatchCalls) {
|
|
11167
|
+
const name = String(call.name ?? "");
|
|
11168
|
+
if (!name) continue;
|
|
11169
|
+
add(name, classifyReadCall(name, call.args, cwd, config));
|
|
11170
|
+
}
|
|
11171
|
+
add(
|
|
11172
|
+
String(currentEvent?.toolName ?? ""),
|
|
11173
|
+
currentClassification,
|
|
11174
|
+
);
|
|
11175
|
+
return items;
|
|
11176
|
+
}
|
|
11177
|
+
|
|
11178
|
+
function deliverReadInstruction(instruction: string, target: string): void {
|
|
11179
|
+
if (!instruction || readGateTurnSteered) return;
|
|
11180
|
+
const rt = reviewRuntime;
|
|
11181
|
+
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
11182
|
+
reviewEnabled: reviewEnabled(),
|
|
11183
|
+
reviewActive: rt?.isRoundActive() === true,
|
|
11184
|
+
hasReviewerSession: rt?.isAlive() === true,
|
|
11185
|
+
manualSessionActive: reviewSessionLive(),
|
|
11186
|
+
});
|
|
11187
|
+
if (!wouldRouteToReviewer) {
|
|
11188
|
+
try {
|
|
11189
|
+
(pi as any).sendUserMessage?.(
|
|
11190
|
+
buildReadGuidanceMessage({ target, instruction }),
|
|
11191
|
+
{ deliverAs: "steer" },
|
|
11192
|
+
);
|
|
11193
|
+
} catch (_e) {
|
|
11194
|
+
void _e;
|
|
11195
|
+
}
|
|
11196
|
+
}
|
|
11197
|
+
readGateTurnSteered = true;
|
|
11198
|
+
}
|
|
11199
|
+
|
|
11200
|
+
/**
|
|
11201
|
+
* Read permission gate. Applies in every workflow mode and to every role,
|
|
11202
|
+
* including Build/explorer/reviewer-adjacent calls. The shared folder floor
|
|
11203
|
+
* decides scope; the sensitive list always wins; the dialog is batched once
|
|
11204
|
+
* per assistant turn.
|
|
11205
|
+
*/
|
|
11206
|
+
async function readPolicyGate(
|
|
11207
|
+
event: any,
|
|
11208
|
+
ctx: any,
|
|
11209
|
+
cwd: string,
|
|
11210
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
11211
|
+
try {
|
|
11212
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
11213
|
+
const readResolved = readReadPolicyConfig(cwd, getAgentDir(), {
|
|
11214
|
+
trusted,
|
|
11215
|
+
});
|
|
11216
|
+
const folderResolved = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11217
|
+
trusted,
|
|
11218
|
+
});
|
|
11219
|
+
const config = readResolved.config;
|
|
11220
|
+
const folderConfig = folderResolved.config;
|
|
11221
|
+
const classification = classifyReadCall(
|
|
11222
|
+
String(event.toolName ?? ""),
|
|
11223
|
+
event.input,
|
|
11224
|
+
cwd,
|
|
11225
|
+
config,
|
|
11226
|
+
);
|
|
11227
|
+
if (classification.exempt) return undefined;
|
|
11228
|
+
if (
|
|
11229
|
+
classification.targets.length === 0 &&
|
|
11230
|
+
classification.uncertain.length === 0
|
|
11231
|
+
)
|
|
11232
|
+
return undefined;
|
|
11233
|
+
|
|
11234
|
+
const grants = [...config.allowedPaths, ...readGateTurnGrantedRoots];
|
|
11235
|
+
const evaluation = evaluateFolderTargets(classification.targets, {
|
|
11236
|
+
config: folderConfig,
|
|
11237
|
+
grants,
|
|
11238
|
+
cwd,
|
|
11239
|
+
lockedRoots: folderResolved.locked?.roots,
|
|
11240
|
+
action: "read",
|
|
11241
|
+
});
|
|
11242
|
+
if (evaluation.sensitive.length > 0) {
|
|
11243
|
+
const hit = evaluation.sensitive[0];
|
|
11244
|
+
return {
|
|
11245
|
+
block: true,
|
|
11246
|
+
reason: buildReadDenyReason({
|
|
11247
|
+
kind: "sensitive",
|
|
11248
|
+
target: hit.path,
|
|
11249
|
+
pattern: hit.pattern,
|
|
11250
|
+
}),
|
|
11251
|
+
};
|
|
11252
|
+
}
|
|
11253
|
+
|
|
11254
|
+
const outside = evaluation.outside;
|
|
11255
|
+
const uncertain = classification.uncertain;
|
|
11256
|
+
let decision = decideReadGate({
|
|
11257
|
+
sensitive: false,
|
|
11258
|
+
outside: outside.length > 0,
|
|
11259
|
+
outsideAction: folderConfig.outside,
|
|
11260
|
+
granted: false,
|
|
11261
|
+
uncertain:
|
|
11262
|
+
uncertain.length > 0 || evaluation.uncertain.length > 0,
|
|
11263
|
+
shell: config.shell,
|
|
11264
|
+
});
|
|
11265
|
+
|
|
11266
|
+
if (uncertain.length > 0 && config.shell === "strict") {
|
|
11267
|
+
const allowed = uncertain.every((reason) =>
|
|
11268
|
+
readGateTurnUncertainAllowed.has(reason),
|
|
11269
|
+
);
|
|
11270
|
+
if (allowed) decision = "allow";
|
|
11271
|
+
}
|
|
11272
|
+
if (decision === "allow") return undefined;
|
|
11273
|
+
if (decision === "block") {
|
|
11274
|
+
return {
|
|
11275
|
+
block: true,
|
|
11276
|
+
reason: buildReadDenyReason({
|
|
11277
|
+
kind: "blocked",
|
|
11278
|
+
target:
|
|
11279
|
+
outside.length > 0
|
|
11280
|
+
? outside.map((target) => target.path).join(", ")
|
|
11281
|
+
: uncertain[0] ?? String(event.toolName),
|
|
11282
|
+
outsideAction: folderConfig.outside,
|
|
11283
|
+
}),
|
|
11284
|
+
};
|
|
11285
|
+
}
|
|
11286
|
+
|
|
11287
|
+
const ui = ctx?.ui;
|
|
11288
|
+
if (!ui || !ctx.hasUI) {
|
|
11289
|
+
return {
|
|
11290
|
+
block: true,
|
|
11291
|
+
reason:
|
|
11292
|
+
"Read policy is 'ask' (workflow.folderPolicy): this read needs approval, but no interactive UI is available. " +
|
|
11293
|
+
"Do not retry; report what you need and let the user approve it.",
|
|
11294
|
+
};
|
|
11295
|
+
}
|
|
11296
|
+
|
|
11297
|
+
const callId = String(event.toolCallId ?? "");
|
|
11298
|
+
if (!readGateBatchCallIds.has(callId)) {
|
|
11299
|
+
readGateTurnAnswer = null;
|
|
11300
|
+
readGateTurnSteered = false;
|
|
11301
|
+
readGateBatchCalls = [];
|
|
11302
|
+
readGateBatchCallIds = new Set();
|
|
11303
|
+
readGateTurnGrantedRoots.clear();
|
|
11304
|
+
readGateTurnUncertainAllowed.clear();
|
|
11305
|
+
}
|
|
11306
|
+
const baseRoot = baseFolderRoots(folderConfig, cwd)[0] ?? cwd;
|
|
11307
|
+
const items = collectReadGateItems(
|
|
11308
|
+
event,
|
|
11309
|
+
classification,
|
|
11310
|
+
config,
|
|
11311
|
+
folderConfig,
|
|
11312
|
+
cwd,
|
|
11313
|
+
folderResolved.locked?.roots,
|
|
11314
|
+
baseRoot,
|
|
11315
|
+
);
|
|
11316
|
+
const currentItems = items.filter((item) => {
|
|
11317
|
+
if (outside.length > 0)
|
|
11318
|
+
return outside.some((target) => item.key === readGateTargetKey(target));
|
|
11319
|
+
return uncertain.some((reason) => item.key === `cmd:${reason}`);
|
|
11320
|
+
});
|
|
11321
|
+
const currentItem = currentItems[0];
|
|
11322
|
+
const remembered = currentItem
|
|
11323
|
+
? readGateRunDenials.get(currentItem.key)
|
|
11324
|
+
: undefined;
|
|
11325
|
+
if (remembered) {
|
|
11326
|
+
return {
|
|
11327
|
+
block: true,
|
|
11328
|
+
reason: buildReadDenyReason({
|
|
11329
|
+
kind: "outside",
|
|
11330
|
+
target: readGateTargetsLabel(currentItems) || "the requested read",
|
|
11331
|
+
instruction: remembered.instruction,
|
|
11332
|
+
}),
|
|
11333
|
+
};
|
|
11334
|
+
}
|
|
11335
|
+
if (readGateTurnAnswer) {
|
|
11336
|
+
if (readGateTurnAnswer.choice === "deny") {
|
|
11337
|
+
return {
|
|
11338
|
+
block: true,
|
|
11339
|
+
reason: buildReadDenyReason({
|
|
11340
|
+
kind: "outside",
|
|
11341
|
+
target: readGateTargetsLabel(currentItems) || "the requested read",
|
|
11342
|
+
instruction: readGateTurnAnswer.instruction,
|
|
11343
|
+
}),
|
|
11344
|
+
};
|
|
11345
|
+
}
|
|
11346
|
+
return undefined;
|
|
11347
|
+
}
|
|
11348
|
+
|
|
11349
|
+
const options = readGateOptions(readGateItemsKind(items));
|
|
11350
|
+
let sel: unknown;
|
|
11351
|
+
try {
|
|
11352
|
+
sel = await ui.select(readGateTitle(items, baseRoot), options);
|
|
11353
|
+
} catch {
|
|
11354
|
+
sel = undefined;
|
|
11355
|
+
}
|
|
11356
|
+
const choice = parseReadGateChoice(sel);
|
|
11357
|
+
if (choice === "allow-once") {
|
|
11358
|
+
for (const item of items) if (item.grantRoot) readGateTurnGrantedRoots.add(item.grantRoot);
|
|
11359
|
+
for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
|
|
11360
|
+
readGateTurnAnswer = { choice: "allow-once", instruction: "" };
|
|
11361
|
+
return undefined;
|
|
11362
|
+
}
|
|
11363
|
+
if (choice === "always") {
|
|
11364
|
+
const roots = [
|
|
11365
|
+
...new Set(items.map((item) => item.grantRoot).filter(Boolean)),
|
|
11366
|
+
];
|
|
11367
|
+
if (roots.length === 0) {
|
|
11368
|
+
for (const reason of uncertain)
|
|
11369
|
+
readGateTurnUncertainAllowed.add(reason);
|
|
11370
|
+
readGateTurnAnswer = { choice: "always", instruction: "" };
|
|
11371
|
+
return undefined;
|
|
11372
|
+
}
|
|
11373
|
+
const broad = roots.find((root) => isBroadGrantRoot(root));
|
|
11374
|
+
if (broad) {
|
|
11375
|
+
return {
|
|
11376
|
+
block: true,
|
|
11377
|
+
reason:
|
|
11378
|
+
`Read policy: \`${broad}\` is too broad to persist from a dialog. Choose Allow once, or use /folder-policy any deliberately.`,
|
|
11379
|
+
};
|
|
11380
|
+
}
|
|
11381
|
+
if (folderConfig.grantScope === "project" && !trusted) {
|
|
11382
|
+
return {
|
|
11383
|
+
block: true,
|
|
11384
|
+
reason:
|
|
11385
|
+
"Read policy 'ask': this project is not trusted, so a project-scoped grant would be ignored. " +
|
|
11386
|
+
"Choose Allow once, trust the project first, or set folderPolicy.grantScope to 'global'.",
|
|
11387
|
+
};
|
|
11388
|
+
}
|
|
11389
|
+
const written = writeReadPolicyConfig(
|
|
11390
|
+
folderConfig.grantScope,
|
|
11391
|
+
cwd,
|
|
11392
|
+
getAgentDir(),
|
|
11393
|
+
{ allowedPaths: roots },
|
|
11394
|
+
);
|
|
11395
|
+
if (!written.ok) {
|
|
11396
|
+
return {
|
|
11397
|
+
block: true,
|
|
11398
|
+
reason: `Read policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}).`,
|
|
11399
|
+
};
|
|
11400
|
+
}
|
|
11401
|
+
for (const root of roots) readGateTurnGrantedRoots.add(root);
|
|
11402
|
+
for (const reason of uncertain) readGateTurnUncertainAllowed.add(reason);
|
|
11403
|
+
readGateTurnAnswer = { choice: "always", instruction: "" };
|
|
11404
|
+
return undefined;
|
|
11405
|
+
}
|
|
11406
|
+
|
|
11407
|
+
let instruction = "";
|
|
11408
|
+
if (choice === "deny-with-instructions") {
|
|
11409
|
+
const inputFn = (ui as any).input;
|
|
11410
|
+
if (typeof inputFn === "function") {
|
|
11411
|
+
try {
|
|
11412
|
+
instruction = sanitizeReadInstruction(
|
|
11413
|
+
await inputFn.call(
|
|
11414
|
+
ui,
|
|
11415
|
+
READ_INSTRUCTION_PROMPT_TITLE,
|
|
11416
|
+
READ_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
11417
|
+
),
|
|
11418
|
+
);
|
|
11419
|
+
} catch {
|
|
11420
|
+
instruction = "";
|
|
11421
|
+
}
|
|
11422
|
+
}
|
|
11423
|
+
}
|
|
11424
|
+
readGateTurnAnswer = { choice: "deny", instruction };
|
|
11425
|
+
for (const item of items) readGateRunDenials.set(item.key, { instruction });
|
|
11426
|
+
if (instruction)
|
|
11427
|
+
deliverReadInstruction(
|
|
11428
|
+
instruction,
|
|
11429
|
+
readGateTargetsLabel(items) || "the requested read",
|
|
11430
|
+
);
|
|
11431
|
+
try {
|
|
11432
|
+
pi.appendEntry("workflow-read-denial", {
|
|
11433
|
+
tool: String(event.toolName ?? ""),
|
|
11434
|
+
target: readGateTargetsLabel(items),
|
|
11435
|
+
instruction,
|
|
11436
|
+
at: Date.now(),
|
|
11437
|
+
});
|
|
11438
|
+
} catch (_e) {
|
|
11439
|
+
void _e;
|
|
11440
|
+
}
|
|
11441
|
+
return {
|
|
11442
|
+
block: true,
|
|
11443
|
+
reason: buildReadDenyReason({
|
|
11444
|
+
kind: "outside",
|
|
11445
|
+
target: readGateTargetsLabel(items) || "the requested read",
|
|
11446
|
+
instruction,
|
|
11447
|
+
}),
|
|
11448
|
+
};
|
|
11449
|
+
} catch (error: unknown) {
|
|
11450
|
+
return {
|
|
11451
|
+
block: true,
|
|
11452
|
+
reason:
|
|
11453
|
+
`Read policy could not be evaluated (${error instanceof Error ? error.message : String(error)}), so the read was blocked. ` +
|
|
11454
|
+
"Do not retry; ask the user to fix /read-policy or /folder-policy.",
|
|
11455
|
+
};
|
|
11456
|
+
}
|
|
11457
|
+
}
|
|
11458
|
+
|
|
9936
11459
|
// Block edits/writes + bash gating
|
|
9937
11460
|
pi.on("tool_call", async (event, ctx) => {
|
|
9938
11461
|
const cwd = (ctx as ExtensionContext).cwd;
|
|
@@ -10066,15 +11589,19 @@ export default function workflowExtension(
|
|
|
10066
11589
|
reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
|
|
10067
11590
|
} as any;
|
|
10068
11591
|
}
|
|
10069
|
-
// ── Write permission gate (workflow.writePolicy
|
|
10070
|
-
//
|
|
10071
|
-
//
|
|
10072
|
-
//
|
|
10073
|
-
|
|
10074
|
-
if (workflowMode === null) {
|
|
11592
|
+
// ── Write permission gate (workflow.writePolicy + shared folder floor) ──
|
|
11593
|
+
// The write MODE is a Default-mode knob; the folder floor applies in every
|
|
11594
|
+
// mode. It runs after the role/commit rules and before the build-todo
|
|
11595
|
+
// guard so a folder/sensitive refusal wins.
|
|
11596
|
+
{
|
|
10075
11597
|
const writeBlock = await writePolicyGate(event, ctx, cwd);
|
|
10076
11598
|
if (writeBlock) return writeBlock as any;
|
|
10077
11599
|
}
|
|
11600
|
+
// ── Read permission gate (workflow.readPolicy + shared folder floor) ──
|
|
11601
|
+
{
|
|
11602
|
+
const readBlock = await readPolicyGate(event, ctx, cwd);
|
|
11603
|
+
if (readBlock) return readBlock as any;
|
|
11604
|
+
}
|
|
10078
11605
|
// In build mode, ensure todos exist before first real edit
|
|
10079
11606
|
if (
|
|
10080
11607
|
(event.toolName === "edit" || event.toolName === "write") &&
|
|
@@ -10136,7 +11663,73 @@ export default function workflowExtension(
|
|
|
10136
11663
|
seedCommittedStepRefs();
|
|
10137
11664
|
const completed = uncommittedDoneSteps();
|
|
10138
11665
|
const dirty = await isDirty(pi as any, cwd);
|
|
10139
|
-
const
|
|
11666
|
+
const trusted = ctx?.isProjectTrusted?.() === true;
|
|
11667
|
+
const commitConfig = readCommitPolicyConfig(cwd, getAgentDir(), {
|
|
11668
|
+
trusted,
|
|
11669
|
+
}).config;
|
|
11670
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11671
|
+
trusted,
|
|
11672
|
+
}).config;
|
|
11673
|
+
const repoRoot = detectRepoRoot(cwd) ?? cwd;
|
|
11674
|
+
const folder = evaluateFolderTargets(
|
|
11675
|
+
[{ path: repoRoot, kind: "dir" }],
|
|
11676
|
+
{
|
|
11677
|
+
config: folderConfig,
|
|
11678
|
+
grants: commitConfig.allowedPaths,
|
|
11679
|
+
cwd,
|
|
11680
|
+
action: "commit",
|
|
11681
|
+
},
|
|
11682
|
+
);
|
|
11683
|
+
if (folder.outside.length > 0) {
|
|
11684
|
+
ctx?.ui?.notify?.(
|
|
11685
|
+
`Per-step commit skipped: ${repoRoot} is outside the permitted folders (use /folder-policy or grant it first).`,
|
|
11686
|
+
"warning",
|
|
11687
|
+
);
|
|
11688
|
+
return;
|
|
11689
|
+
}
|
|
11690
|
+
const status = runGitZ(repoRoot, [
|
|
11691
|
+
"status",
|
|
11692
|
+
"--porcelain=v1",
|
|
11693
|
+
"-z",
|
|
11694
|
+
"--untracked-files=all",
|
|
11695
|
+
]);
|
|
11696
|
+
if (!status.ok) {
|
|
11697
|
+
ctx?.ui?.notify?.(
|
|
11698
|
+
`Per-step commit skipped: sensitive-file check could not run (${status.error ?? "git status failed"}).`,
|
|
11699
|
+
"warning",
|
|
11700
|
+
);
|
|
11701
|
+
return;
|
|
11702
|
+
}
|
|
11703
|
+
const sensitive = status.paths
|
|
11704
|
+
.map((p) => matchSensitivePath(p, folderConfig, repoRoot))
|
|
11705
|
+
.find((hit) => hit.sensitive);
|
|
11706
|
+
if (sensitive) {
|
|
11707
|
+
ctx?.ui?.notify?.(
|
|
11708
|
+
`Per-step commit skipped: ${sensitive.path} is sensitive. Remove it from the working tree/index or add a manual sensitiveExemptions entry.`,
|
|
11709
|
+
"warning",
|
|
11710
|
+
);
|
|
11711
|
+
return;
|
|
11712
|
+
}
|
|
11713
|
+
const statusFolder = evaluateFolderTargets(
|
|
11714
|
+
status.paths.map((p) => ({
|
|
11715
|
+
path: path.join(repoRoot, p),
|
|
11716
|
+
kind: "file" as const,
|
|
11717
|
+
})),
|
|
11718
|
+
{
|
|
11719
|
+
config: folderConfig,
|
|
11720
|
+
grants: commitConfig.allowedPaths,
|
|
11721
|
+
cwd,
|
|
11722
|
+
action: "commit",
|
|
11723
|
+
},
|
|
11724
|
+
);
|
|
11725
|
+
if (statusFolder.outside.length > 0 || statusFolder.uncertain.length > 0) {
|
|
11726
|
+
ctx?.ui?.notify?.(
|
|
11727
|
+
`Per-step commit skipped: staged paths leave the permitted folders or could not be verified. Review the working tree and commit manually.`,
|
|
11728
|
+
"warning",
|
|
11729
|
+
);
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
const mode = commitConfig.mode;
|
|
10140
11733
|
const decision = stepCommitDecision({
|
|
10141
11734
|
mode,
|
|
10142
11735
|
hasTodos: todoItems.length > 0,
|
|
@@ -10193,6 +11786,44 @@ export default function workflowExtension(
|
|
|
10193
11786
|
}
|
|
10194
11787
|
}
|
|
10195
11788
|
|
|
11789
|
+
// Output redaction is defence in depth for channels the tool gate cannot
|
|
11790
|
+
// fully see (grep --hidden, git diff/show, truncated bash output).
|
|
11791
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
11792
|
+
try {
|
|
11793
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
11794
|
+
const folderConfig = readFolderPolicyConfig(cwd, getAgentDir(), {
|
|
11795
|
+
trusted: (ctx as any)?.isProjectTrusted?.() === true,
|
|
11796
|
+
}).config;
|
|
11797
|
+
return redactToolResult(event as any, folderConfig, cwd) as any;
|
|
11798
|
+
} catch (error: unknown) {
|
|
11799
|
+
const tool = String((event as any)?.toolName ?? "");
|
|
11800
|
+
if (
|
|
11801
|
+
[
|
|
11802
|
+
"read",
|
|
11803
|
+
"read_symbol",
|
|
11804
|
+
"read_enclosing",
|
|
11805
|
+
"module_report",
|
|
11806
|
+
"grep",
|
|
11807
|
+
"find",
|
|
11808
|
+
"ls",
|
|
11809
|
+
"bash",
|
|
11810
|
+
"powershell",
|
|
11811
|
+
].includes(tool)
|
|
11812
|
+
) {
|
|
11813
|
+
return {
|
|
11814
|
+
content: [
|
|
11815
|
+
{
|
|
11816
|
+
type: "text",
|
|
11817
|
+
text: `[read policy redaction failed — output withheld: ${error instanceof Error ? error.message : String(error)}]`,
|
|
11818
|
+
},
|
|
11819
|
+
],
|
|
11820
|
+
structuredContent: undefined,
|
|
11821
|
+
} as any;
|
|
11822
|
+
}
|
|
11823
|
+
return undefined;
|
|
11824
|
+
}
|
|
11825
|
+
});
|
|
11826
|
+
|
|
10196
11827
|
pi.on("tool_result", async (_event, ctx) => {
|
|
10197
11828
|
try {
|
|
10198
11829
|
const leaf =
|
|
@@ -10245,6 +11876,10 @@ export default function workflowExtension(
|
|
|
10245
11876
|
writeGateBatchCallIds = callIds;
|
|
10246
11877
|
writeGateTurnAnswer = null;
|
|
10247
11878
|
writeGateTurnSteered = false;
|
|
11879
|
+
readGateBatchCalls = calls;
|
|
11880
|
+
readGateBatchCallIds = callIds;
|
|
11881
|
+
readGateTurnAnswer = null;
|
|
11882
|
+
readGateTurnSteered = false;
|
|
10248
11883
|
} catch (_e) {
|
|
10249
11884
|
void _e;
|
|
10250
11885
|
}
|
|
@@ -10258,6 +11893,12 @@ export default function workflowExtension(
|
|
|
10258
11893
|
writeGateTurnSteered = false;
|
|
10259
11894
|
writeGateBatchCalls = [];
|
|
10260
11895
|
writeGateBatchCallIds = new Set();
|
|
11896
|
+
readGateTurnAnswer = null;
|
|
11897
|
+
readGateTurnSteered = false;
|
|
11898
|
+
readGateBatchCalls = [];
|
|
11899
|
+
readGateBatchCallIds = new Set();
|
|
11900
|
+
readGateTurnGrantedRoots.clear();
|
|
11901
|
+
readGateTurnUncertainAllowed.clear();
|
|
10261
11902
|
// Seed the per-step commit ledger once per session: steps already done
|
|
10262
11903
|
// (restored sessions, re-executed plans) never trigger a spurious commit.
|
|
10263
11904
|
try {
|
|
@@ -10308,6 +11949,13 @@ export default function workflowExtension(
|
|
|
10308
11949
|
writeGateTurnSteered = false;
|
|
10309
11950
|
writeGateBatchCalls = [];
|
|
10310
11951
|
writeGateBatchCallIds = new Set();
|
|
11952
|
+
readGateRunDenials.clear();
|
|
11953
|
+
readGateTurnAnswer = null;
|
|
11954
|
+
readGateTurnSteered = false;
|
|
11955
|
+
readGateBatchCalls = [];
|
|
11956
|
+
readGateBatchCallIds = new Set();
|
|
11957
|
+
readGateTurnGrantedRoots.clear();
|
|
11958
|
+
readGateTurnUncertainAllowed.clear();
|
|
10311
11959
|
// keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
|
|
10312
11960
|
});
|
|
10313
11961
|
|
|
@@ -10356,12 +12004,47 @@ export default function workflowExtension(
|
|
|
10356
12004
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
10357
12005
|
currentSessionCtx = ctx as any;
|
|
10358
12006
|
ensurePlannotatorListeners();
|
|
12007
|
+
// Safety valve for a prompt that bypassed `input` (host queue, another
|
|
12008
|
+
// extension): never let a Review turn run concurrently with a plan/build
|
|
12009
|
+
// turn. Stop the reviewer first, then let the queued prompt proceed.
|
|
12010
|
+
if (reviewTurnOwns(reviewTurn) && reviewTurn !== "transition") {
|
|
12011
|
+
// Invalidate the parked handoff BEFORE settling its gate: otherwise the
|
|
12012
|
+
// runHandoff continuation can observe the old cycle token and render the
|
|
12013
|
+
// abandoned plan alongside the new prompt.
|
|
12014
|
+
reviewCycleId += 1;
|
|
12015
|
+
try {
|
|
12016
|
+
await suspendReview();
|
|
12017
|
+
} catch (_e) {
|
|
12018
|
+
void _e;
|
|
12019
|
+
}
|
|
12020
|
+
endReviewTurn();
|
|
12021
|
+
notifyReview(
|
|
12022
|
+
"Review stopped — a prompt was already queued; the plan was not reviewed.",
|
|
12023
|
+
"info",
|
|
12024
|
+
);
|
|
12025
|
+
}
|
|
10359
12026
|
// Fresh user prompt → reset the passive reminder cadence. Continuation runs
|
|
10360
12027
|
// (triggerTurn/steer/followUp) do not fire this event, so state survives them.
|
|
10361
12028
|
turnsSinceLastTodoWrite = 0;
|
|
10362
12029
|
turnsSinceLastReminder = 0;
|
|
10363
12030
|
mutatedSinceLastTodoWrite = false;
|
|
10364
12031
|
footerInjectedThisTurn = false;
|
|
12032
|
+
// A real user prompt means the user has moved on from a finished plan:
|
|
12033
|
+
// hide the one-line todo panel. `custom` runs (triggerTurn) must not do
|
|
12034
|
+
// this — they can be self-initiated while the user is still reading.
|
|
12035
|
+
if (
|
|
12036
|
+
event.messageType === "user" &&
|
|
12037
|
+
isTodoListSettled(todoItems) &&
|
|
12038
|
+
!todoPanelPromptAfterSettle
|
|
12039
|
+
) {
|
|
12040
|
+
todoPanelPromptAfterSettle = true;
|
|
12041
|
+
updateStatus(ctx);
|
|
12042
|
+
try {
|
|
12043
|
+
persistState();
|
|
12044
|
+
} catch (_e) {
|
|
12045
|
+
void _e;
|
|
12046
|
+
}
|
|
12047
|
+
}
|
|
10365
12048
|
// A fresh user prompt starts a new handoff/provenance run. It also
|
|
10366
12049
|
// supersedes any pending browser/inline decision immediately: the user
|
|
10367
12050
|
// moved on, so a stale tab must not approve the previous plan. Follow-up
|
|
@@ -10386,10 +12069,14 @@ export default function workflowExtension(
|
|
|
10386
12069
|
}
|
|
10387
12070
|
// Every fresh prompt is a new review cycle: reset the round budget, cancel
|
|
10388
12071
|
// an abandoned pause/gate, and tag the cycle so an old handoff cannot
|
|
10389
|
-
// render after this prompt.
|
|
12072
|
+
// render after this prompt. A feedback/refine continuation keeps the
|
|
12073
|
+
// manual cycle alive; a genuine user prompt ends it.
|
|
12074
|
+
const feedbackTurn = reviewFeedbackTurn;
|
|
12075
|
+
reviewFeedbackTurn = false;
|
|
10390
12076
|
try {
|
|
12077
|
+
if (!feedbackTurn) endReviewTurn();
|
|
10391
12078
|
await beginReviewCycle({
|
|
10392
|
-
reason: "prompt",
|
|
12079
|
+
reason: feedbackTurn ? "refine" : "prompt",
|
|
10393
12080
|
keepFindings: true,
|
|
10394
12081
|
planPath: currentPlanFile,
|
|
10395
12082
|
});
|
|
@@ -11002,26 +12689,36 @@ export default function workflowExtension(
|
|
|
11002
12689
|
}
|
|
11003
12690
|
// Build completion
|
|
11004
12691
|
if (workflowMode === "build" && todoItems.length > 0) {
|
|
11005
|
-
const allDone = todoItems
|
|
11006
|
-
(t) => isDone(t) || itemStatus(t) === "cancelled",
|
|
11007
|
-
);
|
|
12692
|
+
const allDone = isTodoListSettled(todoItems);
|
|
11008
12693
|
if (allDone) {
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
|
|
11018
|
-
|
|
11019
|
-
|
|
12694
|
+
// One card per list revision: a settled list stays settled across
|
|
12695
|
+
// turns, and re-sending the same card on every agent_end buried the
|
|
12696
|
+
// transcript in duplicates. A new/renamed list or changed statuses
|
|
12697
|
+
// produce a new signature and therefore a new card.
|
|
12698
|
+
const signature = todoCompletionSignature(todoItems);
|
|
12699
|
+
if (signature !== lastCompletedSignature) {
|
|
12700
|
+
lastCompletedSignature = signature;
|
|
12701
|
+
try {
|
|
12702
|
+
(pi as any).sendMessage?.(
|
|
12703
|
+
{
|
|
12704
|
+
customType: "workflow-complete",
|
|
12705
|
+
content: `**Build Complete! ✓**\n\n${todoItems.map((t) => `~~${todoRef(t)}. ${t.text}~~`).join("\n")}`,
|
|
12706
|
+
display: true,
|
|
12707
|
+
},
|
|
12708
|
+
{ triggerTurn: false },
|
|
12709
|
+
);
|
|
12710
|
+
} catch (_e) {
|
|
12711
|
+
void _e;
|
|
12712
|
+
}
|
|
11020
12713
|
}
|
|
11021
12714
|
// keep todos for history but stay in build
|
|
11022
12715
|
todoStale = false;
|
|
11023
12716
|
updateStatus(ctx as any);
|
|
11024
12717
|
persistState();
|
|
12718
|
+
} else if (lastCompletedSignature !== null) {
|
|
12719
|
+
// The list reopened (new step or a reverted status): allow the next
|
|
12720
|
+
// completion to emit its own card. Persisted on the next mutation.
|
|
12721
|
+
lastCompletedSignature = null;
|
|
11025
12722
|
}
|
|
11026
12723
|
return;
|
|
11027
12724
|
}
|
|
@@ -11238,6 +12935,9 @@ export default function workflowExtension(
|
|
|
11238
12935
|
reviewHandoffGate.reset();
|
|
11239
12936
|
reviewGatePhase = "auditing";
|
|
11240
12937
|
reviewGateCycleId = handoffCycle;
|
|
12938
|
+
// Review owns the turn before the reviewer boots. A plan handoff with
|
|
12939
|
+
// Review Mode off never reaches this block (no Review turn).
|
|
12940
|
+
beginReviewTurn();
|
|
11241
12941
|
try {
|
|
11242
12942
|
const reviewed = await runReviewStep(
|
|
11243
12943
|
authorPlan,
|