@agent-plan/pi-adapter 0.2.19-next.0 → 0.2.19-next.10

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, findPhaseByRef } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap } from "@agent-plan/core";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } 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";
@@ -68,6 +68,29 @@ setWriteNotifyHook(() => {
68
68
  let previousContextPercent = 0;
69
69
  let autoHandoffTriggered = false;
70
70
  let plannerSessionEnabled = false;
71
+ // In-process guard bypass (per session, NOT persisted/shared). The edit/write
72
+ // guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
73
+ // bypass is active the warning is silenced for the window. Lives in memory only:
74
+ // not on disk, not in git, not shared across agents/sessions on the same folder.
75
+ let guardBypassUntil = "";
76
+ function authorizeGuardBypass(durationMinutes = 15) {
77
+ const until = new Date(Date.now() + durationMinutes * 60_000).toISOString();
78
+ guardBypassUntil = until;
79
+ return until;
80
+ }
81
+ function clearGuardBypass() { guardBypassUntil = ""; }
82
+ function isGuardBypassed() {
83
+ if (!guardBypassUntil)
84
+ return false;
85
+ const until = Date.parse(guardBypassUntil);
86
+ if (!Number.isFinite(until))
87
+ return false;
88
+ if (until <= Date.now()) {
89
+ guardBypassUntil = "";
90
+ return false;
91
+ }
92
+ return true;
93
+ }
71
94
  let startupResumePromptPending = false;
72
95
  let startupResumeSummaryPending = false;
73
96
  let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
@@ -227,106 +250,6 @@ function phaseLabel(phase) {
227
250
  function taskLabel(task) {
228
251
  return `T${formatSequence(task.number)} — ${task.title}`;
229
252
  }
230
- async function buildStartupResumeSummary(st) {
231
- const [plan, resume, handoffs] = await Promise.all([
232
- st.loadAll(),
233
- st.refreshResume(),
234
- st.listHandoffs(),
235
- ]);
236
- const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
237
- const orderedPhases = [
238
- ...plan.features.features.flatMap((feature) => {
239
- const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
240
- const linkedIds = new Set(linked.map((phase) => phase.id));
241
- const inferred = plan.phases
242
- .filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
243
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
244
- return [...linked, ...inferred];
245
- }),
246
- ...plan.phases
247
- .filter((phase) => !phase.featureId)
248
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
249
- ];
250
- const allTasks = orderedPhases.flatMap((phase) => [...phase.tasks]
251
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
252
- .map((task) => ({ phase, task })));
253
- const totalFeatures = plan.features.features.length;
254
- const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
255
- const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
256
- const totalPhases = plan.phases.length;
257
- const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
258
- const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
259
- const totalTasks = allTasks.length;
260
- const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
261
- const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
262
- const hasActiveWork = activeFeatures > 0 || activePhases > 0 || activeTasks > 0;
263
- const currentPhase = hasActiveWork
264
- ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
265
- ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
266
- ?? orderedPhases.find((phase) => phase.status === "in-progress")
267
- ?? null
268
- : null;
269
- const currentTask = hasActiveWork
270
- ? ([...(currentPhase?.tasks ?? [])]
271
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
272
- .find((task) => resume.inProgressTaskIds.includes(task.id))
273
- ?? [...(currentPhase?.tasks ?? [])]
274
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
275
- .find((task) => task.status === "in-progress")
276
- ?? allTasks.find(({ task }) => task.status === "in-progress")?.task
277
- ?? null)
278
- : null;
279
- const currentFeature = hasActiveWork
280
- ? (currentPhase?.featureId
281
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
282
- : plan.features.features.find((feature) => feature.status === "in-progress") ?? null)
283
- : null;
284
- const nextActivity = hasActiveWork
285
- ? (resume.nextSteps[0]
286
- ?? (currentTask
287
- ? `riprendere il task ${currentTask.id} — ${taskLabel(currentTask)}`
288
- : currentPhase
289
- ? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
290
- : "riprendere il lavoro attivo corrente"))
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"
293
- : "rivedere il piano e scegliere il prossimo task concreto");
294
- const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
295
- const italian = chatLanguage.includes("ital");
296
- const localUrl = server?.localUrl ?? server?.url ?? "";
297
- const lanUrl = server?.lanUrl ?? "";
298
- const webUrl = lanUrl ? `${localUrl} (LAN: ${lanUrl})` : localUrl;
299
- if (italian || !chatLanguage) {
300
- return [
301
- "### Summary di ripresa planner",
302
- `- Progresso: ${doneFeatures}/${totalFeatures} feature completate (${activeFeatures} attive), ${donePhases}/${totalPhases} fasi completate (${activePhases} attive), ${doneTasks}/${totalTasks} task completati (${activeTasks} attivi).`,
303
- currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
304
- currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
305
- currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
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." : "",
308
- `- Prossima attività consigliata: ${nextActivity}.`,
309
- "",
310
- "Vuoi che riprendiamo da qui?",
311
- "",
312
- webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — avvia con /planner load",
313
- ].filter(Boolean).join("\n");
314
- }
315
- return [
316
- "### Planner resume summary",
317
- `- Progress: ${doneFeatures}/${totalFeatures} features done (${activeFeatures} active), ${donePhases}/${totalPhases} phases done (${activePhases} active), ${doneTasks}/${totalTasks} tasks done (${activeTasks} active).`,
318
- currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
319
- currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
320
- currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
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." : "",
323
- `- Recommended next activity: ${nextActivity}.`,
324
- "",
325
- "Do you want to resume from here?",
326
- "",
327
- webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — start with /planner load",
328
- ].filter(Boolean).join("\n");
329
- }
330
253
  async function buildHandoffMarkdown(st, reason, overrides) {
331
254
  const [plan, resume, activity] = await Promise.all([
332
255
  st.loadAll(),
@@ -493,7 +416,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
493
416
  ...recentActivityLines,
494
417
  "",
495
418
  "## Reminder",
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).",
419
+ "- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/handoff-archive/) or the phase completes. No need to clear it manually.",
497
420
  ].join("\n");
498
421
  }
499
422
  async function writeProjectHandoff(st, reason, overrides) {
@@ -589,6 +512,9 @@ function statusIcon(status) {
589
512
  };
590
513
  return icons[status] ?? "❓";
591
514
  }
515
+ function pad(n) {
516
+ return String(n).padStart(3, "0");
517
+ }
592
518
  function applyTaskLifecycleDates(task, nextStatus, now) {
593
519
  const previousStatus = task.status;
594
520
  if (nextStatus === "in-progress" && !task.startedAt) {
@@ -625,13 +551,23 @@ async function pickProjectPort(ctx, explicitPort) {
625
551
  await st.saveProject(project);
626
552
  return explicitPort;
627
553
  }
628
- if (project.webPort > 0) {
554
+ // Reuse the persisted port if it's still free (stable port across restarts,
555
+ // lets subagents notify the server via HTTP).
556
+ if (project.webPort > 0 && (await isPortAvailable(project.webPort))) {
629
557
  return project.webPort;
630
558
  }
559
+ // Persisted port busy (another planner instance on this project) or first run:
560
+ // scan for a free port. Persist ONLY on first run (canonical); when falling
561
+ // back from a busy persisted port, return transient so we don't hijack the
562
+ // primary session's canonical port (prevents port thrashing).
631
563
  for (let port = 3030; port <= 3999; port += 1) {
564
+ if (port === project.webPort)
565
+ continue;
632
566
  if (await isPortAvailable(port)) {
633
- project.webPort = port;
634
- await st.saveProject(project);
567
+ if (project.webPort <= 0) {
568
+ project.webPort = port;
569
+ await st.saveProject(project);
570
+ }
635
571
  return port;
636
572
  }
637
573
  }
@@ -708,7 +644,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
708
644
  uiConfig: readUiConfig(ctx),
709
645
  isBusy: () => planBusy,
710
646
  });
711
- ctx.ui.notify(`Planner web server: requested port ${port} busy, started on ${server?.url ?? "?"} instead.`, "info");
647
+ ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
712
648
  }
713
649
  catch (e2) {
714
650
  ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
@@ -736,8 +672,13 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
736
672
  const st = ensureStore(ctx);
737
673
  const project = await st.loadProject().catch(() => null);
738
674
  if (project) {
739
- project.webPort = realPort;
740
- await st.saveProject(project);
675
+ // Persist only the canonical port (first run, or realPort matches the
676
+ // existing canonical). Never persist a transient fallback port — that
677
+ // hijacks the primary session's port when two instances share a .planner.
678
+ if (project.webPort <= 0 || project.webPort === realPort) {
679
+ project.webPort = realPort;
680
+ await st.saveProject(project);
681
+ }
741
682
  }
742
683
  }
743
684
  catch { }
@@ -819,6 +760,10 @@ export default function planPiExtension(pi) {
819
760
  const handoffs = await st.listHandoffs().catch(() => []);
820
761
  if (handoffs.length > 0) {
821
762
  ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
763
+ // Mark each handoff as read (recap presented). Do NOT clear — content is
764
+ // kept until a task in the phase starts (auto-archived) or phase completes.
765
+ for (const h of handoffs)
766
+ await st.markHandoffRead(h.phaseId).catch(() => { });
822
767
  }
823
768
  }
824
769
  // If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
@@ -947,6 +892,8 @@ export default function planPiExtension(pi) {
947
892
  return; // nothing to enforce yet
948
893
  if (guard.inProgressTaskIds.length > 0)
949
894
  return; // a task is open → we're good
895
+ if (isGuardBypassed())
896
+ return; // user authorized proceeding without a task
950
897
  const focusHint = guard.focusTaskId
951
898
  ? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
952
899
  : `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
@@ -1113,40 +1060,33 @@ export default function planPiExtension(pi) {
1113
1060
  // Helper: pick a feature interactively
1114
1061
  // ═══════════════════════════════════════════════════════════════
1115
1062
  async function pickFeature() {
1116
- const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1063
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
1064
+ .slice()
1065
+ .sort((a, b) => a.number - b.number);
1117
1066
  if (features.length === 0)
1118
1067
  return null;
1119
- const list = features.map((f, i) => ` ${i + 1}. ${statusIcon(f.status)} ${f.name} (${f.id})`).join("\n");
1120
- ctx.ui.notify(`Pick a feature:\n${list}`, "info");
1121
- const pick = await ctx.ui.input("Enter feature number");
1122
- if (!pick?.trim())
1123
- return null;
1124
- const idx = parseInt(pick.trim(), 10) - 1;
1125
- if (isNaN(idx) || idx < 0 || idx >= features.length) {
1126
- ctx.ui.notify("Invalid number", "error");
1068
+ const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
1069
+ const chosen = await ctx.ui.select("Pick a feature", options);
1070
+ if (chosen == null)
1127
1071
  return null;
1128
- }
1129
- return features[idx];
1072
+ const idx = options.indexOf(chosen);
1073
+ return idx >= 0 ? features[idx] : null;
1130
1074
  }
1131
1075
  // ═══════════════════════════════════════════════════════════════
1132
1076
  // Helper: pick a phase interactively
1133
1077
  // ═══════════════════════════════════════════════════════════════
1134
1078
  async function pickPhase() {
1135
- const phases = await st.loadAllPhases().catch(() => []);
1079
+ const phases = (await st.loadAllPhases().catch(() => [])).slice().sort((a, b) => a.number - b.number);
1136
1080
  if (phases.length === 0) {
1137
1081
  return null;
1138
1082
  }
1139
- const list = phases.map((p, i) => ` ${i + 1}. ${statusIcon(p.status)} ${p.title} (${p.id})`).join("\n");
1140
- ctx.ui.notify(`Pick a phase:\n${list}`, "info");
1141
- const pick = await ctx.ui.input("Enter phase number");
1142
- if (!pick?.trim())
1143
- return null;
1144
- const idx = parseInt(pick.trim(), 10) - 1;
1145
- if (isNaN(idx) || idx < 0 || idx >= phases.length) {
1146
- ctx.ui.notify("Invalid number", "error");
1083
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1084
+ const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
1085
+ const chosen = await ctx.ui.select("Pick a phase", options);
1086
+ if (chosen == null)
1147
1087
  return null;
1148
- }
1149
- return phases[idx];
1088
+ const idx = options.indexOf(chosen);
1089
+ return idx >= 0 ? phases[idx] : null;
1150
1090
  }
1151
1091
  // ═══════════════════════════════════════════════════════════════
1152
1092
  // Helper: pick a task interactively from a phase
@@ -1154,17 +1094,13 @@ export default function planPiExtension(pi) {
1154
1094
  async function pickTask(phase) {
1155
1095
  if (phase.tasks.length === 0)
1156
1096
  return null;
1157
- const list = phase.tasks.map((t, i) => ` ${i + 1}. ${statusIcon(t.status)} ${t.title} (${t.id})`).join("\n");
1158
- ctx.ui.notify(`Pick a task from "${phase.title}":\n${list}`, "info");
1159
- const pick = await ctx.ui.input("Enter task number");
1160
- if (!pick?.trim())
1161
- return null;
1162
- const idx = parseInt(pick.trim(), 10) - 1;
1163
- if (isNaN(idx) || idx < 0 || idx >= phase.tasks.length) {
1164
- ctx.ui.notify("Invalid number", "error");
1097
+ const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1098
+ const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
1099
+ const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1100
+ if (chosen == null)
1165
1101
  return null;
1166
- }
1167
- return phase.tasks[idx];
1102
+ const idx = options.indexOf(chosen);
1103
+ return idx >= 0 ? tasks[idx] : null;
1168
1104
  }
1169
1105
  function parseMultilineList(value) {
1170
1106
  return (value ?? "")
@@ -1183,7 +1119,15 @@ export default function planPiExtension(pi) {
1183
1119
  const normalized = ref.trim().toLowerCase();
1184
1120
  if (!normalized)
1185
1121
  return null;
1186
- return features.find((feature) => feature.id.toLowerCase() === normalized)
1122
+ const fMatch = normalized.match(/^f(\d+)$/);
1123
+ if (fMatch) {
1124
+ const n = parseInt(fMatch[1], 10);
1125
+ const byNum = features.find((f) => f.number === n);
1126
+ if (byNum)
1127
+ return byNum;
1128
+ }
1129
+ return features.find((feature) => feature.shortId && feature.shortId.toLowerCase() === normalized)
1130
+ ?? features.find((feature) => feature.id.toLowerCase() === normalized)
1187
1131
  ?? features.find((feature) => feature.name.toLowerCase() === normalized)
1188
1132
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1189
1133
  ?? null;
@@ -1192,24 +1136,19 @@ export default function planPiExtension(pi) {
1192
1136
  const normalized = ref.trim().toLowerCase();
1193
1137
  if (!normalized)
1194
1138
  return null;
1195
- return phases.find((phase) => phase.id.toLowerCase() === normalized)
1139
+ const pMatch = normalized.match(/^p(\d+)$/);
1140
+ if (pMatch) {
1141
+ const n = parseInt(pMatch[1], 10);
1142
+ const byNum = phases.find((p) => p.number === n);
1143
+ if (byNum)
1144
+ return byNum;
1145
+ }
1146
+ return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1147
+ ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1196
1148
  ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1197
1149
  ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1198
1150
  ?? null;
1199
1151
  }
1200
- function findTaskByRef(phases, ref) {
1201
- const normalized = ref.trim().toLowerCase();
1202
- if (!normalized)
1203
- return null;
1204
- for (const phase of phases) {
1205
- const task = phase.tasks.find((entryTask) => entryTask.id.toLowerCase() === normalized
1206
- || entryTask.title.toLowerCase() === normalized
1207
- || entryTask.title.toLowerCase().includes(normalized));
1208
- if (task)
1209
- return { phase, task };
1210
- }
1211
- return null;
1212
- }
1213
1152
  function profile_packageManager(pkg, lockfile) {
1214
1153
  if (pkg?.packageManager)
1215
1154
  return pkg.packageManager;
@@ -1414,7 +1353,7 @@ export default function planPiExtension(pi) {
1414
1353
  return;
1415
1354
  }
1416
1355
  const now = nowISO();
1417
- const featureNumber = (await st.loadFeatures()).features.length + 1;
1356
+ const featureNumber = await st.allocFeatureNumber();
1418
1357
  const shortId = createShortId(await st.assignedShortIds());
1419
1358
  const priority = await st.nextPriority("feature");
1420
1359
  const feature = {
@@ -1443,7 +1382,7 @@ export default function planPiExtension(pi) {
1443
1382
  return doc;
1444
1383
  });
1445
1384
  await st.writeGenerated();
1446
- ctx.ui.notify(`Feature created: ${feature.id} — ${feature.name}`, "info");
1385
+ ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1447
1386
  return;
1448
1387
  }
1449
1388
  if (b === "show") {
@@ -1524,7 +1463,7 @@ export default function planPiExtension(pi) {
1524
1463
  });
1525
1464
  feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1526
1465
  await st.writeGenerated();
1527
- ctx.ui.notify(`Feature updated: ${feature.id} — ${feature.name} (${feature.status})`, "info");
1466
+ ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
1528
1467
  return;
1529
1468
  }
1530
1469
  if (b === "delete") {
@@ -1601,7 +1540,7 @@ export default function planPiExtension(pi) {
1601
1540
  await withFeatureLock(feature.id, async () => {
1602
1541
  const phases = await st.loadAllPhases();
1603
1542
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1604
- const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
1543
+ const number = await st.allocPhaseNumber();
1605
1544
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1606
1545
  const id = createPhaseId();
1607
1546
  const shortId = createShortId(await st.assignedShortIds());
@@ -1612,6 +1551,8 @@ export default function planPiExtension(pi) {
1612
1551
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1613
1552
  createdAt: nowISO(), updatedAt: nowISO(),
1614
1553
  handoff: "", handoffUpdatedAt: "",
1554
+ handoffReadAt: "",
1555
+ handoffHistory: [],
1615
1556
  };
1616
1557
  await st.savePhase(phase);
1617
1558
  await st.updateFeatures((doc) => {
@@ -1626,7 +1567,7 @@ export default function planPiExtension(pi) {
1626
1567
  });
1627
1568
  if (!phase)
1628
1569
  return;
1629
- ctx.ui.notify(`Phase created: ${phase.id} — ${title} (feature ${feature.name})`, "info");
1570
+ ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
1630
1571
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1631
1572
  if (isYes(startDiscuss)) {
1632
1573
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
@@ -1651,7 +1592,7 @@ export default function planPiExtension(pi) {
1651
1592
  phase.status = "discovery";
1652
1593
  phase.updatedAt = nowISO();
1653
1594
  await st.savePhase(phase);
1654
- ctx.ui.notify(`Discovery for ${phase.id}…`, "info");
1595
+ ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1655
1596
  // ── Recap: give full context before planning tasks ──
1656
1597
  const recapPlan = await st.loadAll();
1657
1598
  const recapProject = recapPlan.project;
@@ -1716,7 +1657,7 @@ export default function planPiExtension(pi) {
1716
1657
  phase.updatedAt = nowISO();
1717
1658
  await st.savePhase(phase);
1718
1659
  await st.writeGenerated();
1719
- ctx.ui.notify(`Phase ${phase.id} is now **planned**.`, "info");
1660
+ ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1720
1661
  return;
1721
1662
  }
1722
1663
  if (b === "show") {
@@ -1764,7 +1705,7 @@ export default function planPiExtension(pi) {
1764
1705
  }
1765
1706
  await st.deletePhase(phase.id);
1766
1707
  await st.writeGenerated();
1767
- ctx.ui.notify(`Phase deleted: ${phase.id}`, "info");
1708
+ ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1768
1709
  return;
1769
1710
  }
1770
1711
  if (b === "update") {
@@ -1816,7 +1757,7 @@ export default function planPiExtension(pi) {
1816
1757
  phase.updatedAt = nowISO();
1817
1758
  await st.savePhase(phase);
1818
1759
  await st.writeGenerated();
1819
- ctx.ui.notify(`Phase updated: ${phase.id} — ${phase.title} (${phase.status})`, "info");
1760
+ ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1820
1761
  return;
1821
1762
  }
1822
1763
  ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
@@ -1842,7 +1783,7 @@ export default function planPiExtension(pi) {
1842
1783
  return;
1843
1784
  }
1844
1785
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1845
- const taskNum = phase.tasks.length + 1;
1786
+ const taskNum = await st.allocTaskNumber();
1846
1787
  const taskId = createTaskId();
1847
1788
  const shortId = createShortId(await st.assignedShortIds());
1848
1789
  const priority = await st.nextPriority("task", phase.id);
@@ -1874,7 +1815,7 @@ export default function planPiExtension(pi) {
1874
1815
  }
1875
1816
  if (b === "discuss") {
1876
1817
  const phases = await st.loadAllPhases();
1877
- let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
1818
+ let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
1878
1819
  if (!resolved) {
1879
1820
  const phase = await pickPhase();
1880
1821
  if (!phase) {
@@ -1908,14 +1849,14 @@ export default function planPiExtension(pi) {
1908
1849
  phase.updatedAt = nowISO();
1909
1850
  await st.savePhase(phase);
1910
1851
  await st.writeGenerated();
1911
- ctx.ui.notify(`Task ${task.id} discussed and updated.`, "info");
1852
+ ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
1912
1853
  return;
1913
1854
  }
1914
1855
  if (b === "show") {
1915
1856
  let phase = null;
1916
1857
  let task = null;
1917
1858
  if (subArgs.trim()) {
1918
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
1859
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
1919
1860
  phase = resolved?.phase ?? null;
1920
1861
  task = resolved?.task ?? null;
1921
1862
  }
@@ -1977,7 +1918,7 @@ export default function planPiExtension(pi) {
1977
1918
  phase.updatedAt = nowISO();
1978
1919
  await st.savePhase(phase);
1979
1920
  await st.writeGenerated();
1980
- ctx.ui.notify(`Task deleted: ${task.id}`, "info");
1921
+ ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
1981
1922
  return;
1982
1923
  }
1983
1924
  if (b === "update") {
@@ -2026,14 +1967,14 @@ export default function planPiExtension(pi) {
2026
1967
  phase.updatedAt = now;
2027
1968
  await st.savePhase(phase);
2028
1969
  await st.writeGenerated();
2029
- ctx.ui.notify(`Task updated: ${task.id} — ${task.title} (${task.status})`, "info");
1970
+ ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2030
1971
  return;
2031
1972
  }
2032
1973
  if (b === "start") {
2033
1974
  let phase = null;
2034
1975
  let task = null;
2035
1976
  if (subArgs.trim()) {
2036
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
1977
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2037
1978
  phase = resolved?.phase ?? null;
2038
1979
  task = resolved?.task ?? null;
2039
1980
  }
@@ -2057,12 +1998,16 @@ export default function planPiExtension(pi) {
2057
1998
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2058
1999
  return;
2059
2000
  }
2060
- // Hygiene notice (non-blocking): surface an existing handoff but never
2061
- // block task_start — the handoff is a captured-context artifact, not a
2062
- // lock (a small modification after writing a handoff should start without
2063
- // forcing deletion, which would lose context).
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");
2001
+ // Auto-archive the handoff on THIS task's phase (reason: task-started) —
2002
+ // the agent is now actively working, so the captured context is consumed.
2003
+ // Other phases' handoffs are left untouched (informational, non-blocking).
2004
+ if ((phase.handoff ?? "") !== "") {
2005
+ await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
2006
+ ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/handoff-archive/.", "info");
2007
+ }
2008
+ const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
2009
+ if (_otherHandoffs.length > 0) {
2010
+ ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
2066
2011
  }
2067
2012
  const now = nowISO();
2068
2013
  // Record status change in the incremental statusLog.
@@ -2081,14 +2026,14 @@ export default function planPiExtension(pi) {
2081
2026
  phase.updatedAt = now;
2082
2027
  await st.savePhase(phase);
2083
2028
  await st.writeGenerated();
2084
- ctx.ui.notify(`✅ Task started: ${task.id} — ${task.title} (in-progress)`, "info");
2029
+ ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2085
2030
  return;
2086
2031
  }
2087
2032
  if (b === "complete") {
2088
2033
  let phase = null;
2089
2034
  let task = null;
2090
2035
  if (subArgs.trim()) {
2091
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2036
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2092
2037
  phase = resolved?.phase ?? null;
2093
2038
  task = resolved?.task ?? null;
2094
2039
  }
@@ -2136,7 +2081,7 @@ export default function planPiExtension(pi) {
2136
2081
  phase.updatedAt = now;
2137
2082
  await st.savePhase(phase);
2138
2083
  await st.writeGenerated();
2139
- ctx.ui.notify(`✅ Task completed: ${task.id} — ${task.title} (done)`, "info");
2084
+ ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2140
2085
  return;
2141
2086
  }
2142
2087
  ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
@@ -2219,7 +2164,7 @@ export default function planPiExtension(pi) {
2219
2164
  ctx.ui.notify(r.error, "warning");
2220
2165
  return;
2221
2166
  }
2222
- await st.clearPhaseHandoff(r.phase.id);
2167
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2223
2168
  ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
2224
2169
  return;
2225
2170
  }
@@ -2301,12 +2246,12 @@ export default function planPiExtension(pi) {
2301
2246
  try {
2302
2247
  if (a === "bypass") {
2303
2248
  const mins = parseInt(subArgs.trim(), 10);
2304
- const until = await st.authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
2305
- ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write will work without a task in-progress for that window.`, "info");
2249
+ const until = authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
2250
+ ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write advisory warnings silenced for that window (per-session, not shared).`, "info");
2306
2251
  }
2307
2252
  else {
2308
- await st.clearGuardBypass();
2309
- ctx.ui.notify("Guard bypass revoked. edit/write now requires a task in-progress again.", "info");
2253
+ clearGuardBypass();
2254
+ ctx.ui.notify("Guard bypass revoked. edit/write advisory warnings re-enabled.", "info");
2310
2255
  }
2311
2256
  }
2312
2257
  catch (e) {
@@ -2334,9 +2279,23 @@ export default function planPiExtension(pi) {
2334
2279
  startupResumeSummaryPending = false;
2335
2280
  startupResumeSummaryTimer = null;
2336
2281
  }, 60000);
2282
+ // Build the recap and embed it in the trigger content. The trigger
2283
+ // message is the channel that reliably reaches the agent on a triggered
2284
+ // turn (before_agent_start's systemPrompt injection is NOT applied to
2285
+ // triggerTurn messages), so the recap data must live here. Uses the shared
2286
+ // core buildRecap so Pi and Claude Code/Codex present identical content.
2287
+ let recapText = "";
2288
+ try {
2289
+ const st = ensureStore(ctx);
2290
+ const srv = server;
2291
+ recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2292
+ }
2293
+ catch (e) {
2294
+ recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2295
+ }
2337
2296
  pi.sendMessage({
2338
2297
  customType: "planner-resume-trigger",
2339
- content: "planner recap",
2298
+ content: "[internal trigger — not a user command] Present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2340
2299
  display: false,
2341
2300
  }, {
2342
2301
  triggerTurn: true,
@@ -2649,7 +2608,7 @@ export default function planPiExtension(pi) {
2649
2608
  pi.registerTool({
2650
2609
  name: "plan_repair",
2651
2610
  label: "Plan Repair",
2652
- description: "Repair dangling feature→phase references and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found or after manual edits to .planner/.",
2611
+ description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug), and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found, tasks appear in the wrong phase, or after manual edits to .planner/.",
2653
2612
  parameters: Type.Object({}),
2654
2613
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2655
2614
  const st = await requirePlan(ctx);
@@ -2657,9 +2616,11 @@ export default function planPiExtension(pi) {
2657
2616
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2658
2617
  const report = await st.repair();
2659
2618
  const m = report.migrated;
2619
+ const c = report.containment;
2660
2620
  const lines = [
2661
2621
  `Repair complete.`,
2662
2622
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2623
+ `Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
2663
2624
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2664
2625
  ];
2665
2626
  if (report.integrity.danglingPhaseIds.length)
@@ -2732,7 +2693,7 @@ export default function planPiExtension(pi) {
2732
2693
  const r = await resolvePhaseForHandoff(st, undefined);
2733
2694
  if (!r.ok)
2734
2695
  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);
2696
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2736
2697
  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
2698
  },
