@agent-plan/pi-adapter 0.2.19-next.2 → 0.2.19-next.20

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, buildRecap } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } 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";
@@ -105,11 +105,13 @@ const PLANNER_COMMAND_COMPLETIONS = [
105
105
  { value: "init", label: "init", description: "Initialize planner in this project" },
106
106
  { value: "show", label: "show", description: "Show planner overview" },
107
107
  { value: "repair", label: "repair", description: "Repair planner integrity" },
108
+ { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
108
109
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
109
110
  { value: "project language", label: "project language", description: "Set persistent language preferences" },
110
111
  { value: "feature list", label: "feature list", description: "List features" },
111
112
  { value: "feature add", label: "feature add", description: "Create a feature" },
112
113
  { value: "feature show", label: "feature show", description: "Show a feature" },
114
+ { value: "feature discuss", label: "feature discuss", description: "Discuss a feature" },
113
115
  { value: "feature update", label: "feature update", description: "Update a feature" },
114
116
  { value: "feature delete", label: "feature delete", description: "Delete a feature" },
115
117
  { value: "phase add", label: "phase add", description: "Add a phase" },
@@ -124,6 +126,9 @@ const PLANNER_COMMAND_COMPLETIONS = [
124
126
  { value: "task delete", label: "task delete", description: "Delete a task" },
125
127
  { value: "task start", label: "task start", description: "Mark a task in-progress" },
126
128
  { value: "task complete", label: "task complete", description: "Mark a task done" },
129
+ { value: "task checklist-add", label: "task checklist-add", description: "Add a checklist step: /planner task checklist-add <T00x> <title>" },
130
+ { value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
131
+ { value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
127
132
  { value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
128
133
  { value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
129
134
  { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
@@ -250,6 +255,34 @@ function phaseLabel(phase) {
250
255
  function taskLabel(task) {
251
256
  return `T${formatSequence(task.number)} — ${task.title}`;
252
257
  }
258
+ function resolveFeatureRefStrict(features, ref) {
259
+ const raw = ref.trim();
260
+ if (!raw)
261
+ return { ok: false, error: "Feature ref is required." };
262
+ const normalized = raw.toLowerCase();
263
+ const byNumber = normalized.match(/^f(\d+)$/)
264
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
265
+ : undefined;
266
+ if (byNumber)
267
+ return { ok: true, feature: byNumber };
268
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
269
+ if (byShortId)
270
+ return { ok: true, feature: byShortId };
271
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
272
+ if (byId)
273
+ return { ok: true, feature: byId };
274
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
275
+ if (exactName.length === 1)
276
+ return { ok: true, feature: exactName[0] };
277
+ if (exactName.length > 1)
278
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
279
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
280
+ if (partialName.length === 1)
281
+ return { ok: true, feature: partialName[0] };
282
+ if (partialName.length > 1)
283
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
284
+ return { ok: false, error: `Feature not found: ${raw}` };
285
+ }
253
286
  async function buildHandoffMarkdown(st, reason, overrides) {
254
287
  const [plan, resume, activity] = await Promise.all([
255
288
  st.loadAll(),
@@ -352,8 +385,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
352
385
  ".planner/features/",
353
386
  currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
354
387
  latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
355
- ".planner/resume.json",
356
- ".planner/generated/PLAN.md",
388
+ ".planner/.local/resume.json",
389
+ ".planner/.local/generated/PLAN.md",
357
390
  ].filter(Boolean);
358
391
  const recentActivityLines = recentActivity.length > 0
359
392
  ? recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`)
@@ -379,7 +412,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
379
412
  ? ["", `## Current Task Statuses (phase ${currentPhase.id})`, ...currentPhaseTaskLines]
380
413
  : [];
381
414
  return [
382
- "# Handoff",
415
+ `# ${overrides?.title?.trim() || `Handoff — ${reason}`}`,
383
416
  "",
384
417
  `Created at: ${createdAt}`,
385
418
  `Updated at: ${updatedAt}`,
@@ -416,7 +449,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
416
449
  ...recentActivityLines,
417
450
  "",
418
451
  "## Reminder",
419
- "- When work is fully resumed and this handoff is no longer needed, clear the phase handoff with handoff clear <ref> (or /planner handoff clear).",
452
+ "- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/.local/handoff-archive/) or the phase completes. No need to clear it manually.",
420
453
  ].join("\n");
421
454
  }
422
455
  async function writeProjectHandoff(st, reason, overrides) {
@@ -512,6 +545,9 @@ function statusIcon(status) {
512
545
  };
513
546
  return icons[status] ?? "❓";
514
547
  }
548
+ function pad(n) {
549
+ return String(n).padStart(3, "0");
550
+ }
515
551
  function applyTaskLifecycleDates(task, nextStatus, now) {
516
552
  const previousStatus = task.status;
517
553
  if (nextStatus === "in-progress" && !task.startedAt) {
@@ -744,9 +780,10 @@ export default function planPiExtension(pi) {
744
780
  // ── Step 1: Enable gating ───────────────────────────────────────
745
781
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
746
782
  // render for NPM-loaded ESM extensions during session_start (they hang
747
- // until the safety-net timeout). The planner is always DISABLED at
748
- // startup; the user enables it with '/planner load' (which also starts
749
- // the web UI on LAN and triggers the resume summary showing the URL).
783
+ // until the safety-net timeout). Tracked upstream via planner task
784
+ // T151 · EBRST. The planner is always DISABLED at startup; the user
785
+ // enables it with '/planner load' (which also starts the web UI on LAN
786
+ // and triggers the resume summary showing the URL).
750
787
  let enablePlanner = false;
751
788
  if (exists) {
752
789
  ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
@@ -757,6 +794,10 @@ export default function planPiExtension(pi) {
757
794
  const handoffs = await st.listHandoffs().catch(() => []);
758
795
  if (handoffs.length > 0) {
759
796
  ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
797
+ // Mark each handoff as read (recap presented). Do NOT clear — content is
798
+ // kept until a task in the phase starts (auto-archived) or phase completes.
799
+ for (const h of handoffs)
800
+ await st.markHandoffRead(h.phaseId).catch(() => { });
760
801
  }
761
802
  }
762
803
  // If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
@@ -935,6 +976,7 @@ export default function planPiExtension(pi) {
935
976
  "init",
936
977
  "show",
937
978
  "repair",
979
+ "cleanup-orphans",
938
980
  "project discuss",
939
981
  "project language",
940
982
  "feature list",
@@ -968,8 +1010,8 @@ export default function planPiExtension(pi) {
968
1010
  "load",
969
1011
  "stop",
970
1012
  ];
971
- const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
972
- "Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
1013
+ const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1014
+ "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
973
1015
  "Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
974
1016
  if (!a) {
975
1017
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
@@ -1125,6 +1167,34 @@ export default function planPiExtension(pi) {
1125
1167
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1126
1168
  ?? null;
1127
1169
  }
1170
+ function resolveFeatureRef(features, ref) {
1171
+ const raw = ref.trim();
1172
+ if (!raw)
1173
+ return { ok: false, error: "Feature ref is required." };
1174
+ const normalized = raw.toLowerCase();
1175
+ const byNumber = normalized.match(/^f(\d+)$/)
1176
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
1177
+ : undefined;
1178
+ if (byNumber)
1179
+ return { ok: true, feature: byNumber };
1180
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
1181
+ if (byShortId)
1182
+ return { ok: true, feature: byShortId };
1183
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
1184
+ if (byId)
1185
+ return { ok: true, feature: byId };
1186
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
1187
+ if (exactName.length == 1)
1188
+ return { ok: true, feature: exactName[0] };
1189
+ if (exactName.length > 1)
1190
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
1191
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
1192
+ if (partialName.length == 1)
1193
+ return { ok: true, feature: partialName[0] };
1194
+ if (partialName.length > 1)
1195
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
1196
+ return { ok: false, error: `Feature not found: ${raw}` };
1197
+ }
1128
1198
  function findPhaseByRef(phases, ref) {
1129
1199
  const normalized = ref.trim().toLowerCase();
1130
1200
  if (!normalized)
@@ -1142,19 +1212,6 @@ export default function planPiExtension(pi) {
1142
1212
  ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1143
1213
  ?? null;
1144
1214
  }
1145
- function findTaskByRef(phases, ref) {
1146
- const normalized = ref.trim().toLowerCase();
1147
- if (!normalized)
1148
- return null;
1149
- for (const phase of phases) {
1150
- const task = phase.tasks.find((entryTask) => entryTask.id.toLowerCase() === normalized
1151
- || entryTask.title.toLowerCase() === normalized
1152
- || entryTask.title.toLowerCase().includes(normalized));
1153
- if (task)
1154
- return { phase, task };
1155
- }
1156
- return null;
1157
- }
1158
1215
  function profile_packageManager(pkg, lockfile) {
1159
1216
  if (pkg?.packageManager)
1160
1217
  return pkg.packageManager;
@@ -1327,7 +1384,7 @@ export default function planPiExtension(pi) {
1327
1384
  // ═══════════════════════════════════════════════════════════════
1328
1385
  if (a === "feature") {
1329
1386
  if (!b) {
1330
- ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
1387
+ ctx.ui.notify("feature actions: list | add [name] | show [id|name] | discuss [id|name] | update [id|name] | delete [id|name]", "info");
1331
1388
  return;
1332
1389
  }
1333
1390
  if (b === "list") {
@@ -1359,7 +1416,7 @@ export default function planPiExtension(pi) {
1359
1416
  return;
1360
1417
  }
1361
1418
  const now = nowISO();
1362
- const featureNumber = (await st.loadFeatures()).features.length + 1;
1419
+ const featureNumber = await st.allocFeatureNumber();
1363
1420
  const shortId = createShortId(await st.assignedShortIds());
1364
1421
  const priority = await st.nextPriority("feature");
1365
1422
  const feature = {
@@ -1380,6 +1437,7 @@ export default function planPiExtension(pi) {
1380
1437
  acceptedDecisions: [],
1381
1438
  phaseIds: [],
1382
1439
  dependsOn: [],
1440
+ statusLog: [],
1383
1441
  createdAt: now,
1384
1442
  updatedAt: now,
1385
1443
  };
@@ -1388,7 +1446,7 @@ export default function planPiExtension(pi) {
1388
1446
  return doc;
1389
1447
  });
1390
1448
  await st.writeGenerated();
1391
- ctx.ui.notify(`Feature created: ${feature.id} — ${feature.name}`, "info");
1449
+ ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1392
1450
  return;
1393
1451
  }
1394
1452
  if (b === "show") {
@@ -1417,6 +1475,44 @@ export default function planPiExtension(pi) {
1417
1475
  ].filter(Boolean).join("\n"), "info");
1418
1476
  return;
1419
1477
  }
1478
+ if (b === "discuss") {
1479
+ const featuresDoc = await st.loadFeatures();
1480
+ let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
1481
+ if (!feature) {
1482
+ feature = await pickFeature();
1483
+ if (!feature) {
1484
+ ctx.ui.notify("No features to discuss. Create one first.", "warning");
1485
+ return;
1486
+ }
1487
+ }
1488
+ const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
1489
+ const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
1490
+ const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
1491
+ const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
1492
+ const featureId = feature.id;
1493
+ const updatedDoc = await st.updateFeatures((doc) => {
1494
+ const target = doc.features.find((entry) => entry.id === featureId);
1495
+ if (!target)
1496
+ return doc;
1497
+ if (description !== undefined)
1498
+ target.description = description.trim();
1499
+ if (workDone !== undefined)
1500
+ target.workDone = workDone.trim();
1501
+ if (workRemaining !== undefined)
1502
+ target.workRemaining = workRemaining.trim();
1503
+ if (dependencies !== undefined)
1504
+ target.dependsOn = splitCsv(dependencies);
1505
+ target.discussedAt = nowISO();
1506
+ target.contextReady = true;
1507
+ target.contextReadyReason = "Updated through /planner feature discuss.";
1508
+ target.updatedAt = nowISO();
1509
+ return doc;
1510
+ });
1511
+ feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1512
+ await st.writeGenerated();
1513
+ ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1514
+ return;
1515
+ }
1420
1516
  if (b === "update") {
1421
1517
  const featuresDoc = await st.loadFeatures();
1422
1518
  let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
@@ -1469,7 +1565,7 @@ export default function planPiExtension(pi) {
1469
1565
  });
1470
1566
  feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1471
1567
  await st.writeGenerated();
1472
- ctx.ui.notify(`Feature updated: ${feature.id} — ${feature.name} (${feature.status})`, "info");
1568
+ ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
1473
1569
  return;
1474
1570
  }
1475
1571
  if (b === "delete") {
@@ -1515,7 +1611,7 @@ export default function planPiExtension(pi) {
1515
1611
  ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1516
1612
  return;
1517
1613
  }
1518
- ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
1614
+ ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
1519
1615
  return;
1520
1616
  }
1521
1617
  // ═══════════════════════════════════════════════════════════════
@@ -1546,7 +1642,7 @@ export default function planPiExtension(pi) {
1546
1642
  await withFeatureLock(feature.id, async () => {
1547
1643
  const phases = await st.loadAllPhases();
1548
1644
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1549
- const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
1645
+ const number = await st.allocPhaseNumber();
1550
1646
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1551
1647
  const id = createPhaseId();
1552
1648
  const shortId = createShortId(await st.assignedShortIds());
@@ -1557,6 +1653,9 @@ export default function planPiExtension(pi) {
1557
1653
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1558
1654
  createdAt: nowISO(), updatedAt: nowISO(),
1559
1655
  handoff: "", handoffUpdatedAt: "",
1656
+ handoffReadAt: "",
1657
+ handoffHistory: [],
1658
+ statusLog: [],
1560
1659
  };
1561
1660
  await st.savePhase(phase);
1562
1661
  await st.updateFeatures((doc) => {
@@ -1571,7 +1670,7 @@ export default function planPiExtension(pi) {
1571
1670
  });
1572
1671
  if (!phase)
1573
1672
  return;
1574
- ctx.ui.notify(`Phase created: ${phase.id} — ${title} (feature ${feature.name})`, "info");
1673
+ ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
1575
1674
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1576
1675
  if (isYes(startDiscuss)) {
1577
1676
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
@@ -1596,7 +1695,7 @@ export default function planPiExtension(pi) {
1596
1695
  phase.status = "discovery";
1597
1696
  phase.updatedAt = nowISO();
1598
1697
  await st.savePhase(phase);
1599
- ctx.ui.notify(`Discovery for ${phase.id}…`, "info");
1698
+ ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1600
1699
  // ── Recap: give full context before planning tasks ──
1601
1700
  const recapPlan = await st.loadAll();
1602
1701
  const recapProject = recapPlan.project;
@@ -1661,7 +1760,7 @@ export default function planPiExtension(pi) {
1661
1760
  phase.updatedAt = nowISO();
1662
1761
  await st.savePhase(phase);
1663
1762
  await st.writeGenerated();
1664
- ctx.ui.notify(`Phase ${phase.id} is now **planned**.`, "info");
1763
+ ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1665
1764
  return;
1666
1765
  }
1667
1766
  if (b === "show") {
@@ -1709,7 +1808,7 @@ export default function planPiExtension(pi) {
1709
1808
  }
1710
1809
  await st.deletePhase(phase.id);
1711
1810
  await st.writeGenerated();
1712
- ctx.ui.notify(`Phase deleted: ${phase.id}`, "info");
1811
+ ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1713
1812
  return;
1714
1813
  }
1715
1814
  if (b === "update") {
@@ -1761,7 +1860,7 @@ export default function planPiExtension(pi) {
1761
1860
  phase.updatedAt = nowISO();
1762
1861
  await st.savePhase(phase);
1763
1862
  await st.writeGenerated();
1764
- ctx.ui.notify(`Phase updated: ${phase.id} — ${phase.title} (${phase.status})`, "info");
1863
+ ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1765
1864
  return;
1766
1865
  }
1767
1866
  ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
@@ -1772,7 +1871,7 @@ export default function planPiExtension(pi) {
1772
1871
  // ═══════════════════════════════════════════════════════════════
1773
1872
  if (a === "task") {
1774
1873
  if (!b) {
1775
- ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
1874
+ ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
1776
1875
  return;
1777
1876
  }
1778
1877
  if (b === "add") {
@@ -1787,7 +1886,7 @@ export default function planPiExtension(pi) {
1787
1886
  return;
1788
1887
  }
1789
1888
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1790
- const taskNum = phase.tasks.length + 1;
1889
+ const taskNum = await st.allocTaskNumber();
1791
1890
  const taskId = createTaskId();
1792
1891
  const shortId = createShortId(await st.assignedShortIds());
1793
1892
  const priority = await st.nextPriority("task", phase.id);
@@ -1819,7 +1918,7 @@ export default function planPiExtension(pi) {
1819
1918
  }
1820
1919
  if (b === "discuss") {
1821
1920
  const phases = await st.loadAllPhases();
1822
- let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
1921
+ let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
1823
1922
  if (!resolved) {
1824
1923
  const phase = await pickPhase();
1825
1924
  if (!phase) {
@@ -1843,7 +1942,7 @@ export default function planPiExtension(pi) {
1843
1942
  if (description?.trim())
1844
1943
  task.description = description.trim();
1845
1944
  if (checklistSeed?.trim()) {
1846
- task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
1945
+ task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
1847
1946
  id: createChecklistItemId(task.id, index + 1, itemTitle),
1848
1947
  title: itemTitle,
1849
1948
  checked: false,
@@ -1853,14 +1952,14 @@ export default function planPiExtension(pi) {
1853
1952
  phase.updatedAt = nowISO();
1854
1953
  await st.savePhase(phase);
1855
1954
  await st.writeGenerated();
1856
- ctx.ui.notify(`Task ${task.id} discussed and updated.`, "info");
1955
+ ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
1857
1956
  return;
1858
1957
  }
1859
1958
  if (b === "show") {
1860
1959
  let phase = null;
1861
1960
  let task = null;
1862
1961
  if (subArgs.trim()) {
1863
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
1962
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
1864
1963
  phase = resolved?.phase ?? null;
1865
1964
  task = resolved?.task ?? null;
1866
1965
  }
@@ -1922,7 +2021,7 @@ export default function planPiExtension(pi) {
1922
2021
  phase.updatedAt = nowISO();
1923
2022
  await st.savePhase(phase);
1924
2023
  await st.writeGenerated();
1925
- ctx.ui.notify(`Task deleted: ${task.id}`, "info");
2024
+ 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");
1926
2025
  return;
1927
2026
  }
1928
2027
  if (b === "update") {
@@ -1971,14 +2070,14 @@ export default function planPiExtension(pi) {
1971
2070
  phase.updatedAt = now;
1972
2071
  await st.savePhase(phase);
1973
2072
  await st.writeGenerated();
1974
- ctx.ui.notify(`Task updated: ${task.id} — ${task.title} (${task.status})`, "info");
2073
+ 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");
1975
2074
  return;
1976
2075
  }
1977
2076
  if (b === "start") {
1978
2077
  let phase = null;
1979
2078
  let task = null;
1980
2079
  if (subArgs.trim()) {
1981
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2080
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
1982
2081
  phase = resolved?.phase ?? null;
1983
2082
  task = resolved?.task ?? null;
1984
2083
  }
@@ -2002,12 +2101,16 @@ export default function planPiExtension(pi) {
2002
2101
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2003
2102
  return;
2004
2103
  }
2005
- // Hygiene notice (non-blocking): surface an existing handoff but never
2006
- // block task_start — the handoff is a captured-context artifact, not a
2007
- // lock (a small modification after writing a handoff should start without
2008
- // forcing deletion, which would lose context).
2009
- if ((await st.listHandoffs()).length > 0) {
2010
- 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");
2104
+ // Auto-archive the handoff on THIS task's phase (reason: task-started) —
2105
+ // the agent is now actively working, so the captured context is consumed.
2106
+ // Other phases' handoffs are left untouched (informational, non-blocking).
2107
+ if ((phase.handoff ?? "") !== "") {
2108
+ await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
2109
+ ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/.local/handoff-archive/.", "info");
2110
+ }
2111
+ const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
2112
+ if (_otherHandoffs.length > 0) {
2113
+ ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
2011
2114
  }
2012
2115
  const now = nowISO();
2013
2116
  // Record status change in the incremental statusLog.
@@ -2026,14 +2129,14 @@ export default function planPiExtension(pi) {
2026
2129
  phase.updatedAt = now;
2027
2130
  await st.savePhase(phase);
2028
2131
  await st.writeGenerated();
2029
- ctx.ui.notify(`✅ Task started: ${task.id} — ${task.title} (in-progress)`, "info");
2132
+ 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");
2030
2133
  return;
2031
2134
  }
2032
2135
  if (b === "complete") {
2033
2136
  let phase = null;
2034
2137
  let task = null;
2035
2138
  if (subArgs.trim()) {
2036
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2139
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2037
2140
  phase = resolved?.phase ?? null;
2038
2141
  task = resolved?.task ?? null;
2039
2142
  }
@@ -2081,10 +2184,78 @@ export default function planPiExtension(pi) {
2081
2184
  phase.updatedAt = now;
2082
2185
  await st.savePhase(phase);
2083
2186
  await st.writeGenerated();
2084
- ctx.ui.notify(`✅ Task completed: ${task.id} — ${task.title} (done)`, "info");
2187
+ 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");
2188
+ return;
2189
+ }
2190
+ // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
2191
+ if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
2192
+ const [taskRef, ...itemParts] = rest;
2193
+ const itemText = itemParts.join(" ").trim();
2194
+ if (!taskRef || !itemText) {
2195
+ ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
2196
+ return;
2197
+ }
2198
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
2199
+ if (!resolved) {
2200
+ ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
2201
+ return;
2202
+ }
2203
+ let checked;
2204
+ if (b === "checklist-toggle") {
2205
+ const m = itemText.match(/\s+(on|off)$/i);
2206
+ if (m) {
2207
+ checked = m[1].toLowerCase() === "on";
2208
+ }
2209
+ }
2210
+ const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
2211
+ let msg = "";
2212
+ await st.updatePhase(resolved.phase.id, (phase) => {
2213
+ const task = phase.tasks.find((t) => t.id === resolved.task.id);
2214
+ if (!task) {
2215
+ msg = `Task not found: ${taskRef}`;
2216
+ return phase;
2217
+ }
2218
+ const ck = task.checklist ?? [];
2219
+ if (b === "checklist-add") {
2220
+ const item = addChecklistItem(ck, task.id, selector);
2221
+ ck.push(item);
2222
+ task.checklist = ck;
2223
+ msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
2224
+ }
2225
+ else if (b === "checklist-remove") {
2226
+ if (ck.length === 0) {
2227
+ msg = `Task "${task.title}" has no checklist.`;
2228
+ return phase;
2229
+ }
2230
+ const removed = removeChecklistItem(ck, selector);
2231
+ if (!removed) {
2232
+ msg = `No checklist item matching "${selector}".`;
2233
+ return phase;
2234
+ }
2235
+ msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
2236
+ }
2237
+ else {
2238
+ if (ck.length === 0) {
2239
+ msg = `Task "${task.title}" has no checklist.`;
2240
+ return phase;
2241
+ }
2242
+ const target = toggleChecklistItem(ck, selector, checked);
2243
+ if (!target) {
2244
+ msg = `No checklist item matching "${selector}".`;
2245
+ return phase;
2246
+ }
2247
+ const doneCount = ck.filter((i) => i.checked).length;
2248
+ msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
2249
+ }
2250
+ task.updatedAt = nowISO();
2251
+ phase.updatedAt = nowISO();
2252
+ return phase;
2253
+ });
2254
+ await st.writeGenerated();
2255
+ ctx.ui.notify(`✅ ${msg}`, "info");
2085
2256
  return;
2086
2257
  }
2087
- ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
2258
+ ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
2088
2259
  return;
2089
2260
  }
2090
2261
  // ── discuss (legacy alias) ──
@@ -2140,7 +2311,9 @@ export default function planPiExtension(pi) {
2140
2311
  return;
2141
2312
  }
2142
2313
  const handoffs = await st.listHandoffs();
2143
- 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" +
2314
+ await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the phase the user explicitly identifies as the one just worked on in this session, using the `handoff_write` tool (entity-scoped, stored on phase.handoff — .planner/HANDOFF.md is deprecated).\n\n" +
2315
+ "Pass a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring') — it becomes the H1 / first line in handoff lists. Generic titles like 'Handoff' or 'Canonical handoff' are REJECTED.\n\n" +
2316
+ "WARNING: Do not default to 'current in-progress phase' or a stale resume pointer. If the user just completed P058, write the handoff on P058 (or its continuation P061), not on P031 just because P031 is marked in-progress or has an existing handoff.\n\n" +
2144
2317
  "The handoff MUST be a structured markdown document containing at minimum:\n" +
2145
2318
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2146
2319
  "- `Reason:` why this handoff is being written\n" +
@@ -2164,7 +2337,7 @@ export default function planPiExtension(pi) {
2164
2337
  ctx.ui.notify(r.error, "warning");
2165
2338
  return;
2166
2339
  }
2167
- await st.clearPhaseHandoff(r.phase.id);
2340
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2168
2341
  ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
2169
2342
  return;
2170
2343
  }
@@ -2226,6 +2399,25 @@ export default function planPiExtension(pi) {
2226
2399
  ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2227
2400
  return;
2228
2401
  }
2402
+ if (a === "cleanup-orphans") {
2403
+ const found = await st.listOrphanPhases();
2404
+ if (found.length === 0) {
2405
+ ctx.ui.notify("No orphan phases found.", "info");
2406
+ return;
2407
+ }
2408
+ const lines = [
2409
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
2410
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2411
+ ];
2412
+ const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
2413
+ if (!confirmed) {
2414
+ ctx.ui.notify("Cleanup aborted.", "warning");
2415
+ return;
2416
+ }
2417
+ const report = await st.cleanupOrphanPhases();
2418
+ ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
2419
+ return;
2420
+ }
2229
2421
  // ── export ──
2230
2422
  if (a === "export" || a === "export-full") {
2231
2423
  const isFull = a === "export-full" || subArgs.includes("--full");
@@ -2595,7 +2787,7 @@ export default function planPiExtension(pi) {
2595
2787
  pi.registerTool({
2596
2788
  name: "plan_render",
2597
2789
  label: "Plan Render",
2598
- description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
2790
+ description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
2599
2791
  parameters: Type.Object({}),
2600
2792
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2601
2793
  const st = await requirePlan(ctx);
@@ -2608,7 +2800,7 @@ export default function planPiExtension(pi) {
2608
2800
  pi.registerTool({
2609
2801
  name: "plan_repair",
2610
2802
  label: "Plan Repair",
2611
- 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/.",
2803
+ 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/.",
2612
2804
  parameters: Type.Object({}),
2613
2805
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2614
2806
  const st = await requirePlan(ctx);
@@ -2616,9 +2808,11 @@ export default function planPiExtension(pi) {
2616
2808
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2617
2809
  const report = await st.repair();
2618
2810
  const m = report.migrated;
2811
+ const c = report.containment;
2619
2812
  const lines = [
2620
2813
  `Repair complete.`,
2621
2814
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2815
+ `Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
2622
2816
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2623
2817
  ];
2624
2818
  if (report.integrity.danglingPhaseIds.length)
@@ -2628,6 +2822,38 @@ export default function planPiExtension(pi) {
2628
2822
  return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2629
2823
  },
2630
2824
  });
2825
+ pi.registerTool({
2826
+ name: "plan_cleanup_orphan_phases",
2827
+ label: "Plan Cleanup Orphan Phases",
2828
+ description: "Discover phase files that no longer resolve to a valid owning feature, and optionally delete them. Run once with confirm=false (default) to inspect, then rerun with confirm=true to remove them.",
2829
+ parameters: Type.Object({
2830
+ confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
2831
+ }),
2832
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2833
+ const st = await requirePlan(ctx);
2834
+ if (!st)
2835
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2836
+ const found = await st.listOrphanPhases();
2837
+ if (!params.confirm) {
2838
+ if (found.length === 0)
2839
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
2840
+ const lines = [
2841
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
2842
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2843
+ "Rerun with confirm=true to delete these orphan phase files.",
2844
+ ];
2845
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
2846
+ }
2847
+ const report = await st.cleanupOrphanPhases();
2848
+ if (report.removed.length === 0)
2849
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
2850
+ const lines = [
2851
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
2852
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
2853
+ ];
2854
+ return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2855
+ },
2856
+ });
2631
2857
  pi.registerTool({
2632
2858
  name: "plan_get_handoff",
2633
2859
  label: "Plan Get Handoff",
@@ -2647,9 +2873,10 @@ export default function planPiExtension(pi) {
2647
2873
  pi.registerTool({
2648
2874
  name: "plan_write_handoff",
2649
2875
  label: "Plan Write Handoff",
2650
- 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.",
2876
+ 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. IMPORTANT: write the handoff on the phase the user explicitly identifies as the one just worked on in this session; do not default to a stale 'current phase' pointer or an existing handoff on a different phase.",
2651
2877
  parameters: Type.Object({
2652
2878
  reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
2879
+ title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Example: 'P049 — featureId validation: tests + adapter wiring'." })),
2653
2880
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
2654
2881
  howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
2655
2882
  extraSections: Type.Optional(Type.Array(Type.Object({
@@ -2662,6 +2889,7 @@ export default function planPiExtension(pi) {
2662
2889
  if (!st)
2663
2890
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2664
2891
  const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2892
+ ...(params.title?.trim() ? { title: params.title.trim() } : {}),
2665
2893
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2666
2894
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2667
2895
  ...(params.extraSections && params.extraSections.length > 0
@@ -2691,13 +2919,16 @@ export default function planPiExtension(pi) {
2691
2919
  const r = await resolvePhaseForHandoff(st, undefined);
2692
2920
  if (!r.ok)
2693
2921
  return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
2694
- await st.clearPhaseHandoff(r.phase.id);
2922
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2695
2923
  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 } };
2696
2924
  },
2697
2925
  });
2698
2926
  // Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
2699
2927
  // UUID | title; omitted -> current in-progress phase (phase.status, or the
2700
2928
  // phase containing an in-progress task). Returns the phase + its composite ref.
2929
+ // NOTE: callers must NOT treat the resolved phase as a directive; it is a
2930
+ // fallback pointer that may be stale if the agent worked on a different
2931
+ // workstream in this session.
2701
2932
  async function resolvePhaseForHandoff(st, ref) {
2702
2933
  const phases = await st.loadAllPhases();
2703
2934
  const features = (await st.loadFeatures()).features;
@@ -2757,9 +2988,10 @@ export default function planPiExtension(pi) {
2757
2988
  pi.registerTool({
2758
2989
  name: "handoff_write",
2759
2990
  label: "Handoff Write",
2760
- 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.",
2991
+ description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). The title becomes the H1 and the first line shown in handoff lists; generic titles like 'Handoff' or 'Canonical handoff' are REJECTED. phaseRef optional (default: current in-progress phase). IMPORTANT: defaulting to 'current in-progress phase' is a fallback only; write the handoff on the phase the user explicitly identifies as the one just worked on in this session, and do not update an existing handoff on a different phase unless the user confirms.",
2761
2992
  parameters: Type.Object({
2762
2993
  phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
2994
+ title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'." })),
2763
2995
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2764
2996
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2765
2997
  }),
@@ -2767,9 +2999,32 @@ export default function planPiExtension(pi) {
2767
2999
  const st = await requirePlan(ctx);
2768
3000
  if (!st)
2769
3001
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2770
- const body = (params.markdown_content ?? params.content ?? "").trim();
3002
+ let body = (params.markdown_content ?? params.content ?? "").trim();
2771
3003
  if (!body)
2772
3004
  return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
3005
+ const title = params.title?.trim();
3006
+ // Derive the current first non-empty line (H1) of the supplied body.
3007
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
3008
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3009
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3010
+ if (!title && isGeneric) {
3011
+ return {
3012
+ content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted." }],
3013
+ details: { error: "generic title", firstHeadingText },
3014
+ };
3015
+ }
3016
+ // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
3017
+ if (title) {
3018
+ const lines = body.split(/\r?\n/);
3019
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
3020
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
3021
+ lines[firstIdx] = `# ${title}`;
3022
+ body = lines.join("\n");
3023
+ }
3024
+ else {
3025
+ body = `# ${title}\n\n${body}`;
3026
+ }
3027
+ }
2773
3028
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2774
3029
  if (!r.ok)
2775
3030
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
@@ -2789,8 +3044,8 @@ export default function planPiExtension(pi) {
2789
3044
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2790
3045
  if (!r.ok)
2791
3046
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2792
- await st.clearPhaseHandoff(r.phase.id);
2793
- return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}. (handoffUpdatedAt preserved as audit)` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3047
+ await st.clearPhaseHandoff(r.phase.id, "manual");
3048
+ return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2794
3049
  },
2795
3050
  });
2796
3051
  pi.registerTool({
@@ -2826,38 +3081,44 @@ export default function planPiExtension(pi) {
2826
3081
  pi.registerTool({
2827
3082
  name: "feature_list",
2828
3083
  label: "Feature List",
2829
- description: "List all features with their ids, statuses, and phase counts.",
2830
- parameters: Type.Object({}),
2831
- async execute(_id, _params, _signal, _onUpdate, ctx) {
3084
+ 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.",
3085
+ parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
3086
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2832
3087
  const st = await requirePlan(ctx);
2833
3088
  if (!st)
2834
3089
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2835
- const features = (await st.loadFeatures()).features;
3090
+ const all = (await st.loadFeatures()).features;
3091
+ const ref = params.featureRef?.trim();
3092
+ const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
3093
+ const features = all.filter(matchF);
2836
3094
  const phases = await st.loadAllPhases();
2837
3095
  const summary = features.map((f) => {
2838
3096
  const fp = phases.filter((p) => p.featureId === f.id);
2839
- return `- ${statusIcon(f.status)} ${f.id} — ${f.name} (${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
3097
+ 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)`;
2840
3098
  }).join("\n");
2841
- return { content: [{ type: "text", text: summary || "No features" }], details: { features } };
3099
+ return { content: [{ type: "text", text: summary || "No features" }], details: {} };
2842
3100
  },
2843
3101
  });
2844
3102
  pi.registerTool({
2845
3103
  name: "feature_get",
2846
3104
  label: "Feature Get",
2847
- description: "Read full details of a feature, including its phases and their tasks.",
3105
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
2848
3106
  parameters: Type.Object({
2849
- featureId: Type.String({ description: "Feature ID, e.g. feature-001-dynamic-header" }),
3107
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3108
+ full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
2850
3109
  }),
2851
3110
  async execute(_id, params, _signal, _onUpdate, ctx) {
2852
3111
  const st = await requirePlan(ctx);
2853
3112
  if (!st)
2854
3113
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2855
3114
  const features = (await st.loadFeatures()).features;
2856
- const feature = features.find((f) => f.id === params.featureId);
3115
+ const ref = params.featureId.trim();
3116
+ const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
2857
3117
  if (!feature)
2858
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3118
+ return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
2859
3119
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
2860
- return { content: [{ type: "text", text: `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${phases.length} phases` }], details: { feature, phases } };
3120
+ const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
3121
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
2861
3122
  },
2862
3123
  });
2863
3124
  pi.registerTool({
@@ -2880,7 +3141,7 @@ export default function planPiExtension(pi) {
2880
3141
  const priority = await st.nextPriority("feature");
2881
3142
  const feature = {
2882
3143
  id: createFeatureId(),
2883
- number: currentFeatures.length + 1,
3144
+ number: await st.allocFeatureNumber(),
2884
3145
  shortId,
2885
3146
  priority,
2886
3147
  name: params.name,
@@ -2896,6 +3157,7 @@ export default function planPiExtension(pi) {
2896
3157
  acceptedDecisions: [],
2897
3158
  phaseIds: [],
2898
3159
  dependsOn: [],
3160
+ statusLog: [],
2899
3161
  createdAt: now,
2900
3162
  updatedAt: now,
2901
3163
  };
@@ -2906,7 +3168,52 @@ export default function planPiExtension(pi) {
2906
3168
  return doc;
2907
3169
  });
2908
3170
  await st.writeGenerated();
2909
- return { content: [{ type: "text", text: `Feature created: ${feature.id}` }], details: feature };
3171
+ return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3172
+ },
3173
+ });
3174
+ pi.registerTool({
3175
+ name: "feature_discuss",
3176
+ label: "Feature Discuss",
3177
+ description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
3178
+ parameters: Type.Object({
3179
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3180
+ description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3181
+ workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3182
+ workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3183
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
3184
+ }),
3185
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3186
+ const st = await requirePlan(ctx);
3187
+ if (!st)
3188
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3189
+ const features = (await st.loadFeatures()).features;
3190
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3191
+ if (!resolvedFeature.ok)
3192
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3193
+ const featureId = resolvedFeature.feature.id;
3194
+ const updatedDoc = await st.updateFeatures((doc) => {
3195
+ const feature = doc.features.find((f) => f.id === featureId);
3196
+ if (!feature)
3197
+ return doc;
3198
+ if (params.description !== undefined)
3199
+ feature.description = params.description;
3200
+ if (params.workDone !== undefined)
3201
+ feature.workDone = params.workDone;
3202
+ if (params.workRemaining !== undefined)
3203
+ feature.workRemaining = params.workRemaining;
3204
+ if (params.dependencies !== undefined)
3205
+ feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3206
+ feature.discussedAt = nowISO();
3207
+ feature.contextReady = true;
3208
+ feature.contextReadyReason = "Updated through feature_discuss tool.";
3209
+ feature.updatedAt = nowISO();
3210
+ return doc;
3211
+ });
3212
+ const feature = updatedDoc.features.find((f) => f.id === featureId);
3213
+ if (!feature)
3214
+ return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3215
+ await st.writeGenerated();
3216
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2910
3217
  },
2911
3218
  });
2912
3219
  pi.registerTool({
@@ -2914,7 +3221,7 @@ export default function planPiExtension(pi) {
2914
3221
  label: "Feature Update",
2915
3222
  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.",
2916
3223
  parameters: Type.Object({
2917
- featureId: Type.String({ description: "Feature ID" }),
3224
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2918
3225
  name: Type.Optional(Type.String({ description: "New name" })),
2919
3226
  description: Type.Optional(Type.String({ description: "New description" })),
2920
3227
  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." })),
@@ -2936,10 +3243,15 @@ export default function planPiExtension(pi) {
2936
3243
  const st = await requirePlan(ctx);
2937
3244
  if (!st)
2938
3245
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3246
+ const features = (await st.loadFeatures()).features;
3247
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3248
+ if (!resolvedFeature.ok)
3249
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3250
+ const featureId = resolvedFeature.feature.id;
2939
3251
  let foundDoc;
2940
3252
  try {
2941
3253
  foundDoc = await st.updateFeatures((doc) => {
2942
- const feature = doc.features.find((f) => f.id === params.featureId);
3254
+ const feature = doc.features.find((f) => f.id === featureId);
2943
3255
  if (!feature)
2944
3256
  return doc; // not found: no-op, handled below
2945
3257
  if (params.name !== undefined)
@@ -2977,11 +3289,11 @@ export default function planPiExtension(pi) {
2977
3289
  catch (e) {
2978
3290
  return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
2979
3291
  }
2980
- const feature = foundDoc?.features.find((f) => f.id === params.featureId);
3292
+ const feature = foundDoc?.features.find((f) => f.id === featureId);
2981
3293
  if (!feature)
2982
3294
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
2983
3295
  await st.writeGenerated();
2984
- return { content: [{ type: "text", text: `Feature updated: ${feature.id} (${feature.status})` }], details: feature };
3296
+ return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2985
3297
  },
2986
3298
  });
2987
3299
  pi.registerTool({
@@ -2989,28 +3301,30 @@ export default function planPiExtension(pi) {
2989
3301
  label: "Feature Delete",
2990
3302
  description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
2991
3303
  parameters: Type.Object({
2992
- featureId: Type.String({ description: "Feature ID to delete" }),
3304
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
2993
3305
  cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
2994
3306
  }),
2995
3307
  async execute(_id, params, _signal, _onUpdate, ctx) {
2996
3308
  const st = await requirePlan(ctx);
2997
3309
  if (!st)
2998
3310
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3311
+ const features = (await st.loadFeatures()).features;
3312
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3313
+ if (!resolvedFeature.ok)
3314
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3315
+ const featureId = resolvedFeature.feature.id;
2999
3316
  let deleted = false;
3000
3317
  await st.updateFeatures((doc) => {
3001
3318
  const before = doc.features.length;
3002
- doc.features = doc.features.filter((f) => f.id !== params.featureId);
3319
+ doc.features = doc.features.filter((f) => f.id !== featureId);
3003
3320
  if (doc.features.length !== before)
3004
3321
  deleted = true;
3005
3322
  return doc;
3006
3323
  });
3007
- if (!deleted) {
3008
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3009
- }
3010
3324
  let cascadeCount = 0;
3011
3325
  if (params.cascade) {
3012
3326
  const phases = await st.loadAllPhases();
3013
- for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
3327
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
3014
3328
  await st.deletePhase(phase.id);
3015
3329
  cascadeCount += 1;
3016
3330
  }
@@ -3023,40 +3337,57 @@ export default function planPiExtension(pi) {
3023
3337
  pi.registerTool({
3024
3338
  name: "phase_list",
3025
3339
  label: "Phase List",
3026
- description: "List all phases (optionally filtered by featureId) with id, status, and task counts.",
3340
+ 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.",
3027
3341
  parameters: Type.Object({
3028
- featureId: Type.Optional(Type.String({ description: "If provided, only list phases of this feature" })),
3342
+ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3343
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3029
3344
  }),
3030
3345
  async execute(_id, params, _signal, _onUpdate, ctx) {
3031
3346
  const st = await requirePlan(ctx);
3032
3347
  if (!st)
3033
3348
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3349
+ const features = (await st.loadFeatures()).features;
3034
3350
  let phases = await st.loadAllPhases();
3035
- if (params.featureId)
3036
- phases = phases.filter((p) => p.featureId === params.featureId);
3037
- const summary = phases.map((p) => `- ${statusIcon(p.status)} ${p.id} — ${p.title} (${p.tasks.length} tasks, feature ${p.featureId ?? "none"})`).join("\n");
3038
- return { content: [{ type: "text", text: summary || "No phases" }], details: { phases } };
3351
+ const fref = params.featureRef?.trim();
3352
+ if (fref) {
3353
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3354
+ if (!f)
3355
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3356
+ phases = phases.filter((p) => p.featureId === f.id);
3357
+ }
3358
+ if (params.status)
3359
+ phases = phases.filter((p) => p.status === params.status);
3360
+ const summary = phases.map((p) => {
3361
+ const fNum = featureNumberOfPhase(p, features);
3362
+ const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3363
+ return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
3364
+ }).join("\n");
3365
+ return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3039
3366
  },
3040
3367
  });
3041
3368
  pi.registerTool({
3042
3369
  name: "phase_get",
3043
3370
  label: "Phase Get",
3044
- description: "Read full details of a phase, including its tasks.",
3371
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3045
3372
  parameters: Type.Object({
3046
- phaseId: Type.String({ description: "Phase ID, e.g. phase-01-setup" }),
3373
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3374
+ full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
3047
3375
  }),
3048
3376
  async execute(_id, params, _signal, _onUpdate, ctx) {
3049
3377
  const st = await requirePlan(ctx);
3050
3378
  if (!st)
3051
3379
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3052
- let phase;
3053
- try {
3054
- phase = await st.loadPhase(params.phaseId);
3055
- }
3056
- catch {
3380
+ const features = (await st.loadFeatures()).features;
3381
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3382
+ if (!phase)
3057
3383
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3058
- }
3059
- return { content: [{ type: "text", text: `${statusIcon(phase.status)} ${phase.title} (${phase.id}) — ${phase.tasks.length} tasks` }], details: phase };
3384
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3385
+ const reqCount = linkedRequirements.length;
3386
+ const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
3387
+ const requirementsBlock = reqCount > 0
3388
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3389
+ : "";
3390
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3060
3391
  },
3061
3392
  });
3062
3393
  pi.registerTool({
@@ -3076,18 +3407,27 @@ export default function planPiExtension(pi) {
3076
3407
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3077
3408
  if (!params.featureId?.trim())
3078
3409
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3410
+ const createPhaseFeatures = (await st.loadFeatures()).features;
3411
+ const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
3412
+ if (!resolvedFeature.ok)
3413
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3414
+ const featureId = resolvedFeature.feature.id;
3415
+ const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
3416
+ if (!featureValidation.ok)
3417
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3418
+ const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
3419
+ if (!existingFeature)
3420
+ return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3079
3421
  let phase;
3080
- await withFeatureLock(params.featureId, async () => {
3081
- const phases = await st.loadAllPhases();
3082
- const featurePhases = phases.filter((p) => p.featureId === params.featureId);
3083
- const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
3422
+ await withFeatureLock(featureId, async () => {
3423
+ const number = await st.allocPhaseNumber();
3084
3424
  const id = createPhaseId();
3085
3425
  const shortId = createShortId(await st.assignedShortIds());
3086
- const priority = await st.nextPriority("phase", params.featureId);
3426
+ const priority = await st.nextPriority("phase", featureId);
3087
3427
  const now = nowISO();
3088
3428
  phase = {
3089
3429
  id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3090
- featureId: params.featureId,
3430
+ featureId,
3091
3431
  status: params.status ?? "draft",
3092
3432
  discussedAt: "",
3093
3433
  contextReady: false,
@@ -3097,11 +3437,14 @@ export default function planPiExtension(pi) {
3097
3437
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3098
3438
  createdAt: now, updatedAt: now,
3099
3439
  handoff: "", handoffUpdatedAt: "",
3440
+ handoffReadAt: "",
3441
+ handoffHistory: [],
3442
+ statusLog: [],
3100
3443
  };
3101
3444
  await st.savePhase(phase);
3102
3445
  // Atomic: serialize concurrent phase_create linking to the same feature.
3103
3446
  await st.updateFeatures((doc) => {
3104
- const feature = doc.features.find((f) => f.id === params.featureId);
3447
+ const feature = doc.features.find((f) => f.id === featureId);
3105
3448
  if (feature && !feature.phaseIds.includes(phase.id)) {
3106
3449
  feature.phaseIds.push(phase.id);
3107
3450
  feature.updatedAt = now;
@@ -3112,7 +3455,8 @@ export default function planPiExtension(pi) {
3112
3455
  });
3113
3456
  if (!phase)
3114
3457
  return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
3115
- return { content: [{ type: "text", text: `Phase created: ${phase.id}` }], details: phase };
3458
+ const phaseCreateFeatures = (await st.loadFeatures()).features;
3459
+ return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3116
3460
  },
3117
3461
  });
3118
3462
  pi.registerTool({
@@ -3120,7 +3464,7 @@ export default function planPiExtension(pi) {
3120
3464
  label: "Phase Update",
3121
3465
  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.",
3122
3466
  parameters: Type.Object({
3123
- phaseId: Type.String({ description: "Phase ID" }),
3467
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3124
3468
  title: Type.Optional(Type.String({ description: "New title" })),
3125
3469
  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." })),
3126
3470
  summary: Type.Optional(Type.String({ description: "New summary" })),
@@ -3147,14 +3491,20 @@ export default function planPiExtension(pi) {
3147
3491
  const st = await requirePlan(ctx);
3148
3492
  if (!st)
3149
3493
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3494
+ const features = (await st.loadFeatures()).features;
3495
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3496
+ if (!resolvedPhase)
3497
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3498
+ const phaseId = resolvedPhase.id;
3150
3499
  let phase;
3151
3500
  try {
3152
- phase = await st.loadPhase(params.phaseId);
3501
+ phase = await st.loadPhase(phaseId);
3153
3502
  }
3154
3503
  catch {
3155
3504
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3156
3505
  }
3157
3506
  const prevFeatureId = phase.featureId;
3507
+ let nextFeatureIdForUpdate = prevFeatureId;
3158
3508
  if (params.title !== undefined) {
3159
3509
  phase.title = params.title;
3160
3510
  phase.slug = normalizeSlug(params.title);
@@ -3184,14 +3534,22 @@ export default function planPiExtension(pi) {
3184
3534
  if (params.completionCriteria !== undefined)
3185
3535
  phase.completionCriteria = params.completionCriteria;
3186
3536
  if (params.featureId !== undefined) {
3187
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3188
- phase.featureId = nextFeatureId;
3189
- if (nextFeatureId !== prevFeatureId) {
3537
+ if (params.featureId === "") {
3538
+ nextFeatureIdForUpdate = undefined;
3539
+ }
3540
+ else {
3541
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3542
+ if (!resolvedFeature.ok)
3543
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3544
+ nextFeatureIdForUpdate = resolvedFeature.feature.id;
3545
+ }
3546
+ phase.featureId = nextFeatureIdForUpdate;
3547
+ if (nextFeatureIdForUpdate !== prevFeatureId) {
3190
3548
  await st.updateFeatures((doc) => {
3191
3549
  for (const f of doc.features) {
3192
3550
  if (f.id === prevFeatureId)
3193
3551
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3194
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3552
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3195
3553
  f.phaseIds.push(phase.id);
3196
3554
  }
3197
3555
  return doc;
@@ -3201,7 +3559,8 @@ export default function planPiExtension(pi) {
3201
3559
  phase.updatedAt = nowISO();
3202
3560
  await st.savePhase(phase);
3203
3561
  await st.writeGenerated();
3204
- return { content: [{ type: "text", text: `Phase updated: ${phase.id} (${phase.status})` }], details: phase };
3562
+ const phaseUpdateFeatures = (await st.loadFeatures()).features;
3563
+ return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3205
3564
  },
3206
3565
  });
3207
3566
  pi.registerTool({
@@ -3209,25 +3568,30 @@ export default function planPiExtension(pi) {
3209
3568
  label: "Phase Delete",
3210
3569
  description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
3211
3570
  parameters: Type.Object({
3212
- phaseId: Type.String({ description: "Phase ID to delete" }),
3571
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
3213
3572
  }),
3214
3573
  async execute(_id, params, _signal, _onUpdate, ctx) {
3215
3574
  const st = await requirePlan(ctx);
3216
3575
  if (!st)
3217
3576
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3577
+ const features = (await st.loadFeatures()).features;
3578
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3579
+ if (!resolvedPhase)
3580
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3581
+ const phaseId = resolvedPhase.id;
3218
3582
  let phase;
3219
3583
  try {
3220
- phase = await st.loadPhase(params.phaseId);
3584
+ phase = await st.loadPhase(phaseId);
3221
3585
  }
3222
3586
  catch {
3223
3587
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3224
3588
  }
3225
- await st.deletePhase(params.phaseId);
3589
+ await st.deletePhase(phaseId);
3226
3590
  if (phase.featureId) {
3227
3591
  await st.updateFeatures((doc) => {
3228
3592
  const feature = doc.features.find((f) => f.id === phase.featureId);
3229
3593
  if (feature) {
3230
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
3594
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
3231
3595
  feature.updatedAt = nowISO();
3232
3596
  }
3233
3597
  return doc;
@@ -3241,42 +3605,64 @@ export default function planPiExtension(pi) {
3241
3605
  pi.registerTool({
3242
3606
  name: "task_list",
3243
3607
  label: "Task List",
3244
- description: "List tasks of a phase with id, status, and title.",
3608
+ 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.",
3245
3609
  parameters: Type.Object({
3246
- phaseId: Type.String({ description: "Phase ID" }),
3610
+ featureRef: Type.Optional(Type.String({})),
3611
+ phaseRef: Type.Optional(Type.String({})),
3612
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3247
3613
  }),
3248
3614
  async execute(_id, params, _signal, _onUpdate, ctx) {
3249
3615
  const st = await requirePlan(ctx);
3250
3616
  if (!st)
3251
3617
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3252
- let phase;
3253
- try {
3254
- phase = await st.loadPhase(params.phaseId);
3618
+ const features = (await st.loadFeatures()).features;
3619
+ let phases = await st.loadAllPhases();
3620
+ const fref = params.featureRef?.trim();
3621
+ if (fref) {
3622
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3623
+ if (!f)
3624
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3625
+ phases = phases.filter((p) => p.featureId === f.id);
3255
3626
  }
3256
- catch {
3257
- return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3627
+ const pref = params.phaseRef?.trim();
3628
+ if (pref) {
3629
+ const p = findPhaseByRef(phases, features, pref);
3630
+ if (!p)
3631
+ return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
3632
+ phases = [p];
3258
3633
  }
3259
- const summary = phase.tasks.map((t) => `- ${statusIcon(t.status)} ${t.id} — ${t.title}`).join("\n");
3260
- return { content: [{ type: "text", text: summary || "No tasks" }], details: { tasks: phase.tasks } };
3634
+ const out = [];
3635
+ for (const phase of phases) {
3636
+ for (const task of phase.tasks) {
3637
+ if (params.status && task.status !== params.status)
3638
+ continue;
3639
+ out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
3640
+ }
3641
+ }
3642
+ return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
3261
3643
  },
3262
3644
  });
3263
3645
  pi.registerTool({
3264
3646
  name: "task_get",
3265
3647
  label: "Task Get",
3266
- description: "Read full details of a single task.",
3648
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
3267
3649
  parameters: Type.Object({
3268
- taskId: Type.String({ description: "Task ID" }),
3650
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3651
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
3269
3652
  }),
3270
3653
  async execute(_id, params, _signal, _onUpdate, ctx) {
3271
3654
  const st = await requirePlan(ctx);
3272
3655
  if (!st)
3273
3656
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3274
- const phases = await st.loadAllPhases();
3275
- const phase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3276
- const task = phase?.tasks.find((t) => t.id === params.taskId);
3277
- if (!task || !phase)
3657
+ const features = (await st.loadFeatures()).features;
3658
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3659
+ if (!found)
3278
3660
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3279
- return { content: [{ type: "text", text: `${statusIcon(task.status)} ${task.title} (${task.id}) — phase ${phase.id}` }], details: { task, phaseId: phase.id } };
3661
+ 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})`;
3662
+ if (!params.full)
3663
+ return { content: [{ type: "text", text: summary }], details: {} };
3664
+ const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3665
+ return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
3280
3666
  },
3281
3667
  });
3282
3668
  pi.registerTool({
@@ -3284,32 +3670,52 @@ export default function planPiExtension(pi) {
3284
3670
  label: "Task Create",
3285
3671
  description: "Add a task to a phase with a RICH description. REQUIRED: description must include code references (file:line), what already exists vs what needs to be built, specific structs/traits/systems to modify, concrete implementation steps, and edge cases to handle. The description is the execution context for agents; one-liners cause misalignment. Status defaults to planned.",
3286
3672
  parameters: Type.Object({
3673
+ featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
3287
3674
  phaseId: Type.String({ description: "Phase ID the task belongs to" }),
3288
3675
  title: Type.String({ description: "Task title" }),
3289
3676
  description: Type.String({ description: "REQUIRED — execution context: code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner. Prefix with 'design-only' for pre-implementation design tasks.", minLength: 50 }),
3290
3677
  status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
3291
3678
  shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
3679
+ checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
3292
3680
  }),
3293
3681
  async execute(_id, params, _signal, _onUpdate, ctx) {
3294
3682
  const st = await requirePlan(ctx);
3295
3683
  if (!st)
3296
3684
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3297
- // Verify phase exists first (fast-fail before generating an id).
3298
- try {
3299
- await st.loadPhase(params.phaseId);
3300
- }
3301
- catch {
3685
+ if (!params.featureId?.trim())
3686
+ return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3687
+ const features = (await st.loadFeatures()).features;
3688
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
3689
+ if (!resolvedFeature.ok)
3690
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3691
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
3692
+ if (!featureValidation.ok)
3693
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3694
+ const feature = resolvedFeature.feature;
3695
+ const existingFeature = features.find((f) => f.id === feature.id);
3696
+ if (!existingFeature)
3697
+ return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
3698
+ // Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
3699
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3700
+ if (!resolvedPhase)
3302
3701
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3303
- }
3702
+ if (resolvedPhase.featureId !== feature.id)
3703
+ return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.` }], details: {} };
3704
+ const phaseId = resolvedPhase.id;
3705
+ const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
3706
+ if (!phaseValidation.ok)
3707
+ return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
3708
+ const existingPhase = await st.loadPhase(phaseId);
3709
+ if (!existingPhase)
3710
+ return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3304
3711
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3305
3712
  const taskId = createTaskId();
3306
3713
  const shortId = createShortId(await st.assignedShortIds());
3307
- const priority = await st.nextPriority("task", params.phaseId);
3714
+ const priority = await st.nextPriority("task", phaseId);
3308
3715
  const now = nowISO();
3309
3716
  const initialStatus = params.status ?? "planned";
3310
- const existingPhase = await st.loadPhase(params.phaseId);
3311
3717
  const task = {
3312
- id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortId, priority, shortName,
3718
+ id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3313
3719
  title: params.title,
3314
3720
  status: initialStatus,
3315
3721
  description: params.description ?? "",
@@ -3317,7 +3723,10 @@ export default function planPiExtension(pi) {
3317
3723
  statusLog: [],
3318
3724
  decisions: [],
3319
3725
  acceptedDecisions: [],
3320
- checklist: [], subtasks: [],
3726
+ checklist: params.checklist
3727
+ ? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
3728
+ : [],
3729
+ subtasks: [],
3321
3730
  dependsOn: [],
3322
3731
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3323
3732
  completedAt: initialStatus === "done" ? now : "",
@@ -3325,14 +3734,15 @@ export default function planPiExtension(pi) {
3325
3734
  };
3326
3735
  // Atomic read-modify-write on the phase file: serializes concurrent
3327
3736
  // task_create calls on the SAME phaseId (batch) so tasks don't get lost.
3328
- await st.updatePhase(params.phaseId, (phase) => {
3737
+ await st.updatePhase(phaseId, (phase) => {
3329
3738
  phase.tasks.push(task);
3330
3739
  phase.taskIds.push(taskId);
3331
3740
  phase.updatedAt = now;
3332
3741
  return phase;
3333
3742
  });
3334
3743
  await st.writeGenerated();
3335
- return { content: [{ type: "text", text: `Task created: ${taskId}` }], details: task };
3744
+ const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
3745
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
3336
3746
  },
3337
3747
  });
3338
3748
  pi.registerTool({
@@ -3340,7 +3750,7 @@ export default function planPiExtension(pi) {
3340
3750
  label: "Task Update",
3341
3751
  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.",
3342
3752
  parameters: Type.Object({
3343
- taskId: Type.String({ description: "Task ID" }),
3753
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3344
3754
  title: Type.Optional(Type.String({ description: "New title" })),
3345
3755
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
3346
3756
  description: Type.Optional(Type.String({ description: "New description" })),
@@ -3363,15 +3773,16 @@ export default function planPiExtension(pi) {
3363
3773
  if (!st)
3364
3774
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3365
3775
  // Find the phase hosting this task first.
3366
- const allPhases = await st.loadAllPhases();
3367
- const hostPhase = allPhases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3368
- if (!hostPhase)
3776
+ const features = (await st.loadFeatures()).features;
3777
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3778
+ if (!found)
3369
3779
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3780
+ const hostPhase = found.phase;
3370
3781
  const now = nowISO();
3371
3782
  let updatedTask;
3372
3783
  try {
3373
3784
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3374
- const task = phase.tasks.find((t) => t.id === params.taskId);
3785
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3375
3786
  if (!task)
3376
3787
  return phase;
3377
3788
  if (params.title !== undefined)
@@ -3387,7 +3798,7 @@ export default function planPiExtension(pi) {
3387
3798
  if (params.acceptedDecisions !== undefined)
3388
3799
  task.acceptedDecisions = params.acceptedDecisions;
3389
3800
  if (params.checklist !== undefined) {
3390
- task.checklist = params.checklist.map((itemTitle, index) => ({
3801
+ task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
3391
3802
  id: createChecklistItemId(task.id, index + 1, itemTitle),
3392
3803
  title: itemTitle,
3393
3804
  checked: false,
@@ -3428,19 +3839,19 @@ export default function planPiExtension(pi) {
3428
3839
  label: "Task Delete",
3429
3840
  description: "Delete a task from its phase.",
3430
3841
  parameters: Type.Object({
3431
- taskId: Type.String({ description: "Task ID to delete" }),
3842
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
3432
3843
  }),
3433
3844
  async execute(_id, params, _signal, _onUpdate, ctx) {
3434
3845
  const st = await requirePlan(ctx);
3435
3846
  if (!st)
3436
3847
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3437
- const phases = await st.loadAllPhases();
3438
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3439
- if (!hostPhase)
3848
+ const features = (await st.loadFeatures()).features;
3849
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3850
+ if (!found)
3440
3851
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3441
- await st.updatePhase(hostPhase.id, (phase) => {
3442
- phase.tasks = phase.tasks.filter((t) => t.id !== params.taskId);
3443
- phase.taskIds = phase.taskIds.filter((id) => id !== params.taskId);
3852
+ await st.updatePhase(found.phase.id, (phase) => {
3853
+ phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
3854
+ phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
3444
3855
  phase.updatedAt = nowISO();
3445
3856
  return phase;
3446
3857
  });
@@ -3454,25 +3865,25 @@ export default function planPiExtension(pi) {
3454
3865
  label: "Task Start",
3455
3866
  description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
3456
3867
  parameters: Type.Object({
3457
- taskId: Type.String({ description: "Task ID to start" }),
3868
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3458
3869
  }),
3459
3870
  async execute(_id, params, _signal, _onUpdate, ctx) {
3460
3871
  const st = await requirePlan(ctx);
3461
3872
  if (!st)
3462
3873
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3463
- const phases = await st.loadAllPhases();
3464
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3465
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3466
- if (!task || !hostPhase)
3874
+ const features = (await st.loadFeatures()).features;
3875
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3876
+ if (!found)
3467
3877
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3878
+ const task = found.task;
3468
3879
  if (task.status === "in-progress")
3469
3880
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3470
3881
  if (task.status === "done")
3471
3882
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
3472
3883
  const now = nowISO();
3473
3884
  let startedTask;
3474
- await st.updatePhase(hostPhase.id, (phase) => {
3475
- const t = phase.tasks.find((x) => x.id === params.taskId);
3885
+ await st.updatePhase(found.phase.id, (phase) => {
3886
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3476
3887
  if (!t)
3477
3888
  return phase;
3478
3889
  applyTaskLifecycleDates(t, "in-progress", now);
@@ -3484,7 +3895,9 @@ export default function planPiExtension(pi) {
3484
3895
  await st.writeGenerated();
3485
3896
  if (!startedTask)
3486
3897
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3487
- return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)` }], details: startedTask };
3898
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
3899
+ const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
3900
+ return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
3488
3901
  },
3489
3902
  });
3490
3903
  pi.registerTool({
@@ -3492,7 +3905,7 @@ export default function planPiExtension(pi) {
3492
3905
  label: "Task Complete",
3493
3906
  description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
3494
3907
  parameters: Type.Object({
3495
- taskId: Type.String({ description: "Task ID to complete" }),
3908
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
3496
3909
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
3497
3910
  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." })),
3498
3911
  }),
@@ -3500,11 +3913,11 @@ export default function planPiExtension(pi) {
3500
3913
  const st = await requirePlan(ctx);
3501
3914
  if (!st)
3502
3915
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3503
- const phases = await st.loadAllPhases();
3504
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3505
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3506
- if (!task || !hostPhase)
3916
+ const features = (await st.loadFeatures()).features;
3917
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3918
+ if (!found)
3507
3919
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3920
+ const task = found.task;
3508
3921
  if (task.status === "done")
3509
3922
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
3510
3923
  const unchecked = task.checklist.filter((item) => !item.checked);
@@ -3516,8 +3929,8 @@ export default function planPiExtension(pi) {
3516
3929
  }
3517
3930
  const now = nowISO();
3518
3931
  let completedTask;
3519
- await st.updatePhase(hostPhase.id, (phase) => {
3520
- const t = phase.tasks.find((x) => x.id === params.taskId);
3932
+ await st.updatePhase(found.phase.id, (phase) => {
3933
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3521
3934
  if (!t)
3522
3935
  return phase;
3523
3936
  applyTaskLifecycleDates(t, "done", now);
@@ -3536,6 +3949,125 @@ export default function planPiExtension(pi) {
3536
3949
  return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
3537
3950
  },
3538
3951
  });
3952
+ pi.registerTool({
3953
+ name: "task_checklist_toggle",
3954
+ label: "Task Checklist Toggle",
3955
+ description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), item id, or title (case-insensitive, first match). Pass checked=true/false to set explicitly, or omit to toggle. Use this instead of writing DONE in step titles. Items are numbered C1/C2… per task.",
3956
+ parameters: Type.Object({
3957
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3958
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
3959
+ checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
3960
+ }),
3961
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3962
+ const st = await requirePlan(ctx);
3963
+ if (!st)
3964
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3965
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
3966
+ if (!found)
3967
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3968
+ let result = "";
3969
+ await st.updatePhase(found.phase.id, (phase) => {
3970
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3971
+ if (!task) {
3972
+ result = `Task not found: ${params.taskId}`;
3973
+ return phase;
3974
+ }
3975
+ const ck = task.checklist ?? [];
3976
+ if (ck.length === 0) {
3977
+ result = `Task "${found.task.title}" has no checklist.`;
3978
+ return phase;
3979
+ }
3980
+ const target = toggleChecklistItem(ck, params.item, params.checked);
3981
+ if (!target) {
3982
+ result = `No checklist item matching "${params.item}".`;
3983
+ return phase;
3984
+ }
3985
+ task.updatedAt = nowISO();
3986
+ phase.updatedAt = nowISO();
3987
+ const doneCount = ck.filter((i) => i.checked).length;
3988
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
3989
+ return phase;
3990
+ });
3991
+ await st.writeGenerated();
3992
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
3993
+ },
3994
+ });
3995
+ pi.registerTool({
3996
+ name: "task_checklist_add",
3997
+ label: "Task Checklist Add",
3998
+ description: "Add a single checklist item (a 'step') to a task without rewriting the list. The new item is appended as C{n} (next progressive number, stable id, unchecked). Use this to subdivide a task into smaller steps INSTEAD of spawning sub-tasks — the checklist keeps description, notes, statusLog and steps concentrated in one task (sub-tasks disperse context). Also use for granular adds instead of replacing the whole checklist via task_update.",
3999
+ parameters: Type.Object({
4000
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4001
+ title: Type.String({ description: "Checklist item text (a single step)" }),
4002
+ }),
4003
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4004
+ const st = await requirePlan(ctx);
4005
+ if (!st)
4006
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4007
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4008
+ if (!found)
4009
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4010
+ let result = "";
4011
+ await st.updatePhase(found.phase.id, (phase) => {
4012
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4013
+ if (!task) {
4014
+ result = `Task not found: ${params.taskId}`;
4015
+ return phase;
4016
+ }
4017
+ const ck = task.checklist ?? [];
4018
+ const item = addChecklistItem(ck, task.id, params.title);
4019
+ ck.push(item);
4020
+ task.checklist = ck;
4021
+ task.updatedAt = nowISO();
4022
+ phase.updatedAt = nowISO();
4023
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
4024
+ return phase;
4025
+ });
4026
+ await st.writeGenerated();
4027
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4028
+ },
4029
+ });
4030
+ pi.registerTool({
4031
+ name: "task_checklist_remove",
4032
+ label: "Task Checklist Remove",
4033
+ description: "Remove a single checklist item (a 'step') from a task by C{n} (e.g. C2), item id, or title (case-insensitive). Remaining items are renumbered C1..Cn for readability; their stable ids are preserved. Use this for granular removes instead of replacing the whole checklist via task_update.",
4034
+ parameters: Type.Object({
4035
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4036
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4037
+ }),
4038
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4039
+ const st = await requirePlan(ctx);
4040
+ if (!st)
4041
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4042
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4043
+ if (!found)
4044
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4045
+ let result = "";
4046
+ await st.updatePhase(found.phase.id, (phase) => {
4047
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4048
+ if (!task) {
4049
+ result = `Task not found: ${params.taskId}`;
4050
+ return phase;
4051
+ }
4052
+ const ck = task.checklist ?? [];
4053
+ if (ck.length === 0) {
4054
+ result = `Task "${found.task.title}" has no checklist.`;
4055
+ return phase;
4056
+ }
4057
+ const removed = removeChecklistItem(ck, params.item);
4058
+ if (!removed) {
4059
+ result = `No checklist item matching "${params.item}".`;
4060
+ return phase;
4061
+ }
4062
+ task.updatedAt = nowISO();
4063
+ phase.updatedAt = nowISO();
4064
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
4065
+ return phase;
4066
+ });
4067
+ await st.writeGenerated();
4068
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4069
+ },
4070
+ });
3539
4071
  // ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
3540
4072
  // These let the agent manage the web dashboard directly, without relying
3541
4073
  // on the /planner slash command (which is not intercepted when the planner
@@ -3632,6 +4164,7 @@ export default function planPiExtension(pi) {
3632
4164
  // add seconds of latency on every turn if run unconditionally.
3633
4165
  if (!plannerHeavyInitDone) {
3634
4166
  await migrateToUuids(st);
4167
+ await migrateToGlobalSequence(st).catch(() => null);
3635
4168
  await st.ensureShortIdsAndPriority().catch(() => null);
3636
4169
  await ensureProjectLanguagePreferences(st).catch(() => null);
3637
4170
  await maybeHealStatuses(st, ctx);
@@ -3664,8 +4197,9 @@ export default function planPiExtension(pi) {
3664
4197
  const profile = await st.loadCodebaseProfile();
3665
4198
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3666
4199
  const activity = await st.loadActivityLog();
3667
- const recentActivity = activity.entries.slice(-8).reverse();
4200
+ const recentActivity = activity.entries.slice(-3).reverse();
3668
4201
  const handoffs = await st.listHandoffs();
4202
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3669
4203
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3670
4204
  const orderedPhases = [
3671
4205
  ...plan.features.features.flatMap((feature) => {
@@ -3678,21 +4212,9 @@ export default function planPiExtension(pi) {
3678
4212
  ...plan.phases.filter((phase) => !phase.featureId)
3679
4213
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3680
4214
  ];
3681
- const phaseLines = orderedPhases
3682
- .map((p) => `- ${statusIcon(p.status)} \`${p.id}\` — ${phaseLabel(p)} (${p.status})`)
3683
- .join("\n");
3684
- const active = orderedPhases
3685
- .filter((p) => p.status === "in-progress")
3686
- .map((p) => `\`${p.id}\``)
3687
- .join(", ");
3688
- const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
3689
- const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
3690
- const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
3691
- const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
3692
- const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
3693
- const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
3694
- const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
3695
- const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
4215
+ const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
4216
+ || plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
4217
+ || orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
3696
4218
  const currentPhase = hasActiveWork
3697
4219
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3698
4220
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3709,16 +4231,51 @@ export default function planPiExtension(pi) {
3709
4231
  ?? null)
3710
4232
  : null;
3711
4233
  const currentFeature = hasActiveWork && currentPhase?.featureId
3712
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4234
+ ? featureById.get(currentPhase.featureId) ?? null
3713
4235
  : hasActiveWork
3714
4236
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3715
4237
  : null;
3716
- const nextActivity = hasActiveWork
3717
- ? (resume.nextSteps[0]
3718
- ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3719
- : (handoffs.length > 0
3720
- ? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task."
3721
- : "Review the current plan and choose the next task.");
4238
+ const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
4239
+ const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
4240
+ const currentTaskRef = currentTask && currentPhase
4241
+ ? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
4242
+ : "";
4243
+ const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
4244
+ const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
4245
+ for (const phase of plan.phases) {
4246
+ phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
4247
+ }
4248
+ const phaseCountsSummary = phaseStatusOrder
4249
+ .map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
4250
+ .join(" ");
4251
+ const activeTaskMap = new Map();
4252
+ for (const phase of orderedPhases) {
4253
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4254
+ for (const task of phase.tasks) {
4255
+ if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
4256
+ continue;
4257
+ activeTaskMap.set(task.id, { phase, task, featureNumber });
4258
+ }
4259
+ }
4260
+ const inProgressTaskLines = [...activeTaskMap.values()]
4261
+ .sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
4262
+ .map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
4263
+ const otherActiveOrBlockedPhases = orderedPhases
4264
+ .filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
4265
+ .slice(0, 6)
4266
+ .map((phase) => {
4267
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4268
+ return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
4269
+ });
4270
+ const openQuestions = plan.phases
4271
+ .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
4272
+ .slice(0, 8);
4273
+ const acceptedDecisions = [
4274
+ ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
4275
+ ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
4276
+ ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
4277
+ ...plan.phases.flatMap((phase) => phase.tasks.flatMap((task) => task.acceptedDecisions.map((decision) => `[task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}] ${decision.title}`))),
4278
+ ].slice(0, 20);
3722
4279
  const webServer = server;
3723
4280
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3724
4281
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3731,15 +4288,6 @@ export default function planPiExtension(pi) {
3731
4288
  catch { }
3732
4289
  const webUrl = webLocalUrl;
3733
4290
  const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
3734
- const openQuestions = plan.phases
3735
- .flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
3736
- .slice(0, 12);
3737
- const acceptedDecisions = [
3738
- ...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
3739
- ...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
3740
- ...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
3741
- ...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
3742
- ].slice(0, 20);
3743
4291
  const pkgSummary = profile?.packageJson
3744
4292
  ? `name=${profile.packageJson.name ?? "?"} pm=${profile.packageJson.packageManager ?? "npm"} scripts=${Object.keys(profile.packageJson.scripts).join(",") || "none"} deps=${Object.keys(profile.packageJson.dependencies).length} devDeps=${Object.keys(profile.packageJson.devDependencies).length}`
3745
4293
  : "(no package.json)";
@@ -3761,96 +4309,62 @@ export default function planPiExtension(pi) {
3761
4309
  // Build/refresh the context block (slow path: cache miss).
3762
4310
  const contextBlock = [
3763
4311
  `[Plan Context — ${project.name}]`,
3764
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
3765
4312
  webUrl ? `🌐 WEB UI RUNNING: ${webUrlFull}${webPort ? ` (port ${webPort})` : ""}. Include this address+port at the end of your resume summary.` : `Web UI: not running. Start it with '/planner web start'.`,
3766
4313
  "",
3767
- // ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
4314
+ "Operational rules:",
4315
+ "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4316
+ "- BEFORE any code edit/write: task_start. AFTER finishing a task: task_complete.",
4317
+ "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4318
+ "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4319
+ "- Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4320
+ "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
3768
4321
  "",
3769
- "═══════════════════════════════════════════════════════════════",
3770
- "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
3771
- "═══════════════════════════════════════════════════════════════",
3772
- "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.",
3773
- "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.",
3774
- "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.",
3775
- "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.",
3776
- "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
3777
- "6. ENTITY REFERENCES: When you mention a feature/phase/task in chat, use its composite ID (F00x / P00x / T00x, e.g. T003(P002/F001)) or its 5-char shortId (e.g. UUXD1) — NEVER a raw UUID like bd6ed366. Tool responses already show these refs (compositeRef, shortId); reuse them.",
3778
- "═══════════════════════════════════════════════════════════════",
3779
- "",
3780
- // ── Project details ──
3781
4322
  `Goal: ${project.goal || "(not set)"}`,
3782
4323
  project.description ? `Description: ${project.description}` : "",
3783
4324
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
3784
4325
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
3785
4326
  `Ambient: ${ambientSummary}`,
3786
4327
  "",
3787
- "Global rules (re-read before every phase/task start):",
3788
- project.globalRules.length ? project.globalRules.map((r) => `- ${r}`).join("\n") : "- (none)",
3789
- "",
3790
- "Workflow rules:",
3791
- ` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
3792
- ` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
3793
- ` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4328
+ "Global rules:",
4329
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
3794
4330
  "",
4331
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
3795
4332
  `Scope: ${project.scope.join(", ") || "(none)"}`,
3796
4333
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
3797
4334
  "",
3798
4335
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
3799
- ...acceptedDecisions.map((d) => `- ${d}`),
4336
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
3800
4337
  openQuestions.length ? "" : "",
3801
4338
  openQuestions.length ? "Open questions:" : "",
3802
- ...openQuestions.map((q) => `- ${q}`),
4339
+ ...openQuestions.map((question) => `- ${question}`),
3803
4340
  "",
3804
- "Resume focus (where we are now):",
3805
- resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
3806
- resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
4341
+ "Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
4342
+ currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
4343
+ currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
4344
+ currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
4345
+ inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
3807
4346
  resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
3808
- resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}` : " next steps: (none)",
3809
- resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
4347
+ handoffs.length === 0 && resume.nextSteps.length
4348
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4349
+ : "",
4350
+ "",
4351
+ "CRITICAL: The 'Resume focus' pointers above are persisted across sessions. They are NOT necessarily the phase you worked on right now. When asked to write a handoff, use the phase the user explicitly identifies as the one just completed in this session, not a stale pointer.",
3810
4352
  "",
3811
- recentActivity.length ? "Recent activity (most recent first):" : "",
3812
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
4353
+ "Phase overview:",
4354
+ ` counts: ${phaseCountsSummary}`,
4355
+ currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
4356
+ otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
4357
+ ...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
3813
4358
  "",
3814
- `${plan.phases.length} phases defined.`,
3815
- "Current phases:",
3816
- phaseLines || " _none_",
3817
- active ? `Active: ${active}` : "",
4359
+ recentActivity.length ? "Recent activity:" : "",
4360
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
3818
4361
  "",
3819
- "Primary command UX:",
3820
- "- Use `/planner <TAB>` for grouped subcommand suggestions.",
3821
- "- Use `/planner` with no args to open the navigable planner menu.",
3822
- "- Feature commands: planner feature list|add|show|update|delete.",
3823
- "- Phase commands: planner phase add|show|discuss|update|delete.",
3824
- "- Task commands: planner task add|show|discuss|update|delete|start|complete.",
3825
- "- Handoff commands: planner handoff prepare|show|write|clear.",
3826
- "- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
3827
- "Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
3828
- "Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
3829
- `Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
3830
- "If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
3831
- "Ask for language only if BOTH preferences are unset.",
3832
- "If one preference is set and the other is unset, infer the missing one from the saved value and persist it immediately with project_set_language_preferences without asking the user again.",
3833
- "Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
3834
- "If you are creating a planner from an existing plan or document, do NOT leave project title, project description, project goal, or top-level requirements empty when the source material provides them.",
3835
- "When deriving a project title from an existing document, prefer a concise human project title; if the candidate title is long or compound, ask the user to confirm before using it.",
3836
- "Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
4362
+ handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
4363
+ ...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
4364
+ handoffs.length > 0 ? "Use handoff show <ref> for context. A pending handoff is a candidate to validate; do not default to it as the current focus unless it matches the phase you actually worked on in this session." : "",
3837
4365
  "",
3838
- "TASK STATUS RULES (strict — do not skip):",
3839
- "- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
3840
- "- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
3841
- "- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
3842
- "- Therefore, if not strictly necessary, do NOT change phase.status or feature.status directly. Move the task statuses instead and let rollup derive the parent statuses.",
3843
- "- The extension blocks edit/write when no task is in-progress (bash stays free so git pull/build/test work). If you hit that block, either call task_start, OR ask the user to authorize a one-time bypass (they can run /planner bypass, or you can call plan_authorize_bypass) and then retry.",
3844
- "- If you forget these task status calls, the derived rollup breaks: phase/feature can stay stale, the dashboard shows wrong active work, and resume focus is wrong.",
3845
- "- 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.",
3846
- "- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
3847
- "- To reopen a completed task, use task_start (recommended) or task_update.",
3848
- handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
3849
- ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3850
- 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." : "",
3851
- 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." : "",
3852
- handoffs.length > 0 ? "Once a handoff is fully consumed and work is safely resumed, clear it with handoff clear <ref> (or /planner handoff clear)." : "",
3853
- "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",
4366
+ `Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
4367
+ "Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
3854
4368
  ].filter(Boolean).join("\n");
3855
4369
  contextBlockCache = contextBlock;
3856
4370
  contextBlockDirty = false;