@agent-plan/pi-adapter 0.2.18 → 0.2.19-next.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/dist/index.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * - inject context into prompts
10
10
  */
11
11
  import { Type } from "typebox";
12
- import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug } from "@agent-plan/core/naming";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef } from "@agent-plan/core";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
14
14
  import { join, dirname } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -101,10 +101,11 @@ const PLANNER_COMMAND_COMPLETIONS = [
101
101
  { value: "task delete", label: "task delete", description: "Delete a task" },
102
102
  { value: "task start", label: "task start", description: "Mark a task in-progress" },
103
103
  { value: "task complete", label: "task complete", description: "Mark a task done" },
104
- { value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to create/update the handoff" },
105
- { value: "handoff show", label: "handoff show", description: "Show the current handoff" },
106
- { value: "handoff write", label: "handoff write", description: "Write handoff directly from planner data" },
107
- { value: "handoff clear", label: "handoff clear", description: "Delete the current handoff" },
104
+ { value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
105
+ { value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
106
+ { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
107
+ { value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (default: in-progress phase)" },
108
+ { value: "handoff clear", label: "handoff clear [P00x]", description: "Clear a phase handoff (default: in-progress phase)" },
108
109
  { value: "web start", label: "web start", description: "Start the web UI" },
109
110
  { value: "web stop", label: "web stop", description: "Stop the web UI" },
110
111
  { value: "web status", label: "web status", description: "Show web UI status" },
@@ -157,11 +158,17 @@ function resetState() {
157
158
  editedThisTurn = false;
158
159
  taskCompleteReminderSaidThisTurn = false;
159
160
  }
160
- async function maybeHealStatuses(st) {
161
+ async function maybeHealStatuses(st, ctx) {
161
162
  if (healedStatusRoots.has(st.root))
162
163
  return;
163
- await st.syncStatuses();
164
+ const cleared = await st.syncStatuses();
164
165
  healedStatusRoots.add(st.root);
166
+ if (cleared.length > 0 && ctx?.ui?.notify) {
167
+ try {
168
+ ctx.ui.notify(`ℹ️ Handoff auto-cleared (phase completed): ${cleared.join(", ")}`, "info");
169
+ }
170
+ catch { }
171
+ }
165
172
  }
166
173
  function normalizeLanguagePref(value) {
167
174
  return value?.trim() ?? "";
@@ -221,10 +228,10 @@ function taskLabel(task) {
221
228
  return `T${formatSequence(task.number)} — ${task.title}`;
222
229
  }
223
230
  async function buildStartupResumeSummary(st) {
224
- const [plan, resume, handoff] = await Promise.all([
231
+ const [plan, resume, handoffs] = await Promise.all([
225
232
  st.loadAll(),
226
233
  st.refreshResume(),
227
- st.loadHandoff(),
234
+ st.listHandoffs(),
228
235
  ]);
229
236
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
230
237
  const orderedPhases = [
@@ -281,8 +288,8 @@ async function buildStartupResumeSummary(st) {
281
288
  : currentPhase
282
289
  ? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
283
290
  : "riprendere il lavoro attivo corrente"))
284
- : (handoff?.updatedAt
285
- ? "leggere l'handoff e verificare ordine, dipendenze e stato reale del piano prima di scegliere il prossimo task"
291
+ : (handoffs.length > 0
292
+ ? "leggere l'handoff di fase più recente (handoff show <ref>) e verificarne ordine e stato rispetto al piano prima di scegliere il prossimo task"
286
293
  : "rivedere il piano e scegliere il prossimo task concreto");
287
294
  const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
288
295
  const italian = chatLanguage.includes("ital");
@@ -296,8 +303,8 @@ async function buildStartupResumeSummary(st) {
296
303
  currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
297
304
  currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
298
305
  currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
299
- handoff?.updatedAt ? `- Handoff letto da .planner/HANDOFF.md (aggiornato ${handoff.updatedAt}).` : "- Nessun handoff strutturato disponibile.",
300
- !hasActiveWork && handoff?.updatedAt ? "- Nota: l'handoff è un suggerimento della sessione precedente; con 0 task/fasi attivi va validato contro stato e dipendenze correnti prima di riprendere un task specifico." : "",
306
+ handoffs.length > 0 ? `- Handoff di fase pendenti (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- Nessun handoff di fase pendente.",
307
+ !hasActiveWork && handoffs.length > 0 ? "- Nota: l'handoff di fase è un suggerimento della sessione precedente; con 0 task/fasi attivi va validato contro stato e dipendenze correnti prima di riprendere un task specifico." : "",
301
308
  `- Prossima attività consigliata: ${nextActivity}.`,
302
309
  "",
303
310
  "Vuoi che riprendiamo da qui?",
@@ -311,8 +318,8 @@ async function buildStartupResumeSummary(st) {
311
318
  currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
312
319
  currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
313
320
  currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
314
- handoff?.updatedAt ? `- Handoff loaded from .planner/HANDOFF.md (updated ${handoff.updatedAt}).` : "- No structured handoff found.",
315
- !hasActiveWork && handoff?.updatedAt ? "- Note: the handoff is only a previous-session hint; with 0 active tasks/phases it must be validated against current planner state and dependencies before resuming a specific task." : "",
321
+ handoffs.length > 0 ? `- Pending phase handoffs (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- No pending phase handoffs.",
322
+ !hasActiveWork && handoffs.length > 0 ? "- Note: the phase handoff is only a previous-session hint; with 0 active tasks/phases it must be validated against current planner state and dependencies before resuming a specific task." : "",
316
323
  `- Recommended next activity: ${nextActivity}.`,
317
324
  "",
318
325
  "Do you want to resume from here?",
@@ -321,13 +328,12 @@ async function buildStartupResumeSummary(st) {
321
328
  ].filter(Boolean).join("\n");
322
329
  }
323
330
  async function buildHandoffMarkdown(st, reason, overrides) {
324
- const [plan, resume, activity, existingHandoff] = await Promise.all([
331
+ const [plan, resume, activity] = await Promise.all([
325
332
  st.loadAll(),
326
333
  st.refreshResume(),
327
334
  st.loadActivityLog(),
328
- st.loadHandoff(),
329
335
  ]);
330
- const createdAt = existingHandoff?.createdAt || nowISO();
336
+ const createdAt = nowISO();
331
337
  const updatedAt = nowISO();
332
338
  const allTasks = plan.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
333
339
  const nonTerminalTasks = allTasks.filter(({ task }) => task.status !== "done" && task.status !== "canceled");
@@ -412,7 +418,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
412
418
  : currentPhase
413
419
  ? `1. Open phase ${currentPhase.id} (${currentPhase.title}).`
414
420
  : "1. Open the planner dashboard and inspect the latest feature/phase state.",
415
- "2. Read `.planner/HANDOFF.md` and compare it with the latest planner data.",
421
+ "2. Run handoff show <ref> (or /planner handoff show <ref>) to read the phase handoff and compare it with the latest planner data.",
416
422
  currentTask && currentTask.status !== "in-progress"
417
423
  ? `3. Before implementation work, run /planner task start ${currentTask.id} (or call task_start).`
418
424
  : "3. Confirm whether the current task is already in-progress before doing implementation work.",
@@ -420,11 +426,10 @@ async function buildHandoffMarkdown(st, reason, overrides) {
420
426
  ].join("\n");
421
427
  const filesTouched = [
422
428
  ".planner/project.json",
423
- ".planner/features.json",
429
+ ".planner/features/",
424
430
  currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
425
431
  latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
426
432
  ".planner/resume.json",
427
- ".planner/HANDOFF.md",
428
433
  ".planner/generated/PLAN.md",
429
434
  ].filter(Boolean);
430
435
  const recentActivityLines = recentActivity.length > 0
@@ -488,12 +493,19 @@ async function buildHandoffMarkdown(st, reason, overrides) {
488
493
  ...recentActivityLines,
489
494
  "",
490
495
  "## Reminder",
491
- "- When work is fully resumed and this handoff is no longer needed, delete `.planner/HANDOFF.md`.",
496
+ "- When work is fully resumed and this handoff is no longer needed, clear the phase handoff with handoff clear <ref> (or /planner handoff clear).",
492
497
  ].join("\n");
493
498
  }
494
499
  async function writeProjectHandoff(st, reason, overrides) {
500
+ // Entity-scoped: write the auto-handoff on the current in-progress phase
501
+ // (not the deprecated .planner/HANDOFF.md file). If no in-progress phase,
502
+ // skip silently — the resume flow falls back to resume.json.
503
+ const phases = await st.loadAllPhases();
504
+ const phase = phases.find((p) => p.status === "in-progress") ?? null;
505
+ if (!phase)
506
+ return;
495
507
  const markdown = await buildHandoffMarkdown(st, reason, overrides);
496
- await st.saveHandoff(markdown);
508
+ await st.setPhaseHandoff(phase.id, markdown);
497
509
  }
498
510
  function compactShortcut(spec) {
499
511
  return {
@@ -802,11 +814,11 @@ export default function planPiExtension(pi) {
802
814
  ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
803
815
  }
804
816
  plannerSessionEnabled = enablePlanner;
805
- // Proactive check for leftover handoff files.
817
+ // Proactive review hint: surface pending phase handoffs (entity-scoped).
806
818
  if (plannerSessionEnabled) {
807
- const handoffPath = join(st.root, "HANDOFF.md");
808
- if (existsSync(handoffPath)) {
809
- ctx.ui.notify("🚨 HANDOFF DETECTED: Read and delete .planner/HANDOFF.md immediately to avoid state conflicts.", "warning");
819
+ const handoffs = await st.listHandoffs().catch(() => []);
820
+ if (handoffs.length > 0) {
821
+ ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
810
822
  }
811
823
  }
812
824
  // If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
@@ -929,7 +941,7 @@ export default function planPiExtension(pi) {
929
941
  const st = loadStore(ctx);
930
942
  if (!(await st.exists().catch(() => false)))
931
943
  return;
932
- await maybeHealStatuses(st).catch(() => { });
944
+ await maybeHealStatuses(st, ctx).catch(() => { });
933
945
  const guard = await getPlannerExecutionGuard(st).catch(() => null);
934
946
  if (!guard || guard.totalTasks === 0)
935
947
  return; // nothing to enforce yet
@@ -1018,7 +1030,7 @@ export default function planPiExtension(pi) {
1018
1030
  ];
1019
1031
  const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1020
1032
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
1021
- "Handoff actions: /planner handoff prepare | show | write | clear";
1033
+ "Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
1022
1034
  if (!a) {
1023
1035
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
1024
1036
  if (!action) {
@@ -1403,9 +1415,13 @@ export default function planPiExtension(pi) {
1403
1415
  }
1404
1416
  const now = nowISO();
1405
1417
  const featureNumber = (await st.loadFeatures()).features.length + 1;
1418
+ const shortId = createShortId(await st.assignedShortIds());
1419
+ const priority = await st.nextPriority("feature");
1406
1420
  const feature = {
1407
1421
  id: createFeatureId(),
1408
1422
  number: featureNumber,
1423
+ shortId,
1424
+ priority,
1409
1425
  name: nameInput.trim(),
1410
1426
  description: description?.trim() ?? "",
1411
1427
  status: status,
@@ -1588,11 +1604,14 @@ export default function planPiExtension(pi) {
1588
1604
  const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
1589
1605
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1590
1606
  const id = createPhaseId();
1607
+ const shortId = createShortId(await st.assignedShortIds());
1608
+ const priority = await st.nextPriority("phase", feature.id);
1591
1609
  phase = {
1592
- id, number, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
1610
+ id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
1593
1611
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1594
1612
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1595
1613
  createdAt: nowISO(), updatedAt: nowISO(),
1614
+ handoff: "", handoffUpdatedAt: "",
1596
1615
  };
1597
1616
  await st.savePhase(phase);
1598
1617
  await st.updateFeatures((doc) => {
@@ -1825,9 +1844,11 @@ export default function planPiExtension(pi) {
1825
1844
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1826
1845
  const taskNum = phase.tasks.length + 1;
1827
1846
  const taskId = createTaskId();
1847
+ const shortId = createShortId(await st.assignedShortIds());
1848
+ const priority = await st.nextPriority("task", phase.id);
1828
1849
  const now = nowISO();
1829
1850
  const task = {
1830
- id: taskId, phaseId: phase.id, number: taskNum, shortName,
1851
+ id: taskId, phaseId: phase.id, number: taskNum, shortId, priority, shortName,
1831
1852
  title: title.trim(), status: "planned",
1832
1853
  description: "",
1833
1854
  notes: "",
@@ -1994,8 +2015,8 @@ export default function planPiExtension(pi) {
1994
2015
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
1995
2016
  return;
1996
2017
  }
1997
- if (normalizedStatus === "in-progress" && existsSync(join(st.root, "HANDOFF.md"))) {
1998
- ctx.ui.notify("ℹ️ A handoff exists at .planner/HANDOFF.md — read it for context, then delete it with /planner handoff clear (or plan_delete_handoff) when no longer needed. Proceeding with the status change.", "info");
2018
+ if (normalizedStatus === "in-progress" && (await st.listHandoffs()).length > 0) {
2019
+ ctx.ui.notify("ℹ️ One or more phases have a pending handoff — if relevant to this task, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with the status change.", "info");
1999
2020
  }
2000
2021
  applyTaskLifecycleDates(task, normalizedStatus, now);
2001
2022
  }
@@ -2040,8 +2061,8 @@ export default function planPiExtension(pi) {
2040
2061
  // block task_start — the handoff is a captured-context artifact, not a
2041
2062
  // lock (a small modification after writing a handoff should start without
2042
2063
  // forcing deletion, which would lose context).
2043
- if (existsSync(join(st.root, "HANDOFF.md"))) {
2044
- ctx.ui.notify("ℹ️ A handoff exists at .planner/HANDOFF.md — read it for context, then delete it with /planner handoff clear (or plan_delete_handoff) when no longer needed. Proceeding with task start.", "info");
2064
+ if ((await st.listHandoffs()).length > 0) {
2065
+ ctx.ui.notify("ℹ️ One or more phases have a pending handoff — if relevant to the task you're starting, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with task start.", "info");
2045
2066
  }
2046
2067
  const now = nowISO();
2047
2068
  // Record status change in the incremental statusLog.
@@ -2129,60 +2150,80 @@ export default function planPiExtension(pi) {
2129
2150
  // ── handoff ──
2130
2151
  if (a === "handoff") {
2131
2152
  const action = b || "show";
2153
+ const phaseRef = parts[2]?.trim();
2132
2154
  if (!(await st.exists())) {
2133
2155
  ctx.ui.notify("No .planner/ found.", "warning");
2134
2156
  return;
2135
2157
  }
2158
+ if (action === "list") {
2159
+ const list = await st.listHandoffs();
2160
+ if (list.length === 0) {
2161
+ ctx.ui.notify("No phase handoffs set.", "info");
2162
+ return;
2163
+ }
2164
+ ctx.ui.notify(list.map((e) => `• ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`).join("\n"), "info");
2165
+ return;
2166
+ }
2136
2167
  if (action === "show") {
2137
- const handoff = await st.loadHandoff();
2138
- if (!handoff) {
2139
- ctx.ui.notify("No .planner/HANDOFF.md present.", "info");
2168
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2169
+ if (!r.ok) {
2170
+ ctx.ui.notify(r.error, "warning");
2140
2171
  return;
2141
2172
  }
2142
- ctx.ui.notify(handoff.content, "info");
2173
+ const content = await st.getPhaseHandoff(r.phase.id);
2174
+ ctx.ui.notify(content.trim() ? `Handoff ${r.compositeRef}:\n${content}` : `No handoff set on ${r.compositeRef}.`, "info");
2143
2175
  return;
2144
2176
  }
2145
- if (action === "prepare" || action === "write") {
2146
- if (action === "prepare") {
2147
- if (!capturedPi) {
2148
- ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2149
- return;
2150
- }
2151
- const handoff = await st.loadHandoff();
2152
- await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it to `.planner/HANDOFF.md` using the `plan_write_handoff` tool.\n\n" +
2153
- "The handoff MUST be a structured markdown document containing at minimum:\n" +
2154
- "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2155
- "- `Reason:` why this handoff is being written\n" +
2156
- "- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
2157
- "- `## What was being done`: a concrete 3–10 line narrative of the work in progress, based on our conversation and the current task notes/description.\n" +
2158
- "- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
2159
- "- `## Files touched`: explicit file paths modified during this session.\n" +
2160
- "- `## Blockers`: any open blockers, or `None recorded`.\n" +
2161
- "- `## Next steps`: the immediate next steps.\n" +
2162
- "- `## Recent decisions`: accepted decisions made this session, if any.\n" +
2163
- "- `## Reminder`: note that when the work is fully resumed, the agent must delete `.planner/HANDOFF.md` using `plan_delete_handoff`.\n\n" +
2164
- "Do NOT write the handoff to `.pi/`. The canonical path is `.planner/HANDOFF.md`. " +
2165
- (handoff?.content ? "A previous handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
2166
- "Once written, confirm to me with a one-line summary.");
2167
- ctx.ui.notify("Instructing the agent to prepare .planner/HANDOFF.md…", "info");
2177
+ if (action === "write") {
2178
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2179
+ if (!r.ok) {
2180
+ ctx.ui.notify(r.error, "warning");
2168
2181
  return;
2169
2182
  }
2170
- // action === "write": auto-generate from plan data without agent narrative.
2171
- const whatWasBeingDone = await ctx.ui.input("What was being done? (optional override)");
2172
- const howToResume = await ctx.ui.input("How should the next agent resume? (optional override)");
2173
- await writeProjectHandoff(st, "manual on-demand handoff", {
2174
- ...(whatWasBeingDone?.trim() ? { whatWasBeingDone: whatWasBeingDone.trim() } : {}),
2175
- ...(howToResume?.trim() ? { howToResume: howToResume.trim() } : {}),
2176
- });
2177
- ctx.ui.notify("Wrote .planner/HANDOFF.md", "info");
2183
+ const text = await ctx.ui.input(`Handoff text for ${r.compositeRef}`);
2184
+ if (!text?.trim()) {
2185
+ ctx.ui.notify("No text provided; nothing written.", "info");
2186
+ return;
2187
+ }
2188
+ await st.setPhaseHandoff(r.phase.id, text);
2189
+ ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
2190
+ return;
2191
+ }
2192
+ if (action === "prepare") {
2193
+ if (!capturedPi) {
2194
+ ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2195
+ return;
2196
+ }
2197
+ const handoffs = await st.listHandoffs();
2198
+ await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the current in-progress phase using the `handoff_write` tool (entity-scoped, stored on phase.handoff — .planner/HANDOFF.md is deprecated).\n\n" +
2199
+ "The handoff MUST be a structured markdown document containing at minimum:\n" +
2200
+ "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2201
+ "- `Reason:` why this handoff is being written\n" +
2202
+ "- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
2203
+ "- `## What was being done`: a concrete 3–10 line narrative of the work in progress, based on our conversation and the current task notes/description.\n" +
2204
+ "- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
2205
+ "- `## Files touched`: explicit file paths modified during this session.\n" +
2206
+ "- `## Blockers`: any open blockers, or `None recorded`.\n" +
2207
+ "- `## Next steps`: the immediate next steps.\n" +
2208
+ "- `## Recent decisions`: accepted decisions made this session, if any.\n" +
2209
+ "- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
2210
+ "The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
2211
+ (handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
2212
+ "Once written, confirm to me with a one-line summary.");
2213
+ ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2178
2214
  return;
2179
2215
  }
2180
2216
  if (action === "clear" || action === "delete") {
2181
- await st.deleteHandoff();
2182
- ctx.ui.notify("Deleted .planner/HANDOFF.md", "info");
2217
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2218
+ if (!r.ok) {
2219
+ ctx.ui.notify(r.error, "warning");
2220
+ return;
2221
+ }
2222
+ await st.clearPhaseHandoff(r.phase.id);
2223
+ ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
2183
2224
  return;
2184
2225
  }
2185
- ctx.ui.notify("handoff actions: prepare | show | write | clear\nUse: /planner handoff prepare | show | write | clear", "info");
2226
+ ctx.ui.notify("handoff actions: list | show [phaseRef] | write [phaseRef] | clear [phaseRef] | prepare\nUse: /planner handoff list | show | write | clear | prepare", "info");
2186
2227
  return;
2187
2228
  }
2188
2229
  // ── web ──
@@ -2338,7 +2379,7 @@ export default function planPiExtension(pi) {
2338
2379
  const st = loadStore(ctx);
2339
2380
  if (!(await st.exists()))
2340
2381
  return null;
2341
- await maybeHealStatuses(st);
2382
+ await maybeHealStatuses(st, ctx);
2342
2383
  return st;
2343
2384
  }
2344
2385
  // ── plan lifecycle ──────────────────────────────────────────────────
@@ -2631,22 +2672,23 @@ export default function planPiExtension(pi) {
2631
2672
  pi.registerTool({
2632
2673
  name: "plan_get_handoff",
2633
2674
  label: "Plan Get Handoff",
2634
- description: "Read the current .planner/HANDOFF.md if present.",
2675
+ description: "DEPRECATED — redirects to the entity-scoped handoff (phase.handoff). Lists current phase handoffs. Prefer handoff_list / handoff_show.",
2635
2676
  parameters: Type.Object({}),
2636
2677
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2637
2678
  const st = await requirePlan(ctx);
2638
2679
  if (!st)
2639
2680
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2640
- const handoff = await st.loadHandoff();
2641
- if (!handoff)
2642
- return { content: [{ type: "text", text: "No .planner/HANDOFF.md present." }], details: { exists: false } };
2643
- return { content: [{ type: "text", text: handoff.content }], details: { exists: true, ...handoff } };
2681
+ const list = await st.listHandoffs();
2682
+ if (list.length === 0)
2683
+ return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0 } };
2684
+ const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
2685
+ return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { deprecated: true, count: list.length, handoffs: list } };
2644
2686
  },
2645
2687
  });
2646
2688
  pi.registerTool({
2647
2689
  name: "plan_write_handoff",
2648
2690
  label: "Plan Write Handoff",
2649
- description: "Write or refresh the canonical .planner/HANDOFF.md document. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made.",
2691
+ description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. Write or refresh the canonical phase handoff. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made.",
2650
2692
  parameters: Type.Object({
2651
2693
  reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
2652
2694
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
@@ -2660,7 +2702,7 @@ export default function planPiExtension(pi) {
2660
2702
  const st = await requirePlan(ctx);
2661
2703
  if (!st)
2662
2704
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2663
- await writeProjectHandoff(st, params.reason?.trim() || "manual tool handoff", {
2705
+ const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2664
2706
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2665
2707
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2666
2708
  ...(params.extraSections && params.extraSections.length > 0
@@ -2671,21 +2713,125 @@ export default function planPiExtension(pi) {
2671
2713
  }
2672
2714
  : {}),
2673
2715
  });
2674
- const handoff = await st.loadHandoff();
2675
- return { content: [{ type: "text", text: "Wrote .planner/HANDOFF.md" }], details: handoff ?? {} };
2716
+ const r = await resolvePhaseForHandoff(st, undefined);
2717
+ if (!r.ok)
2718
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${r.error}` }], details: { deprecated: true, error: r.error } };
2719
+ await st.setPhaseHandoff(r.phase.id, markdown);
2720
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${r.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
2676
2721
  },
2677
2722
  });
2678
2723
  pi.registerTool({
2679
2724
  name: "plan_delete_handoff",
2680
2725
  label: "Plan Delete Handoff",
2681
- description: "Delete .planner/HANDOFF.md after the resume has been fully consumed.",
2726
+ description: "DEPRECATED — clears the entity-scoped handoff of the current in-progress phase. Prefer handoff_clear.",
2727
+ parameters: Type.Object({}),
2728
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
2729
+ const st = await requirePlan(ctx);
2730
+ if (!st)
2731
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2732
+ const r = await resolvePhaseForHandoff(st, undefined);
2733
+ if (!r.ok)
2734
+ return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
2735
+ await st.clearPhaseHandoff(r.phase.id);
2736
+ return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — cleared handoff on ${r.compositeRef} (phase.handoff). Prefer handoff_clear.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
2737
+ },
2738
+ });
2739
+ // Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
2740
+ // UUID | title; omitted -> current in-progress phase (phase.status, or the
2741
+ // phase containing an in-progress task). Returns the phase + its composite ref.
2742
+ async function resolvePhaseForHandoff(st, ref) {
2743
+ const phases = await st.loadAllPhases();
2744
+ const features = (await st.loadFeatures()).features;
2745
+ let phase;
2746
+ if (ref && ref.trim()) {
2747
+ phase = findPhaseByRef(phases, features, ref.trim());
2748
+ }
2749
+ else {
2750
+ phase = phases.find((p) => p.status === "in-progress")
2751
+ ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
2752
+ }
2753
+ if (!phase) {
2754
+ return {
2755
+ ok: false,
2756
+ error: ref && ref.trim()
2757
+ ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
2758
+ : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
2759
+ };
2760
+ }
2761
+ const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
2762
+ return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
2763
+ }
2764
+ pi.registerTool({
2765
+ name: "handoff_list",
2766
+ label: "Handoff List",
2767
+ description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
2682
2768
  parameters: Type.Object({}),
2683
2769
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2684
2770
  const st = await requirePlan(ctx);
2685
2771
  if (!st)
2686
2772
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2687
- await st.deleteHandoff();
2688
- return { content: [{ type: "text", text: "Deleted .planner/HANDOFF.md" }], details: { deleted: true } };
2773
+ const list = await st.listHandoffs();
2774
+ if (list.length === 0)
2775
+ return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
2776
+ const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
2777
+ return { content: [{ type: "text", text: `Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { count: list.length, handoffs: list } };
2778
+ },
2779
+ });
2780
+ pi.registerTool({
2781
+ name: "handoff_show",
2782
+ label: "Handoff Show",
2783
+ description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title. Omit to target the current in-progress phase.",
2784
+ parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase." })) }),
2785
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2786
+ const st = await requirePlan(ctx);
2787
+ if (!st)
2788
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2789
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2790
+ if (!r.ok)
2791
+ return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2792
+ const content = await st.getPhaseHandoff(r.phase.id);
2793
+ if (!content.trim())
2794
+ return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, empty: true } };
2795
+ return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2796
+ },
2797
+ });
2798
+ pi.registerTool({
2799
+ name: "handoff_write",
2800
+ label: "Handoff Write",
2801
+ description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content). phaseRef optional (default: current in-progress phase). Captures design context for a resuming agent on that phase.",
2802
+ parameters: Type.Object({
2803
+ phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
2804
+ content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2805
+ markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2806
+ }),
2807
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2808
+ const st = await requirePlan(ctx);
2809
+ if (!st)
2810
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2811
+ const body = (params.markdown_content ?? params.content ?? "").trim();
2812
+ if (!body)
2813
+ return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
2814
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2815
+ if (!r.ok)
2816
+ return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2817
+ await st.setPhaseHandoff(r.phase.id, body);
2818
+ return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2819
+ },
2820
+ });
2821
+ pi.registerTool({
2822
+ name: "handoff_clear",
2823
+ label: "Handoff Clear",
2824
+ description: "Clear the entity-scoped handoff of a phase (sets handoff to empty, keeps handoffUpdatedAt as an audit trail). phaseRef optional (default: current in-progress phase).",
2825
+ parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })) }),
2826
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2827
+ const st = await requirePlan(ctx);
2828
+ if (!st)
2829
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2830
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2831
+ if (!r.ok)
2832
+ return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2833
+ await st.clearPhaseHandoff(r.phase.id);
2834
+ return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}. (handoffUpdatedAt preserved as audit)` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2689
2835
  },
2690
2836
  });
2691
2837
  pi.registerTool({
@@ -2771,9 +2917,13 @@ export default function planPiExtension(pi) {
2771
2917
  const now = nowISO();
2772
2918
  const status = params.status ?? "planned";
2773
2919
  const currentFeatures = (await st.loadFeatures()).features;
2920
+ const shortId = createShortId(await st.assignedShortIds());
2921
+ const priority = await st.nextPriority("feature");
2774
2922
  const feature = {
2775
2923
  id: createFeatureId(),
2776
2924
  number: currentFeatures.length + 1,
2925
+ shortId,
2926
+ priority,
2777
2927
  name: params.name,
2778
2928
  description: params.description ?? "",
2779
2929
  status,
@@ -2813,6 +2963,7 @@ export default function planPiExtension(pi) {
2813
2963
  endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
2814
2964
  workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
2815
2965
  workRemaining: Type.Optional(Type.String({ description: "Notes on remaining work" })),
2966
+ priority: Type.Optional(Type.Number({ description: "Display order within the project (lower = higher). Tiebreak by number then createdAt." })),
2816
2967
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
2817
2968
  id: Type.String(),
2818
2969
  title: Type.String(),
@@ -2844,6 +2995,8 @@ export default function planPiExtension(pi) {
2844
2995
  feature.startDate = params.startDate;
2845
2996
  if (params.endDate !== undefined)
2846
2997
  feature.endDate = params.endDate;
2998
+ if (params.priority !== undefined)
2999
+ feature.priority = params.priority;
2847
3000
  if (params.acceptedDecisions !== undefined)
2848
3001
  feature.acceptedDecisions = params.acceptedDecisions;
2849
3002
  if (params.status !== undefined) {
@@ -2970,9 +3123,11 @@ export default function planPiExtension(pi) {
2970
3123
  const featurePhases = phases.filter((p) => p.featureId === params.featureId);
2971
3124
  const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
2972
3125
  const id = createPhaseId();
3126
+ const shortId = createShortId(await st.assignedShortIds());
3127
+ const priority = await st.nextPriority("phase", params.featureId);
2973
3128
  const now = nowISO();
2974
3129
  phase = {
2975
- id, number, slug: normalizeSlug(params.title), title: params.title,
3130
+ id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
2976
3131
  featureId: params.featureId,
2977
3132
  status: params.status ?? "draft",
2978
3133
  discussedAt: "",
@@ -2982,6 +3137,7 @@ export default function planPiExtension(pi) {
2982
3137
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
2983
3138
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
2984
3139
  createdAt: now, updatedAt: now,
3140
+ handoff: "", handoffUpdatedAt: "",
2985
3141
  };
2986
3142
  await st.savePhase(phase);
2987
3143
  // Atomic: serialize concurrent phase_create linking to the same feature.
@@ -3011,6 +3167,7 @@ export default function planPiExtension(pi) {
3011
3167
  summary: Type.Optional(Type.String({ description: "New summary" })),
3012
3168
  description: Type.Optional(Type.String({ description: "New description" })),
3013
3169
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
3170
+ priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
3014
3171
  goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
3015
3172
  nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
3016
3173
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
@@ -3049,6 +3206,8 @@ export default function planPiExtension(pi) {
3049
3206
  phase.summary = params.summary;
3050
3207
  if (params.description !== undefined)
3051
3208
  phase.description = params.description;
3209
+ if (params.priority !== undefined)
3210
+ phase.priority = params.priority;
3052
3211
  if (params.goals !== undefined)
3053
3212
  phase.goals = params.goals;
3054
3213
  if (params.nonGoals !== undefined)
@@ -3185,11 +3344,13 @@ export default function planPiExtension(pi) {
3185
3344
  }
3186
3345
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3187
3346
  const taskId = createTaskId();
3347
+ const shortId = createShortId(await st.assignedShortIds());
3348
+ const priority = await st.nextPriority("task", params.phaseId);
3188
3349
  const now = nowISO();
3189
3350
  const initialStatus = params.status ?? "planned";
3190
3351
  const existingPhase = await st.loadPhase(params.phaseId);
3191
3352
  const task = {
3192
- id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortName,
3353
+ id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortId, priority, shortName,
3193
3354
  title: params.title,
3194
3355
  status: initialStatus,
3195
3356
  description: params.description ?? "",
@@ -3235,6 +3396,7 @@ export default function planPiExtension(pi) {
3235
3396
  implementationNotes: Type.String(),
3236
3397
  acceptedAt: Type.String(),
3237
3398
  }), { description: "Replace accepted decisions list" })),
3399
+ priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
3238
3400
  checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
3239
3401
  }),
3240
3402
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -3255,6 +3417,8 @@ export default function planPiExtension(pi) {
3255
3417
  return phase;
3256
3418
  if (params.title !== undefined)
3257
3419
  task.title = params.title;
3420
+ if (params.priority !== undefined)
3421
+ task.priority = params.priority;
3258
3422
  if (params.description !== undefined)
3259
3423
  task.description = params.description;
3260
3424
  if (params.notes !== undefined)
@@ -3510,8 +3674,9 @@ export default function planPiExtension(pi) {
3510
3674
  // add seconds of latency on every turn if run unconditionally.
3511
3675
  if (!plannerHeavyInitDone) {
3512
3676
  await migrateToUuids(st);
3677
+ await st.ensureShortIdsAndPriority().catch(() => null);
3513
3678
  await ensureProjectLanguagePreferences(st).catch(() => null);
3514
- await maybeHealStatuses(st);
3679
+ await maybeHealStatuses(st, ctx);
3515
3680
  await st.refreshResume();
3516
3681
  plannerHeavyInitDone = true;
3517
3682
  }
@@ -3542,7 +3707,7 @@ export default function planPiExtension(pi) {
3542
3707
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3543
3708
  const activity = await st.loadActivityLog();
3544
3709
  const recentActivity = activity.entries.slice(-8).reverse();
3545
- const handoff = await st.loadHandoff();
3710
+ const handoffs = await st.listHandoffs();
3546
3711
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3547
3712
  const orderedPhases = [
3548
3713
  ...plan.features.features.flatMap((feature) => {
@@ -3593,8 +3758,8 @@ export default function planPiExtension(pi) {
3593
3758
  const nextActivity = hasActiveWork
3594
3759
  ? (resume.nextSteps[0]
3595
3760
  ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3596
- : (handoff?.updatedAt
3597
- ? "Read the handoff, then validate current plan ordering and dependencies before picking the next task."
3761
+ : (handoffs.length > 0
3762
+ ? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task."
3598
3763
  : "Review the current plan and choose the next task.");
3599
3764
  const webServer = server;
3600
3765
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
@@ -3634,7 +3799,12 @@ export default function planPiExtension(pi) {
3634
3799
  currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
3635
3800
  currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
3636
3801
  currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
3637
- !hasActiveWork ? "- If a handoff exists, describe it only as a previous-session hint to validate against current planner state, ordering, and dependencies. Do NOT present it as the current focus." : "",
3802
+ handoffs.length > 0 ? `- PHASE HANDOFFS (${handoffs.length} pending):` : "",
3803
+ ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3804
+ handoffs.length > 0 ? "- Present this numbered list to the user. Recommend the most recent (or the one aligned with the current resume focus)." : "",
3805
+ handoffs.length > 0 ? "- Ask the user to confirm which one to resume. Then run handoff show <ref> (or /planner handoff show <ref>) to read it." : "",
3806
+ handoffs.length > 0 ? "- After reading and confirming the resume, CLEAR that handoff with handoff clear <ref> (or /planner handoff clear) BEFORE starting implementation work — a resumed handoff must not go stale." : "",
3807
+ handoffs.length === 0 ? "- No pending phase handoffs: fall back to the resume.json focus (current in-progress task/phase) above." : "",
3638
3808
  webUrl ? `- At the END of your summary, you MUST print a dedicated line with the web dashboard address and port, exactly: '🌐 Web UI: ${webLocalUrl}${webPort ? ` (port ${webPort})` : ""}${webLanUrl ? ` — LAN: ${webLanUrl}` : ""}'. This line is mandatory; do not omit it.` : "- Explicitly tell the user whether the web dashboard is active in this session (and how to start it with '/planner web start' if not).",
3639
3809
  `- Mention the next suggested activity: ${nextActivity}`,
3640
3810
  "- THEN ask the user explicitly whether they want to resume that activity now.",
@@ -3652,7 +3822,7 @@ export default function planPiExtension(pi) {
3652
3822
  "═══════════════════════════════════════════════════════════════",
3653
3823
  "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
3654
3824
  "═══════════════════════════════════════════════════════════════",
3655
- "1. HANDOFF HYGIENE: If .planner/HANDOFF.md exists, READ and DELETE it IMMEDIATELY using the handoff delete tool. Do NOT summarize first. Do NOT ask for confirmation. Do NOT defer cleanup. DELETE \u2192 THEN work.",
3825
+ "1. HANDOFF HYGIENE: If one or more phases have a pending handoff (entity-scoped, phase.handoff), present the list, read the relevant one with handoff show <ref>, then CLEAR it with handoff clear <ref> once consumed. Handoffs are non-blocking context artifacts, not locks — they never block task_start.",
3656
3826
  "2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'. NOTE: Planner operations (plan_write_handoff / /planner handoff, task_start, task_complete, task_update, status reads) are NOT code edits. They are ALWAYS allowed regardless of task state — you may write/refresh the handoff even when no task is in-progress (e.g., all tasks done, or capturing state mid-flight). Never refuse to write a handoff because 'no task is open'; that is incorrect.",
3657
3827
  "3. IMMEDIATE SYNC: Update task status AT THE EXACT MOMENT of transition. Start = task_start NOW. Done = task_complete NOW. Blocked = task_update with motivation NOW. Never batch status updates.",
3658
3828
  "4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
@@ -3727,11 +3897,12 @@ export default function planPiExtension(pi) {
3727
3897
  "- Before your final answer after implementation work, either call task_complete if the task is finished, or explicitly tell the user the task remains in-progress.",
3728
3898
  "- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
3729
3899
  "- To reopen a completed task, use task_start (recommended) or task_update.",
3730
- handoff?.content ? "Handoff detected at `.planner/HANDOFF.md`. Read it as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
3731
- handoff?.content ? "If planner state shows no task/phase in-progress, do NOT present the handoff target as the current focus; present it only as a candidate to validate." : "",
3732
- handoff?.content ? "When the handoff has been fully consumed and work is safely resumed, delete `.planner/HANDOFF.md` using the dedicated handoff delete command/tool." : "",
3733
- handoff?.content ? handoff.content : "",
3734
- "Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, plan_authorize_bypass, plan_clear_bypass",
3900
+ handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
3901
+ ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3902
+ handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
3903
+ handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
3904
+ handoffs.length > 0 ? "Once a handoff is fully consumed and work is safely resumed, clear it with handoff clear <ref> (or /planner handoff clear)." : "",
3905
+ "Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
3735
3906
  ].filter(Boolean).join("\n");
3736
3907
  contextBlockCache = contextBlock;
3737
3908
  contextBlockDirty = false;