2738
2699
  });
@@ -2830,36 +2791,36 @@ export default function planPiExtension(pi) {
2830
2791
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2831
2792
  if (!r.ok)
2832
2793
  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 } };
2794
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2795
+ return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2835
2796
  },
2836
2797
  });
2837
2798
  pi.registerTool({
2838
2799
  name: "plan_authorize_bypass",
2839
2800
  label: "Plan Authorize Guard Bypass",
2840
- description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write tools can proceed even when no task is in-progress. Use this ONLY after the user explicitly authorizes proceeding without a task. Harness-agnostic: stored in resume.json so all adapters respect it.",
2801
+ description: "Authorize a temporary guard bypass (default 15 minutes) so the edit/write advisory guard stops warning when no task is in-progress. Per-session in-memory: not persisted, not shared across agents. Use ONLY after the user explicitly authorizes proceeding without a task.",
2841
2802
  parameters: Type.Object({
2842
2803
  durationMinutes: Type.Optional(Type.Number({ description: "Bypass window in minutes. Default 15." })),
2843
2804
  }),
2844
2805
  async execute(_id, params, _signal, _onUpdate, ctx) {
2845
- const st = await requirePlan(ctx);
2846
- if (!st)
2806
+ const ok = await requirePlan(ctx);
2807
+ if (!ok)
2847
2808
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2848
2809
  const mins = params.durationMinutes ?? 15;
2849
- const until = await st.authorizeGuardBypass(mins);
2850
- return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. edit/write is allowed without a task in-progress for ${mins} minutes.` }], details: { until } };
2810
+ const until = authorizeGuardBypass(mins);
2811
+ return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. Advisory edit/write warnings silenced for ${mins} minutes (per-session, not shared).` }], details: { until } };
2851
2812
  },
