@dieulc/workflow 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/index.ts +326 -53
- package/package.json +2 -2
- package/review-runtime.ts +7 -2
- package/utils.ts +77 -5
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
|
|
|
41
41
|
| `/review-mode timeout <seconds\|unlimited> [global\|project]` | Set or show the per-pass time cap (`unlimited`/`0` = no cap). Writes to global settings by default |
|
|
42
42
|
| `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
|
|
43
43
|
| `/review-status` | Show review phase, model, plan hash, findings and any error |
|
|
44
|
-
| `/review` | Run a one-shot review round now
|
|
44
|
+
| `/review [path]` | Run a one-shot review round now. Without a path it uses this run's plan write, then the session's latest plan write; works with Review Mode on or off (mode stays OFF). An explicit path must be under `.pi/plans/` |
|
|
45
45
|
| `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated; the confirmation arms the commit exemption for that run |
|
|
46
46
|
| `/commit-policy [never\|ask\|allow] [global\|project]` | View or set the commit policy (default `never`: the agent cannot `git add`/`commit`/`push` — you commit, or use `/commit`) |
|
|
47
47
|
| `/write-policy [allow\|ask\|never] [global\|project]` | View or set the write permission for Default mode (default `allow`: the agent writes freely; `ask` prompts per write; `never` blocks writes — see [Write permission](#write-permission)) |
|
|
@@ -81,14 +81,13 @@ Profile config lives in `~/.pi/agent/profiles.json` v1 (user) with optional per-
|
|
|
81
81
|
|
|
82
82
|
## Review Mode
|
|
83
83
|
|
|
84
|
-
An independent second agent
|
|
84
|
+
An independent second agent audits the plan before the handoff renders. The handoff waits for the gated round; the reviewed plan (the reviewer writes the plan file mid-round) is what you see, and the todo steps are re-synced to match. The reviewer runs in its own child session, so Plan Mode and Build Mode never see its prompt, findings or transcript.
|
|
85
85
|
|
|
86
86
|
```text
|
|
87
|
-
Plan Mode (main session) ──plan file──▶
|
|
88
|
-
│
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
│ "review landed" notice
|
|
87
|
+
Plan Mode (main session) ──plan file──▶ Review Mode (child session, gated)
|
|
88
|
+
│ rewrites the plan file
|
|
89
|
+
▼
|
|
90
|
+
handoff renders the reviewed plan
|
|
92
91
|
▼
|
|
93
92
|
Plannotator webview ◀── plan file + `## Review changes`
|
|
94
93
|
│ approve ──▶ Build Mode (review activity suspends, session stays warm)
|
|
@@ -233,7 +232,9 @@ Delivery to the reviewer is best-effort: if it fails, the message stays in the p
|
|
|
233
232
|
|
|
234
233
|
The handoff **waits for the reviewer**: no browser prompt or inline choice renders until the gated round lands, fails, or you pause/abort it. The reviewed plan (the reviewer writes the plan file mid-round) becomes the handed-off text, with the todo steps re-synced to match. A broken reviewer never blocks the plan — the author's draft is handed over with a warning instead.
|
|
235
234
|
|
|
236
|
-
A handoff opens **only when the current
|
|
235
|
+
A handoff opens **only when the current run writes or edits a `.pi/plans/` file while Plan mode is active**. A mid-run switch into Plan counts — the write after the switch is eligible. A plan written before the switch does not auto-handoff: the extension shows `Plan file ready: <path>. Run /review to audit it.` and leaves the plan for the manual command. Switching Build → Plan keeps the previous plan and todos as reference; a fresh prompt also supersedes any pending browser/inline handoff prompt, and a stale browser approval/denial from a previous plan is ignored.
|
|
236
|
+
|
|
237
|
+
`/review` resolves its target independently of the handoff, so a plan is never unreachable because of how Plan mode was entered: explicit path → this run's last plan write → the session's last plan write → the last handed-off plan → the newest plan write in the session branch. Missing, empty or unreadable candidates are skipped; the resolved path is named when it came from a fallback; the reviewer writes back to that same file. With no candidate it lists the newest `.pi/plans/*.md` files and the explicit-path syntax. `/review <path>` is restricted to `.pi/plans/`.
|
|
237
238
|
|
|
238
239
|
- **Mode switching is locked** from the moment a review round starts (including the cold boot) until it lands, fails, or is aborted, and for the whole paused state. `Tab` (when no suggestion list is open), `Ctrl+Alt+P`, `/plan`, `/build`, `/default`, browser approve and external plan-mode changes are refused with one notice, and the footer shows `🔒 modes locked`. `/review-abort` and `/review-mode off` end the lock; the post-review handoff prompt is intentionally unlocked because the independent reviewer has finished.
|
|
239
240
|
- **ESC** pauses the round **everywhere it is active** — while the gated handoff waits, while a manual round runs, with no prompt on screen at all. One long-lived terminal listener maps it to `/review-pause` semantics (warm session kept) and stays registered through the paused window: a second ESC while a gated review is paused is swallowed with a hint instead of aborting the parked plan turn (that abort used to poison the turn's signal and stall every `/review-resume`). With no review owning the turn it passes ESC through, so the editor, questionnaire and handoff selector keep it. Headless/RPC hosts have no listener and keep `/review-abort` as their stop path.
|
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,
|
|
@@ -247,6 +250,7 @@ import {
|
|
|
247
250
|
type ReviewFinding,
|
|
248
251
|
type ReviewExecutionResult,
|
|
249
252
|
type ReviewHandoffGate,
|
|
253
|
+
type ReviewSdkLoader,
|
|
250
254
|
type ReviewHandoffPhase,
|
|
251
255
|
type ReviewHandoffResult,
|
|
252
256
|
type ReviewModeLockKind,
|
|
@@ -475,7 +479,19 @@ function getFinalOutput(messages: any[]): string {
|
|
|
475
479
|
|
|
476
480
|
// ── Main Extension ───────────────────────────────────────────────────
|
|
477
481
|
|
|
478
|
-
|
|
482
|
+
/**
|
|
483
|
+
* Optional seams for tests. Production callers (Pi) pass only `pi`, so the
|
|
484
|
+
* default remains byte-for-byte the shipped extension; tests inject a fake
|
|
485
|
+
* reviewer SDK to exercise `/review` and the handoff without a provider.
|
|
486
|
+
*/
|
|
487
|
+
export interface WorkflowExtensionDeps {
|
|
488
|
+
loadSdk?: ReviewSdkLoader;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export default function workflowExtension(
|
|
492
|
+
pi: ExtensionAPI,
|
|
493
|
+
deps: WorkflowExtensionDeps = {},
|
|
494
|
+
) {
|
|
479
495
|
// Review Mode streams the reviewer's output into the main transcript as
|
|
480
496
|
// display-only entries. Registering once here keeps /reload idempotent.
|
|
481
497
|
registerReviewEntryRenderers(pi);
|
|
@@ -557,6 +573,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
557
573
|
| { provider: string; id: string; thinking?: string }
|
|
558
574
|
| undefined;
|
|
559
575
|
let currentPlanFile: string | undefined;
|
|
576
|
+
/** Last plan path written into a persisted `workflow` entry. */
|
|
577
|
+
let lastPersistedPlanFile: string | undefined;
|
|
560
578
|
let awaitingDecision = false;
|
|
561
579
|
let lastHandoffAt: number | undefined;
|
|
562
580
|
let handoffInFlight: Promise<void> | null = null;
|
|
@@ -1635,6 +1653,121 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1635
1653
|
}
|
|
1636
1654
|
}
|
|
1637
1655
|
|
|
1656
|
+
interface ReviewPlanTarget {
|
|
1657
|
+
abs: string;
|
|
1658
|
+
text: string;
|
|
1659
|
+
source: PlanPathSource;
|
|
1660
|
+
display: string;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
type ReviewPlanResolution =
|
|
1664
|
+
| { kind: "ok"; target: ReviewPlanTarget }
|
|
1665
|
+
| { kind: "no-plan"; recent: string[] }
|
|
1666
|
+
| { kind: "error"; message: string };
|
|
1667
|
+
|
|
1668
|
+
/** Newest `.pi/plans/*.md` files, relative to cwd, newest first. */
|
|
1669
|
+
function listRecentPlanFiles(cwd: string, limit = 3): string[] {
|
|
1670
|
+
try {
|
|
1671
|
+
const dir = path.join(cwd, CONFIG_DIR_NAME, "plans");
|
|
1672
|
+
return fs
|
|
1673
|
+
.readdirSync(dir)
|
|
1674
|
+
.filter((f) => f.toLowerCase().endsWith(".md"))
|
|
1675
|
+
.map((f) => {
|
|
1676
|
+
const abs = path.join(dir, f);
|
|
1677
|
+
return { abs, mtime: fs.statSync(abs).mtimeMs };
|
|
1678
|
+
})
|
|
1679
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
1680
|
+
.slice(0, limit)
|
|
1681
|
+
.map((r) => path.relative(cwd, r.abs) || r.abs);
|
|
1682
|
+
} catch (_e) {
|
|
1683
|
+
void _e;
|
|
1684
|
+
return [];
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Resolve the plan a manual `/review` should audit.
|
|
1690
|
+
*
|
|
1691
|
+
* Priority (plan 2026-10-31): explicit path → this run's write → the
|
|
1692
|
+
* session's last write → the handoff target → the newest write in the
|
|
1693
|
+
* session branch. A candidate that no longer exists or is empty is skipped.
|
|
1694
|
+
* An explicit path is authoritative: if it is unreadable or outside
|
|
1695
|
+
* `.pi/plans/`, the command reports that instead of silently falling back
|
|
1696
|
+
* to a different file.
|
|
1697
|
+
*/
|
|
1698
|
+
function resolveReviewTarget(
|
|
1699
|
+
ctx: any,
|
|
1700
|
+
explicitArg: string | undefined,
|
|
1701
|
+
): ReviewPlanResolution {
|
|
1702
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
1703
|
+
const display = (abs: string) => path.relative(cwd, abs) || abs;
|
|
1704
|
+
const candidates: Array<{ path: string; source: PlanPathSource }> = [];
|
|
1705
|
+
const push = (raw: string | null | undefined, source: PlanPathSource) => {
|
|
1706
|
+
if (typeof raw !== "string" || raw.length === 0) return;
|
|
1707
|
+
candidates.push({
|
|
1708
|
+
path: path.isAbsolute(raw) ? raw : path.join(cwd, raw),
|
|
1709
|
+
source,
|
|
1710
|
+
});
|
|
1711
|
+
};
|
|
1712
|
+
if (explicitArg) {
|
|
1713
|
+
const abs = path.isAbsolute(explicitArg)
|
|
1714
|
+
? explicitArg
|
|
1715
|
+
: path.join(cwd, explicitArg);
|
|
1716
|
+
if (!isPlanWritePath(abs, cwd)) {
|
|
1717
|
+
return {
|
|
1718
|
+
kind: "error",
|
|
1719
|
+
message: `Review only reads plans under ${path.join(CONFIG_DIR_NAME, "plans")}${path.sep} — got ${explicitArg}`,
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
push(abs, "explicit");
|
|
1723
|
+
} else {
|
|
1724
|
+
push(planWritePathThisRun, "run");
|
|
1725
|
+
push(lastPlanWritePath, "session");
|
|
1726
|
+
push(currentPlanFile, "handoff");
|
|
1727
|
+
try {
|
|
1728
|
+
const branch = (ctx as any)?.sessionManager?.getBranch?.() ?? [];
|
|
1729
|
+
push(findLatestPlanWriteInBranch(branch, cwd), "branch");
|
|
1730
|
+
} catch (_e) {
|
|
1731
|
+
void _e;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const read = (abs: string): string | undefined => {
|
|
1735
|
+
try {
|
|
1736
|
+
if (!fs.statSync(abs).isFile()) return undefined;
|
|
1737
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
1738
|
+
return text.length > 0 ? text : undefined;
|
|
1739
|
+
} catch {
|
|
1740
|
+
return undefined;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
const chosen = choosePlanPath(candidates, (abs) => read(abs) !== undefined);
|
|
1744
|
+
if (!chosen) {
|
|
1745
|
+
if (explicitArg) {
|
|
1746
|
+
return {
|
|
1747
|
+
kind: "error",
|
|
1748
|
+
message: `Plan file not readable: ${explicitArg}`,
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
return { kind: "no-plan", recent: listRecentPlanFiles(cwd) };
|
|
1752
|
+
}
|
|
1753
|
+
const text = read(chosen.path);
|
|
1754
|
+
if (text === undefined) {
|
|
1755
|
+
return {
|
|
1756
|
+
kind: "error",
|
|
1757
|
+
message: `Plan file not readable: ${display(chosen.path)}`,
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
return {
|
|
1761
|
+
kind: "ok",
|
|
1762
|
+
target: {
|
|
1763
|
+
abs: chosen.path,
|
|
1764
|
+
text,
|
|
1765
|
+
source: chosen.source,
|
|
1766
|
+
display: display(chosen.path),
|
|
1767
|
+
},
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1638
1771
|
function reviewConfigNow() {
|
|
1639
1772
|
try {
|
|
1640
1773
|
return readReviewModeConfig(reviewCwd(), reviewAgentDir());
|
|
@@ -1873,6 +2006,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1873
2006
|
}
|
|
1874
2007
|
},
|
|
1875
2008
|
onTranscriptOps: (ops) => pushReviewOps(ops),
|
|
2009
|
+
...(deps.loadSdk ? { loadSdk: deps.loadSdk } : {}),
|
|
1876
2010
|
});
|
|
1877
2011
|
return reviewRuntime;
|
|
1878
2012
|
}
|
|
@@ -2932,14 +3066,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2932
3066
|
|
|
2933
3067
|
pi.registerCommand("review", {
|
|
2934
3068
|
description:
|
|
2935
|
-
"Run a one-shot Review round now
|
|
3069
|
+
"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",
|
|
3070
|
+
getArgumentCompletions: (prefix: string) => {
|
|
3071
|
+
const cwd = (currentSessionCtx as any)?.cwd ?? process.cwd();
|
|
3072
|
+
const items = [
|
|
3073
|
+
{
|
|
3074
|
+
value: "say",
|
|
3075
|
+
label: "say",
|
|
3076
|
+
description: "Send a message to the in-flight reviewer",
|
|
3077
|
+
},
|
|
3078
|
+
...listRecentPlanFiles(cwd, 8).map((p) => ({
|
|
3079
|
+
value: p,
|
|
3080
|
+
label: p,
|
|
3081
|
+
description: "Review this plan",
|
|
3082
|
+
})),
|
|
3083
|
+
];
|
|
3084
|
+
const p = prefix.trim();
|
|
3085
|
+
const filtered = items.filter(
|
|
3086
|
+
(i) => i.value.startsWith(p) || i.label.startsWith(p),
|
|
3087
|
+
);
|
|
3088
|
+
return filtered.length ? filtered : null;
|
|
3089
|
+
},
|
|
2936
3090
|
handler: async (args, ctx) => {
|
|
2937
3091
|
// Explicit reviewer steering (plan 2026-10-23, step 3): the only path
|
|
2938
3092
|
// that delivers user text into an in-flight review round. Plain chat
|
|
2939
3093
|
// stays with the plan agent (see the `input` handler).
|
|
2940
3094
|
const raw = (args || "").trim();
|
|
2941
|
-
if (/^say
|
|
2942
|
-
const message = raw.replace(/^say
|
|
3095
|
+
if (/^say(?:\s|$)/i.test(raw)) {
|
|
3096
|
+
const message = raw.replace(/^say(?:\s|$)/i, "").trim();
|
|
2943
3097
|
if (!message) {
|
|
2944
3098
|
ctx.ui.notify(
|
|
2945
3099
|
"Usage: /review say <message> — send a message to the in-flight reviewer.",
|
|
@@ -2978,27 +3132,45 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
2978
3132
|
// Manual one-shot review: works whether or not Review Mode is on.
|
|
2979
3133
|
// No `enabled` check here — readReviewModeConfig never throws and
|
|
2980
3134
|
// runReviewStep("manual") supplies defaults when unreadable.
|
|
2981
|
-
|
|
2982
|
-
|
|
3135
|
+
//
|
|
3136
|
+
// The plan is resolved independently of handoff eligibility: a run that
|
|
3137
|
+
// started in Default and switched to Plan, or a plan written before a
|
|
3138
|
+
// mode switch, must still be auditable (plan 2026-10-31).
|
|
3139
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
3140
|
+
let explicit: string | undefined = raw.length
|
|
3141
|
+
? raw.replace(/^(["'])(.*)\1$/, "$2").trim()
|
|
3142
|
+
: undefined;
|
|
3143
|
+
if (explicit) {
|
|
3144
|
+
const abs = path.isAbsolute(explicit)
|
|
3145
|
+
? explicit
|
|
3146
|
+
: path.join(cwd, explicit);
|
|
3147
|
+
// Mirror the write-path auto-correction: a mistyped UTC date prefix
|
|
3148
|
+
// should resolve to the file the extension actually wrote.
|
|
3149
|
+
const normalized = normalizePlanPath(abs, cwd, getUtcDatePrefix());
|
|
3150
|
+
explicit =
|
|
3151
|
+
normalized.corrected &&
|
|
3152
|
+
fs.existsSync(normalized.path) &&
|
|
3153
|
+
!fs.existsSync(abs)
|
|
3154
|
+
? normalized.path
|
|
3155
|
+
: abs;
|
|
3156
|
+
}
|
|
3157
|
+
const resolution = resolveReviewTarget(ctx, explicit);
|
|
3158
|
+
if (resolution.kind === "no-plan") {
|
|
3159
|
+
const recent = resolution.recent;
|
|
3160
|
+
const hint = recent.length
|
|
3161
|
+
? `\nRecent plans:\n${recent.map((r) => ` /review ${r}`).join("\n")}`
|
|
3162
|
+
: "";
|
|
2983
3163
|
ctx.ui.notify(
|
|
2984
|
-
|
|
3164
|
+
`No plan file found for this session — run /review <path> with a file under .pi/plans/.${hint}`,
|
|
2985
3165
|
"warning",
|
|
2986
3166
|
);
|
|
2987
3167
|
return;
|
|
2988
3168
|
}
|
|
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");
|
|
3169
|
+
if (resolution.kind === "error") {
|
|
3170
|
+
ctx.ui.notify(resolution.message, "warning");
|
|
3000
3171
|
return;
|
|
3001
3172
|
}
|
|
3173
|
+
const target = resolution.target;
|
|
3002
3174
|
// The gated handoff owns the reviewer while it is open: a fresh round
|
|
3003
3175
|
// here would race the handoff it is about to render. Steer the running
|
|
3004
3176
|
// round with `/review say`, pause with ESC, or stop it with
|
|
@@ -3031,12 +3203,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3031
3203
|
void _e;
|
|
3032
3204
|
}
|
|
3033
3205
|
}
|
|
3206
|
+
// The reviewer writes through `currentPlanFile`; point it at the file
|
|
3207
|
+
// `/review` actually read before the round starts. A fallback source is
|
|
3208
|
+
// announced so the user can see which plan is being audited.
|
|
3209
|
+
if (target.source !== "run" && target.source !== "explicit") {
|
|
3210
|
+
notifyReview(`Reviewing ${target.display}`, "info");
|
|
3211
|
+
}
|
|
3212
|
+
currentPlanFile = target.abs;
|
|
3213
|
+
lastPlanWritePath = target.abs;
|
|
3214
|
+
persistState();
|
|
3034
3215
|
lastReviewedPlanHash = undefined; // force a fresh review
|
|
3035
3216
|
abortedReviewHash = undefined;
|
|
3036
3217
|
pausedReview = undefined;
|
|
3037
3218
|
reviewRound = 0;
|
|
3038
3219
|
const result = await runReviewStep(
|
|
3039
|
-
text,
|
|
3220
|
+
target.text,
|
|
3040
3221
|
ctx as any,
|
|
3041
3222
|
(ctx as any).signal as AbortSignal | undefined,
|
|
3042
3223
|
"manual",
|
|
@@ -3645,6 +3826,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3645
3826
|
defaultModel,
|
|
3646
3827
|
lastModeSwitch,
|
|
3647
3828
|
} as any);
|
|
3829
|
+
lastPersistedPlanFile = currentPlanFile;
|
|
3648
3830
|
}
|
|
3649
3831
|
|
|
3650
3832
|
async function setPlanMode(
|
|
@@ -3666,9 +3848,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3666
3848
|
if (workflowMode === null) captureDefaultModel(ctx);
|
|
3667
3849
|
workflowMode = "plan";
|
|
3668
3850
|
syncLegacyFlags();
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
|
|
3851
|
+
// Do NOT reset per-run provenance here. A fresh prompt already does it at
|
|
3852
|
+
// run start; preserving a plan write made earlier in THIS run is what lets
|
|
3853
|
+
// `/review` (and the "Plan file ready" notice) see it after a mid-run
|
|
3854
|
+
// switch. The mode-at-write flag keeps a Default-written plan from being
|
|
3855
|
+
// auto-handoffed unless the write itself happened under Plan.
|
|
3672
3856
|
clearPendingHandoffDecision();
|
|
3673
3857
|
if (toolsBeforePlanMode === undefined)
|
|
3674
3858
|
toolsBeforePlanMode = pi.getActiveTools();
|
|
@@ -5899,7 +6083,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5899
6083
|
const chips: string[] = [];
|
|
5900
6084
|
for (let i = 0; i < tabIds.length; i++) {
|
|
5901
6085
|
const roleName = tabIds[i];
|
|
5902
|
-
const st = staged.get(roleName)
|
|
6086
|
+
const st = staged.get(roleName);
|
|
6087
|
+
if (!st) continue;
|
|
5903
6088
|
const base =
|
|
5904
6089
|
roleName === "default"
|
|
5905
6090
|
? existing?.defaultModel
|
|
@@ -5952,7 +6137,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5952
6137
|
);
|
|
5953
6138
|
lines.push("");
|
|
5954
6139
|
for (const roleName of tabIds) {
|
|
5955
|
-
const st = staged.get(roleName)
|
|
6140
|
+
const st = staged.get(roleName);
|
|
6141
|
+
if (!st) continue;
|
|
5956
6142
|
lines.push(
|
|
5957
6143
|
...wrap(
|
|
5958
6144
|
` ${theme.fg("muted", `${roleIcon(roleName)} ${roleName}: `)}${theme.fg(
|
|
@@ -5966,8 +6152,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
5966
6152
|
}
|
|
5967
6153
|
} else {
|
|
5968
6154
|
const roleName = tabIds[currentTab];
|
|
5969
|
-
const st = staged.get(roleName)
|
|
5970
|
-
const meta = roleMeta.get(roleName)
|
|
6155
|
+
const st = staged.get(roleName);
|
|
6156
|
+
const meta = roleMeta.get(roleName);
|
|
6157
|
+
if (!st || !meta) return cached ?? lines;
|
|
5971
6158
|
lines.push(
|
|
5972
6159
|
...wrap(
|
|
5973
6160
|
` ${theme.fg("text", `${roleIcon(roleName)} ${roleName} — ${meta.description}`)}`,
|
|
@@ -7750,6 +7937,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7750
7937
|
// ── Session & Agent lifecycle ────────────────────────────────────
|
|
7751
7938
|
|
|
7752
7939
|
pi.on("session_start", async (event, ctx) => {
|
|
7940
|
+
// A session boundary owns the session-scoped plan fallback; the restore
|
|
7941
|
+
// loop below re-seeds it from the persisted `planFile`.
|
|
7942
|
+
resetSessionPlanProvenance();
|
|
7753
7943
|
// A reloaded/forked session must never inherit a stale write denial.
|
|
7754
7944
|
writeGateRunDenials.clear();
|
|
7755
7945
|
writeGateTurnAnswer = null;
|
|
@@ -7797,7 +7987,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7797
7987
|
if (Array.isArray(d.todos)) todoItems = ensureTodoLabels(d.todos);
|
|
7798
7988
|
todoPlanPath = d.planFile;
|
|
7799
7989
|
toolsBeforePlanMode = d.toolsBeforePlanMode;
|
|
7800
|
-
|
|
7990
|
+
// An entry without a planFile must not erase a value restored
|
|
7991
|
+
// from an earlier entry (the entry shape has changed over time).
|
|
7992
|
+
if (typeof d.planFile === "string" && d.planFile) {
|
|
7993
|
+
currentPlanFile = d.planFile;
|
|
7994
|
+
}
|
|
7801
7995
|
// restore role state (file config is source of truth for roles;
|
|
7802
7996
|
// session entry carries the latest activeRole) and the session's
|
|
7803
7997
|
// own profile activation
|
|
@@ -8107,6 +8301,8 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8107
8301
|
// pending browser correlation. The persisted awaitingDecision recovery
|
|
8108
8302
|
// above is kept for a recent modal; the first fresh prompt supersedes it.
|
|
8109
8303
|
resetPlanRunProvenance();
|
|
8304
|
+
lastPlanWritePath = currentPlanFile ?? null;
|
|
8305
|
+
lastPersistedPlanFile = currentPlanFile;
|
|
8110
8306
|
pendingHandoffPlanPath = null;
|
|
8111
8307
|
pendingHandoffPlanHash = null;
|
|
8112
8308
|
lastReviewId = null;
|
|
@@ -8167,6 +8363,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8167
8363
|
// Ensure a handoff decision/provenance does not persist stale across restart
|
|
8168
8364
|
const clearedAtShutdown = clearPendingHandoffDecision();
|
|
8169
8365
|
resetPlanRunProvenance();
|
|
8366
|
+
resetSessionPlanProvenance();
|
|
8170
8367
|
if (clearedAtShutdown) {
|
|
8171
8368
|
try {
|
|
8172
8369
|
persistState();
|
|
@@ -8191,6 +8388,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8191
8388
|
todoItems = [];
|
|
8192
8389
|
todoPlanPath = undefined;
|
|
8193
8390
|
btwNotes = [];
|
|
8391
|
+
// A rewind is a branch boundary: no per-run write from the old branch
|
|
8392
|
+
// may authorize a handoff or a manual review on the new one.
|
|
8393
|
+
resetPlanRunProvenance();
|
|
8394
|
+
clearPendingHandoffDecision();
|
|
8194
8395
|
// A rewind must not preserve a /commit exemption across branches.
|
|
8195
8396
|
commitAuthorization = null;
|
|
8196
8397
|
pendingCommitFlow = null;
|
|
@@ -8214,6 +8415,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
8214
8415
|
todoItems = ensureTodoLabels(e.message.details.todos);
|
|
8215
8416
|
}
|
|
8216
8417
|
}
|
|
8418
|
+
// Re-derive the plan from the branch we rewound to. Keeping the old
|
|
8419
|
+
// value would let `/review` audit a plan that no longer exists here.
|
|
8420
|
+
const latest = findLatestPlanWriteInBranch(branch, ctx.cwd);
|
|
8421
|
+
currentPlanFile = latest;
|
|
8422
|
+
lastPlanWritePath = latest ?? null;
|
|
8217
8423
|
} catch (_e) {
|
|
8218
8424
|
void _e;
|
|
8219
8425
|
}
|
|
@@ -9179,24 +9385,18 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9179
9385
|
// moved on, so a stale tab must not approve the previous plan. Follow-up
|
|
9180
9386
|
// runs keep the current run's provenance.
|
|
9181
9387
|
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
|
-
}
|
|
9388
|
+
const age = lastHandoffAt ? Date.now() - lastHandoffAt : Infinity;
|
|
9389
|
+
const cleared = clearPendingHandoffDecision();
|
|
9390
|
+
resetPlanRunProvenance();
|
|
9391
|
+
if (cleared) {
|
|
9392
|
+
persistState();
|
|
9393
|
+
try {
|
|
9394
|
+
pi.appendEntry("workflow-handoff-reset-before-start", {
|
|
9395
|
+
at: Date.now(),
|
|
9396
|
+
age,
|
|
9397
|
+
});
|
|
9398
|
+
} catch (_e) {
|
|
9399
|
+
void _e;
|
|
9200
9400
|
}
|
|
9201
9401
|
}
|
|
9202
9402
|
} catch (_e) {
|
|
@@ -9365,23 +9565,40 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9365
9565
|
});
|
|
9366
9566
|
|
|
9367
9567
|
// Plan file detection + full render + decision gate
|
|
9568
|
+
//
|
|
9569
|
+
// Two lifetimes (plan 2026-10-31):
|
|
9570
|
+
// - per-run: `planWritten*` / `planWritePathThisRun` decide the automatic
|
|
9571
|
+
// handoff for THIS run and reset on every fresh prompt/mode switch;
|
|
9572
|
+
// - session: `lastPlanWritePath` is the manual `/review` fallback and must
|
|
9573
|
+
// survive mode switches and fresh prompts. Only a session boundary clears
|
|
9574
|
+
// it. (`currentPlanFile` is the reviewer/Build target and is kept too.)
|
|
9368
9575
|
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
9576
|
/** This run wrote/edited a plan file under .pi/plans/. */
|
|
9372
9577
|
let planWrittenThisRun = false;
|
|
9578
|
+
/** This run wrote a plan while Plan mode was active (a mid-run switch counts). */
|
|
9579
|
+
let planWrittenInPlanModeThisRun = false;
|
|
9373
9580
|
/** Absolute path of this run's last plan write. */
|
|
9374
9581
|
let planWritePathThisRun: string | null = null;
|
|
9375
9582
|
/** Plan path/hash the currently rendered handoff prompt belongs to. */
|
|
9376
9583
|
let pendingHandoffPlanPath: string | null = null;
|
|
9377
9584
|
let pendingHandoffPlanHash: string | null = null;
|
|
9585
|
+
/** Last path announced by the suppressed-handoff notice (per run). */
|
|
9586
|
+
let lastPlanReadyNoticeKey: string | null = null;
|
|
9378
9587
|
|
|
9379
|
-
/** Clear per-run handoff provenance (fresh prompt / mode switch /
|
|
9588
|
+
/** Clear per-run handoff provenance (fresh prompt / mode switch / run end). */
|
|
9380
9589
|
function resetPlanRunProvenance(): void {
|
|
9381
|
-
handoffEligibleThisRun = false;
|
|
9382
9590
|
planWrittenThisRun = false;
|
|
9591
|
+
planWrittenInPlanModeThisRun = false;
|
|
9383
9592
|
planWritePathThisRun = null;
|
|
9593
|
+
lastPlanReadyNoticeKey = null;
|
|
9594
|
+
// Deliberately NOT lastPlanWritePath: a mode switch or a new prompt must
|
|
9595
|
+
// not make the plan the user just wrote unreachable to `/review`.
|
|
9596
|
+
}
|
|
9597
|
+
|
|
9598
|
+
/** Clear the session-scoped plan fallback (session start/shutdown only). */
|
|
9599
|
+
function resetSessionPlanProvenance(): void {
|
|
9384
9600
|
lastPlanWritePath = null;
|
|
9601
|
+
lastPersistedPlanFile = undefined;
|
|
9385
9602
|
}
|
|
9386
9603
|
|
|
9387
9604
|
/**
|
|
@@ -9466,11 +9683,20 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9466
9683
|
// handoff. `lastPlanWritePath` alone is session-lifetime stale.
|
|
9467
9684
|
planWritePathThisRun = abs;
|
|
9468
9685
|
planWrittenThisRun = true;
|
|
9686
|
+
// The reviewer's `review_submit_plan` writes this path; a manual
|
|
9687
|
+
// `/review` must also audit the file the user just wrote.
|
|
9688
|
+
currentPlanFile = abs;
|
|
9689
|
+
// A run that starts in Default/Build and switches into Plan mid-run
|
|
9690
|
+
// still qualifies: what matters is the mode at write time.
|
|
9691
|
+
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9469
9692
|
} catch (_e) {
|
|
9470
9693
|
void _e;
|
|
9471
|
-
|
|
9472
|
-
|
|
9694
|
+
const raw = String(p);
|
|
9695
|
+
lastPlanWritePath = raw;
|
|
9696
|
+
planWritePathThisRun = raw;
|
|
9473
9697
|
planWrittenThisRun = true;
|
|
9698
|
+
currentPlanFile = raw;
|
|
9699
|
+
if (workflowMode === "plan") planWrittenInPlanModeThisRun = true;
|
|
9474
9700
|
}
|
|
9475
9701
|
try {
|
|
9476
9702
|
pi.appendEntry("workflow-handoff-resolve", {
|
|
@@ -9538,16 +9764,59 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
9538
9764
|
: path.join(cwd, found);
|
|
9539
9765
|
lastPlanWritePath = planWritePathThisRun;
|
|
9540
9766
|
planWrittenThisRun = true;
|
|
9767
|
+
// The fallback only scans THIS run and we are in Plan mode now, so
|
|
9768
|
+
// this current-run write is attributable to the Plan cycle.
|
|
9769
|
+
planWrittenInPlanModeThisRun = true;
|
|
9541
9770
|
}
|
|
9542
9771
|
}
|
|
9772
|
+
// Persist the plan target once per run, in every mode, before the
|
|
9773
|
+
// eligibility gate. A plan written in Default/Build still needs to be
|
|
9774
|
+
// reachable by `/review` and by a later resume.
|
|
9775
|
+
if (planWritePathThisRun) {
|
|
9776
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
9777
|
+
const abs = path.isAbsolute(planWritePathThisRun)
|
|
9778
|
+
? planWritePathThisRun
|
|
9779
|
+
: path.join(cwd, planWritePathThisRun);
|
|
9780
|
+
currentPlanFile = abs;
|
|
9781
|
+
lastPlanWritePath = abs;
|
|
9782
|
+
// Persist once per run even when the tool_result handler already set
|
|
9783
|
+
// the in-memory target: resume must find the plan without a mode
|
|
9784
|
+
// switch or a todo update happening to persist it first.
|
|
9785
|
+
if (currentPlanFile !== lastPersistedPlanFile) persistState();
|
|
9786
|
+
}
|
|
9543
9787
|
if (workflowMode === "plan") {
|
|
9544
9788
|
const eligible = shouldHandoffPlan({
|
|
9545
9789
|
mode: workflowMode,
|
|
9546
|
-
runStartedInPlan: handoffEligibleThisRun,
|
|
9547
9790
|
planWrittenThisRun,
|
|
9791
|
+
planWrittenInPlanMode: planWrittenInPlanModeThisRun,
|
|
9548
9792
|
});
|
|
9549
9793
|
if (!eligible) {
|
|
9550
|
-
if (
|
|
9794
|
+
if (planWrittenThisRun) {
|
|
9795
|
+
// The plan is real and current, but it was written before this
|
|
9796
|
+
// run entered Plan mode. Do not hand it off automatically; make
|
|
9797
|
+
// the manual path discoverable instead.
|
|
9798
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
9799
|
+
const abs = planWritePathThisRun
|
|
9800
|
+
? path.isAbsolute(planWritePathThisRun)
|
|
9801
|
+
? planWritePathThisRun
|
|
9802
|
+
: path.join(cwd, planWritePathThisRun)
|
|
9803
|
+
: undefined;
|
|
9804
|
+
const key = abs ?? planWritePathThisRun;
|
|
9805
|
+
if (key && key !== lastPlanReadyNoticeKey) {
|
|
9806
|
+
lastPlanReadyNoticeKey = key;
|
|
9807
|
+
const rel = abs ? path.relative(cwd, abs) || abs : key;
|
|
9808
|
+
try {
|
|
9809
|
+
ctx.ui.notify(
|
|
9810
|
+
`Plan file ready: ${rel}. Run /review to audit it.`,
|
|
9811
|
+
"info",
|
|
9812
|
+
);
|
|
9813
|
+
} catch (_e) {
|
|
9814
|
+
void _e;
|
|
9815
|
+
}
|
|
9816
|
+
}
|
|
9817
|
+
} else {
|
|
9818
|
+
clearPendingHandoffDecision();
|
|
9819
|
+
}
|
|
9551
9820
|
return;
|
|
9552
9821
|
}
|
|
9553
9822
|
}
|
|
@@ -10667,6 +10936,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
10667
10936
|
}
|
|
10668
10937
|
toolsBeforePlanMode = undefined;
|
|
10669
10938
|
}
|
|
10939
|
+
// Leaving Plan starts a new cycle; entering Plan preserves a plan
|
|
10940
|
+
// written earlier in this run (see setPlanMode).
|
|
10941
|
+
if (!state.active) resetPlanRunProvenance();
|
|
10942
|
+
clearPendingHandoffDecision();
|
|
10670
10943
|
// Find a ctx to update status — use a no-op if none
|
|
10671
10944
|
// Status will be refreshed on next session_start/turn anyway
|
|
10672
10945
|
persistState();
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
7
7
|
"description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
|
|
10
|
+
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
|
|
11
11
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
12
12
|
"check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
|
|
13
13
|
},
|
package/review-runtime.ts
CHANGED
|
@@ -532,6 +532,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
532
532
|
const remaining = (): number | undefined =>
|
|
533
533
|
deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
|
|
534
534
|
let sendError: unknown;
|
|
535
|
+
// The losing waiters in the pass race must be cancelled when the winner
|
|
536
|
+
// settles. Otherwise their timers (up to `timeoutMs`, 5 minutes by
|
|
537
|
+
// default) stay referenced and keep the process alive after the round.
|
|
538
|
+
const waitAbort = new AbortController();
|
|
535
539
|
|
|
536
540
|
const stop = await new Promise<PassStop>((resolve) => {
|
|
537
541
|
let done = false;
|
|
@@ -542,6 +546,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
542
546
|
done = true;
|
|
543
547
|
if (timer !== undefined) clearTimeout(timer);
|
|
544
548
|
rc.signal.removeEventListener("abort", onAbort);
|
|
549
|
+
waitAbort.abort();
|
|
545
550
|
resolve(s);
|
|
546
551
|
};
|
|
547
552
|
if (rc.signal.aborted) {
|
|
@@ -551,10 +556,10 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
551
556
|
const cap = remaining();
|
|
552
557
|
if (cap !== undefined) timer = setTimeout(() => finish("timeout"), cap);
|
|
553
558
|
rc.signal.addEventListener("abort", onAbort, { once: true });
|
|
554
|
-
void gate.waitForPassDone(remaining(),
|
|
559
|
+
void gate.waitForPassDone(remaining(), waitAbort.signal).then((result) => {
|
|
555
560
|
if (result) finish("passDone");
|
|
556
561
|
});
|
|
557
|
-
void waitForSettle(baseline, remaining(),
|
|
562
|
+
void waitForSettle(baseline, remaining(), waitAbort.signal).then((did) => {
|
|
558
563
|
if (did) finish("settled");
|
|
559
564
|
});
|
|
560
565
|
void send().catch((e: unknown) => {
|
package/utils.ts
CHANGED
|
@@ -934,22 +934,27 @@ export function isPlanWritePath(p: string, cwd: string): boolean {
|
|
|
934
934
|
/** Inputs for deciding whether this run may hand a plan off. */
|
|
935
935
|
export interface PlanHandoffEligibility {
|
|
936
936
|
mode: "plan" | "build" | null;
|
|
937
|
-
/** The run started in Plan mode (not a Build turn that flipped to Plan). */
|
|
938
|
-
runStartedInPlan: boolean;
|
|
939
937
|
/** This run wrote/edited a file under `.pi/plans/`. */
|
|
940
938
|
planWrittenThisRun: boolean;
|
|
939
|
+
/**
|
|
940
|
+
* The write happened while Plan mode was active. A run that starts in
|
|
941
|
+
* Default/Build and switches to Plan mid-run counts; a plan written before
|
|
942
|
+
* the switch does not. `agent_end`'s current-run message scan sets this too
|
|
943
|
+
* when the run ends in Plan (the tool_result handler was missed).
|
|
944
|
+
*/
|
|
945
|
+
planWrittenInPlanMode: boolean;
|
|
941
946
|
}
|
|
942
947
|
|
|
943
948
|
/**
|
|
944
949
|
* A handoff requires all three: the workflow is in Plan mode NOW, the run
|
|
945
|
-
*
|
|
950
|
+
* wrote a plan, and that write happened under Plan mode. Session-lifetime
|
|
946
951
|
* plan files and old todos never authorize a handoff.
|
|
947
952
|
*/
|
|
948
953
|
export function shouldHandoffPlan(input: PlanHandoffEligibility): boolean {
|
|
949
954
|
return (
|
|
950
955
|
input.mode === "plan" &&
|
|
951
|
-
input.
|
|
952
|
-
input.
|
|
956
|
+
input.planWrittenThisRun === true &&
|
|
957
|
+
input.planWrittenInPlanMode === true
|
|
953
958
|
);
|
|
954
959
|
}
|
|
955
960
|
|
|
@@ -978,6 +983,73 @@ export function findPlanWritePathInMessages(
|
|
|
978
983
|
return undefined;
|
|
979
984
|
}
|
|
980
985
|
|
|
986
|
+
/** Where a manual `/review` found the plan it is about to audit. */
|
|
987
|
+
export type PlanPathSource =
|
|
988
|
+
| "explicit"
|
|
989
|
+
| "run"
|
|
990
|
+
| "session"
|
|
991
|
+
| "handoff"
|
|
992
|
+
| "branch";
|
|
993
|
+
|
|
994
|
+
export interface PlanPathCandidate {
|
|
995
|
+
path?: string | null;
|
|
996
|
+
source: PlanPathSource;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export interface PlanPathSelection {
|
|
1000
|
+
path: string;
|
|
1001
|
+
source: PlanPathSource;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Pick the first readable plan for a manual `/review`.
|
|
1006
|
+
*
|
|
1007
|
+
* Candidates arrive in priority order (`explicit` → `run` → `session` →
|
|
1008
|
+
* `handoff` → `branch`). Missing, empty and unreadable candidates are skipped;
|
|
1009
|
+
* a plan that disappeared since it was written must never win over the next
|
|
1010
|
+
* candidate. Pure: the caller owns path resolution and the filesystem probe.
|
|
1011
|
+
*/
|
|
1012
|
+
export function choosePlanPath(
|
|
1013
|
+
candidates: readonly PlanPathCandidate[],
|
|
1014
|
+
isReadable: (absolutePath: string) => boolean,
|
|
1015
|
+
): PlanPathSelection | undefined {
|
|
1016
|
+
for (const candidate of candidates) {
|
|
1017
|
+
const p = candidate.path;
|
|
1018
|
+
if (typeof p !== "string" || p.length === 0) continue;
|
|
1019
|
+
if (isReadable(p)) return { path: p, source: candidate.source };
|
|
1020
|
+
}
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Newest plan reference in a session branch, used as the last manual
|
|
1026
|
+
* `/review` fallback after a reload or when the in-memory path was lost.
|
|
1027
|
+
*
|
|
1028
|
+
* Recognises the persisted `workflow` entry (`planFile`), the
|
|
1029
|
+
* `workflow-handoff-resolve` entry (`planPath`), and an assistant `write`/
|
|
1030
|
+
* `edit` tool call under `.pi/plans/`. Scans newest-first and never throws.
|
|
1031
|
+
*/
|
|
1032
|
+
export function findLatestPlanWriteInBranch(
|
|
1033
|
+
entries: readonly unknown[],
|
|
1034
|
+
cwd: string,
|
|
1035
|
+
): string | undefined {
|
|
1036
|
+
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
1037
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1038
|
+
const e = entries[i] as any;
|
|
1039
|
+
if (!e || typeof e !== "object") continue;
|
|
1040
|
+
if (e.type === "custom") {
|
|
1041
|
+
const p = e.data?.planPath ?? e.data?.planFile;
|
|
1042
|
+
if (typeof p === "string" && p && isPlanWritePath(p, cwd)) return p;
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (e.type === "message" && e.message) {
|
|
1046
|
+
const found = findPlanWritePathInMessages([e.message], cwd);
|
|
1047
|
+
if (found) return found;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
981
1053
|
// ── Plan date helpers (UTC) ──────────────────────────────────────
|
|
982
1054
|
|
|
983
1055
|
/** Return YYYY-MM-DD in UTC for the given date (default: now). Uses toISOString slice, deterministic across locales. */
|