@dieulc/workflow 0.8.0 → 0.9.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 +10 -9
- package/index.ts +580 -224
- package/package.json +2 -2
- package/review-runtime.ts +7 -2
- package/utils.ts +77 -5
package/index.ts
CHANGED
|
@@ -57,6 +57,8 @@ import {
|
|
|
57
57
|
todoKey,
|
|
58
58
|
isPlanWritePath,
|
|
59
59
|
findPlanWritePathInMessages,
|
|
60
|
+
findLatestPlanWriteInBranch,
|
|
61
|
+
choosePlanPath,
|
|
60
62
|
shouldHandoffPlan,
|
|
61
63
|
getUtcDatePrefix,
|
|
62
64
|
normalizePlanPath,
|
|
@@ -82,6 +84,7 @@ import {
|
|
|
82
84
|
type TodoStatus,
|
|
83
85
|
type TodoItem,
|
|
84
86
|
type TodoUpdateEntry,
|
|
87
|
+
type PlanPathSource,
|
|
85
88
|
} from "./utils.ts";
|
|
86
89
|
import {
|
|
87
90
|
WORKFLOW_ROLE_ENV,
|
|
@@ -164,12 +167,21 @@ import {
|
|
|
164
167
|
roleIcon,
|
|
165
168
|
BUILT_IN_ROLES,
|
|
166
169
|
REVIEW_ROLE_NAME,
|
|
167
|
-
VALID_THINKING_LEVELS,
|
|
168
170
|
type RoleConfig,
|
|
169
171
|
type Role,
|
|
170
172
|
type RoleModel,
|
|
171
173
|
type ThinkingLevel,
|
|
172
174
|
} from "./roles.ts";
|
|
175
|
+
import {
|
|
176
|
+
clampThinkingForModel,
|
|
177
|
+
thinkingLevelsForModel,
|
|
178
|
+
thinkingLevelsForPair,
|
|
179
|
+
} from "./thinking-levels.ts";
|
|
180
|
+
import {
|
|
181
|
+
buildWorkflowStatusSegments,
|
|
182
|
+
setWorkflowStatus,
|
|
183
|
+
WORKFLOW_STATUS_KEYS,
|
|
184
|
+
} from "./workflow-status.ts";
|
|
173
185
|
import {
|
|
174
186
|
defaultProfileConfig,
|
|
175
187
|
loadProfilesDetailed,
|
|
@@ -247,6 +259,7 @@ import {
|
|
|
247
259
|
type ReviewFinding,
|
|
248
260
|
type ReviewExecutionResult,
|
|
249
261
|
type ReviewHandoffGate,
|
|
262
|
+
type ReviewSdkLoader,
|
|
250
263
|
type ReviewHandoffPhase,
|
|
251
264
|
type ReviewHandoffResult,
|
|
252
265
|
type ReviewModeLockKind,
|
|
@@ -475,7 +488,19 @@ function getFinalOutput(messages: any[]): string {
|
|
|
475
488
|
|
|
476
489
|
// ── Main Extension ───────────────────────────────────────────────────
|
|
477
490
|
|
|
478
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Optional seams for tests. Production callers (Pi) pass only `pi`, so the
|
|
493
|
+
* default remains byte-for-byte the shipped extension; tests inject a fake
|
|
494
|
+
* reviewer SDK to exercise `/review` and the handoff without a provider.
|
|
495
|
+
*/
|
|
496
|
+
export interface WorkflowExtensionDeps {
|
|
497
|
+
loadSdk?: ReviewSdkLoader;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export default function workflowExtension(
|
|
501
|
+
pi: ExtensionAPI,
|
|
502
|
+
deps: WorkflowExtensionDeps = {},
|
|
503
|
+
) {
|
|
479
504
|
// Review Mode streams the reviewer's output into the main transcript as
|
|
480
505
|
// display-only entries. Registering once here keeps /reload idempotent.
|
|
481
506
|
registerReviewEntryRenderers(pi);
|
|
@@ -557,6 +582,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
557
582
|
| { provider: string; id: string; thinking?: string }
|
|
558
583
|
| undefined;
|
|
559
584
|
let currentPlanFile: string | undefined;
|
|
585
|
+
/** Last plan path written into a persisted `workflow` entry. */
|
|
586
|
+
let lastPersistedPlanFile: string | undefined;
|
|
560
587
|
let awaitingDecision = false;
|
|
561
588
|
let lastHandoffAt: number | undefined;
|
|
562
589
|
let handoffInFlight: Promise<void> | null = null;
|
|
@@ -1026,9 +1053,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1026
1053
|
if (found) {
|
|
1027
1054
|
await withPersistSuppressed(async () => {
|
|
1028
1055
|
await (pi as any).setModel?.(found);
|
|
1029
|
-
|
|
1030
|
-
(pi as any).setThinkingLevel?.(m.thinking);
|
|
1031
|
-
}
|
|
1056
|
+
applyThinkingForModel(ctx, found, m.thinking, "Default mode");
|
|
1032
1057
|
});
|
|
1033
1058
|
if (!opts?.quiet) {
|
|
1034
1059
|
ctx.ui.notify(
|
|
@@ -1205,8 +1230,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1205
1230
|
// Profile prefix (⚡ name) when one is active, then the role label.
|
|
1206
1231
|
const profileLabel = activeProfileLabel();
|
|
1207
1232
|
const roleLabel = activeRoleLabel();
|
|
1208
|
-
const statusParts = [profileLabel, roleLabel].filter(Boolean);
|
|
1209
|
-
const suffix = statusParts.length ? ` | ${statusParts.join(" | ")}` : "";
|
|
1210
1233
|
// Commit-policy surfacing (workflow.commitPolicy): always visible so the
|
|
1211
1234
|
// user can see at a glance what the agent may do to git history. Read
|
|
1212
1235
|
// live so /commit-policy changes show up without a reload.
|
|
@@ -1219,7 +1242,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1219
1242
|
} catch {
|
|
1220
1243
|
commitMode = "never";
|
|
1221
1244
|
}
|
|
1222
|
-
const policy = ` · commits:${commitMode}`;
|
|
1223
1245
|
// Write-policy surfacing (workflow.writePolicy): the active file-write
|
|
1224
1246
|
// permission, visible at a glance like the commit policy. Read live so a
|
|
1225
1247
|
// /write-policy change shows up without a reload. It only applies in
|
|
@@ -1233,67 +1255,38 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1233
1255
|
} catch {
|
|
1234
1256
|
writeMode = "allow";
|
|
1235
1257
|
}
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
if (workflowMode === "build") {
|
|
1269
|
-
const label =
|
|
1270
|
-
total > 0
|
|
1271
|
-
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
|
|
1272
|
-
: `▶ build${suffix}${policy}${writePolicy}${reviewSegment}`;
|
|
1273
|
-
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
|
|
1274
|
-
} else if (workflowMode === "plan") {
|
|
1275
|
-
const base = plannotatorActive
|
|
1276
|
-
? "⏸ plan — reviewing in browser"
|
|
1277
|
-
: "⏸ plan";
|
|
1278
|
-
const label =
|
|
1279
|
-
total > 0
|
|
1280
|
-
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`
|
|
1281
|
-
: `${base}${suffix}${policy}${writePolicy}${reviewSegment}`;
|
|
1282
|
-
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
|
|
1283
|
-
} else if (total > 0) {
|
|
1284
|
-
ctx.ui.setStatus(
|
|
1285
|
-
"workflow",
|
|
1286
|
-
ctx.ui.theme.fg(
|
|
1287
|
-
"muted",
|
|
1288
|
-
`◦ ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}${reviewSegment}`,
|
|
1289
|
-
),
|
|
1290
|
-
);
|
|
1291
|
-
} else {
|
|
1292
|
-
ctx.ui.setStatus(
|
|
1293
|
-
"workflow",
|
|
1294
|
-
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}${writePolicy}${reviewSegment}`),
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1258
|
+
const segments = buildWorkflowStatusSegments({
|
|
1259
|
+
mode: workflowMode,
|
|
1260
|
+
plannotatorActive,
|
|
1261
|
+
done: todoItems.filter((t) => isDone(t)).length,
|
|
1262
|
+
total: todoItems.length,
|
|
1263
|
+
stale: todoStale,
|
|
1264
|
+
profileLabel,
|
|
1265
|
+
roleLabel,
|
|
1266
|
+
commitMode,
|
|
1267
|
+
writeMode: workflowMode === null ? writeMode : null,
|
|
1268
|
+
});
|
|
1269
|
+
const modeTone =
|
|
1270
|
+
workflowMode === "build"
|
|
1271
|
+
? "accent"
|
|
1272
|
+
: workflowMode === "plan"
|
|
1273
|
+
? "warning"
|
|
1274
|
+
: "muted";
|
|
1275
|
+
setWorkflowStatus(
|
|
1276
|
+
ctx.ui,
|
|
1277
|
+
WORKFLOW_STATUS_KEYS.mode,
|
|
1278
|
+
ctx.ui.theme.fg(modeTone, segments.mode),
|
|
1279
|
+
);
|
|
1280
|
+
setWorkflowStatus(
|
|
1281
|
+
ctx.ui,
|
|
1282
|
+
WORKFLOW_STATUS_KEYS.profile,
|
|
1283
|
+
segments.profile ? ctx.ui.theme.fg("muted", segments.profile) : "",
|
|
1284
|
+
);
|
|
1285
|
+
setWorkflowStatus(
|
|
1286
|
+
ctx.ui,
|
|
1287
|
+
WORKFLOW_STATUS_KEYS.policy,
|
|
1288
|
+
ctx.ui.theme.fg("dim", segments.policy),
|
|
1289
|
+
);
|
|
1297
1290
|
if (todoItems.length > 0) {
|
|
1298
1291
|
ctx.ui.setWidget("workflow-todos", (_tui, theme) => {
|
|
1299
1292
|
return {
|
|
@@ -1601,8 +1594,6 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1601
1594
|
reviewTruncationNoted = false;
|
|
1602
1595
|
}
|
|
1603
1596
|
|
|
1604
|
-
const REVIEW_STATUS_WIDGET = "workflow-review-status";
|
|
1605
|
-
|
|
1606
1597
|
function reviewAgentDir(): string {
|
|
1607
1598
|
try {
|
|
1608
1599
|
return getAgentDir();
|
|
@@ -1635,6 +1626,121 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1635
1626
|
}
|
|
1636
1627
|
}
|
|
1637
1628
|
|
|
1629
|
+
interface ReviewPlanTarget {
|
|
1630
|
+
abs: string;
|
|
1631
|
+
text: string;
|
|
1632
|
+
source: PlanPathSource;
|
|
1633
|
+
display: string;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
type ReviewPlanResolution =
|
|
1637
|
+
| { kind: "ok"; target: ReviewPlanTarget }
|
|
1638
|
+
| { kind: "no-plan"; recent: string[] }
|
|
1639
|
+
| { kind: "error"; message: string };
|
|
1640
|
+
|
|
1641
|
+
/** Newest `.pi/plans/*.md` files, relative to cwd, newest first. */
|
|
1642
|
+
function listRecentPlanFiles(cwd: string, limit = 3): string[] {
|
|
1643
|
+
try {
|
|
1644
|
+
const dir = path.join(cwd, CONFIG_DIR_NAME, "plans");
|
|
1645
|
+
return fs
|
|
1646
|
+
.readdirSync(dir)
|
|
1647
|
+
.filter((f) => f.toLowerCase().endsWith(".md"))
|
|
1648
|
+
.map((f) => {
|
|
1649
|
+
const abs = path.join(dir, f);
|
|
1650
|
+
return { abs, mtime: fs.statSync(abs).mtimeMs };
|
|
1651
|
+
})
|
|
1652
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
1653
|
+
.slice(0, limit)
|
|
1654
|
+
.map((r) => path.relative(cwd, r.abs) || r.abs);
|
|
1655
|
+
} catch (_e) {
|
|
1656
|
+
void _e;
|
|
1657
|
+
return [];
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Resolve the plan a manual `/review` should audit.
|
|
1663
|
+
*
|
|
1664
|
+
* Priority (plan 2026-10-31): explicit path → this run's write → the
|
|
1665
|
+
* session's last write → the handoff target → the newest write in the
|
|
1666
|
+
* session branch. A candidate that no longer exists or is empty is skipped.
|
|
1667
|
+
* An explicit path is authoritative: if it is unreadable or outside
|
|
1668
|
+
* `.pi/plans/`, the command reports that instead of silently falling back
|
|
1669
|
+
* to a different file.
|
|
1670
|
+
*/
|
|
1671
|
+
function resolveReviewTarget(
|
|
1672
|
+
ctx: any,
|
|
1673
|
+
explicitArg: string | undefined,
|
|
1674
|
+
): ReviewPlanResolution {
|
|
1675
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
1676
|
+
const display = (abs: string) => path.relative(cwd, abs) || abs;
|
|
1677
|
+
const candidates: Array<{ path: string; source: PlanPathSource }> = [];
|
|
1678
|
+
const push = (raw: string | null | undefined, source: PlanPathSource) => {
|
|
1679
|
+
if (typeof raw !== "string" || raw.length === 0) return;
|
|
1680
|
+
candidates.push({
|
|
1681
|
+
path: path.isAbsolute(raw) ? raw : path.join(cwd, raw),
|
|
1682
|
+
source,
|
|
1683
|
+
});
|
|
1684
|
+
};
|
|
1685
|
+
if (explicitArg) {
|
|
1686
|
+
const abs = path.isAbsolute(explicitArg)
|
|
1687
|
+
? explicitArg
|
|
1688
|
+
: path.join(cwd, explicitArg);
|
|
1689
|
+
if (!isPlanWritePath(abs, cwd)) {
|
|
1690
|
+
return {
|
|
1691
|
+
kind: "error",
|
|
1692
|
+
message: `Review only reads plans under ${path.join(CONFIG_DIR_NAME, "plans")}${path.sep} — got ${explicitArg}`,
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
push(abs, "explicit");
|
|
1696
|
+
} else {
|
|
1697
|
+
push(planWritePathThisRun, "run");
|
|
1698
|
+
push(lastPlanWritePath, "session");
|
|
1699
|
+
push(currentPlanFile, "handoff");
|
|
1700
|
+
try {
|
|
1701
|
+
const branch = (ctx as any)?.sessionManager?.getBranch?.() ?? [];
|
|
1702
|
+
push(findLatestPlanWriteInBranch(branch, cwd), "branch");
|
|
1703
|
+
} catch (_e) {
|
|
1704
|
+
void _e;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
const read = (abs: string): string | undefined => {
|
|
1708
|
+
try {
|
|
1709
|
+
if (!fs.statSync(abs).isFile()) return undefined;
|
|
1710
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
1711
|
+
return text.length > 0 ? text : undefined;
|
|
1712
|
+
} catch {
|
|
1713
|
+
return undefined;
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
const chosen = choosePlanPath(candidates, (abs) => read(abs) !== undefined);
|
|
1717
|
+
if (!chosen) {
|
|
1718
|
+
if (explicitArg) {
|
|
1719
|
+
return {
|
|
1720
|
+
kind: "error",
|
|
1721
|
+
message: `Plan file not readable: ${explicitArg}`,
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
return { kind: "no-plan", recent: listRecentPlanFiles(cwd) };
|
|
1725
|
+
}
|
|
1726
|
+
const text = read(chosen.path);
|
|
1727
|
+
if (text === undefined) {
|
|
1728
|
+
return {
|
|
1729
|
+
kind: "error",
|
|
1730
|
+
message: `Plan file not readable: ${display(chosen.path)}`,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
return {
|
|
1734
|
+
kind: "ok",
|
|
1735
|
+
target: {
|
|
1736
|
+
abs: chosen.path,
|
|
1737
|
+
text,
|
|
1738
|
+
source: chosen.source,
|
|
1739
|
+
display: display(chosen.path),
|
|
1740
|
+
},
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1638
1744
|
function reviewConfigNow() {
|
|
1639
1745
|
try {
|
|
1640
1746
|
return readReviewModeConfig(reviewCwd(), reviewAgentDir());
|
|
@@ -1711,26 +1817,23 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1711
1817
|
roundActive,
|
|
1712
1818
|
paused,
|
|
1713
1819
|
});
|
|
1714
|
-
// The
|
|
1820
|
+
// The review status is a LIVE indicator, not a result holder: the last
|
|
1715
1821
|
// round's state stays for `/review-status` and the REVIEW SUMMARY band,
|
|
1716
|
-
// but the
|
|
1822
|
+
// but the footer segment must disappear as soon as the round ends. While
|
|
1717
1823
|
// paused it shows the paused form (never the terminal "aborted" summary).
|
|
1718
|
-
const
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1824
|
+
const reviewText = visible ? reviewFooterSegment(reviewState, paused) : "";
|
|
1825
|
+
let lockText = "";
|
|
1826
|
+
try {
|
|
1827
|
+
lockText = reviewModeLockFooter(reviewModeLockKindNow());
|
|
1828
|
+
} catch (_e) {
|
|
1829
|
+
void _e;
|
|
1722
1830
|
}
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
),
|
|
1730
|
-
],
|
|
1731
|
-
invalidate: () => {},
|
|
1732
|
-
dispose: () => {},
|
|
1733
|
-
}));
|
|
1831
|
+
const text = [reviewText, lockText].filter(Boolean).join(" · ");
|
|
1832
|
+
setWorkflowStatus(
|
|
1833
|
+
ctx.ui,
|
|
1834
|
+
WORKFLOW_STATUS_KEYS.review,
|
|
1835
|
+
text ? ctx.ui.theme.fg("muted", text) : "",
|
|
1836
|
+
);
|
|
1734
1837
|
} catch (_e) {
|
|
1735
1838
|
void _e;
|
|
1736
1839
|
}
|
|
@@ -1873,6 +1976,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1873
1976
|
}
|
|
1874
1977
|
},
|
|
1875
1978
|
onTranscriptOps: (ops) => pushReviewOps(ops),
|
|
1979
|
+
...(deps.loadSdk ? { loadSdk: deps.loadSdk } : {}),
|
|
1876
1980
|
});
|
|
1877
1981
|
return reviewRuntime;
|
|
1878
1982
|
}
|
|
@@ -2496,7 +2600,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2496
2600
|
resetReviewStream();
|
|
2497
2601
|
detachReviewEscListenerNow();
|
|
2498
2602
|
try {
|
|
2499
|
-
currentSessionCtx?.ui.
|
|
2603
|
+
currentSessionCtx?.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
2500
2604
|
} catch (_e) {
|
|
2501
2605
|
void _e;
|
|
2502
2606
|
}
|
|
@@ -2932,14 +3036,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2932
3036
|
|
|
2933
3037
|
pi.registerCommand("review", {
|
|
2934
3038
|
description:
|
|
2935
|
-
"Run a one-shot Review round now
|
|
3039
|
+
"Run a one-shot Review round now: /review [path] works whether Review Mode is on or off (mode stays OFF); /review say <msg> talks to the in-flight reviewer",
|
|
3040
|
+
getArgumentCompletions: (prefix: string) => {
|
|
3041
|
+
const cwd = (currentSessionCtx as any)?.cwd ?? process.cwd();
|
|
3042
|
+
const items = [
|
|
3043
|
+
{
|
|
3044
|
+
value: "say",
|
|
3045
|
+
label: "say",
|
|
3046
|
+
description: "Send a message to the in-flight reviewer",
|
|
3047
|
+
},
|
|
3048
|
+
...listRecentPlanFiles(cwd, 8).map((p) => ({
|
|
3049
|
+
value: p,
|
|
3050
|
+
label: p,
|
|
3051
|
+
description: "Review this plan",
|
|
3052
|
+
})),
|
|
3053
|
+
];
|
|
3054
|
+
const p = prefix.trim();
|
|
3055
|
+
const filtered = items.filter(
|
|
3056
|
+
(i) => i.value.startsWith(p) || i.label.startsWith(p),
|
|
3057
|
+
);
|
|
3058
|
+
return filtered.length ? filtered : null;
|
|
3059
|
+
},
|
|
2936
3060
|
handler: async (args, ctx) => {
|
|
2937
3061
|
// Explicit reviewer steering (plan 2026-10-23, step 3): the only path
|
|
2938
3062
|
// that delivers user text into an in-flight review round. Plain chat
|
|
2939
3063
|
// stays with the plan agent (see the `input` handler).
|
|
2940
3064
|
const raw = (args || "").trim();
|
|
2941
|
-
if (/^say
|
|
2942
|
-
const message = raw.replace(/^say
|
|
3065
|
+
if (/^say(?:\s|$)/i.test(raw)) {
|
|
3066
|
+
const message = raw.replace(/^say(?:\s|$)/i, "").trim();
|
|
2943
3067
|
if (!message) {
|
|
2944
3068
|
ctx.ui.notify(
|
|
2945
3069
|
"Usage: /review say <message> — send a message to the in-flight reviewer.",
|
|
@@ -2978,27 +3102,45 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2978
3102
|
// Manual one-shot review: works whether or not Review Mode is on.
|
|
2979
3103
|
// No `enabled` check here — readReviewModeConfig never throws and
|
|
2980
3104
|
// runReviewStep("manual") supplies defaults when unreadable.
|
|
2981
|
-
|
|
2982
|
-
|
|
3105
|
+
//
|
|
3106
|
+
// The plan is resolved independently of handoff eligibility: a run that
|
|
3107
|
+
// started in Default and switched to Plan, or a plan written before a
|
|
3108
|
+
// mode switch, must still be auditable (plan 2026-10-31).
|
|
3109
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3110
|
+
let explicit: string | undefined = raw.length
|
|
3111
|
+
? raw.replace(/^(["'])(.*)\1$/, "$2").trim()
|
|
3112
|
+
: undefined;
|
|
3113
|
+
if (explicit) {
|
|
3114
|
+
const abs = path.isAbsolute(explicit)
|
|
3115
|
+
? explicit
|
|
3116
|
+
: path.join(cwd, explicit);
|
|
3117
|
+
// Mirror the write-path auto-correction: a mistyped UTC date prefix
|
|
3118
|
+
// should resolve to the file the extension actually wrote.
|
|
3119
|
+
const normalized = normalizePlanPath(abs, cwd, getUtcDatePrefix());
|
|
3120
|
+
explicit =
|
|
3121
|
+
normalized.corrected &&
|
|
3122
|
+
fs.existsSync(normalized.path) &&
|
|
3123
|
+
!fs.existsSync(abs)
|
|
3124
|
+
? normalized.path
|
|
3125
|
+
: abs;
|
|
3126
|
+
}
|
|
3127
|
+
const resolution = resolveReviewTarget(ctx, explicit);
|
|
3128
|
+
if (resolution.kind === "no-plan") {
|
|
3129
|
+
const recent = resolution.recent;
|
|
3130
|
+
const hint = recent.length
|
|
3131
|
+
? `\nRecent plans:\n${recent.map((r) => ` /review ${r}`).join("\n")}`
|
|
3132
|
+
: "";
|
|
2983
3133
|
ctx.ui.notify(
|
|
2984
|
-
|
|
3134
|
+
`No plan file found for this session — run /review <path> with a file under .pi/plans/.${hint}`,
|
|
2985
3135
|
"warning",
|
|
2986
3136
|
);
|
|
2987
3137
|
return;
|
|
2988
3138
|
}
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
const abs = path.isAbsolute(planPath)
|
|
2992
|
-
? planPath
|
|
2993
|
-
: path.join((ctx as any).cwd ?? process.cwd(), planPath);
|
|
2994
|
-
text = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : undefined;
|
|
2995
|
-
} catch (_e) {
|
|
2996
|
-
void _e;
|
|
2997
|
-
}
|
|
2998
|
-
if (!text) {
|
|
2999
|
-
ctx.ui.notify(`Plan file not readable: ${planPath}`, "warning");
|
|
3139
|
+
if (resolution.kind === "error") {
|
|
3140
|
+
ctx.ui.notify(resolution.message, "warning");
|
|
3000
3141
|
return;
|
|
3001
3142
|
}
|
|
3143
|
+
const target = resolution.target;
|
|
3002
3144
|
// The gated handoff owns the reviewer while it is open: a fresh round
|
|
3003
3145
|
// here would race the handoff it is about to render. Steer the running
|
|
3004
3146
|
// round with `/review say`, pause with ESC, or stop it with
|
|
@@ -3031,12 +3173,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3031
3173
|
void _e;
|
|
3032
3174
|
}
|
|
3033
3175
|
}
|
|
3176
|
+
// The reviewer writes through `currentPlanFile`; point it at the file
|
|
3177
|
+
// `/review` actually read before the round starts. A fallback source is
|
|
3178
|
+
// announced so the user can see which plan is being audited.
|
|
3179
|
+
if (target.source !== "run" && target.source !== "explicit") {
|
|
3180
|
+
notifyReview(`Reviewing ${target.display}`, "info");
|
|
3181
|
+
}
|
|
3182
|
+
currentPlanFile = target.abs;
|
|
3183
|
+
lastPlanWritePath = target.abs;
|
|
3184
|
+
persistState();
|
|
3034
3185
|
lastReviewedPlanHash = undefined; // force a fresh review
|
|
3035
3186
|
abortedReviewHash = undefined;
|
|
3036
3187
|
pausedReview = undefined;
|
|
3037
3188
|
reviewRound = 0;
|
|
3038
3189
|
const result = await runReviewStep(
|
|
3039
|
-
text,
|
|
3190
|
+
target.text,
|
|
3040
3191
|
ctx as any,
|
|
3041
3192
|
(ctx as any).signal as AbortSignal | undefined,
|
|
3042
3193
|
"manual",
|
|
@@ -3316,9 +3467,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3316
3467
|
) {
|
|
3317
3468
|
await withPersistSuppressed(async () => {
|
|
3318
3469
|
try {
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3470
|
+
applyThinkingForModel(
|
|
3471
|
+
ctx,
|
|
3472
|
+
current,
|
|
3473
|
+
m.thinking,
|
|
3474
|
+
`Workflow ${workflowMode ?? "mode"}`,
|
|
3475
|
+
);
|
|
3322
3476
|
} catch (_e) {
|
|
3323
3477
|
void _e;
|
|
3324
3478
|
}
|
|
@@ -3353,9 +3507,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3353
3507
|
}
|
|
3354
3508
|
await withPersistSuppressed(async () => {
|
|
3355
3509
|
try {
|
|
3356
|
-
|
|
3357
|
-
(pi as any).setThinkingLevel?.(m.thinking);
|
|
3358
|
-
}
|
|
3510
|
+
applyThinkingForModel(ctx, found, m.thinking, `Workflow ${workflowMode ?? "mode"}`);
|
|
3359
3511
|
} catch (_e) {
|
|
3360
3512
|
void _e;
|
|
3361
3513
|
}
|
|
@@ -3417,12 +3569,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3417
3569
|
current.id === defaultModel.id
|
|
3418
3570
|
) {
|
|
3419
3571
|
try {
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3572
|
+
applyThinkingForModel(
|
|
3573
|
+
ctx,
|
|
3574
|
+
current,
|
|
3575
|
+
defaultModel.thinking as ThinkingLevel | undefined,
|
|
3576
|
+
"Default mode",
|
|
3577
|
+
);
|
|
3426
3578
|
} catch (_e) {
|
|
3427
3579
|
void _e;
|
|
3428
3580
|
}
|
|
@@ -3438,12 +3590,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3438
3590
|
if (!found) return; // registry no longer has it — keep current model
|
|
3439
3591
|
try {
|
|
3440
3592
|
await (pi as any).setModel?.(found);
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3593
|
+
applyThinkingForModel(
|
|
3594
|
+
ctx,
|
|
3595
|
+
found,
|
|
3596
|
+
defaultModel.thinking as ThinkingLevel | undefined,
|
|
3597
|
+
"Default mode",
|
|
3598
|
+
);
|
|
3447
3599
|
if (!opts?.quiet) {
|
|
3448
3600
|
ctx.ui.notify(
|
|
3449
3601
|
`Default mode: model → ${defaultModel.provider}/${defaultModel.id}${defaultModel.thinking ? ` (${defaultModel.thinking})` : ""}`,
|
|
@@ -3605,6 +3757,31 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3605
3757
|
}
|
|
3606
3758
|
}
|
|
3607
3759
|
|
|
3760
|
+
/**
|
|
3761
|
+
* Apply a role/default thinking level, clamped to the model Pi will actually
|
|
3762
|
+
* use. The role config is shared with terminal Pi and may predate the model's
|
|
3763
|
+
* current `thinkingLevelMap`; clamping here keeps the live session valid.
|
|
3764
|
+
*
|
|
3765
|
+
* The clamp is **silent**: the host already shows the live thinking level, and
|
|
3766
|
+
* notifying on every mode switch buried the transcript and toasts in the same
|
|
3767
|
+
* advisory (2026-10-31 plan). Explicit `/role set` validation still warns
|
|
3768
|
+
* because that is a direct user edit.
|
|
3769
|
+
*/
|
|
3770
|
+
function applyThinkingForModel(
|
|
3771
|
+
_ctx: ExtensionContext | null,
|
|
3772
|
+
model: any,
|
|
3773
|
+
level: ThinkingLevel | undefined,
|
|
3774
|
+
_label: string,
|
|
3775
|
+
): void {
|
|
3776
|
+
if (!level || !isValidThinkingLevel(level)) return;
|
|
3777
|
+
const clamped = clampThinkingForModel(model, level);
|
|
3778
|
+
try {
|
|
3779
|
+
(pi as any).setThinkingLevel?.(clamped);
|
|
3780
|
+
} catch (_e) {
|
|
3781
|
+
void _e;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3608
3785
|
/**
|
|
3609
3786
|
* Read the persisted default from disk. Pure: it never mutates the in-memory
|
|
3610
3787
|
* `defaultModel`, because precedence is resolved in one place at session
|
|
@@ -3645,6 +3822,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3645
3822
|
defaultModel,
|
|
3646
3823
|
lastModeSwitch,
|
|
3647
3824
|
} as any);
|
|
3825
|
+
lastPersistedPlanFile = currentPlanFile;
|
|
3648
3826
|
}
|
|
3649
3827
|
|
|
3650
3828
|
async function setPlanMode(
|
|
@@ -3666,9 +3844,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3666
3844
|
if (workflowMode === null) captureDefaultModel(ctx);
|
|
3667
3845
|
workflowMode = "plan";
|
|
3668
3846
|
syncLegacyFlags();
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
|
|
3847
|
+
// Do NOT reset per-run provenance here. A fresh prompt already does it at
|
|
3848
|
+
// run start; preserving a plan write made earlier in THIS run is what lets
|
|
3849
|
+
// `/review` (and the "Plan file ready" notice) see it after a mid-run
|
|
3850
|
+
// switch. The mode-at-write flag keeps a Default-written plan from being
|
|
3851
|
+
// auto-handoffed unless the write itself happened under Plan.
|
|
3672
3852
|
clearPendingHandoffDecision();
|
|
3673
3853
|
if (toolsBeforePlanMode === undefined)
|
|
3674
3854
|
toolsBeforePlanMode = pi.getActiveTools();
|
|
@@ -4853,21 +5033,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4853
5033
|
}
|
|
4854
5034
|
thinking = thinkingRaw.toLowerCase() as any;
|
|
4855
5035
|
}
|
|
4856
|
-
//
|
|
5036
|
+
// Validate against the registry. A missing model is warn-only (it may be
|
|
5037
|
+
// added later), but a known model clamps the stored level to its map.
|
|
5038
|
+
let found: any;
|
|
4857
5039
|
try {
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
5040
|
+
found = (ctx as any)?.modelRegistry?.find?.(provider, id);
|
|
5041
|
+
} catch (_e) {
|
|
5042
|
+
void _e;
|
|
5043
|
+
}
|
|
5044
|
+
if (!found) {
|
|
5045
|
+
ctx.ui.notify(
|
|
5046
|
+
`Warning: ${provider}/${id} not in model registry — saved anyway.`,
|
|
5047
|
+
"warning",
|
|
5048
|
+
);
|
|
5049
|
+
} else {
|
|
5050
|
+
const pair = thinkingLevelsForPair(found, thinking);
|
|
5051
|
+
if (pair.level !== thinking) {
|
|
4864
5052
|
ctx.ui.notify(
|
|
4865
|
-
`
|
|
5053
|
+
`Thinking ${thinking} is not supported by ${provider}/${id}; using ${pair.level}.`,
|
|
4866
5054
|
"warning",
|
|
4867
5055
|
);
|
|
4868
5056
|
}
|
|
4869
|
-
|
|
4870
|
-
void _e;
|
|
5057
|
+
thinking = pair.level;
|
|
4871
5058
|
}
|
|
4872
5059
|
role.model = { provider, id, thinking };
|
|
4873
5060
|
const saved = saveAndSync(
|
|
@@ -5736,12 +5923,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5736
5923
|
const buildModelItems = (): FilterableItem[] =>
|
|
5737
5924
|
modelPickerItems(showAll ? catalog.all : catalog.scoped);
|
|
5738
5925
|
const modelLists = new Map<string, FilterableList>();
|
|
5739
|
-
const
|
|
5740
|
-
items: VALID_THINKING_LEVELS.map((t) => ({ value: t, label: t })),
|
|
5741
|
-
maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
|
|
5742
|
-
theme: themeList,
|
|
5743
|
-
noMatchText: "No matching thinking levels",
|
|
5744
|
-
});
|
|
5926
|
+
const thinkingLists = new Map<string, FilterableList>();
|
|
5745
5927
|
const getModelList = (roleName: string): FilterableList => {
|
|
5746
5928
|
let list = modelLists.get(roleName);
|
|
5747
5929
|
if (!list) {
|
|
@@ -5760,6 +5942,26 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5760
5942
|
}
|
|
5761
5943
|
return list;
|
|
5762
5944
|
};
|
|
5945
|
+
/** One list per model: the supported set differs per role/default tab. */
|
|
5946
|
+
const getThinkingList = (roleName: string): FilterableList => {
|
|
5947
|
+
let list = thinkingLists.get(roleName);
|
|
5948
|
+
if (!list) {
|
|
5949
|
+
const st = staged.get(roleName)!;
|
|
5950
|
+
const levels = thinkingLevelsForModel(
|
|
5951
|
+
registryModel(ctx, st.provider, st.id),
|
|
5952
|
+
);
|
|
5953
|
+
const idx = levels.indexOf(st.thinking);
|
|
5954
|
+
list = new FilterableList({
|
|
5955
|
+
items: levels.map((t) => ({ value: t, label: t })),
|
|
5956
|
+
maxVisible: Math.min(levels.length, 8),
|
|
5957
|
+
theme: themeList,
|
|
5958
|
+
initialIndex: Math.max(0, idx),
|
|
5959
|
+
noMatchText: "No matching thinking levels",
|
|
5960
|
+
});
|
|
5961
|
+
thinkingLists.set(roleName, list);
|
|
5962
|
+
}
|
|
5963
|
+
return list;
|
|
5964
|
+
};
|
|
5763
5965
|
// Ctrl+O re-scopes the rows: keep each tab's query and re-filter instead
|
|
5764
5966
|
// of rebuilding the lists from scratch.
|
|
5765
5967
|
const rebuildModelLists = () => {
|
|
@@ -5768,11 +5970,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5768
5970
|
};
|
|
5769
5971
|
const syncThinkingCursor = (roleName: string) => {
|
|
5770
5972
|
const st = staged.get(roleName)!;
|
|
5771
|
-
|
|
5772
|
-
//
|
|
5773
|
-
|
|
5774
|
-
const
|
|
5775
|
-
|
|
5973
|
+
const list = getThinkingList(roleName);
|
|
5974
|
+
// Clear first: the list may still be filtered from a previous visit.
|
|
5975
|
+
list.clearFilter();
|
|
5976
|
+
const levels = thinkingLevelsForModel(
|
|
5977
|
+
registryModel(ctx, st.provider, st.id),
|
|
5978
|
+
);
|
|
5979
|
+
const idx = levels.indexOf(st.thinking);
|
|
5980
|
+
list.setSelectedIndex(idx >= 0 ? idx : 0);
|
|
5776
5981
|
};
|
|
5777
5982
|
const commitModelSelection = (roleName: string) => {
|
|
5778
5983
|
const sel = getModelList(roleName).getSelectedItem();
|
|
@@ -5780,11 +5985,23 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5780
5985
|
const { provider, id } = parseModelRef(sel.value);
|
|
5781
5986
|
if (!provider || !id) return false;
|
|
5782
5987
|
const st = staged.get(roleName)!;
|
|
5783
|
-
staged
|
|
5988
|
+
// A new model can drop the staged level (DeepSeek has no `medium`).
|
|
5989
|
+
const pair = thinkingLevelsForPair(
|
|
5990
|
+
registryModel(ctx, provider, id),
|
|
5991
|
+
st.thinking,
|
|
5992
|
+
);
|
|
5993
|
+
staged.set(roleName, {
|
|
5994
|
+
...st,
|
|
5995
|
+
provider,
|
|
5996
|
+
id,
|
|
5997
|
+
thinking: pair.level,
|
|
5998
|
+
});
|
|
5999
|
+
// The old list belongs to the old model; rebuild on the next visit.
|
|
6000
|
+
thinkingLists.delete(roleName);
|
|
5784
6001
|
return true;
|
|
5785
6002
|
};
|
|
5786
6003
|
const commitThinkingSelection = (roleName: string) => {
|
|
5787
|
-
const sel =
|
|
6004
|
+
const sel = getThinkingList(roleName).getSelectedItem();
|
|
5788
6005
|
if (!sel || !isValidThinkingLevel(sel.value)) return false;
|
|
5789
6006
|
const st = staged.get(roleName)!;
|
|
5790
6007
|
staged.set(roleName, { ...st, thinking: sel.value });
|
|
@@ -5842,7 +6059,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5842
6059
|
// (thinking → model) or cancels the editor.
|
|
5843
6060
|
if (matchesKey(data, Key.escape)) {
|
|
5844
6061
|
const activeList =
|
|
5845
|
-
phase === "model" ? getModelList(roleName) :
|
|
6062
|
+
phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
|
|
5846
6063
|
if (activeList.handleInput(data)) {
|
|
5847
6064
|
cached = undefined;
|
|
5848
6065
|
tui.requestRender();
|
|
@@ -5871,7 +6088,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5871
6088
|
return;
|
|
5872
6089
|
}
|
|
5873
6090
|
const activeList =
|
|
5874
|
-
phase === "model" ? getModelList(roleName) :
|
|
6091
|
+
phase === "model" ? getModelList(roleName) : getThinkingList(roleName);
|
|
5875
6092
|
activeList.handleInput(data);
|
|
5876
6093
|
cached = undefined;
|
|
5877
6094
|
tui.requestRender();
|
|
@@ -5899,7 +6116,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5899
6116
|
const chips: string[] = [];
|
|
5900
6117
|
for (let i = 0; i < tabIds.length; i++) {
|
|
5901
6118
|
const roleName = tabIds[i];
|
|
5902
|
-
const st = staged.get(roleName)
|
|
6119
|
+
const st = staged.get(roleName);
|
|
6120
|
+
if (!st) continue;
|
|
5903
6121
|
const base =
|
|
5904
6122
|
roleName === "default"
|
|
5905
6123
|
? existing?.defaultModel
|
|
@@ -5952,7 +6170,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5952
6170
|
);
|
|
5953
6171
|
lines.push("");
|
|
5954
6172
|
for (const roleName of tabIds) {
|
|
5955
|
-
const st = staged.get(roleName)
|
|
6173
|
+
const st = staged.get(roleName);
|
|
6174
|
+
if (!st) continue;
|
|
5956
6175
|
lines.push(
|
|
5957
6176
|
...wrap(
|
|
5958
6177
|
` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
|
|
@@ -5966,8 +6185,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5966
6185
|
}
|
|
5967
6186
|
} else {
|
|
5968
6187
|
const roleName = tabIds[currentTab];
|
|
5969
|
-
const st = staged.get(roleName)
|
|
5970
|
-
const meta = roleMeta.get(roleName)
|
|
6188
|
+
const st = staged.get(roleName);
|
|
6189
|
+
const meta = roleMeta.get(roleName);
|
|
6190
|
+
if (!st || !meta) return cached ?? lines;
|
|
5971
6191
|
lines.push(
|
|
5972
6192
|
...wrap(
|
|
5973
6193
|
` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
|
|
@@ -6025,9 +6245,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6025
6245
|
lines.push(
|
|
6026
6246
|
...wrap(` ${theme.fg("accent", "Thinking level:")}`),
|
|
6027
6247
|
);
|
|
6028
|
-
|
|
6248
|
+
const list = getThinkingList(roleName);
|
|
6249
|
+
for (const l of list.renderQuery(Math.max(1, W - 2)))
|
|
6029
6250
|
lines.push(` ${l}`);
|
|
6030
|
-
for (const l of
|
|
6251
|
+
for (const l of list.renderList(Math.max(1, W - 2)))
|
|
6031
6252
|
lines.push(` ${l}`);
|
|
6032
6253
|
}
|
|
6033
6254
|
}
|
|
@@ -6063,21 +6284,30 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6063
6284
|
);
|
|
6064
6285
|
return "cancelled";
|
|
6065
6286
|
}
|
|
6066
|
-
// Validate against registry (warn-only, matching /role set).
|
|
6287
|
+
// Validate against registry (warn-only, matching /role set). A known model
|
|
6288
|
+
// also clamps its staged thinking level so this save cannot write a pair
|
|
6289
|
+
// the model does not support.
|
|
6067
6290
|
try {
|
|
6068
6291
|
const reg = (ctx as any)?.modelRegistry;
|
|
6069
6292
|
if (reg && typeof reg.find === "function") {
|
|
6070
6293
|
for (const [roleName, st] of saved.staged) {
|
|
6071
|
-
if (
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
typeof reg.find === "function" &&
|
|
6075
|
-
!reg.find(st.provider, st.id)
|
|
6076
|
-
)
|
|
6294
|
+
if (!st.provider || !st.id) continue;
|
|
6295
|
+
const model = reg.find(st.provider, st.id);
|
|
6296
|
+
if (!model) {
|
|
6077
6297
|
ctx.ui.notify(
|
|
6078
6298
|
`Warning: ${st.provider}/${st.id} (${roleName}) not in model registry — saved anyway.`,
|
|
6079
6299
|
"warning",
|
|
6080
6300
|
);
|
|
6301
|
+
continue;
|
|
6302
|
+
}
|
|
6303
|
+
const pair = thinkingLevelsForPair(model, st.thinking);
|
|
6304
|
+
if (pair.level !== st.thinking) {
|
|
6305
|
+
ctx.ui.notify(
|
|
6306
|
+
`Thinking ${st.thinking} → ${pair.level} for ${roleName} (${st.provider}/${st.id} does not support ${st.thinking}).`,
|
|
6307
|
+
"warning",
|
|
6308
|
+
);
|
|
6309
|
+
st.thinking = pair.level;
|
|
6310
|
+
}
|
|
6081
6311
|
}
|
|
6082
6312
|
}
|
|
6083
6313
|
} catch (_e) {
|
|
@@ -6611,15 +6841,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6611
6841
|
const buildModelItems = (): FilterableItem[] =>
|
|
6612
6842
|
modelPickerItems(showAll ? catalog.all : catalog.scoped);
|
|
6613
6843
|
const modelLists = new Map<string, FilterableList>();
|
|
6614
|
-
const
|
|
6615
|
-
items: VALID_THINKING_LEVELS.map((t) => ({
|
|
6616
|
-
value: t,
|
|
6617
|
-
label: t,
|
|
6618
|
-
})),
|
|
6619
|
-
maxVisible: Math.min(VALID_THINKING_LEVELS.length, 8),
|
|
6620
|
-
theme: listTheme,
|
|
6621
|
-
noMatchText: "No matching thinking levels",
|
|
6622
|
-
});
|
|
6844
|
+
const thinkingLists = new Map<string, FilterableList>();
|
|
6623
6845
|
const getModelList = (role: Role): FilterableList => {
|
|
6624
6846
|
let list = modelLists.get(role.name);
|
|
6625
6847
|
if (!list) {
|
|
@@ -6638,6 +6860,26 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6638
6860
|
}
|
|
6639
6861
|
return list;
|
|
6640
6862
|
};
|
|
6863
|
+
/** One list per role: the supported set follows that role's model. */
|
|
6864
|
+
const getThinkingList = (role: Role): FilterableList => {
|
|
6865
|
+
let list = thinkingLists.get(role.name);
|
|
6866
|
+
if (!list) {
|
|
6867
|
+
const st = staged.get(role.name)!;
|
|
6868
|
+
const levels = thinkingLevelsForModel(
|
|
6869
|
+
registryModel(ctx, st.provider, st.id),
|
|
6870
|
+
);
|
|
6871
|
+
const idx = levels.indexOf(st.thinking);
|
|
6872
|
+
list = new FilterableList({
|
|
6873
|
+
items: levels.map((t) => ({ value: t, label: t })),
|
|
6874
|
+
maxVisible: Math.min(levels.length, 8),
|
|
6875
|
+
theme: listTheme,
|
|
6876
|
+
initialIndex: Math.max(0, idx),
|
|
6877
|
+
noMatchText: "No matching thinking levels",
|
|
6878
|
+
});
|
|
6879
|
+
thinkingLists.set(role.name, list);
|
|
6880
|
+
}
|
|
6881
|
+
return list;
|
|
6882
|
+
};
|
|
6641
6883
|
// Ctrl+O re-scopes the rows: keep each tab's query and re-filter
|
|
6642
6884
|
// instead of rebuilding the lists from scratch.
|
|
6643
6885
|
const rebuildModelLists = () => {
|
|
@@ -6646,11 +6888,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6646
6888
|
};
|
|
6647
6889
|
const syncThinkingCursor = (role: Role) => {
|
|
6648
6890
|
const st = staged.get(role.name)!;
|
|
6649
|
-
|
|
6650
|
-
//
|
|
6651
|
-
|
|
6652
|
-
const
|
|
6653
|
-
|
|
6891
|
+
const list = getThinkingList(role);
|
|
6892
|
+
// Clear first: the list may still be filtered from a previous visit.
|
|
6893
|
+
list.clearFilter();
|
|
6894
|
+
const levels = thinkingLevelsForModel(
|
|
6895
|
+
registryModel(ctx, st.provider, st.id),
|
|
6896
|
+
);
|
|
6897
|
+
const idx = levels.indexOf(st.thinking);
|
|
6898
|
+
list.setSelectedIndex(idx >= 0 ? idx : 0);
|
|
6654
6899
|
};
|
|
6655
6900
|
const isSubmitTab = () => currentTab === tabs.length;
|
|
6656
6901
|
|
|
@@ -6660,13 +6905,22 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6660
6905
|
const { provider, id } = parseModelRef(sel.value);
|
|
6661
6906
|
if (!provider || !id) return false;
|
|
6662
6907
|
const st = staged.get(role.name)!;
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6908
|
+
const pair = thinkingLevelsForPair(
|
|
6909
|
+
registryModel(ctx, provider, id),
|
|
6910
|
+
st.thinking,
|
|
6911
|
+
);
|
|
6912
|
+
staged.set(role.name, {
|
|
6913
|
+
...st,
|
|
6914
|
+
provider,
|
|
6915
|
+
id,
|
|
6916
|
+
thinking: pair.level,
|
|
6917
|
+
});
|
|
6918
|
+
// The old list belongs to the old model; rebuild on the next visit.
|
|
6919
|
+
thinkingLists.delete(role.name);
|
|
6666
6920
|
return true;
|
|
6667
6921
|
};
|
|
6668
6922
|
const commitThinkingSelection = (role: Role) => {
|
|
6669
|
-
const sel =
|
|
6923
|
+
const sel = getThinkingList(role).getSelectedItem();
|
|
6670
6924
|
if (!sel || !isValidThinkingLevel(sel.value)) return false;
|
|
6671
6925
|
const st = staged.get(role.name)!;
|
|
6672
6926
|
staged.set(role.name, { ...st, thinking: sel.value });
|
|
@@ -6723,7 +6977,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6723
6977
|
// (thinking → model) or cancels the picker.
|
|
6724
6978
|
if (matchesKey(data, Key.escape)) {
|
|
6725
6979
|
const activeList =
|
|
6726
|
-
phase === "model" ? getModelList(role) :
|
|
6980
|
+
phase === "model" ? getModelList(role) : getThinkingList(role);
|
|
6727
6981
|
if (activeList.handleInput(data)) {
|
|
6728
6982
|
cached = undefined;
|
|
6729
6983
|
tui.requestRender();
|
|
@@ -6752,7 +7006,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6752
7006
|
return;
|
|
6753
7007
|
}
|
|
6754
7008
|
const activeList =
|
|
6755
|
-
phase === "model" ? getModelList(role) :
|
|
7009
|
+
phase === "model" ? getModelList(role) : getThinkingList(role);
|
|
6756
7010
|
activeList.handleInput(data);
|
|
6757
7011
|
cached = undefined;
|
|
6758
7012
|
tui.requestRender();
|
|
@@ -6895,9 +7149,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6895
7149
|
lines.push(
|
|
6896
7150
|
...wrap(` ${theme.fg("accent", "Thinking level:")}`),
|
|
6897
7151
|
);
|
|
6898
|
-
|
|
7152
|
+
const list = getThinkingList(role);
|
|
7153
|
+
for (const l of list.renderQuery(Math.max(1, W - 2)))
|
|
6899
7154
|
lines.push(` ${l}`);
|
|
6900
|
-
for (const l of
|
|
7155
|
+
for (const l of list.renderList(Math.max(1, W - 2)))
|
|
6901
7156
|
lines.push(` ${l}`);
|
|
6902
7157
|
}
|
|
6903
7158
|
}
|
|
@@ -6931,16 +7186,28 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6931
7186
|
ctx.ui.notify("Role picker cancelled.", "info");
|
|
6932
7187
|
return;
|
|
6933
7188
|
}
|
|
6934
|
-
// Validate against registry (warn-only, matching /role set)
|
|
7189
|
+
// Validate against registry (warn-only, matching /role set) and clamp each
|
|
7190
|
+
// staged thinking level to the model it will run on.
|
|
6935
7191
|
try {
|
|
6936
7192
|
const reg = (ctx as any)?.modelRegistry;
|
|
6937
7193
|
if (reg && typeof reg.find === "function") {
|
|
6938
7194
|
for (const [name, st] of saved) {
|
|
6939
|
-
|
|
7195
|
+
const model = reg.find(st.provider, st.id);
|
|
7196
|
+
if (!model) {
|
|
6940
7197
|
ctx.ui.notify(
|
|
6941
7198
|
`Warning: ${st.provider}/${st.id} (${name}) not in model registry — saved anyway.`,
|
|
6942
7199
|
"warning",
|
|
6943
7200
|
);
|
|
7201
|
+
continue;
|
|
7202
|
+
}
|
|
7203
|
+
const pair = thinkingLevelsForPair(model, st.thinking);
|
|
7204
|
+
if (pair.level !== st.thinking) {
|
|
7205
|
+
ctx.ui.notify(
|
|
7206
|
+
`Thinking ${st.thinking} → ${pair.level} for ${name} (${st.provider}/${st.id} does not support ${st.thinking}).`,
|
|
7207
|
+
"warning",
|
|
7208
|
+
);
|
|
7209
|
+
st.thinking = pair.level;
|
|
7210
|
+
}
|
|
6944
7211
|
}
|
|
6945
7212
|
}
|
|
6946
7213
|
} catch (_e) {
|
|
@@ -7750,6 +8017,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7750
8017
|
// ── Session & Agent lifecycle ────────────────────────────────────
|
|
7751
8018
|
|
|
7752
8019
|
pi.on("session_start", async (event, ctx) => {
|
|
8020
|
+
// A session boundary owns the session-scoped plan fallback; the restore
|
|
8021
|
+
// loop below re-seeds it from the persisted `planFile`.
|
|
8022
|
+
resetSessionPlanProvenance();
|
|
7753
8023
|
// A reloaded/forked session must never inherit a stale write denial.
|
|
7754
8024
|
writeGateRunDenials.clear();
|
|
7755
8025
|
writeGateTurnAnswer = null;
|
|
@@ -7797,7 +8067,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7797
8067
|
if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
|
|
7798
8068
|
todoPlanPath = d.planFile;
|
|
7799
8069
|
toolsBeforePlanMode = d.toolsBeforePlanMode;
|
|
7800
|
-
|
|
8070
|
+
// An entry without a planFile must not erase a value restored
|
|
8071
|
+
// from an earlier entry (the entry shape has changed over time).
|
|
8072
|
+
if (typeof d.planFile === "string" && d.planFile) {
|
|
8073
|
+
currentPlanFile = d.planFile;
|
|
8074
|
+
}
|
|
7801
8075
|
// restore role state (file config is source of truth for roles;
|
|
7802
8076
|
// session entry carries the latest activeRole) and the session's
|
|
7803
8077
|
// own profile activation
|
|
@@ -8041,12 +8315,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8041
8315
|
await withPersistSuppressed(async () => {
|
|
8042
8316
|
const ok = await (pi as any)?.setModel?.(found);
|
|
8043
8317
|
if (ok === false) throw new Error("setModel returned false");
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8318
|
+
applyThinkingForModel(
|
|
8319
|
+
ctx as any,
|
|
8320
|
+
found,
|
|
8321
|
+
effectiveDefault.thinking,
|
|
8322
|
+
"Session start",
|
|
8323
|
+
);
|
|
8050
8324
|
});
|
|
8051
8325
|
} catch (e: any) {
|
|
8052
8326
|
// Auth missing or the provider rejected the switch: keep Pi's pick.
|
|
@@ -8107,6 +8381,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8107
8381
|
// pending browser correlation. The persisted awaitingDecision recovery
|
|
8108
8382
|
// above is kept for a recent modal; the first fresh prompt supersedes it.
|
|
8109
8383
|
resetPlanRunProvenance();
|
|
8384
|
+
lastPlanWritePath = currentPlanFile ?? null;
|
|
8385
|
+
lastPersistedPlanFile = currentPlanFile;
|
|
8110
8386
|
pendingHandoffPlanPath = null;
|
|
8111
8387
|
pendingHandoffPlanHash = null;
|
|
8112
8388
|
lastReviewId = null;
|
|
@@ -8167,6 +8443,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8167
8443
|
// Ensure a handoff decision/provenance does not persist stale across restart
|
|
8168
8444
|
const clearedAtShutdown = clearPendingHandoffDecision();
|
|
8169
8445
|
resetPlanRunProvenance();
|
|
8446
|
+
resetSessionPlanProvenance();
|
|
8170
8447
|
if (clearedAtShutdown) {
|
|
8171
8448
|
try {
|
|
8172
8449
|
persistState();
|
|
@@ -8174,7 +8451,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8174
8451
|
void _e;
|
|
8175
8452
|
}
|
|
8176
8453
|
}
|
|
8177
|
-
ctx.ui.setStatus(
|
|
8454
|
+
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.mode, undefined);
|
|
8455
|
+
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.profile, undefined);
|
|
8456
|
+
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.policy, undefined);
|
|
8457
|
+
ctx.ui.setStatus(WORKFLOW_STATUS_KEYS.review, undefined);
|
|
8178
8458
|
ctx.ui.setWidget("workflow-todos", undefined);
|
|
8179
8459
|
// Drop any /commit authorization or flow: a reload/quit must never leave
|
|
8180
8460
|
// an exemption armed for whatever runs next.
|
|
@@ -8191,6 +8471,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8191
8471
|
todoItems = [];
|
|
8192
8472
|
todoPlanPath = undefined;
|
|
8193
8473
|
btwNotes = [];
|
|
8474
|
+
// A rewind is a branch boundary: no per-run write from the old branch
|
|
8475
|
+
// may authorize a handoff or a manual review on the new one.
|
|
8476
|
+
resetPlanRunProvenance();
|
|
8477
|
+
clearPendingHandoffDecision();
|
|
8194
8478
|
// A rewind must not preserve a /commit exemption across branches.
|
|
8195
8479
|
commitAuthorization = null;
|
|
8196
8480
|
pendingCommitFlow = null;
|
|
@@ -8214,6 +8498,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8214
8498
|
todoItems = ensureTodoLabels(e.message.details.todos);
|
|
8215
8499
|
}
|
|
8216
8500
|
}
|
|
8501
|
+
// Re-derive the plan from the branch we rewound to. Keeping the old
|
|
8502
|
+
// value would let `/review` audit a plan that no longer exists here.
|
|
8503
|
+
const latest = findLatestPlanWriteInBranch(branch, ctx.cwd);
|
|
8504
|
+
currentPlanFile = latest;
|
|
8505
|
+
lastPlanWritePath = latest ?? null;
|
|
8217
8506
|
} catch (_e) {
|
|
8218
8507
|
void _e;
|
|
8219
8508
|
}
|
|
@@ -9179,24 +9468,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9179
9468
|
// moved on, so a stale tab must not approve the previous plan. Follow-up
|
|
9180
9469
|
// runs keep the current run's provenance.
|
|
9181
9470
|
try {
|
|
9182
|
-
const
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
if (
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
at: Date.now(),
|
|
9195
|
-
age,
|
|
9196
|
-
});
|
|
9197
|
-
} catch (_e) {
|
|
9198
|
-
void _e;
|
|
9199
|
-
}
|
|
9471
|
+
const age = lastHandoffAt ? Date.now() - lastHandoffAt : Infinity;
|
|
9472
|
+
const cleared = clearPendingHandoffDecision();
|
|
9473
|
+
resetPlanRunProvenance();
|
|
9474
|
+
if (cleared) {
|
|
9475
|
+
persistState();
|
|
9476
|
+
try {
|
|
9477
|
+
pi.appendEntry("workflow-handoff-reset-before-start", {
|
|
9478
|
+
at: Date.now(),
|
|
9479
|
+
age,
|
|
9480
|
+
});
|
|
9481
|
+
} catch (_e) {
|
|
9482
|
+
void _e;
|
|
9200
9483
|
}
|
|
9201
9484
|
}
|
|
9202
9485
|
} catch (_e) {
|
|
@@ -9365,23 +9648,40 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9365
9648
|
});
|
|
9366
9649
|
|
|
9367
9650
|
// Plan file detection + full render + decision gate
|
|
9651
|
+
//
|
|
9652
|
+
// Two lifetimes (plan 2026-10-31):
|
|
9653
|
+
// - per-run: `planWritten*` / `planWritePathThisRun` decide the automatic
|
|
9654
|
+
// handoff for THIS run and reset on every fresh prompt/mode switch;
|
|
9655
|
+
// - session: `lastPlanWritePath` is the manual `/review` fallback and must
|
|
9656
|
+
// survive mode switches and fresh prompts. Only a session boundary clears
|
|
9657
|
+
// it. (`currentPlanFile` is the reviewer/Build target and is kept too.)
|
|
9368
9658
|
let lastPlanWritePath: string | null = null;
|
|
9369
|
-
/** This user run started in Plan mode; a Build-originated run never hands off. */
|
|
9370
|
-
let handoffEligibleThisRun = false;
|
|
9371
9659
|
/** This run wrote/edited a plan file under .pi/plans/. */
|
|
9372
9660
|
let planWrittenThisRun = false;
|
|
9661
|
+
/** This run wrote a plan while Plan mode was active (a mid-run switch counts). */
|
|
9662
|
+
let planWrittenInPlanModeThisRun = false;
|
|
9373
9663
|
/** Absolute path of this run's last plan write. */
|
|
9374
9664
|
let planWritePathThisRun: string | null = null;
|
|
9375
9665
|
/** Plan path/hash the currently rendered handoff prompt belongs to. */
|
|
9376
9666
|
let pendingHandoffPlanPath: string | null = null;
|
|
9377
9667
|
let pendingHandoffPlanHash: string | null = null;
|
|
9668
|
+
/** Last path announced by the suppressed-handoff notice (per run). */
|
|
9669
|
+
let lastPlanReadyNoticeKey: string | null = null;
|
|
9378
9670
|
|
|
9379
|
-
/** Clear per-run handoff provenance (fresh prompt / mode switch /
|
|
9671
|
+
/** Clear per-run handoff provenance (fresh prompt / mode switch / run end). */
|
|
9380
9672
|
function resetPlanRunProvenance(): void {
|
|
9381
|
-
handoffEligibleThisRun = false;
|
|
9382
9673
|
planWrittenThisRun = false;
|
|
9674
|
+
planWrittenInPlanModeThisRun = false;
|
|
9383
9675
|
planWritePathThisRun = null;
|
|
9676
|
+
lastPlanReadyNoticeKey = null;
|
|
9677
|
+
// Deliberately NOT lastPlanWritePath: a mode switch or a new prompt must
|
|
9678
|
+
// not make the plan the user just wrote unreachable to `/review`.
|
|
9679
|
+
}
|
|
9680
|
+
|
|
9681
|
+
/** Clear the session-scoped plan fallback (session start/shutdown only). */
|
|
9682
|
+
function resetSessionPlanProvenance(): void {
|
|
9384
9683
|
lastPlanWritePath = null;
|
|
9684
|
+
lastPersistedPlanFile = undefined;
|
|
9385
9685
|
}
|
|
9386
9686
|
|
|
9387
9687
|
/**
|
|
@@ -9466,11 +9766,20 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9466
9766
|
// handoff. `lastPlanWritePath` alone is session-lifetime stale.
|
|
9467
9767
|
planWritePathThisRun = abs;
|
|
9468
9768
|
planWrittenThisRun = true;
|
|
9769
|
+
// The reviewer's `review_submit_plan` writes this path; a manual
|
|
9770
|
+
// `/review` must also audit the file the user just wrote.
|
|
9771
|
+
currentPlanFile = abs;
|
|
9772
|
+
// A run that starts in Default/Build and switches into Plan mid-run
|
|
9773
|
+
// still qualifies: what matters is the mode at write time.
|
|
9774
|
+
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9469
9775
|
} catch (_e) {
|
|
9470
9776
|
void _e;
|
|
9471
|
-
|
|
9472
|
-
|
|
9777
|
+
const raw = String(p);
|
|
9778
|
+
lastPlanWritePath = raw;
|
|
9779
|
+
planWritePathThisRun = raw;
|
|
9473
9780
|
planWrittenThisRun = true;
|
|
9781
|
+
currentPlanFile = raw;
|
|
9782
|
+
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9474
9783
|
}
|
|
9475
9784
|
try {
|
|
9476
9785
|
pi.appendEntry("workflow-handoff-resolve", {
|
|
@@ -9538,16 +9847,59 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9538
9847
|
: path.join(cwd, found);
|
|
9539
9848
|
lastPlanWritePath = planWritePathThisRun;
|
|
9540
9849
|
planWrittenThisRun = true;
|
|
9850
|
+
// The fallback only scans THIS run and we are in Plan mode now, so
|
|
9851
|
+
// this current-run write is attributable to the Plan cycle.
|
|
9852
|
+
planWrittenInPlanModeThisRun = true;
|
|
9541
9853
|
}
|
|
9542
9854
|
}
|
|
9855
|
+
// Persist the plan target once per run, in every mode, before the
|
|
9856
|
+
// eligibility gate. A plan written in Default/Build still needs to be
|
|
9857
|
+
// reachable by `/review` and by a later resume.
|
|
9858
|
+
if (planWritePathThisRun) {
|
|
9859
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
9860
|
+
const abs = path.isAbsolute(planWritePathThisRun)
|
|
9861
|
+
? planWritePathThisRun
|
|
9862
|
+
: path.join(cwd, planWritePathThisRun);
|
|
9863
|
+
currentPlanFile = abs;
|
|
9864
|
+
lastPlanWritePath = abs;
|
|
9865
|
+
// Persist once per run even when the tool_result handler already set
|
|
9866
|
+
// the in-memory target: resume must find the plan without a mode
|
|
9867
|
+
// switch or a todo update happening to persist it first.
|
|
9868
|
+
if (currentPlanFile !== lastPersistedPlanFile) persistState();
|
|
9869
|
+
}
|
|
9543
9870
|
if (workflowMode === "plan") {
|
|
9544
9871
|
const eligible = shouldHandoffPlan({
|
|
9545
9872
|
mode: workflowMode,
|
|
9546
|
-
runStartedInPlan: handoffEligibleThisRun,
|
|
9547
9873
|
planWrittenThisRun,
|
|
9874
|
+
planWrittenInPlanMode: planWrittenInPlanModeThisRun,
|
|
9548
9875
|
});
|
|
9549
9876
|
if (!eligible) {
|
|
9550
|
-
if (
|
|
9877
|
+
if (planWrittenThisRun) {
|
|
9878
|
+
// The plan is real and current, but it was written before this
|
|
9879
|
+
// run entered Plan mode. Do not hand it off automatically; make
|
|
9880
|
+
// the manual path discoverable instead.
|
|
9881
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
9882
|
+
const abs = planWritePathThisRun
|
|
9883
|
+
? path.isAbsolute(planWritePathThisRun)
|
|
9884
|
+
? planWritePathThisRun
|
|
9885
|
+
: path.join(cwd, planWritePathThisRun)
|
|
9886
|
+
: undefined;
|
|
9887
|
+
const key = abs ?? planWritePathThisRun;
|
|
9888
|
+
if (key && key !== lastPlanReadyNoticeKey) {
|
|
9889
|
+
lastPlanReadyNoticeKey = key;
|
|
9890
|
+
const rel = abs ? path.relative(cwd, abs) || abs : key;
|
|
9891
|
+
try {
|
|
9892
|
+
ctx.ui.notify(
|
|
9893
|
+
`Plan file ready: ${rel}. Run /review to audit it.`,
|
|
9894
|
+
"info",
|
|
9895
|
+
);
|
|
9896
|
+
} catch (_e) {
|
|
9897
|
+
void _e;
|
|
9898
|
+
}
|
|
9899
|
+
}
|
|
9900
|
+
} else {
|
|
9901
|
+
clearPendingHandoffDecision();
|
|
9902
|
+
}
|
|
9551
9903
|
return;
|
|
9552
9904
|
}
|
|
9553
9905
|
}
|
|
@@ -10667,6 +11019,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
10667
11019
|
}
|
|
10668
11020
|
toolsBeforePlanMode = undefined;
|
|
10669
11021
|
}
|
|
11022
|
+
// Leaving Plan starts a new cycle; entering Plan preserves a plan
|
|
11023
|
+
// written earlier in this run (see setPlanMode).
|
|
11024
|
+
if (!state.active) resetPlanRunProvenance();
|
|
11025
|
+
clearPendingHandoffDecision();
|
|
10670
11026
|
// Find a ctx to update status — use a no-op if none
|
|
10671
11027
|
// Status will be refreshed on next session_start/turn anyway
|
|
10672
11028
|
persistState();
|