2852
2813
  });
2853
2814
  pi.registerTool({
2854
2815
  name: "plan_clear_bypass",
2855
2816
  label: "Plan Clear Guard Bypass",
2856
- description: "Revoke any active guard bypass so edit/write again requires a task in-progress.",
2817
+ description: "Revoke the per-session guard bypass so the edit/write advisory warnings resume.",
2857
2818
  parameters: Type.Object({}),
2858
2819
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2859
- const st = await requirePlan(ctx);
2860
- if (!st)
2820
+ const ok = await requirePlan(ctx);
2821
+ if (!ok)
2861
2822
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2862
- await st.clearGuardBypass();
2823
+ clearGuardBypass();
2863
2824
  return { content: [{ type: "text", text: "Guard bypass revoked." }], details: { cleared: true } };
2864
2825
  },
2865
2826
  });
@@ -2867,38 +2828,44 @@ export default function planPiExtension(pi) {
2867
2828
  pi.registerTool({
2868
2829
  name: "feature_list",
2869
2830
  label: "Feature List",
2870
- description: "List all features with their ids, statuses, and phase counts.",
2871
- parameters: Type.Object({}),
2872
- async execute(_id, _params, _signal, _onUpdate, ctx) {
2831
+ description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
2832
+ parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
2833
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2873
2834
  const st = await requirePlan(ctx);
2874
2835
  if (!st)
2875
2836
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2876
- const features = (await st.loadFeatures()).features;
2837
+ const all = (await st.loadFeatures()).features;
2838
+ const ref = params.featureRef?.trim();
2839
+ const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
2840
+ const features = all.filter(matchF);
2877
2841
  const phases = await st.loadAllPhases();
2878
2842
  const summary = features.map((f) => {
2879
2843
  const fp = phases.filter((p) => p.featureId === f.id);
2880
- return `- ${statusIcon(f.status)} ${f.id} — ${f.name} (${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
2844
+ return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
2881
2845
  }).join("\n");
2882
- return { content: [{ type: "text", text: summary || "No features" }], details: { features } };
2846
+ return { content: [{ type: "text", text: summary || "No features" }], details: {} };
2883
2847
  },
2884
2848
  });
2885
2849
  pi.registerTool({
2886
2850
  name: "feature_get",
2887
2851
  label: "Feature Get",
2888
- description: "Read full details of a feature, including its phases and their tasks.",
2852
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
2889
2853
  parameters: Type.Object({
2890
- featureId: Type.String({ description: "Feature ID, e.g. feature-001-dynamic-header" }),
2854
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2855
+ full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
2891
2856
  }),
2892
2857
  async execute(_id, params, _signal, _onUpdate, ctx) {
2893
2858
  const st = await requirePlan(ctx);
2894
2859
  if (!st)
2895
2860
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2896
2861
  const features = (await st.loadFeatures()).features;
2897
- const feature = features.find((f) => f.id === params.featureId);
2862
+ const ref = params.featureId.trim();
2863
+ const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
2898
2864
  if (!feature)
2899
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
2865
+ return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
2900
2866
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
2901
- return { content: [{ type: "text", text: `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${phases.length} phases` }], details: { feature, phases } };
2867
+ const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
2868
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
2902
2869
  },
2903
2870
  });
2904
2871
  pi.registerTool({
@@ -2921,7 +2888,7 @@ export default function planPiExtension(pi) {
2921
2888
  const priority = await st.nextPriority("feature");
2922
2889
  const feature = {
2923
2890
  id: createFeatureId(),
2924
- number: currentFeatures.length + 1,
2891
+ number: await st.allocFeatureNumber(),
2925
2892
  shortId,
2926
2893
  priority,
2927
2894
  name: params.name,
@@ -2947,7 +2914,7 @@ export default function planPiExtension(pi) {
2947
2914
  return doc;
2948
2915
  });
2949
2916
  await st.writeGenerated();
2950
- return { content: [{ type: "text", text: `Feature created: ${feature.id}` }], details: feature };
2917
+ return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2951
2918
  },
2952
2919
  });
2953
2920
  pi.registerTool({
@@ -2955,7 +2922,7 @@ export default function planPiExtension(pi) {
2955
2922
  label: "Feature Update",
2956
2923
  description: "Update one or more fields of a feature. Only provided fields are changed. IMPORTANT: feature status is derived from child phases/tasks, so do not update feature.status directly unless truly necessary.",
2957
2924
  parameters: Type.Object({
2958
- featureId: Type.String({ description: "Feature ID" }),
2925
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2959
2926
  name: Type.Optional(Type.String({ description: "New name" })),
2960
2927
  description: Type.Optional(Type.String({ description: "New description" })),
2961
2928
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: feature status is derived from child phases/tasks." })),
@@ -3022,7 +2989,7 @@ export default function planPiExtension(pi) {
3022
2989
  if (!feature)
3023
2990
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3024
2991
  await st.writeGenerated();
3025
- return { content: [{ type: "text", text: `Feature updated: ${feature.id} (${feature.status})` }], details: feature };
2992
+ return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3026
2993
  },
3027
2994
  });
3028
2995
  pi.registerTool({
@@ -3030,7 +2997,7 @@ export default function planPiExtension(pi) {
3030
2997
  label: "Feature Delete",
3031
2998
  description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
3032
2999
  parameters: Type.Object({
3033
- featureId: Type.String({ description: "Feature ID to delete" }),
3000
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
3034
3001
  cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
3035
3002
  }),
3036
3003
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -3064,40 +3031,52 @@ export default function planPiExtension(pi) {
3064
3031
  pi.registerTool({
3065
3032
  name: "phase_list",
3066
3033
  label: "Phase List",
3067
- description: "List all phases (optionally filtered by featureId) with id, status, and task counts.",
3034
+ description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
3068
3035
  parameters: Type.Object({
3069
- featureId: Type.Optional(Type.String({ description: "If provided, only list phases of this feature" })),
3036
+ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3037
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3070
3038
  }),
3071
3039
  async execute(_id, params, _signal, _onUpdate, ctx) {
3072
3040
  const st = await requirePlan(ctx);
3073
3041
  if (!st)
3074
3042
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3043
+ const features = (await st.loadFeatures()).features;
3075
3044
  let phases = await st.loadAllPhases();
3076
- if (params.featureId)
3077
- phases = phases.filter((p) => p.featureId === params.featureId);
3078
- const summary = phases.map((p) => `- ${statusIcon(p.status)} ${p.id} — ${p.title} (${p.tasks.length} tasks, feature ${p.featureId ?? "none"})`).join("\n");
3079
- return { content: [{ type: "text", text: summary || "No phases" }], details: { phases } };
3045
+ const fref = params.featureRef?.trim();
3046
+ if (fref) {
3047
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3048
+ if (!f)
3049
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3050
+ phases = phases.filter((p) => p.featureId === f.id);
3051
+ }
3052
+ if (params.status)
3053
+ phases = phases.filter((p) => p.status === params.status);
3054
+ const summary = phases.map((p) => {
3055
+ const fNum = featureNumberOfPhase(p, features);
3056
+ const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3057
+ return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
3058
+ }).join("\n");
3059
+ return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3080
3060
  },
3081
3061
  });
3082
3062
  pi.registerTool({
3083
3063
  name: "phase_get",
3084
3064
  label: "Phase Get",
3085
- description: "Read full details of a phase, including its tasks.",
3065
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
3086
3066
  parameters: Type.Object({
3087
- phaseId: Type.String({ description: "Phase ID, e.g. phase-01-setup" }),
3067
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3068
+ full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
3088
3069
  }),
3089
3070
  async execute(_id, params, _signal, _onUpdate, ctx) {
3090
3071
  const st = await requirePlan(ctx);
3091
3072
  if (!st)
3092
3073
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3093
- let phase;
3094
- try {
3095
- phase = await st.loadPhase(params.phaseId);
3096
- }
3097
- catch {
3074
+ const features = (await st.loadFeatures()).features;
3075
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3076
+ if (!phase)
3098
3077
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3099
- }
3100
- return { content: [{ type: "text", text: `${statusIcon(phase.status)} ${phase.title} (${phase.id}) — ${phase.tasks.length} tasks` }], details: phase };
3078
+ const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
3079
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}` : summary }], details: {} };
3101
3080
  },
3102
3081
  });
