@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 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 — works with Review Mode on or off (mode stays OFF) |
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 supervises the plan without freezing your session. The handoff renders **immediately** with the author's plan (marked "review pending") while the reviewer works **in the background**; the plan file is updated when the review lands.
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──▶ handoff renders at once (author's plan)
88
- │ ╰─▶ Review Mode (child session, background)
89
- │ │ rewrites the plan file
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 Plan run writes or edits a `.pi/plans/` file**. Switching Build → Plan keeps the previous plan and todos as reference, but they cannot be handed off again until the new run writes a plan. A fresh prompt also supersedes any pending browser/inline handoff prompt, and a stale browser approval/denial from a previous plan is ignored.
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
- export default function workflowExtension(pi: ExtensionAPI) {
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; works whether Review Mode is on or off (mode stays OFF); /review say <msg> talks to the in-flight reviewer",
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\b/i.test(raw)) {
2942
- const message = raw.replace(/^say\b/i, "").trim();
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
- const planPath = currentPlanFile;
2982
- if (!planPath) {
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
- "No plan file yet — write one to .pi/plans/ first.",
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
- let text: string | undefined;
2990
- try {
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
- // Entering Plan starts a new handoff cycle. Old plan/todos stay as
3670
- // reference, but only the next fresh prompt can make a run eligible.
3671
- resetPlanRunProvenance();
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
- currentPlanFile = d.planFile;
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 fw =
9183
- (event as any).followUp === true ||
9184
- (event as any).deliverAs === "followUp";
9185
- if (!fw) {
9186
- const age = lastHandoffAt ? Date.now() - lastHandoffAt : Infinity;
9187
- const cleared = clearPendingHandoffDecision();
9188
- resetPlanRunProvenance();
9189
- handoffEligibleThisRun = workflowMode === "plan";
9190
- if (cleared) {
9191
- persistState();
9192
- try {
9193
- pi.appendEntry("workflow-handoff-reset-before-start", {
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 / session boundary). */
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
- lastPlanWritePath = String(p);
9472
- planWritePathThisRun = String(p);
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 (!planWrittenThisRun) clearPendingHandoffDecision();
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.0",
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(), rc.signal).then((result) => {
559
+ void gate.waitForPassDone(remaining(), waitAbort.signal).then((result) => {
555
560
  if (result) finish("passDone");
556
561
  });
557
- void waitForSettle(baseline, remaining(), rc.signal).then((did) => {
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
- * started in Plan mode, and the run actually wrote a plan. Session-lifetime
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.runStartedInPlan === true &&
952
- input.planWrittenThisRun === true
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. */