3103
3082
  pi.registerTool({
@@ -3121,7 +3100,7 @@ export default function planPiExtension(pi) {
3121
3100
  await withFeatureLock(params.featureId, async () => {
3122
3101
  const phases = await st.loadAllPhases();
3123
3102
  const featurePhases = phases.filter((p) => p.featureId === params.featureId);
3124
- const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
3103
+ const number = await st.allocPhaseNumber();
3125
3104
  const id = createPhaseId();
3126
3105
  const shortId = createShortId(await st.assignedShortIds());
3127
3106
  const priority = await st.nextPriority("phase", params.featureId);
@@ -3138,6 +3117,8 @@ export default function planPiExtension(pi) {
3138
3117
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3139
3118
  createdAt: now, updatedAt: now,
3140
3119
  handoff: "", handoffUpdatedAt: "",
3120
+ handoffReadAt: "",
3121
+ handoffHistory: [],
3141
3122
  };
3142
3123
  await st.savePhase(phase);
3143
3124
  // Atomic: serialize concurrent phase_create linking to the same feature.
@@ -3153,7 +3134,8 @@ export default function planPiExtension(pi) {
3153
3134
  });
3154
3135
  if (!phase)
3155
3136
  return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
3156
- return { content: [{ type: "text", text: `Phase created: ${phase.id}` }], details: phase };
3137
+ const phaseCreateFeatures = (await st.loadFeatures()).features;
3138
+ return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3157
3139
  },
3158
3140
  });
3159
3141
  pi.registerTool({
@@ -3161,7 +3143,7 @@ export default function planPiExtension(pi) {
3161
3143
  label: "Phase Update",
3162
3144
  description: "Update one or more fields of a phase. Only provided fields are changed. Supports re-linking to a feature via featureId. IMPORTANT: phase status is derived from task statuses, so do not update phase.status directly unless truly necessary.",
3163
3145
  parameters: Type.Object({
3164
- phaseId: Type.String({ description: "Phase ID" }),
3146
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3165
3147
  title: Type.Optional(Type.String({ description: "New title" })),
3166
3148
  status: Type.Optional(Type.String({ description: "New status: draft|discovery|planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: phase status is derived from task statuses." })),
3167
3149
  summary: Type.Optional(Type.String({ description: "New summary" })),
@@ -3242,7 +3224,8 @@ export default function planPiExtension(pi) {
3242
3224
  phase.updatedAt = nowISO();
3243
3225
  await st.savePhase(phase);
3244
3226
  await st.writeGenerated();
3245
- return { content: [{ type: "text", text: `Phase updated: ${phase.id} (${phase.status})` }], details: phase };
3227
+ const phaseUpdateFeatures = (await st.loadFeatures()).features;
3228
+ return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3246
3229
  },
3247
3230
  });
3248
3231
  pi.registerTool({
@@ -3250,7 +3233,7 @@ export default function planPiExtension(pi) {
3250
3233
  label: "Phase Delete",
3251
3234
  description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
3252
3235
  parameters: Type.Object({
3253
- phaseId: Type.String({ description: "Phase ID to delete" }),
3236
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
3254
3237
  }),
3255
3238
  async execute(_id, params, _signal, _onUpdate, ctx) {
3256
3239
  const st = await requirePlan(ctx);
@@ -3282,42 +3265,64 @@ export default function planPiExtension(pi) {
3282
3265
  pi.registerTool({
3283
3266
  name: "task_list",
3284
3267
  label: "Task List",
3285
- description: "List tasks of a phase with id, status, and title.",
3268
+ description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
3286
3269
  parameters: Type.Object({
3287
- phaseId: Type.String({ description: "Phase ID" }),
3270
+ featureRef: Type.Optional(Type.String({})),
3271
+ phaseRef: Type.Optional(Type.String({})),
3272
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3288
3273
  }),
3289
3274
  async execute(_id, params, _signal, _onUpdate, ctx) {
3290
3275
  const st = await requirePlan(ctx);
3291
3276
  if (!st)
3292
3277
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3293
- let phase;
3294
- try {
3295
- phase = await st.loadPhase(params.phaseId);
3296
- }
3297
- catch {
3298
- return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3278
+ const features = (await st.loadFeatures()).features;
3279
+ let phases = await st.loadAllPhases();
3280
+ const fref = params.featureRef?.trim();
3281
+ if (fref) {
3282
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3283
+ if (!f)
3284
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3285
+ phases = phases.filter((p) => p.featureId === f.id);
3286
+ }
3287
+ const pref = params.phaseRef?.trim();
3288
+ if (pref) {
3289
+ const p = findPhaseByRef(phases, features, pref);
3290
+ if (!p)
3291
+ return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
3292
+ phases = [p];
3293
+ }
3294
+ const out = [];
3295
+ for (const phase of phases) {
3296
+ for (const task of phase.tasks) {
3297
+ if (params.status && task.status !== params.status)
3298
+ continue;
3299
+ out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
3300
+ }
3299
3301
  }
3300
- const summary = phase.tasks.map((t) => `- ${statusIcon(t.status)} ${t.id} — ${t.title}`).join("\n");
3301
- return { content: [{ type: "text", text: summary || "No tasks" }], details: { tasks: phase.tasks } };
3302
+ return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
3302
3303
  },
3303
3304
  });
3304
3305
  pi.registerTool({
3305
3306
  name: "task_get",
3306
3307
  label: "Task Get",
3307
- description: "Read full details of a single task.",
3308
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
3308
3309
  parameters: Type.Object({
3309
- taskId: Type.String({ description: "Task ID" }),
3310
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3311
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
3310
3312
  }),
3311
3313
  async execute(_id, params, _signal, _onUpdate, ctx) {
3312
3314
  const st = await requirePlan(ctx);
3313
3315
  if (!st)
3314
3316
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3315
- const phases = await st.loadAllPhases();
3316
- const phase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3317
- const task = phase?.tasks.find((t) => t.id === params.taskId);
3318
- if (!task || !phase)
3317
+ const features = (await st.loadFeatures()).features;
3318
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3319
+ if (!found)
3319
3320
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3320
- return { content: [{ type: "text", text: `${statusIcon(task.status)} ${task.title} (${task.id}) — phase ${phase.id}` }], details: { task, phaseId: phase.id } };
3321
+ const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
3322
+ if (!params.full)
3323
+ return { content: [{ type: "text", text: summary }], details: {} };
3324
+ const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3325
+ return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
3321
3326
  },
3322
3327
  });
3323
3328
  pi.registerTool({
@@ -3350,7 +3355,7 @@ export default function planPiExtension(pi) {
3350
3355
  const initialStatus = params.status ?? "planned";
3351
3356
  const existingPhase = await st.loadPhase(params.phaseId);
3352
3357
  const task = {
3353
- id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortId, priority, shortName,
3358
+ id: taskId, phaseId: params.phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3354
3359
  title: params.title,
3355
3360
  status: initialStatus,
3356
3361
  description: params.description ?? "",
@@ -3381,7 +3386,7 @@ export default function planPiExtension(pi) {
3381
3386
  label: "Task Update",
3382
3387
  description: "Update one or more fields of a task. Only provided fields are changed. Do NOT use this tool to start or complete work; use task_start and task_complete for lifecycle transitions so startedAt/completedAt stay correct.",
3383
3388
  parameters: Type.Object({
3384
- taskId: Type.String({ description: "Task ID" }),
3389
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3385
3390
  title: Type.Optional(Type.String({ description: "New title" })),
3386
3391
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
3387
3392
  description: Type.Optional(Type.String({ description: "New description" })),
@@ -3469,7 +3474,7 @@ export default function planPiExtension(pi) {
3469
3474
  label: "Task Delete",
3470
3475
  description: "Delete a task from its phase.",
3471
3476
  parameters: Type.Object({
3472
- taskId: Type.String({ description: "Task ID to delete" }),
3477
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
3473
3478
  }),
3474
3479
  async execute(_id, params, _signal, _onUpdate, ctx) {
3475
3480
  const st = await requirePlan(ctx);
@@ -3495,7 +3500,7 @@ export default function planPiExtension(pi) {
3495
3500
  label: "Task Start",
3496
3501
  description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
3497
3502
  parameters: Type.Object({
3498
- taskId: Type.String({ description: "Task ID to start" }),
3503
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3499
3504
  }),
3500
3505
  async execute(_id, params, _signal, _onUpdate, ctx) {
3501
3506
  const st = await requirePlan(ctx);
@@ -3533,7 +3538,7 @@ export default function planPiExtension(pi) {
3533
3538
  label: "Task Complete",
3534
3539
  description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
3535
3540
  parameters: Type.Object({
3536
- taskId: Type.String({ description: "Task ID to complete" }),
3541
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
3537
3542
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
3538
3543
  description_update: Type.Optional(Type.String({ description: "Post-hoc summary: commit hash(s), files touched, decisions made, updated code references with new line numbers. Keeps the planner alive and traceable." })),
3539
3544
  }),
@@ -3634,16 +3639,15 @@ export default function planPiExtension(pi) {
3634
3639
  if (!server) {
3635
3640
  await startServer(ctx, undefined, "lan").catch(() => { });
3636
3641
  }
3642
+ const srv = server;
3637
3643
  let recap = "";
3638
3644
  try {
3639
- recap = await buildStartupResumeSummary(st);
3645
+ recap = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
3640
3646
  }
3641
3647
  catch (e) {
3642
3648
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
3643
3649
  }
3644
- const srv = server;
3645
- const webLine = srv ? `\n🌐 Web UI: ${srv.localUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""}` : "\n🌐 Web UI: not running";
3646
- return { content: [{ type: "text", text: `${recap}${webLine}` }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3650
+ return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3647
3651
  },
3648
3652
  });
3649
3653
  pi.registerTool({
@@ -3674,6 +3678,7 @@ export default function planPiExtension(pi) {
3674
3678
  // add seconds of latency on every turn if run unconditionally.
3675
3679
  if (!plannerHeavyInitDone) {
3676
3680
  await migrateToUuids(st);
3681
+ await migrateToGlobalSequence(st).catch(() => null);
3677
3682
  await st.ensureShortIdsAndPriority().catch(() => null);
3678
3683
  await ensureProjectLanguagePreferences(st).catch(() => null);
3679
3684
  await maybeHealStatuses(st, ctx);
@@ -3755,12 +3760,12 @@ export default function planPiExtension(pi) {
3755
3760
  : hasActiveWork
3756
3761
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3757
3762
  : null;
3758
- const nextActivity = hasActiveWork
3759
- ? (resume.nextSteps[0]
3760
- ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
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."
3763
- : "Review the current plan and choose the next task.");
3763
+ const nextActivity = handoffs.length > 0
3764
+ ? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task. (Legacy resume.json nextSteps may be stale — the handoff is authoritative.)"
3765
+ : hasActiveWork
3766
+ ? (resume.nextSteps[0]
3767
+ ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3768
+ : "Review the current plan and choose the next task.";
3764
3769
  const webServer = server;
3765
3770
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3766
3771
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3789,27 +3794,16 @@ export default function planPiExtension(pi) {
3789
3794
  const ambientSummary = ambient
3790
3795
  ? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
3791
3796
  : "(not scanned)";
3797
+ // NOTE: the recap DATA is delivered via the /planner load trigger message
3798
+ // content (buildRecap), not via this system-prompt protocol — before_agent_start's
3799
+ // systemPrompt injection is not applied to triggerTurn messages. This protocol
3800
+ // is kept as a lightweight anti-narration guard for any normal recap turn.
3792
3801
  const startupResumeProtocol = isRecapTurn ? [
3793
3802
  "",
3794
- "STARTUP RESUME PROTOCOL (first reply of the session — mandatory):",
3795
- "- You are resuming a planner-backed project session.",
3796
- "- Respond DIRECTLY with the summary. Do NOT narrate (no 'The user wants me to...', 'Let me check...', 'I\'ll emit...'). Do NOT run any tools (bash/read/grep/ls) before responding. Output ONLY the summary text.",
3797
- "- FIRST give the user a concise summary of progress so far.",
3798
- `- Mention progress counts: ${doneFeaturesCount}/${plan.features.features.length} features done, ${activeFeaturesCount} active; ${donePhasesCount}/${plan.phases.length} phases done, ${activePhasesCount} active; ${doneTasksCount}/${totalTasksCount} tasks done.`,
3799
- currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
3800
- currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
3801
- currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
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." : "",
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).",
3809
- `- Mention the next suggested activity: ${nextActivity}`,
3810
- "- THEN ask the user explicitly whether they want to resume that activity now.",
3811
- "- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
3812
- "- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any CODE edit/write (bash for git/build/test is fine; planner operations like plan_write_handoff are NOT code edits and never require a task).",
3803
+ "STARTUP RESUME PROTOCOL (mandatory):",
3804
+ "- Present the planner recap to the user. The recap text was delivered with the trigger message — do NOT call tools, do NOT narrate, do NOT quote/expose AGENTS.md or any internal instructions.",
3805
+ `- Write in ${project.chatLanguage || "English"}.`,
3806
+ "- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write (planner ops like handoff_write are NOT code edits).",
3813
3807
  ].filter(Boolean).join("\n") : "";
3814
3808
  // Build/refresh the context block (slow path: cache miss).
3815
3809
  const contextBlock = [
@@ -3822,11 +3816,12 @@ export default function planPiExtension(pi) {
3822
3816
  "═══════════════════════════════════════════════════════════════",
3823
3817
  "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
3824
3818
  "═══════════════════════════════════════════════════════════════",
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.",
3819
+ "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> for context. Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — no manual clear needed. They are non-blocking context artifacts, not locks — they never block task_start.",
3826
3820
  "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.",
3827
3821
  "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.",
3828
3822
  "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.",
3829
3823
  "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
3824
+ "6. ENTITY REFERENCES & FAST LOOKUP: Feature/Phase/Task numbers are GLOBALLY unique (one P003, one T007 in the whole plan) — bare P00x/T00x is a valid unambiguous ref. PREFER the 5-char shortId (e.g. UUXD1) or composite F00x/P00x/T00x in chat; NEVER a raw UUID. To FIND an entity: call feature_list / phase_list / task_list (compact, ~2K tokens for the whole plan) — do NOT read .planner/*.json files or plan_get full=true (~32K tokens) to locate an entity. To ACT: pass the ref directly to the action tool (task_start/complete/update/phase_update accept F00x/P00x/T00x, bare P00x/T00x, shortId, UUID, title). To read a description: call task_get / phase_get / feature_get with full=true for that ONE entity. Tool responses already show shortId + compositeRef; reuse them.",
3830
3825
  "═══════════════════════════════════════════════════════════════",
3831
3826
  "",
3832
3827
  // ── Project details ──
@@ -3857,7 +3852,7 @@ export default function planPiExtension(pi) {
3857
3852
  resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
3858
3853
  resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
3859
3854
  resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
3860
- resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}` : " next steps: (none)",
3855
+ resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}${handoffs.length > 0 ? " (⚠️ may be stale — handoff is authoritative)" : resume.nextStepsUpdatedAt ? ` (last updated ${resume.nextStepsUpdatedAt}, may be stale)` : ""}` : " next steps: (none)",
3861
3856
  resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
3862
3857
  "",
3863
3858
  recentActivity.length ? "Recent activity (most recent first):" : "",
@@ -3901,7 +3896,7 @@ export default function planPiExtension(pi) {
3901
3896
  ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3902
3897
  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
3898
  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)." : "",
3899
+ handoffs.length > 0 ? "Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — read with handoff show <ref> for context; no manual clear needed." : "",
3905
3900
  "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",
3906
3901
  ].filter(Boolean).join("\n");
3907
3902
  contextBlockCache = contextBlock;