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

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, recommendNextTask } 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,11 +126,14 @@ 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
- { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
130
- { value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (default: in-progress phase)" },
131
- { value: "handoff clear", label: "handoff clear [P00x]", description: "Clear a phase handoff (default: in-progress phase)" },
134
+ { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
135
+ { value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
136
+ { value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
132
137
  { value: "web start", label: "web start", description: "Start the web UI" },
133
138
  { value: "web stop", label: "web stop", description: "Stop the web UI" },
134
139
  { value: "web status", label: "web status", description: "Show web UI status" },
@@ -139,6 +144,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
139
144
  { value: "load", label: "load", description: "Enable planner + start web UI (LAN) and show resume summary" },
140
145
  { value: "stop", label: "stop", description: "Disable planner and shut down the web UI" },
141
146
  ];
147
+ /** A completed nested command followed by a space is now receiving free-form
148
+ * arguments (entity refs, titles, etc.), not another command completion. */
149
+ function hasPlannerCommandArgument(prefix) {
150
+ const normalized = prefix.trimStart().toLowerCase();
151
+ return PLANNER_COMMAND_COMPLETIONS.some((item) => normalized.startsWith(`${item.value.toLowerCase()} `));
152
+ }
142
153
  // ─── Helpers ────────────────────────────────────────────────────────────
143
154
  function nowISO() {
144
155
  return new Date().toISOString();
@@ -196,7 +207,7 @@ async function maybeHealStatuses(st, ctx) {
196
207
  function normalizeLanguagePref(value) {
197
208
  return value?.trim() ?? "";
198
209
  }
199
- async function ensureProjectLanguagePreferences(st) {
210
+ async function ensureProjectLanguagePreferences(st, persist = true) {
200
211
  const project = await st.loadProject();
201
212
  const contentLanguage = normalizeLanguagePref(project.contentLanguage);
202
213
  const chatLanguage = normalizeLanguagePref(project.chatLanguage);
@@ -204,7 +215,8 @@ async function ensureProjectLanguagePreferences(st) {
204
215
  if (project.contentLanguage !== contentLanguage || project.chatLanguage !== chatLanguage) {
205
216
  project.contentLanguage = contentLanguage;
206
217
  project.chatLanguage = chatLanguage;
207
- await st.saveProject(project);
218
+ if (persist)
219
+ await st.saveProject(project);
208
220
  }
209
221
  return project;
210
222
  }
@@ -212,7 +224,8 @@ async function ensureProjectLanguagePreferences(st) {
212
224
  const fallback = contentLanguage || chatLanguage;
213
225
  project.contentLanguage = contentLanguage || fallback;
214
226
  project.chatLanguage = chatLanguage || fallback;
215
- await st.saveProject(project);
227
+ if (persist)
228
+ await st.saveProject(project);
216
229
  return project;
217
230
  }
218
231
  return project;
@@ -250,6 +263,34 @@ function phaseLabel(phase) {
250
263
  function taskLabel(task) {
251
264
  return `T${formatSequence(task.number)} — ${task.title}`;
252
265
  }
266
+ function resolveFeatureRefStrict(features, ref) {
267
+ const raw = ref.trim();
268
+ if (!raw)
269
+ return { ok: false, error: "Feature ref is required." };
270
+ const normalized = raw.toLowerCase();
271
+ const byNumber = normalized.match(/^f(\d+)$/)
272
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
273
+ : undefined;
274
+ if (byNumber)
275
+ return { ok: true, feature: byNumber };
276
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
277
+ if (byShortId)
278
+ return { ok: true, feature: byShortId };
279
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
280
+ if (byId)
281
+ return { ok: true, feature: byId };
282
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
283
+ if (exactName.length === 1)
284
+ return { ok: true, feature: exactName[0] };
285
+ if (exactName.length > 1)
286
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
287
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
288
+ if (partialName.length === 1)
289
+ return { ok: true, feature: partialName[0] };
290
+ if (partialName.length > 1)
291
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
292
+ return { ok: false, error: `Feature not found: ${raw}` };
293
+ }
253
294
  async function buildHandoffMarkdown(st, reason, overrides) {
254
295
  const [plan, resume, activity] = await Promise.all([
255
296
  st.loadAll(),
@@ -263,7 +304,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
263
304
  const latestTaskUpdate = [...allTasks].sort((left, right) => right.task.updatedAt.localeCompare(left.task.updatedAt))[0] ?? null;
264
305
  const latestPhaseUpdate = [...plan.phases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
265
306
  const latestFeatureUpdate = [...plan.features.features].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
266
- const currentPhase = plan.phases.find((phase) => phase.id === resume.currentPhaseId)
307
+ const currentPhase = (overrides?.phaseId ? plan.phases.find((phase) => phase.id === overrides.phaseId) : undefined)
308
+ ?? plan.phases.find((phase) => phase.id === resume.currentPhaseId)
267
309
  ?? plan.phases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
268
310
  ?? plan.phases.find((phase) => phase.status === "in-progress")
269
311
  ?? nonTerminalTasks[0]?.phase
@@ -352,8 +394,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
352
394
  ".planner/features/",
353
395
  currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
354
396
  latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
355
- ".planner/resume.json",
356
- ".planner/generated/PLAN.md",
397
+ ".planner/.local/resume.json",
398
+ ".planner/.local/generated/PLAN.md",
357
399
  ].filter(Boolean);
358
400
  const recentActivityLines = recentActivity.length > 0
359
401
  ? recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`)
@@ -379,7 +421,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
379
421
  ? ["", `## Current Task Statuses (phase ${currentPhase.id})`, ...currentPhaseTaskLines]
380
422
  : [];
381
423
  return [
382
- "# Handoff",
424
+ `# ${overrides?.title?.trim() || `Handoff — ${reason}`}`,
383
425
  "",
384
426
  `Created at: ${createdAt}`,
385
427
  `Updated at: ${updatedAt}`,
@@ -416,7 +458,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
416
458
  ...recentActivityLines,
417
459
  "",
418
460
  "## 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).",
461
+ "- 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
462
  ].join("\n");
421
463
  }
422
464
  async function writeProjectHandoff(st, reason, overrides) {
@@ -512,6 +554,9 @@ function statusIcon(status) {
512
554
  };
513
555
  return icons[status] ?? "❓";
514
556
  }
557
+ function pad(n) {
558
+ return String(n).padStart(3, "0");
559
+ }
515
560
  function applyTaskLifecycleDates(task, nextStatus, now) {
516
561
  const previousStatus = task.status;
517
562
  if (nextStatus === "in-progress" && !task.startedAt) {
@@ -617,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
617
662
  return;
618
663
  const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
619
664
  const port = await pickProjectPort(ctx, requestedPort);
665
+ // A started server must be reflected in the next context block; the cache
666
+ // may hold a stale "Web UI: not running" block from a stop+prompt pair.
667
+ contextBlockDirty = true;
620
668
  try {
621
669
  server = await serve({
622
670
  port,
@@ -629,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
629
677
  });
630
678
  }
631
679
  catch (e) {
632
- // EADDRINUSE or other listen failure: retry on a random free port.
633
- if (e instanceof Error && /EADDRINUSE|EACCES|EADDRNOTAVAIL/.test(e.message)) {
634
- try {
635
- server = await serve({
636
- port: 0,
637
- host,
638
- planRoot: resolvePlanRoot(ctx.cwd),
639
- staticDir: resolveStaticDir(),
640
- quiet: true,
641
- uiConfig: readUiConfig(ctx),
642
- isBusy: () => planBusy,
643
- });
644
- ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
645
- }
646
- catch (e2) {
647
- ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
648
- }
680
+ // Any listen failure at startup is potentially transient — EADDRINUSE from
681
+ // a racy port scan (another process grabbed the port between our check and
682
+ // bind), EMFILE/ENFILE under heavy load, EAGAIN, etc. Retry ONCE on a random
683
+ // free port (port 0) so a planner load never silently ends up with no server.
684
+ // serve() throws here only during startup binding/serving setup; if the retry
685
+ // also fails, surface the error to the user.
686
+ try {
687
+ server = await serve({
688
+ port: 0,
689
+ host,
690
+ planRoot: resolvePlanRoot(ctx.cwd),
691
+ staticDir: resolveStaticDir(),
692
+ quiet: true,
693
+ uiConfig: readUiConfig(ctx),
694
+ isBusy: () => planBusy,
695
+ });
696
+ ctx.ui.notify(`Planner web server: port ${port} unavailable (${e instanceof Error ? e.message : String(e)}), started on ${server?.url ?? "?"} instead.`, "info");
649
697
  }
650
- else {
651
- ctx.ui.notify(`Failed to start web server: ${e instanceof Error ? e.message : String(e)}`, "error");
698
+ catch (e2) {
699
+ ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
652
700
  }
653
701
  }
654
702
  if (server) {
@@ -681,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
681
729
  catch { }
682
730
  }
683
731
  }
732
+ function disablePlannerSession() {
733
+ plannerSessionEnabled = false;
734
+ startupResumePromptPending = false;
735
+ startupResumeSummaryPending = false;
736
+ startupResumeSummaryText = "";
737
+ if (startupResumeSummaryTimer)
738
+ clearTimeout(startupResumeSummaryTimer);
739
+ startupResumeSummaryTimer = null;
740
+ contextBlockCache = "";
741
+ contextBlockDirty = true;
742
+ }
684
743
  async function stopServer() {
685
744
  if (server) {
686
745
  await server.close();
687
746
  server = null;
688
747
  }
689
748
  capturedPi?.appendEntry("plan-web-state", { running: false });
749
+ // The cached context block embeds the web UI address; after stop it must
750
+ // not keep claiming the dashboard is running (T241 — context invalidation).
751
+ contextBlockDirty = true;
690
752
  }
691
753
  // ─── Extension factory ──────────────────────────────────────────────────
692
754
  export default function planPiExtension(pi) {
@@ -705,6 +767,9 @@ export default function planPiExtension(pi) {
705
767
  }
706
768
  const argPrefix = match[2];
707
769
  const prefix = argPrefix === undefined ? match[1] : argPrefix;
770
+ if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
771
+ return null;
772
+ }
708
773
  const normalized = (argPrefix ?? "").trimStart().toLowerCase();
709
774
  const items = PLANNER_COMMAND_COMPLETIONS
710
775
  .filter((item) => !normalized || item.value.startsWith(normalized))
@@ -744,9 +809,10 @@ export default function planPiExtension(pi) {
744
809
  // ── Step 1: Enable gating ───────────────────────────────────────
745
810
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
746
811
  // 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).
812
+ // until the safety-net timeout). Tracked upstream via planner task
813
+ // T151 · EBRST. The planner is always DISABLED at startup; the user
814
+ // enables it with '/planner load' (which also starts the web UI on LAN
815
+ // and triggers the resume summary showing the URL).
750
816
  let enablePlanner = false;
751
817
  if (exists) {
752
818
  ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
@@ -757,6 +823,10 @@ export default function planPiExtension(pi) {
757
823
  const handoffs = await st.listHandoffs().catch(() => []);
758
824
  if (handoffs.length > 0) {
759
825
  ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
826
+ // Mark each handoff as read (recap presented). Do NOT clear — content is
827
+ // kept until a task in the phase starts (auto-archived) or phase completes.
828
+ for (const h of handoffs)
829
+ await st.markHandoffRead(h.phaseId).catch(() => { });
760
830
  }
761
831
  }
762
832
  // If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
@@ -924,8 +994,45 @@ export default function planPiExtension(pi) {
924
994
  });
925
995
  // ── Commands ───────────────────────────────────────────────────────
926
996
  // ── Planner handler (reusable from multiple commands) ──────────
997
+ const plannerInteractionCancelled = Symbol("planner-interaction-cancelled");
998
+ /**
999
+ * Pi returns `undefined` when Escape closes an input/editor/select. Treat
1000
+ * that as a transaction-wide cancellation, not as an omitted optional
1001
+ * field: interactive planner commands must never keep prompting or write a
1002
+ * partial edit after Escape.
1003
+ */
1004
+ function withCancellablePlannerUi(ctx) {
1005
+ const cancellableCtx = Object.create(ctx);
1006
+ const ui = new Proxy(ctx.ui, {
1007
+ get(target, property, receiver) {
1008
+ const member = Reflect.get(target, property, receiver);
1009
+ if (typeof member !== "function")
1010
+ return member;
1011
+ if (property === "input" || property === "editor" || property === "select") {
1012
+ return async (...args) => {
1013
+ const result = await member.apply(target, args);
1014
+ if (result === undefined || result === null)
1015
+ throw plannerInteractionCancelled;
1016
+ return result;
1017
+ };
1018
+ }
1019
+ return member.bind(target);
1020
+ },
1021
+ });
1022
+ Object.defineProperty(cancellableCtx, "ui", { value: ui, enumerable: true });
1023
+ return cancellableCtx;
1024
+ }
927
1025
  async function handlePlanner(args, ctx) {
928
- await withPlanBusy(() => runPlanner(args, ctx));
1026
+ try {
1027
+ await withPlanBusy(() => runPlanner(args, withCancellablePlannerUi(ctx)));
1028
+ }
1029
+ catch (error) {
1030
+ if (error === plannerInteractionCancelled) {
1031
+ ctx.ui.notify("Cancelled — no changes saved.", "info");
1032
+ return;
1033
+ }
1034
+ throw error;
1035
+ }
929
1036
  }
930
1037
  async function runPlanner(args, ctx) {
931
1038
  const parts = args.trim().split(/\s+/).filter(Boolean);
@@ -935,6 +1042,7 @@ export default function planPiExtension(pi) {
935
1042
  "init",
936
1043
  "show",
937
1044
  "repair",
1045
+ "cleanup-orphans",
938
1046
  "project discuss",
939
1047
  "project language",
940
1048
  "feature list",
@@ -968,9 +1076,9 @@ export default function planPiExtension(pi) {
968
1076
  "load",
969
1077
  "stop",
970
1078
  ];
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" +
973
- "Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
1079
+ 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" +
1080
+ "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1081
+ "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
974
1082
  if (!a) {
975
1083
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
976
1084
  if (!action) {
@@ -990,6 +1098,14 @@ export default function planPiExtension(pi) {
990
1098
  return;
991
1099
  }
992
1100
  const oldProject = await st.loadProject();
1101
+ // Collect the replacement metadata before deleting the existing plan;
1102
+ // Escape must leave the old project intact.
1103
+ const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
1104
+ if (!name?.trim()) {
1105
+ ctx.ui.notify("Aborted", "warning");
1106
+ return;
1107
+ }
1108
+ const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
993
1109
  ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
994
1110
  if (server) {
995
1111
  ctx.ui.notify("Stopping active web server...", "info");
@@ -997,12 +1113,6 @@ export default function planPiExtension(pi) {
997
1113
  server = null;
998
1114
  }
999
1115
  await rm(st.root, { recursive: true, force: true });
1000
- const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
1001
- if (!name?.trim()) {
1002
- ctx.ui.notify("Aborted", "warning");
1003
- return;
1004
- }
1005
- const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
1006
1116
  await st.init(name.trim());
1007
1117
  const project = await st.loadProject();
1008
1118
  project.description = description?.trim() ?? oldProject.description;
@@ -1125,35 +1235,33 @@ export default function planPiExtension(pi) {
1125
1235
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1126
1236
  ?? null;
1127
1237
  }
1128
- function findPhaseByRef(phases, ref) {
1129
- const normalized = ref.trim().toLowerCase();
1130
- if (!normalized)
1131
- return null;
1132
- const pMatch = normalized.match(/^p(\d+)$/);
1133
- if (pMatch) {
1134
- const n = parseInt(pMatch[1], 10);
1135
- const byNum = phases.find((p) => p.number === n);
1136
- if (byNum)
1137
- return byNum;
1138
- }
1139
- return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1140
- ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1141
- ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1142
- ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1143
- ?? null;
1144
- }
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;
1238
+ function resolveFeatureRef(features, ref) {
1239
+ const raw = ref.trim();
1240
+ if (!raw)
1241
+ return { ok: false, error: "Feature ref is required." };
1242
+ const normalized = raw.toLowerCase();
1243
+ const byNumber = normalized.match(/^f(\d+)$/)
1244
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
1245
+ : undefined;
1246
+ if (byNumber)
1247
+ return { ok: true, feature: byNumber };
1248
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
1249
+ if (byShortId)
1250
+ return { ok: true, feature: byShortId };
1251
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
1252
+ if (byId)
1253
+ return { ok: true, feature: byId };
1254
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
1255
+ if (exactName.length == 1)
1256
+ return { ok: true, feature: exactName[0] };
1257
+ if (exactName.length > 1)
1258
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
1259
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
1260
+ if (partialName.length == 1)
1261
+ return { ok: true, feature: partialName[0] };
1262
+ if (partialName.length > 1)
1263
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
1264
+ return { ok: false, error: `Feature not found: ${raw}` };
1157
1265
  }
1158
1266
  function profile_packageManager(pkg, lockfile) {
1159
1267
  if (pkg?.packageManager)
@@ -1168,7 +1276,7 @@ export default function planPiExtension(pi) {
1168
1276
  return "npm";
1169
1277
  return "";
1170
1278
  }
1171
- async function scanCodebase() {
1279
+ async function scanCodebase(persist = true) {
1172
1280
  const root = ctx.cwd;
1173
1281
  const rootFiles = [];
1174
1282
  const directories = [];
@@ -1250,7 +1358,8 @@ export default function planPiExtension(pi) {
1250
1358
  tree,
1251
1359
  ambient,
1252
1360
  };
1253
- await st.saveCodebaseProfile(profile);
1361
+ if (persist)
1362
+ await st.saveCodebaseProfile(profile);
1254
1363
  return profile;
1255
1364
  }
1256
1365
  // ═══════════════════════════════════════════════════════════════
@@ -1262,8 +1371,8 @@ export default function planPiExtension(pi) {
1262
1371
  return;
1263
1372
  }
1264
1373
  if (b === "discuss") {
1265
- const project = await ensureProjectLanguagePreferences(st);
1266
- const profile = await scanCodebase();
1374
+ const project = await ensureProjectLanguagePreferences(st, false);
1375
+ const profile = await scanCodebase(false);
1267
1376
  ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
1268
1377
  ctx.ui.notify("Opening text editors for the initial project profile. These will serve as the foundation for the agent's deep discovery process.", "info");
1269
1378
  const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
@@ -1290,6 +1399,7 @@ export default function planPiExtension(pi) {
1290
1399
  : project.acceptedDecisions,
1291
1400
  };
1292
1401
  await st.saveProject(draftProject);
1402
+ await st.saveCodebaseProfile(profile);
1293
1403
  await st.writeGenerated();
1294
1404
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
1295
1405
  if (capturedPi) {
@@ -1327,7 +1437,7 @@ export default function planPiExtension(pi) {
1327
1437
  // ═══════════════════════════════════════════════════════════════
1328
1438
  if (a === "feature") {
1329
1439
  if (!b) {
1330
- ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
1440
+ ctx.ui.notify("feature actions: list | add [name] | show [id|name] | discuss [id|name] | update [id|name] | delete [id|name]", "info");
1331
1441
  return;
1332
1442
  }
1333
1443
  if (b === "list") {
@@ -1359,7 +1469,7 @@ export default function planPiExtension(pi) {
1359
1469
  return;
1360
1470
  }
1361
1471
  const now = nowISO();
1362
- const featureNumber = (await st.loadFeatures()).features.length + 1;
1472
+ const featureNumber = await st.allocFeatureNumber();
1363
1473
  const shortId = createShortId(await st.assignedShortIds());
1364
1474
  const priority = await st.nextPriority("feature");
1365
1475
  const feature = {
@@ -1380,6 +1490,7 @@ export default function planPiExtension(pi) {
1380
1490
  acceptedDecisions: [],
1381
1491
  phaseIds: [],
1382
1492
  dependsOn: [],
1493
+ statusLog: [],
1383
1494
  createdAt: now,
1384
1495
  updatedAt: now,
1385
1496
  };
@@ -1388,7 +1499,7 @@ export default function planPiExtension(pi) {
1388
1499
  return doc;
1389
1500
  });
1390
1501
  await st.writeGenerated();
1391
- ctx.ui.notify(`Feature created: ${feature.id} — ${feature.name}`, "info");
1502
+ ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1392
1503
  return;
1393
1504
  }
1394
1505
  if (b === "show") {
@@ -1417,6 +1528,44 @@ export default function planPiExtension(pi) {
1417
1528
  ].filter(Boolean).join("\n"), "info");
1418
1529
  return;
1419
1530
  }
1531
+ if (b === "discuss") {
1532
+ const featuresDoc = await st.loadFeatures();
1533
+ let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
1534
+ if (!feature) {
1535
+ feature = await pickFeature();
1536
+ if (!feature) {
1537
+ ctx.ui.notify("No features to discuss. Create one first.", "warning");
1538
+ return;
1539
+ }
1540
+ }
1541
+ const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
1542
+ const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
1543
+ const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
1544
+ const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
1545
+ const featureId = feature.id;
1546
+ const updatedDoc = await st.updateFeatures((doc) => {
1547
+ const target = doc.features.find((entry) => entry.id === featureId);
1548
+ if (!target)
1549
+ return doc;
1550
+ if (description !== undefined)
1551
+ target.description = description.trim();
1552
+ if (workDone !== undefined)
1553
+ target.workDone = workDone.trim();
1554
+ if (workRemaining !== undefined)
1555
+ target.workRemaining = workRemaining.trim();
1556
+ if (dependencies !== undefined)
1557
+ target.dependsOn = splitCsv(dependencies);
1558
+ target.discussedAt = nowISO();
1559
+ target.contextReady = true;
1560
+ target.contextReadyReason = "Updated through /planner feature discuss.";
1561
+ target.updatedAt = nowISO();
1562
+ return doc;
1563
+ });
1564
+ feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1565
+ await st.writeGenerated();
1566
+ ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1567
+ return;
1568
+ }
1420
1569
  if (b === "update") {
1421
1570
  const featuresDoc = await st.loadFeatures();
1422
1571
  let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
@@ -1469,7 +1618,7 @@ export default function planPiExtension(pi) {
1469
1618
  });
1470
1619
  feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1471
1620
  await st.writeGenerated();
1472
- ctx.ui.notify(`Feature updated: ${feature.id} — ${feature.name} (${feature.status})`, "info");
1621
+ ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
1473
1622
  return;
1474
1623
  }
1475
1624
  if (b === "delete") {
@@ -1515,7 +1664,7 @@ export default function planPiExtension(pi) {
1515
1664
  ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1516
1665
  return;
1517
1666
  }
1518
- ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
1667
+ ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
1519
1668
  return;
1520
1669
  }
1521
1670
  // ═══════════════════════════════════════════════════════════════
@@ -1542,11 +1691,15 @@ export default function planPiExtension(pi) {
1542
1691
  ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
1543
1692
  return;
1544
1693
  }
1694
+ // This belongs to the creation transaction: Escape before the phase
1695
+ // is persisted cancels the whole command rather than creating a phase
1696
+ // and only then abandoning the follow-up question.
1697
+ const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1545
1698
  let phase;
1546
1699
  await withFeatureLock(feature.id, async () => {
1547
1700
  const phases = await st.loadAllPhases();
1548
1701
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1549
- const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
1702
+ const number = await st.allocPhaseNumber();
1550
1703
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1551
1704
  const id = createPhaseId();
1552
1705
  const shortId = createShortId(await st.assignedShortIds());
@@ -1557,6 +1710,9 @@ export default function planPiExtension(pi) {
1557
1710
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1558
1711
  createdAt: nowISO(), updatedAt: nowISO(),
1559
1712
  handoff: "", handoffUpdatedAt: "",
1713
+ handoffReadAt: "",
1714
+ handoffHistory: [],
1715
+ statusLog: [],
1560
1716
  };
1561
1717
  await st.savePhase(phase);
1562
1718
  await st.updateFeatures((doc) => {
@@ -1571,8 +1727,7 @@ export default function planPiExtension(pi) {
1571
1727
  });
1572
1728
  if (!phase)
1573
1729
  return;
1574
- ctx.ui.notify(`Phase created: ${phase.id} — ${title} (feature ${feature.name})`, "info");
1575
- const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1730
+ ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
1576
1731
  if (isYes(startDiscuss)) {
1577
1732
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
1578
1733
  }
@@ -1581,7 +1736,7 @@ export default function planPiExtension(pi) {
1581
1736
  if (b === "discuss") {
1582
1737
  let phase = null;
1583
1738
  if (subArgs.trim()) {
1584
- phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
1739
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1585
1740
  }
1586
1741
  if (!phase) {
1587
1742
  phase = await pickPhase();
@@ -1593,10 +1748,7 @@ export default function planPiExtension(pi) {
1593
1748
  if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
1594
1749
  ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
1595
1750
  }
1596
- phase.status = "discovery";
1597
- phase.updatedAt = nowISO();
1598
- await st.savePhase(phase);
1599
- ctx.ui.notify(`Discovery for ${phase.id}…`, "info");
1751
+ ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1600
1752
  // ── Recap: give full context before planning tasks ──
1601
1753
  const recapPlan = await st.loadAll();
1602
1754
  const recapProject = recapPlan.project;
@@ -1661,13 +1813,13 @@ export default function planPiExtension(pi) {
1661
1813
  phase.updatedAt = nowISO();
1662
1814
  await st.savePhase(phase);
1663
1815
  await st.writeGenerated();
1664
- ctx.ui.notify(`Phase ${phase.id} is now **planned**.`, "info");
1816
+ ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1665
1817
  return;
1666
1818
  }
1667
1819
  if (b === "show") {
1668
1820
  let phase = null;
1669
1821
  if (subArgs.trim()) {
1670
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1822
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1671
1823
  }
1672
1824
  if (!phase) {
1673
1825
  phase = await pickPhase();
@@ -1693,7 +1845,7 @@ export default function planPiExtension(pi) {
1693
1845
  if (b === "delete") {
1694
1846
  let phase = null;
1695
1847
  if (subArgs.trim()) {
1696
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1848
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1697
1849
  }
1698
1850
  if (!phase) {
1699
1851
  phase = await pickPhase();
@@ -1709,13 +1861,13 @@ export default function planPiExtension(pi) {
1709
1861
  }
1710
1862
  await st.deletePhase(phase.id);
1711
1863
  await st.writeGenerated();
1712
- ctx.ui.notify(`Phase deleted: ${phase.id}`, "info");
1864
+ ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1713
1865
  return;
1714
1866
  }
1715
1867
  if (b === "update") {
1716
1868
  let phase = null;
1717
1869
  if (subArgs.trim()) {
1718
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1870
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1719
1871
  }
1720
1872
  if (!phase) {
1721
1873
  phase = await pickPhase();
@@ -1761,7 +1913,7 @@ export default function planPiExtension(pi) {
1761
1913
  phase.updatedAt = nowISO();
1762
1914
  await st.savePhase(phase);
1763
1915
  await st.writeGenerated();
1764
- ctx.ui.notify(`Phase updated: ${phase.id} — ${phase.title} (${phase.status})`, "info");
1916
+ ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
1765
1917
  return;
1766
1918
  }
1767
1919
  ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
@@ -1772,7 +1924,7 @@ export default function planPiExtension(pi) {
1772
1924
  // ═══════════════════════════════════════════════════════════════
1773
1925
  if (a === "task") {
1774
1926
  if (!b) {
1775
- ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
1927
+ 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
1928
  return;
1777
1929
  }
1778
1930
  if (b === "add") {
@@ -1786,8 +1938,11 @@ export default function planPiExtension(pi) {
1786
1938
  ctx.ui.notify("Aborted", "warning");
1787
1939
  return;
1788
1940
  }
1941
+ // Keep the optional follow-up inside the creation transaction so
1942
+ // Escape can still cancel before a task is persisted.
1943
+ const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1789
1944
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1790
- const taskNum = phase.tasks.length + 1;
1945
+ const taskNum = await st.allocTaskNumber();
1791
1946
  const taskId = createTaskId();
1792
1947
  const shortId = createShortId(await st.assignedShortIds());
1793
1948
  const priority = await st.nextPriority("task", phase.id);
@@ -1811,7 +1966,6 @@ export default function planPiExtension(pi) {
1811
1966
  await st.savePhase(phase);
1812
1967
  await st.writeGenerated();
1813
1968
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
1814
- const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1815
1969
  if (isYes(startDiscuss)) {
1816
1970
  await handlePlanner(`task discuss ${taskId}`, ctx);
1817
1971
  }
@@ -1819,7 +1973,7 @@ export default function planPiExtension(pi) {
1819
1973
  }
1820
1974
  if (b === "discuss") {
1821
1975
  const phases = await st.loadAllPhases();
1822
- let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
1976
+ let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
1823
1977
  if (!resolved) {
1824
1978
  const phase = await pickPhase();
1825
1979
  if (!phase) {
@@ -1843,7 +1997,7 @@ export default function planPiExtension(pi) {
1843
1997
  if (description?.trim())
1844
1998
  task.description = description.trim();
1845
1999
  if (checklistSeed?.trim()) {
1846
- task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
2000
+ task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
1847
2001
  id: createChecklistItemId(task.id, index + 1, itemTitle),
1848
2002
  title: itemTitle,
1849
2003
  checked: false,
@@ -1853,14 +2007,14 @@ export default function planPiExtension(pi) {
1853
2007
  phase.updatedAt = nowISO();
1854
2008
  await st.savePhase(phase);
1855
2009
  await st.writeGenerated();
1856
- ctx.ui.notify(`Task ${task.id} discussed and updated.`, "info");
2010
+ 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
2011
  return;
1858
2012
  }
1859
2013
  if (b === "show") {
1860
2014
  let phase = null;
1861
2015
  let task = null;
1862
2016
  if (subArgs.trim()) {
1863
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2017
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
1864
2018
  phase = resolved?.phase ?? null;
1865
2019
  task = resolved?.task ?? null;
1866
2020
  }
@@ -1894,7 +2048,7 @@ export default function planPiExtension(pi) {
1894
2048
  if (b === "delete") {
1895
2049
  let phase = null;
1896
2050
  if (subArgs.trim()) {
1897
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2051
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1898
2052
  }
1899
2053
  if (!phase) {
1900
2054
  phase = await pickPhase();
@@ -1922,13 +2076,13 @@ export default function planPiExtension(pi) {
1922
2076
  phase.updatedAt = nowISO();
1923
2077
  await st.savePhase(phase);
1924
2078
  await st.writeGenerated();
1925
- ctx.ui.notify(`Task deleted: ${task.id}`, "info");
2079
+ 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
2080
  return;
1927
2081
  }
1928
2082
  if (b === "update") {
1929
2083
  let phase = null;
1930
2084
  if (subArgs.trim()) {
1931
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2085
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1932
2086
  }
1933
2087
  if (!phase) {
1934
2088
  phase = await pickPhase();
@@ -1970,15 +2124,16 @@ export default function planPiExtension(pi) {
1970
2124
  task.updatedAt = now;
1971
2125
  phase.updatedAt = now;
1972
2126
  await st.savePhase(phase);
2127
+ await st.syncTaskStatusRollup(phase.id);
1973
2128
  await st.writeGenerated();
1974
- ctx.ui.notify(`Task updated: ${task.id} — ${task.title} (${task.status})`, "info");
2129
+ 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
2130
  return;
1976
2131
  }
1977
2132
  if (b === "start") {
1978
2133
  let phase = null;
1979
2134
  let task = null;
1980
2135
  if (subArgs.trim()) {
1981
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2136
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
1982
2137
  phase = resolved?.phase ?? null;
1983
2138
  task = resolved?.task ?? null;
1984
2139
  }
@@ -2002,13 +2157,9 @@ export default function planPiExtension(pi) {
2002
2157
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2003
2158
  return;
2004
2159
  }
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");
2011
- }
2160
+ // A pending handoff is context, not a lock: task start RETAINS it.
2161
+ // It is archived only when the phase completes (auto) or the user
2162
+ // explicitly clears it.
2012
2163
  const now = nowISO();
2013
2164
  // Record status change in the incremental statusLog.
2014
2165
  const prevStatus = task.status;
@@ -2025,15 +2176,16 @@ export default function planPiExtension(pi) {
2025
2176
  task.updatedAt = now;
2026
2177
  phase.updatedAt = now;
2027
2178
  await st.savePhase(phase);
2179
+ await st.syncTaskStatusRollup(phase.id);
2028
2180
  await st.writeGenerated();
2029
- ctx.ui.notify(`✅ Task started: ${task.id} — ${task.title} (in-progress)`, "info");
2181
+ 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
2182
  return;
2031
2183
  }
2032
2184
  if (b === "complete") {
2033
2185
  let phase = null;
2034
2186
  let task = null;
2035
2187
  if (subArgs.trim()) {
2036
- const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
2188
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2037
2189
  phase = resolved?.phase ?? null;
2038
2190
  task = resolved?.task ?? null;
2039
2191
  }
@@ -2079,12 +2231,80 @@ export default function planPiExtension(pi) {
2079
2231
  applyTaskLifecycleDates(task, "done", now);
2080
2232
  task.updatedAt = now;
2081
2233
  phase.updatedAt = now;
2082
- await st.savePhase(phase);
2234
+ const clearedRef = await st.syncTaskStatusRollup(phase.id);
2235
+ await st.writeGenerated();
2236
+ 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}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
2237
+ return;
2238
+ }
2239
+ // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
2240
+ if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
2241
+ const [taskRef, ...itemParts] = rest;
2242
+ const itemText = itemParts.join(" ").trim();
2243
+ if (!taskRef || !itemText) {
2244
+ ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
2245
+ return;
2246
+ }
2247
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
2248
+ if (!resolved) {
2249
+ ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
2250
+ return;
2251
+ }
2252
+ let checked;
2253
+ if (b === "checklist-toggle") {
2254
+ const m = itemText.match(/\s+(on|off)$/i);
2255
+ if (m) {
2256
+ checked = m[1].toLowerCase() === "on";
2257
+ }
2258
+ }
2259
+ const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
2260
+ let msg = "";
2261
+ await st.updatePhase(resolved.phase.id, (phase) => {
2262
+ const task = phase.tasks.find((t) => t.id === resolved.task.id);
2263
+ if (!task) {
2264
+ msg = `Task not found: ${taskRef}`;
2265
+ return phase;
2266
+ }
2267
+ const ck = task.checklist ?? [];
2268
+ if (b === "checklist-add") {
2269
+ const item = addChecklistItem(ck, task.id, selector);
2270
+ ck.push(item);
2271
+ task.checklist = ck;
2272
+ msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
2273
+ }
2274
+ else if (b === "checklist-remove") {
2275
+ if (ck.length === 0) {
2276
+ msg = `Task "${task.title}" has no checklist.`;
2277
+ return phase;
2278
+ }
2279
+ const removed = removeChecklistItem(ck, selector);
2280
+ if (!removed) {
2281
+ msg = `No checklist item matching "${selector}".`;
2282
+ return phase;
2283
+ }
2284
+ msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
2285
+ }
2286
+ else {
2287
+ if (ck.length === 0) {
2288
+ msg = `Task "${task.title}" has no checklist.`;
2289
+ return phase;
2290
+ }
2291
+ const target = toggleChecklistItem(ck, selector, checked);
2292
+ if (!target) {
2293
+ msg = `No checklist item matching "${selector}".`;
2294
+ return phase;
2295
+ }
2296
+ const doneCount = ck.filter((i) => i.checked).length;
2297
+ msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
2298
+ }
2299
+ task.updatedAt = nowISO();
2300
+ phase.updatedAt = nowISO();
2301
+ return phase;
2302
+ });
2083
2303
  await st.writeGenerated();
2084
- ctx.ui.notify(`✅ Task completed: ${task.id} — ${task.title} (done)`, "info");
2304
+ ctx.ui.notify(`✅ ${msg}`, "info");
2085
2305
  return;
2086
2306
  }
2087
- ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
2307
+ ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
2088
2308
  return;
2089
2309
  }
2090
2310
  // ── discuss (legacy alias) ──
@@ -2130,6 +2350,11 @@ export default function planPiExtension(pi) {
2130
2350
  ctx.ui.notify("No text provided; nothing written.", "info");
2131
2351
  return;
2132
2352
  }
2353
+ const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
2354
+ if (!confirmed) {
2355
+ ctx.ui.notify("Handoff write cancelled.", "info");
2356
+ return;
2357
+ }
2133
2358
  await st.setPhaseHandoff(r.phase.id, text);
2134
2359
  ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
2135
2360
  return;
@@ -2139,22 +2364,17 @@ export default function planPiExtension(pi) {
2139
2364
  ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2140
2365
  return;
2141
2366
  }
2142
- 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" +
2144
- "The handoff MUST be a structured markdown document containing at minimum:\n" +
2367
+ await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
2368
+ "If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
2369
+ "Before calling `handoff_write`, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. Only after confirmation call `handoff_write` with that exact phaseRef. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
2370
+ "After confirmation, the handoff MUST contain at minimum:\n" +
2145
2371
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2146
2372
  "- `Reason:` why this handoff is being written\n" +
2147
- "- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
2148
- "- `## What was being done`: a concrete 3–10 line narrative of the work in progress, based on our conversation and the current task notes/description.\n" +
2149
- "- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
2150
- "- `## Files touched`: explicit file paths modified during this session.\n" +
2151
- "- `## Blockers`: any open blockers, or `None recorded`.\n" +
2152
- "- `## Next steps`: the immediate next steps.\n" +
2153
- "- `## Recent decisions`: accepted decisions made this session, if any.\n" +
2154
- "- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
2155
- "The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
2156
- (handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
2157
- "Once written, confirm to me with a one-line summary.");
2373
+ "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2374
+ "- `## What was being done`: the concrete decisions/work from this session\n" +
2375
+ "- `## How to resume`: explicit ordered steps\n" +
2376
+ "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2377
+ "Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
2158
2378
  ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2159
2379
  return;
2160
2380
  }
@@ -2164,7 +2384,7 @@ export default function planPiExtension(pi) {
2164
2384
  ctx.ui.notify(r.error, "warning");
2165
2385
  return;
2166
2386
  }
2167
- await st.clearPhaseHandoff(r.phase.id);
2387
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2168
2388
  ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
2169
2389
  return;
2170
2390
  }
@@ -2223,7 +2443,26 @@ export default function planPiExtension(pi) {
2223
2443
  const m = report.migrated;
2224
2444
  const dup = report.integrity.duplicatePhaseIds.length;
2225
2445
  const dang = report.integrity.danglingPhaseIds.length;
2226
- ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2446
+ ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}, archived ${report.handoffs.archived} stale handoff(s). Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2447
+ return;
2448
+ }
2449
+ if (a === "cleanup-orphans") {
2450
+ const found = await st.listOrphanPhases();
2451
+ if (found.length === 0) {
2452
+ ctx.ui.notify("No orphan phases found.", "info");
2453
+ return;
2454
+ }
2455
+ const lines = [
2456
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
2457
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2458
+ ];
2459
+ const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
2460
+ if (!confirmed) {
2461
+ ctx.ui.notify("Cleanup aborted.", "warning");
2462
+ return;
2463
+ }
2464
+ const report = await st.cleanupOrphanPhases();
2465
+ ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
2227
2466
  return;
2228
2467
  }
2229
2468
  // ── export ──
@@ -2279,6 +2518,10 @@ export default function planPiExtension(pi) {
2279
2518
  startupResumeSummaryPending = false;
2280
2519
  startupResumeSummaryTimer = null;
2281
2520
  }, 60000);
2521
+ // Safety timeout must not keep the Node process alive on its own
2522
+ // (relevant for test hosts that load this entrypoint). Inside Pi the
2523
+ // process is long-lived, so unref is a no-op there.
2524
+ startupResumeSummaryTimer.unref?.();
2282
2525
  // Build the recap and embed it in the trigger content. The trigger
2283
2526
  // message is the channel that reliably reaches the agent on a triggered
2284
2527
  // turn (before_agent_start's systemPrompt injection is NOT applied to
@@ -2303,7 +2546,7 @@ export default function planPiExtension(pi) {
2303
2546
  return;
2304
2547
  }
2305
2548
  if (a === "stop" || a === "disable") {
2306
- plannerSessionEnabled = false;
2549
+ disablePlannerSession();
2307
2550
  await stopServer().catch(() => { });
2308
2551
  try {
2309
2552
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -2319,6 +2562,11 @@ export default function planPiExtension(pi) {
2319
2562
  description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
2320
2563
  getArgumentCompletions: (prefix) => {
2321
2564
  const normalized = prefix.trim().toLowerCase();
2565
+ // Once a complete command path is followed by whitespace, the remaining
2566
+ // text is a free-form human ref/title. Returning the fallback menu here
2567
+ // lets Pi select `init` and replace input such as `feature update F001`.
2568
+ if (hasPlannerCommandArgument(prefix))
2569
+ return null;
2322
2570
  const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
2323
2571
  return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
2324
2572
  },
@@ -2595,7 +2843,7 @@ export default function planPiExtension(pi) {
2595
2843
  pi.registerTool({
2596
2844
  name: "plan_render",
2597
2845
  label: "Plan Render",
2598
- description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
2846
+ description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
2599
2847
  parameters: Type.Object({}),
2600
2848
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2601
2849
  const st = await requirePlan(ctx);
@@ -2608,7 +2856,7 @@ export default function planPiExtension(pi) {
2608
2856
  pi.registerTool({
2609
2857
  name: "plan_repair",
2610
2858
  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/.",
2859
+ 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
2860
  parameters: Type.Object({}),
2613
2861
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2614
2862
  const st = await requirePlan(ctx);
@@ -2616,9 +2864,12 @@ export default function planPiExtension(pi) {
2616
2864
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2617
2865
  const report = await st.repair();
2618
2866
  const m = report.migrated;
2867
+ const c = report.containment;
2619
2868
  const lines = [
2620
2869
  `Repair complete.`,
2621
2870
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2871
+ `Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
2872
+ `Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
2622
2873
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2623
2874
  ];
2624
2875
  if (report.integrity.danglingPhaseIds.length)
@@ -2628,6 +2879,38 @@ export default function planPiExtension(pi) {
2628
2879
  return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2629
2880
  },
2630
2881
  });
2882
+ pi.registerTool({
2883
+ name: "plan_cleanup_orphan_phases",
2884
+ label: "Plan Cleanup Orphan Phases",
2885
+ 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.",
2886
+ parameters: Type.Object({
2887
+ confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
2888
+ }),
2889
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2890
+ const st = await requirePlan(ctx);
2891
+ if (!st)
2892
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2893
+ const found = await st.listOrphanPhases();
2894
+ if (!params.confirm) {
2895
+ if (found.length === 0)
2896
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
2897
+ const lines = [
2898
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
2899
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2900
+ "Rerun with confirm=true to delete these orphan phase files.",
2901
+ ];
2902
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
2903
+ }
2904
+ const report = await st.cleanupOrphanPhases();
2905
+ if (report.removed.length === 0)
2906
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
2907
+ const lines = [
2908
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
2909
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
2910
+ ];
2911
+ return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2912
+ },
2913
+ });
2631
2914
  pi.registerTool({
2632
2915
  name: "plan_get_handoff",
2633
2916
  label: "Plan Get Handoff",
@@ -2647,9 +2930,12 @@ export default function planPiExtension(pi) {
2647
2930
  pi.registerTool({
2648
2931
  name: "plan_write_handoff",
2649
2932
  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.",
2933
+ description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. phaseRef is required and must be the exact phase confirmed with the user; never infer a target from current status or resume pointers. Completed/canceled phases reject new handoffs.",
2651
2934
  parameters: Type.Object({
2935
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
2936
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2652
2937
  reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
2938
+ 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
2939
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
2654
2940
  howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
2655
2941
  extraSections: Type.Optional(Type.Array(Type.Object({
@@ -2661,7 +2947,14 @@ export default function planPiExtension(pi) {
2661
2947
  const st = await requirePlan(ctx);
2662
2948
  if (!st)
2663
2949
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2950
+ const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
2951
+ if (!resolved.ok)
2952
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${resolved.error}` }], details: { deprecated: true, error: resolved.error } };
2953
+ if (!params.confirmed)
2954
+ return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${resolved.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, confirmationRequired: true } };
2664
2955
  const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2956
+ phaseId: resolved.phase.id,
2957
+ ...(params.title?.trim() ? { title: params.title.trim() } : {}),
2665
2958
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2666
2959
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2667
2960
  ...(params.extraSections && params.extraSections.length > 0
@@ -2672,32 +2965,29 @@ export default function planPiExtension(pi) {
2672
2965
  }
2673
2966
  : {}),
2674
2967
  });
2675
- const r = await resolvePhaseForHandoff(st, undefined);
2676
- if (!r.ok)
2677
- return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${r.error}` }], details: { deprecated: true, error: r.error } };
2678
- await st.setPhaseHandoff(r.phase.id, markdown);
2679
- return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${r.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
2968
+ await st.setPhaseHandoff(resolved.phase.id, markdown);
2969
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${resolved.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id } };
2680
2970
  },
2681
2971
  });
2682
2972
  pi.registerTool({
2683
2973
  name: "plan_delete_handoff",
2684
2974
  label: "Plan Delete Handoff",
2685
- description: "DEPRECATED — clears the entity-scoped handoff of the current in-progress phase. Prefer handoff_clear.",
2686
- parameters: Type.Object({}),
2687
- async execute(_id, _params, _signal, _onUpdate, ctx) {
2975
+ description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
2976
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
2977
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2688
2978
  const st = await requirePlan(ctx);
2689
2979
  if (!st)
2690
2980
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2691
- const r = await resolvePhaseForHandoff(st, undefined);
2981
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2692
2982
  if (!r.ok)
2693
2983
  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);
2984
+ await st.clearPhaseHandoff(r.phase.id, "manual");
2695
2985
  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
2986
  },
2697
2987
  });
2698
2988
  // Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
2699
- // UUID | title; omitted -> current in-progress phase (phase.status, or the
2700
- // phase containing an in-progress task). Returns the phase + its composite ref.
2989
+ // UUID | title. A write target is always explicit: never guess from the
2990
+ // first in-progress phase or a stale resume pointer.
2701
2991
  async function resolvePhaseForHandoff(st, ref) {
2702
2992
  const phases = await st.loadAllPhases();
2703
2993
  const features = (await st.loadFeatures()).features;
@@ -2705,16 +2995,12 @@ export default function planPiExtension(pi) {
2705
2995
  if (ref && ref.trim()) {
2706
2996
  phase = findPhaseByRef(phases, features, ref.trim());
2707
2997
  }
2708
- else {
2709
- phase = phases.find((p) => p.status === "in-progress")
2710
- ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
2711
- }
2712
2998
  if (!phase) {
2713
2999
  return {
2714
3000
  ok: false,
2715
3001
  error: ref && ref.trim()
2716
3002
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
2717
- : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
3003
+ : "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
2718
3004
  };
2719
3005
  }
2720
3006
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -2739,8 +3025,8 @@ export default function planPiExtension(pi) {
2739
3025
  pi.registerTool({
2740
3026
  name: "handoff_show",
2741
3027
  label: "Handoff Show",
2742
- description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title. Omit to target the current in-progress phase.",
2743
- parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase." })) }),
3028
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
3029
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
2744
3030
  async execute(_id, params, _signal, _onUpdate, ctx) {
2745
3031
  const st = await requirePlan(ctx);
2746
3032
  if (!st)
@@ -2757,9 +3043,11 @@ export default function planPiExtension(pi) {
2757
3043
  pi.registerTool({
2758
3044
  name: "handoff_write",
2759
3045
  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.",
3046
+ description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). Never guess the phase from in-progress status or stale context. Completed/canceled phases reject new handoffs.",
2761
3047
  parameters: Type.Object({
2762
- phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
3048
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3049
+ 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'." })),
3050
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2763
3051
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2764
3052
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2765
3053
  }),
@@ -2767,12 +3055,38 @@ export default function planPiExtension(pi) {
2767
3055
  const st = await requirePlan(ctx);
2768
3056
  if (!st)
2769
3057
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2770
- const body = (params.markdown_content ?? params.content ?? "").trim();
3058
+ let body = (params.markdown_content ?? params.content ?? "").trim();
2771
3059
  if (!body)
2772
3060
  return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
3061
+ const title = params.title?.trim();
3062
+ // Derive the current first non-empty line (H1) of the supplied body.
3063
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
3064
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3065
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3066
+ if (!title && isGeneric) {
3067
+ return {
3068
+ 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." }],
3069
+ details: { error: "generic title", firstHeadingText },
3070
+ };
3071
+ }
3072
+ // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
3073
+ if (title) {
3074
+ const lines = body.split(/\r?\n/);
3075
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
3076
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
3077
+ lines[firstIdx] = `# ${title}`;
3078
+ body = lines.join("\n");
3079
+ }
3080
+ else {
3081
+ body = `# ${title}\n\n${body}`;
3082
+ }
3083
+ }
2773
3084
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2774
3085
  if (!r.ok)
2775
3086
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3087
+ if (!params.confirmed) {
3088
+ return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
3089
+ }
2776
3090
  await st.setPhaseHandoff(r.phase.id, body);
2777
3091
  return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2778
3092
  },
@@ -2780,8 +3094,8 @@ export default function planPiExtension(pi) {
2780
3094
  pi.registerTool({
2781
3095
  name: "handoff_clear",
2782
3096
  label: "Handoff Clear",
2783
- description: "Clear the entity-scoped handoff of a phase (sets handoff to empty, keeps handoffUpdatedAt as an audit trail). phaseRef optional (default: current in-progress phase).",
2784
- parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })) }),
3097
+ description: "Clear the entity-scoped handoff of a phase (archives it and keeps handoffUpdatedAt as an audit trail). phaseRef is required; never infer a phase automatically.",
3098
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
2785
3099
  async execute(_id, params, _signal, _onUpdate, ctx) {
2786
3100
  const st = await requirePlan(ctx);
2787
3101
  if (!st)
@@ -2789,8 +3103,8 @@ export default function planPiExtension(pi) {
2789
3103
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2790
3104
  if (!r.ok)
2791
3105
  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 } };
3106
+ await st.clearPhaseHandoff(r.phase.id, "manual");
3107
+ 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
3108
  },
2795
3109
  });
2796
3110
  pi.registerTool({
@@ -2826,38 +3140,49 @@ export default function planPiExtension(pi) {
2826
3140
  pi.registerTool({
2827
3141
  name: "feature_list",
2828
3142
  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) {
3143
+ 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.",
3144
+ parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
3145
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2832
3146
  const st = await requirePlan(ctx);
2833
3147
  if (!st)
2834
3148
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2835
- const features = (await st.loadFeatures()).features;
3149
+ const all = (await st.loadFeatures()).features;
3150
+ const ref = params.featureRef?.trim();
3151
+ const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
3152
+ const features = all.filter(matchF);
2836
3153
  const phases = await st.loadAllPhases();
2837
3154
  const summary = features.map((f) => {
2838
3155
  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)`;
3156
+ 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
3157
  }).join("\n");
2841
- return { content: [{ type: "text", text: summary || "No features" }], details: { features } };
3158
+ return { content: [{ type: "text", text: summary || "No features" }], details: {} };
2842
3159
  },
2843
3160
  });
2844
3161
  pi.registerTool({
2845
3162
  name: "feature_get",
2846
3163
  label: "Feature Get",
2847
- description: "Read full details of a feature, including its phases and their tasks.",
3164
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
2848
3165
  parameters: Type.Object({
2849
- featureId: Type.String({ description: "Feature ID, e.g. feature-001-dynamic-header" }),
3166
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3167
+ full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
2850
3168
  }),
2851
3169
  async execute(_id, params, _signal, _onUpdate, ctx) {
2852
3170
  const st = await requirePlan(ctx);
2853
3171
  if (!st)
2854
3172
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2855
3173
  const features = (await st.loadFeatures()).features;
2856
- const feature = features.find((f) => f.id === params.featureId);
2857
- if (!feature)
2858
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3174
+ const ref = params.featureId.trim();
3175
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
3176
+ if (!resolvedFeature.ok)
3177
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3178
+ const feature = resolvedFeature.feature;
2859
3179
  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 } };
3180
+ const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3181
+ const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
3182
+ const requirementsBlock = linkedRequirements.length > 0
3183
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3184
+ : "\n\nLinked requirements:\n- None linked to this feature.";
3185
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
2861
3186
  },
2862
3187
  });
2863
3188
  pi.registerTool({
@@ -2880,7 +3205,7 @@ export default function planPiExtension(pi) {
2880
3205
  const priority = await st.nextPriority("feature");
2881
3206
  const feature = {
2882
3207
  id: createFeatureId(),
2883
- number: currentFeatures.length + 1,
3208
+ number: await st.allocFeatureNumber(),
2884
3209
  shortId,
2885
3210
  priority,
2886
3211
  name: params.name,
@@ -2896,6 +3221,7 @@ export default function planPiExtension(pi) {
2896
3221
  acceptedDecisions: [],
2897
3222
  phaseIds: [],
2898
3223
  dependsOn: [],
3224
+ statusLog: [],
2899
3225
  createdAt: now,
2900
3226
  updatedAt: now,
2901
3227
  };
@@ -2906,7 +3232,52 @@ export default function planPiExtension(pi) {
2906
3232
  return doc;
2907
3233
  });
2908
3234
  await st.writeGenerated();
2909
- return { content: [{ type: "text", text: `Feature created: ${feature.id}` }], details: feature };
3235
+ return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3236
+ },
3237
+ });
3238
+ pi.registerTool({
3239
+ name: "feature_discuss",
3240
+ label: "Feature Discuss",
3241
+ description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
3242
+ parameters: Type.Object({
3243
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3244
+ description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3245
+ workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3246
+ workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3247
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
3248
+ }),
3249
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3250
+ const st = await requirePlan(ctx);
3251
+ if (!st)
3252
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3253
+ const features = (await st.loadFeatures()).features;
3254
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3255
+ if (!resolvedFeature.ok)
3256
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3257
+ const featureId = resolvedFeature.feature.id;
3258
+ const updatedDoc = await st.updateFeatures((doc) => {
3259
+ const feature = doc.features.find((f) => f.id === featureId);
3260
+ if (!feature)
3261
+ return doc;
3262
+ if (params.description !== undefined)
3263
+ feature.description = params.description;
3264
+ if (params.workDone !== undefined)
3265
+ feature.workDone = params.workDone;
3266
+ if (params.workRemaining !== undefined)
3267
+ feature.workRemaining = params.workRemaining;
3268
+ if (params.dependencies !== undefined)
3269
+ feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3270
+ feature.discussedAt = nowISO();
3271
+ feature.contextReady = true;
3272
+ feature.contextReadyReason = "Updated through feature_discuss tool.";
3273
+ feature.updatedAt = nowISO();
3274
+ return doc;
3275
+ });
3276
+ const feature = updatedDoc.features.find((f) => f.id === featureId);
3277
+ if (!feature)
3278
+ return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3279
+ await st.writeGenerated();
3280
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2910
3281
  },
2911
3282
  });
2912
3283
  pi.registerTool({
@@ -2914,7 +3285,7 @@ export default function planPiExtension(pi) {
2914
3285
  label: "Feature Update",
2915
3286
  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
3287
  parameters: Type.Object({
2917
- featureId: Type.String({ description: "Feature ID" }),
3288
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2918
3289
  name: Type.Optional(Type.String({ description: "New name" })),
2919
3290
  description: Type.Optional(Type.String({ description: "New description" })),
2920
3291
  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 +3307,36 @@ export default function planPiExtension(pi) {
2936
3307
  const st = await requirePlan(ctx);
2937
3308
  if (!st)
2938
3309
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3310
+ const features = (await st.loadFeatures()).features;
3311
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3312
+ if (!resolvedFeature.ok)
3313
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3314
+ const featureId = resolvedFeature.feature.id;
3315
+ const mutableFields = [
3316
+ "name",
3317
+ "description",
3318
+ "status",
3319
+ "startDate",
3320
+ "endDate",
3321
+ "workDone",
3322
+ "workRemaining",
3323
+ "priority",
3324
+ "acceptedDecisions",
3325
+ ];
3326
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3327
+ if (receivedFields.length === 0) {
3328
+ return {
3329
+ content: [{
3330
+ type: "text",
3331
+ text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
3332
+ }],
3333
+ details: { updated: false, reason: "no-mutable-fields" },
3334
+ };
3335
+ }
2939
3336
  let foundDoc;
2940
3337
  try {
2941
3338
  foundDoc = await st.updateFeatures((doc) => {
2942
- const feature = doc.features.find((f) => f.id === params.featureId);
3339
+ const feature = doc.features.find((f) => f.id === featureId);
2943
3340
  if (!feature)
2944
3341
  return doc; // not found: no-op, handled below
2945
3342
  if (params.name !== undefined)
@@ -2977,11 +3374,17 @@ export default function planPiExtension(pi) {
2977
3374
  catch (e) {
2978
3375
  return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
2979
3376
  }
2980
- const feature = foundDoc?.features.find((f) => f.id === params.featureId);
3377
+ const feature = foundDoc?.features.find((f) => f.id === featureId);
2981
3378
  if (!feature)
2982
3379
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
2983
3380
  await st.writeGenerated();
2984
- return { content: [{ type: "text", text: `Feature updated: ${feature.id} (${feature.status})` }], details: feature };
3381
+ return {
3382
+ content: [{
3383
+ type: "text",
3384
+ text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
3385
+ }],
3386
+ details: { ...feature, updated: true, updatedFields: receivedFields },
3387
+ };
2985
3388
  },
2986
3389
  });
2987
3390
  pi.registerTool({
@@ -2989,28 +3392,30 @@ export default function planPiExtension(pi) {
2989
3392
  label: "Feature Delete",
2990
3393
  description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
2991
3394
  parameters: Type.Object({
2992
- featureId: Type.String({ description: "Feature ID to delete" }),
3395
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
2993
3396
  cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
2994
3397
  }),
2995
3398
  async execute(_id, params, _signal, _onUpdate, ctx) {
2996
3399
  const st = await requirePlan(ctx);
2997
3400
  if (!st)
2998
3401
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3402
+ const features = (await st.loadFeatures()).features;
3403
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3404
+ if (!resolvedFeature.ok)
3405
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3406
+ const featureId = resolvedFeature.feature.id;
2999
3407
  let deleted = false;
3000
3408
  await st.updateFeatures((doc) => {
3001
3409
  const before = doc.features.length;
3002
- doc.features = doc.features.filter((f) => f.id !== params.featureId);
3410
+ doc.features = doc.features.filter((f) => f.id !== featureId);
3003
3411
  if (doc.features.length !== before)
3004
3412
  deleted = true;
3005
3413
  return doc;
3006
3414
  });
3007
- if (!deleted) {
3008
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3009
- }
3010
3415
  let cascadeCount = 0;
3011
3416
  if (params.cascade) {
3012
3417
  const phases = await st.loadAllPhases();
3013
- for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
3418
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
3014
3419
  await st.deletePhase(phase.id);
3015
3420
  cascadeCount += 1;
3016
3421
  }
@@ -3023,40 +3428,57 @@ export default function planPiExtension(pi) {
3023
3428
  pi.registerTool({
3024
3429
  name: "phase_list",
3025
3430
  label: "Phase List",
3026
- description: "List all phases (optionally filtered by featureId) with id, status, and task counts.",
3431
+ 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
3432
  parameters: Type.Object({
3028
- featureId: Type.Optional(Type.String({ description: "If provided, only list phases of this feature" })),
3433
+ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3434
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3029
3435
  }),
3030
3436
  async execute(_id, params, _signal, _onUpdate, ctx) {
3031
3437
  const st = await requirePlan(ctx);
3032
3438
  if (!st)
3033
3439
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3440
+ const features = (await st.loadFeatures()).features;
3034
3441
  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 } };
3442
+ const fref = params.featureRef?.trim();
3443
+ if (fref) {
3444
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3445
+ if (!f)
3446
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3447
+ phases = phases.filter((p) => p.featureId === f.id);
3448
+ }
3449
+ if (params.status)
3450
+ phases = phases.filter((p) => p.status === params.status);
3451
+ const summary = phases.map((p) => {
3452
+ const fNum = featureNumberOfPhase(p, features);
3453
+ const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3454
+ return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
3455
+ }).join("\n");
3456
+ return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3039
3457
  },
3040
3458
  });
3041
3459
  pi.registerTool({
3042
3460
  name: "phase_get",
3043
3461
  label: "Phase Get",
3044
- description: "Read full details of a phase, including its tasks.",
3462
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3045
3463
  parameters: Type.Object({
3046
- phaseId: Type.String({ description: "Phase ID, e.g. phase-01-setup" }),
3464
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3465
+ full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
3047
3466
  }),
3048
3467
  async execute(_id, params, _signal, _onUpdate, ctx) {
3049
3468
  const st = await requirePlan(ctx);
3050
3469
  if (!st)
3051
3470
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3052
- let phase;
3053
- try {
3054
- phase = await st.loadPhase(params.phaseId);
3055
- }
3056
- catch {
3471
+ const features = (await st.loadFeatures()).features;
3472
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3473
+ if (!phase)
3057
3474
  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 };
3475
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3476
+ const reqCount = linkedRequirements.length;
3477
+ 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"}` : ""})`;
3478
+ const requirementsBlock = reqCount > 0
3479
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3480
+ : "";
3481
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3060
3482
  },
3061
3483
  });
3062
3484
  pi.registerTool({
@@ -3076,18 +3498,27 @@ export default function planPiExtension(pi) {
3076
3498
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3077
3499
  if (!params.featureId?.trim())
3078
3500
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3501
+ const createPhaseFeatures = (await st.loadFeatures()).features;
3502
+ const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
3503
+ if (!resolvedFeature.ok)
3504
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3505
+ const featureId = resolvedFeature.feature.id;
3506
+ const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
3507
+ if (!featureValidation.ok)
3508
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3509
+ const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
3510
+ if (!existingFeature)
3511
+ return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3079
3512
  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;
3513
+ await withFeatureLock(featureId, async () => {
3514
+ const number = await st.allocPhaseNumber();
3084
3515
  const id = createPhaseId();
3085
3516
  const shortId = createShortId(await st.assignedShortIds());
3086
- const priority = await st.nextPriority("phase", params.featureId);
3517
+ const priority = await st.nextPriority("phase", featureId);
3087
3518
  const now = nowISO();
3088
3519
  phase = {
3089
3520
  id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3090
- featureId: params.featureId,
3521
+ featureId,
3091
3522
  status: params.status ?? "draft",
3092
3523
  discussedAt: "",
3093
3524
  contextReady: false,
@@ -3097,11 +3528,14 @@ export default function planPiExtension(pi) {
3097
3528
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3098
3529
  createdAt: now, updatedAt: now,
3099
3530
  handoff: "", handoffUpdatedAt: "",
3531
+ handoffReadAt: "",
3532
+ handoffHistory: [],
3533
+ statusLog: [],
3100
3534
  };
3101
3535
  await st.savePhase(phase);
3102
3536
  // Atomic: serialize concurrent phase_create linking to the same feature.
3103
3537
  await st.updateFeatures((doc) => {
3104
- const feature = doc.features.find((f) => f.id === params.featureId);
3538
+ const feature = doc.features.find((f) => f.id === featureId);
3105
3539
  if (feature && !feature.phaseIds.includes(phase.id)) {
3106
3540
  feature.phaseIds.push(phase.id);
3107
3541
  feature.updatedAt = now;
@@ -3112,7 +3546,8 @@ export default function planPiExtension(pi) {
3112
3546
  });
3113
3547
  if (!phase)
3114
3548
  return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
3115
- return { content: [{ type: "text", text: `Phase created: ${phase.id}` }], details: phase };
3549
+ const phaseCreateFeatures = (await st.loadFeatures()).features;
3550
+ return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3116
3551
  },
3117
3552
  });
3118
3553
  pi.registerTool({
@@ -3120,7 +3555,7 @@ export default function planPiExtension(pi) {
3120
3555
  label: "Phase Update",
3121
3556
  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
3557
  parameters: Type.Object({
3123
- phaseId: Type.String({ description: "Phase ID" }),
3558
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3124
3559
  title: Type.Optional(Type.String({ description: "New title" })),
3125
3560
  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
3561
  summary: Type.Optional(Type.String({ description: "New summary" })),
@@ -3147,14 +3582,20 @@ export default function planPiExtension(pi) {
3147
3582
  const st = await requirePlan(ctx);
3148
3583
  if (!st)
3149
3584
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3585
+ const features = (await st.loadFeatures()).features;
3586
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3587
+ if (!resolvedPhase)
3588
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3589
+ const phaseId = resolvedPhase.id;
3150
3590
  let phase;
3151
3591
  try {
3152
- phase = await st.loadPhase(params.phaseId);
3592
+ phase = await st.loadPhase(phaseId);
3153
3593
  }
3154
3594
  catch {
3155
3595
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3156
3596
  }
3157
3597
  const prevFeatureId = phase.featureId;
3598
+ let nextFeatureIdForUpdate = prevFeatureId;
3158
3599
  if (params.title !== undefined) {
3159
3600
  phase.title = params.title;
3160
3601
  phase.slug = normalizeSlug(params.title);
@@ -3184,14 +3625,22 @@ export default function planPiExtension(pi) {
3184
3625
  if (params.completionCriteria !== undefined)
3185
3626
  phase.completionCriteria = params.completionCriteria;
3186
3627
  if (params.featureId !== undefined) {
3187
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3188
- phase.featureId = nextFeatureId;
3189
- if (nextFeatureId !== prevFeatureId) {
3628
+ if (params.featureId === "") {
3629
+ nextFeatureIdForUpdate = undefined;
3630
+ }
3631
+ else {
3632
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3633
+ if (!resolvedFeature.ok)
3634
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3635
+ nextFeatureIdForUpdate = resolvedFeature.feature.id;
3636
+ }
3637
+ phase.featureId = nextFeatureIdForUpdate;
3638
+ if (nextFeatureIdForUpdate !== prevFeatureId) {
3190
3639
  await st.updateFeatures((doc) => {
3191
3640
  for (const f of doc.features) {
3192
3641
  if (f.id === prevFeatureId)
3193
3642
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3194
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3643
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3195
3644
  f.phaseIds.push(phase.id);
3196
3645
  }
3197
3646
  return doc;
@@ -3201,7 +3650,79 @@ export default function planPiExtension(pi) {
3201
3650
  phase.updatedAt = nowISO();
3202
3651
  await st.savePhase(phase);
3203
3652
  await st.writeGenerated();
3204
- return { content: [{ type: "text", text: `Phase updated: ${phase.id} (${phase.status})` }], details: phase };
3653
+ const phaseUpdateFeatures = (await st.loadFeatures()).features;
3654
+ return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3655
+ },
3656
+ });
3657
+ pi.registerTool({
3658
+ name: "decision_record",
3659
+ label: "Decision Record",
3660
+ description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
3661
+ parameters: Type.Object({
3662
+ featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
3663
+ phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
3664
+ title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
3665
+ decision: Type.String({ minLength: 1, description: "The agreed decision" }),
3666
+ rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
3667
+ implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
3668
+ }),
3669
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3670
+ const st = await requirePlan(ctx);
3671
+ if (!st)
3672
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3673
+ const features = (await st.loadFeatures()).features;
3674
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3675
+ if (!resolvedFeature.ok)
3676
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3677
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
3678
+ if (!resolvedPhase)
3679
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3680
+ if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
3681
+ return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
3682
+ }
3683
+ const acceptedAt = nowISO();
3684
+ const acceptedDecision = {
3685
+ id: crypto.randomUUID(),
3686
+ title: params.title.trim(),
3687
+ decision: params.decision.trim(),
3688
+ rationale: params.rationale.trim(),
3689
+ implementationNotes: params.implementationNotes.trim(),
3690
+ acceptedAt,
3691
+ };
3692
+ await withFeatureLock(resolvedFeature.feature.id, async () => {
3693
+ let featureWritten = false;
3694
+ try {
3695
+ await st.updateFeatures((doc) => {
3696
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3697
+ if (!feature)
3698
+ throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
3699
+ feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
3700
+ feature.updatedAt = acceptedAt;
3701
+ return doc;
3702
+ });
3703
+ featureWritten = true;
3704
+ await st.updatePhase(resolvedPhase.id, (phase) => {
3705
+ phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
3706
+ phase.updatedAt = acceptedAt;
3707
+ return phase;
3708
+ });
3709
+ }
3710
+ catch (error) {
3711
+ // Best-effort compensation keeps a failed dual-write from reporting
3712
+ // success with a decision persisted only on the feature.
3713
+ if (featureWritten) {
3714
+ await st.updateFeatures((doc) => {
3715
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3716
+ if (feature)
3717
+ feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
3718
+ return doc;
3719
+ }).catch(() => { });
3720
+ }
3721
+ throw error;
3722
+ }
3723
+ });
3724
+ await st.writeGenerated();
3725
+ return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
3205
3726
  },
3206
3727
  });
3207
3728
  pi.registerTool({
@@ -3209,25 +3730,30 @@ export default function planPiExtension(pi) {
3209
3730
  label: "Phase Delete",
3210
3731
  description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
3211
3732
  parameters: Type.Object({
3212
- phaseId: Type.String({ description: "Phase ID to delete" }),
3733
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
3213
3734
  }),
3214
3735
  async execute(_id, params, _signal, _onUpdate, ctx) {
3215
3736
  const st = await requirePlan(ctx);
3216
3737
  if (!st)
3217
3738
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3739
+ const features = (await st.loadFeatures()).features;
3740
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3741
+ if (!resolvedPhase)
3742
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3743
+ const phaseId = resolvedPhase.id;
3218
3744
  let phase;
3219
3745
  try {
3220
- phase = await st.loadPhase(params.phaseId);
3746
+ phase = await st.loadPhase(phaseId);
3221
3747
  }
3222
3748
  catch {
3223
3749
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3224
3750
  }
3225
- await st.deletePhase(params.phaseId);
3751
+ await st.deletePhase(phaseId);
3226
3752
  if (phase.featureId) {
3227
3753
  await st.updateFeatures((doc) => {
3228
3754
  const feature = doc.features.find((f) => f.id === phase.featureId);
3229
3755
  if (feature) {
3230
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
3756
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
3231
3757
  feature.updatedAt = nowISO();
3232
3758
  }
3233
3759
  return doc;
@@ -3241,42 +3767,64 @@ export default function planPiExtension(pi) {
3241
3767
  pi.registerTool({
3242
3768
  name: "task_list",
3243
3769
  label: "Task List",
3244
- description: "List tasks of a phase with id, status, and title.",
3770
+ 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
3771
  parameters: Type.Object({
3246
- phaseId: Type.String({ description: "Phase ID" }),
3772
+ featureRef: Type.Optional(Type.String({})),
3773
+ phaseRef: Type.Optional(Type.String({})),
3774
+ status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
3247
3775
  }),
3248
3776
  async execute(_id, params, _signal, _onUpdate, ctx) {
3249
3777
  const st = await requirePlan(ctx);
3250
3778
  if (!st)
3251
3779
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3252
- let phase;
3253
- try {
3254
- phase = await st.loadPhase(params.phaseId);
3780
+ const features = (await st.loadFeatures()).features;
3781
+ let phases = await st.loadAllPhases();
3782
+ const fref = params.featureRef?.trim();
3783
+ if (fref) {
3784
+ const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
3785
+ if (!f)
3786
+ return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
3787
+ phases = phases.filter((p) => p.featureId === f.id);
3255
3788
  }
3256
- catch {
3257
- return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3789
+ const pref = params.phaseRef?.trim();
3790
+ if (pref) {
3791
+ const p = findPhaseByRef(phases, features, pref);
3792
+ if (!p)
3793
+ return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
3794
+ phases = [p];
3258
3795
  }
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 } };
3796
+ const out = [];
3797
+ for (const phase of phases) {
3798
+ for (const task of phase.tasks) {
3799
+ if (params.status && task.status !== params.status)
3800
+ continue;
3801
+ out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
3802
+ }
3803
+ }
3804
+ return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
3261
3805
  },
3262
3806
  });
3263
3807
  pi.registerTool({
3264
3808
  name: "task_get",
3265
3809
  label: "Task Get",
3266
- description: "Read full details of a single task.",
3810
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
3267
3811
  parameters: Type.Object({
3268
- taskId: Type.String({ description: "Task ID" }),
3812
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3813
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
3269
3814
  }),
3270
3815
  async execute(_id, params, _signal, _onUpdate, ctx) {
3271
3816
  const st = await requirePlan(ctx);
3272
3817
  if (!st)
3273
3818
  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)
3819
+ const features = (await st.loadFeatures()).features;
3820
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3821
+ if (!found)
3278
3822
  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 } };
3823
+ 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})`;
3824
+ if (!params.full)
3825
+ return { content: [{ type: "text", text: summary }], details: {} };
3826
+ const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3827
+ return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
3280
3828
  },
3281
3829
  });
3282
3830
  pi.registerTool({
@@ -3284,32 +3832,52 @@ export default function planPiExtension(pi) {
3284
3832
  label: "Task Create",
3285
3833
  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
3834
  parameters: Type.Object({
3835
+ featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
3287
3836
  phaseId: Type.String({ description: "Phase ID the task belongs to" }),
3288
3837
  title: Type.String({ description: "Task title" }),
3289
3838
  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
3839
  status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
3291
3840
  shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
3841
+ checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
3292
3842
  }),
3293
3843
  async execute(_id, params, _signal, _onUpdate, ctx) {
3294
3844
  const st = await requirePlan(ctx);
3295
3845
  if (!st)
3296
3846
  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 {
3847
+ if (!params.featureId?.trim())
3848
+ return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3849
+ const features = (await st.loadFeatures()).features;
3850
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
3851
+ if (!resolvedFeature.ok)
3852
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3853
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
3854
+ if (!featureValidation.ok)
3855
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3856
+ const feature = resolvedFeature.feature;
3857
+ const existingFeature = features.find((f) => f.id === feature.id);
3858
+ if (!existingFeature)
3859
+ return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
3860
+ // Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
3861
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3862
+ if (!resolvedPhase)
3302
3863
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3303
- }
3864
+ if (resolvedPhase.featureId !== feature.id)
3865
+ 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: {} };
3866
+ const phaseId = resolvedPhase.id;
3867
+ const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
3868
+ if (!phaseValidation.ok)
3869
+ return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
3870
+ const existingPhase = await st.loadPhase(phaseId);
3871
+ if (!existingPhase)
3872
+ return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3304
3873
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3305
3874
  const taskId = createTaskId();
3306
3875
  const shortId = createShortId(await st.assignedShortIds());
3307
- const priority = await st.nextPriority("task", params.phaseId);
3876
+ const priority = await st.nextPriority("task", phaseId);
3308
3877
  const now = nowISO();
3309
3878
  const initialStatus = params.status ?? "planned";
3310
- const existingPhase = await st.loadPhase(params.phaseId);
3311
3879
  const task = {
3312
- id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortId, priority, shortName,
3880
+ id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3313
3881
  title: params.title,
3314
3882
  status: initialStatus,
3315
3883
  description: params.description ?? "",
@@ -3317,7 +3885,10 @@ export default function planPiExtension(pi) {
3317
3885
  statusLog: [],
3318
3886
  decisions: [],
3319
3887
  acceptedDecisions: [],
3320
- checklist: [], subtasks: [],
3888
+ checklist: params.checklist
3889
+ ? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
3890
+ : [],
3891
+ subtasks: [],
3321
3892
  dependsOn: [],
3322
3893
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3323
3894
  completedAt: initialStatus === "done" ? now : "",
@@ -3325,14 +3896,15 @@ export default function planPiExtension(pi) {
3325
3896
  };
3326
3897
  // Atomic read-modify-write on the phase file: serializes concurrent
3327
3898
  // task_create calls on the SAME phaseId (batch) so tasks don't get lost.
3328
- await st.updatePhase(params.phaseId, (phase) => {
3899
+ await st.updatePhase(phaseId, (phase) => {
3329
3900
  phase.tasks.push(task);
3330
3901
  phase.taskIds.push(taskId);
3331
3902
  phase.updatedAt = now;
3332
3903
  return phase;
3333
3904
  });
3334
3905
  await st.writeGenerated();
3335
- return { content: [{ type: "text", text: `Task created: ${taskId}` }], details: task };
3906
+ const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
3907
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
3336
3908
  },
3337
3909
  });
3338
3910
  pi.registerTool({
@@ -3340,7 +3912,7 @@ export default function planPiExtension(pi) {
3340
3912
  label: "Task Update",
3341
3913
  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
3914
  parameters: Type.Object({
3343
- taskId: Type.String({ description: "Task ID" }),
3915
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3344
3916
  title: Type.Optional(Type.String({ description: "New title" })),
3345
3917
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
3346
3918
  description: Type.Optional(Type.String({ description: "New description" })),
@@ -3363,15 +3935,29 @@ export default function planPiExtension(pi) {
3363
3935
  if (!st)
3364
3936
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3365
3937
  // 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)
3938
+ const features = (await st.loadFeatures()).features;
3939
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3940
+ if (!found)
3369
3941
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3942
+ const hostPhase = found.phase;
3370
3943
  const now = nowISO();
3371
3944
  let updatedTask;
3945
+ // Validate the motivation requirement for restrictive status transitions,
3946
+ // mirroring the MCP adapter and the /planner task update command
3947
+ // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
3948
+ if (params.status !== undefined &&
3949
+ params.status !== found.task.status &&
3950
+ needsMotivation(found.task.status, params.status)) {
3951
+ if (!params.motivation || !params.motivation.trim()) {
3952
+ return {
3953
+ content: [{ type: "text", text: `Status transition "${found.task.status} → ${params.status}" requires a motivation. Provide the "motivation" parameter with a detailed explanation of why this change is needed.` }],
3954
+ details: {},
3955
+ };
3956
+ }
3957
+ }
3372
3958
  try {
3373
3959
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3374
- const task = phase.tasks.find((t) => t.id === params.taskId);
3960
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3375
3961
  if (!task)
3376
3962
  return phase;
3377
3963
  if (params.title !== undefined)
@@ -3387,7 +3973,7 @@ export default function planPiExtension(pi) {
3387
3973
  if (params.acceptedDecisions !== undefined)
3388
3974
  task.acceptedDecisions = params.acceptedDecisions;
3389
3975
  if (params.checklist !== undefined) {
3390
- task.checklist = params.checklist.map((itemTitle, index) => ({
3976
+ task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
3391
3977
  id: createChecklistItemId(task.id, index + 1, itemTitle),
3392
3978
  title: itemTitle,
3393
3979
  checked: false,
@@ -3419,6 +4005,7 @@ export default function planPiExtension(pi) {
3419
4005
  }
3420
4006
  if (!updatedTask)
3421
4007
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4008
+ await st.syncTaskStatusRollup(found.phase.id);
3422
4009
  await st.writeGenerated();
3423
4010
  return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
3424
4011
  },
@@ -3428,19 +4015,19 @@ export default function planPiExtension(pi) {
3428
4015
  label: "Task Delete",
3429
4016
  description: "Delete a task from its phase.",
3430
4017
  parameters: Type.Object({
3431
- taskId: Type.String({ description: "Task ID to delete" }),
4018
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
3432
4019
  }),
3433
4020
  async execute(_id, params, _signal, _onUpdate, ctx) {
3434
4021
  const st = await requirePlan(ctx);
3435
4022
  if (!st)
3436
4023
  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)
4024
+ const features = (await st.loadFeatures()).features;
4025
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4026
+ if (!found)
3440
4027
  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);
4028
+ await st.updatePhase(found.phase.id, (phase) => {
4029
+ phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
4030
+ phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
3444
4031
  phase.updatedAt = nowISO();
3445
4032
  return phase;
3446
4033
  });
@@ -3449,30 +4036,99 @@ export default function planPiExtension(pi) {
3449
4036
  },
3450
4037
  });
3451
4038
  // ── task start / complete tools ──────────────────────────────────
4039
+ pi.registerTool({
4040
+ name: "task_recommend",
4041
+ label: "Task Recommend",
4042
+ description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4043
+ parameters: Type.Object({}),
4044
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
4045
+ const st = await requirePlan(ctx);
4046
+ if (!st)
4047
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4048
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4049
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
4050
+ if (!result.candidate)
4051
+ return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4052
+ const { candidate } = result;
4053
+ const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
4054
+ return {
4055
+ content: [{ type: "text", text: `Recommended (${result.kind}): ${reference} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}` }],
4056
+ details: result,
4057
+ };
4058
+ },
4059
+ });
4060
+ pi.registerTool({
4061
+ name: "task_deviation",
4062
+ label: "Task Deviation",
4063
+ description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task but never starts, pauses, or blocks work; use normal task lifecycle tools for those transitions.",
4064
+ parameters: Type.Object({
4065
+ temporary_task: Type.String({ description: "Temporary task ref" }),
4066
+ resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
4067
+ reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
4068
+ }),
4069
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4070
+ const st = await requirePlan(ctx);
4071
+ if (!st)
4072
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4073
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4074
+ const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4075
+ if (!temporary)
4076
+ return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4077
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
4078
+ const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4079
+ if (!resume)
4080
+ return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
4081
+ if (temporary.task.id === resume.task.id)
4082
+ return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
4083
+ if (temporary.task.status !== "planned")
4084
+ return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
4085
+ const timestamp = nowISO();
4086
+ const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason: params.reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
4087
+ await st.addWorkDeviation(record);
4088
+ await st.writeGenerated();
4089
+ const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
4090
+ const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
4091
+ return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
4092
+ },
4093
+ });
3452
4094
  pi.registerTool({
3453
4095
  name: "task_start",
3454
4096
  label: "Task Start",
3455
- description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
4097
+ description: "Set a task to in-progress. BEFORE calling, read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true). Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Sets startedAt automatically and returns the ordered feature/phase requirements briefing.",
3456
4098
  parameters: Type.Object({
3457
- taskId: Type.String({ description: "Task ID to start" }),
4099
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3458
4100
  }),
3459
4101
  async execute(_id, params, _signal, _onUpdate, ctx) {
3460
4102
  const st = await requirePlan(ctx);
3461
4103
  if (!st)
3462
4104
  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)
4105
+ const features = (await st.loadFeatures()).features;
4106
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4107
+ if (!found)
3467
4108
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4109
+ const task = found.task;
3468
4110
  if (task.status === "in-progress")
3469
4111
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3470
4112
  if (task.status === "done")
3471
4113
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4114
+ const project = await st.loadProject();
4115
+ const selection = recommendNextTask(features, await st.loadAllPhases(), project.workDeviations);
4116
+ if (!selection.candidate || selection.candidate.task.id !== task.id) {
4117
+ const recommended = selection.candidate ? ` Start ${selection.candidate.task.id} instead, or record a user-approved task_deviation first.` : "";
4118
+ return { content: [{ type: "text", text: `Task start denied: ${selection.reason}.${recommended}` }], details: selection };
4119
+ }
4120
+ // Assemble the mandatory parent context before changing task lifecycle state.
4121
+ // The output order is feature + its requirements, then phase + its requirements.
4122
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
4123
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
4124
+ st.loadPhaseWithRequirements(found.phase.id),
4125
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
4126
+ ]);
4127
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
3472
4128
  const now = nowISO();
3473
4129
  let startedTask;
3474
- await st.updatePhase(hostPhase.id, (phase) => {
3475
- const t = phase.tasks.find((x) => x.id === params.taskId);
4130
+ await st.updatePhase(found.phase.id, (phase) => {
4131
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3476
4132
  if (!t)
3477
4133
  return phase;
3478
4134
  applyTaskLifecycleDates(t, "in-progress", now);
@@ -3481,10 +4137,14 @@ export default function planPiExtension(pi) {
3481
4137
  startedTask = t;
3482
4138
  return phase;
3483
4139
  });
4140
+ await st.syncTaskStatusRollup(found.phase.id);
4141
+ if (selection.deviation?.temporaryTaskId === task.id && selection.deviation.state === "approved") {
4142
+ await st.setWorkDeviationState(selection.deviation.id, "active", now);
4143
+ }
3484
4144
  await st.writeGenerated();
3485
4145
  if (!startedTask)
3486
4146
  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 };
4147
+ return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
3488
4148
  },
3489
4149
  });
3490
4150
  pi.registerTool({
@@ -3492,7 +4152,7 @@ export default function planPiExtension(pi) {
3492
4152
  label: "Task Complete",
3493
4153
  description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
3494
4154
  parameters: Type.Object({
3495
- taskId: Type.String({ description: "Task ID to complete" }),
4155
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
3496
4156
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
3497
4157
  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
4158
  }),
@@ -3500,11 +4160,11 @@ export default function planPiExtension(pi) {
3500
4160
  const st = await requirePlan(ctx);
3501
4161
  if (!st)
3502
4162
  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)
4163
+ const features = (await st.loadFeatures()).features;
4164
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4165
+ if (!found)
3507
4166
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4167
+ const task = found.task;
3508
4168
  if (task.status === "done")
3509
4169
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
3510
4170
  const unchecked = task.checklist.filter((item) => !item.checked);
@@ -3516,8 +4176,8 @@ export default function planPiExtension(pi) {
3516
4176
  }
3517
4177
  const now = nowISO();
3518
4178
  let completedTask;
3519
- await st.updatePhase(hostPhase.id, (phase) => {
3520
- const t = phase.tasks.find((x) => x.id === params.taskId);
4179
+ await st.updatePhase(found.phase.id, (phase) => {
4180
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3521
4181
  if (!t)
3522
4182
  return phase;
3523
4183
  applyTaskLifecycleDates(t, "done", now);
@@ -3530,10 +4190,135 @@ export default function planPiExtension(pi) {
3530
4190
  completedTask = t;
3531
4191
  return phase;
3532
4192
  });
4193
+ const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
4194
+ const completedDeviation = (await st.loadProject()).workDeviations
4195
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
4196
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4197
+ if (completedDeviation)
4198
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
3533
4199
  await st.writeGenerated();
3534
4200
  if (!completedTask)
3535
4201
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3536
- return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
4202
+ return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
4203
+ },
4204
+ });
4205
+ pi.registerTool({
4206
+ name: "task_checklist_toggle",
4207
+ label: "Task Checklist Toggle",
4208
+ 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.",
4209
+ parameters: Type.Object({
4210
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4211
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4212
+ checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
4213
+ }),
4214
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4215
+ const st = await requirePlan(ctx);
4216
+ if (!st)
4217
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4218
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4219
+ if (!found)
4220
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4221
+ let result = "";
4222
+ await st.updatePhase(found.phase.id, (phase) => {
4223
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4224
+ if (!task) {
4225
+ result = `Task not found: ${params.taskId}`;
4226
+ return phase;
4227
+ }
4228
+ const ck = task.checklist ?? [];
4229
+ if (ck.length === 0) {
4230
+ result = `Task "${found.task.title}" has no checklist.`;
4231
+ return phase;
4232
+ }
4233
+ const target = toggleChecklistItem(ck, params.item, params.checked);
4234
+ if (!target) {
4235
+ result = `No checklist item matching "${params.item}".`;
4236
+ return phase;
4237
+ }
4238
+ task.updatedAt = nowISO();
4239
+ phase.updatedAt = nowISO();
4240
+ const doneCount = ck.filter((i) => i.checked).length;
4241
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
4242
+ return phase;
4243
+ });
4244
+ await st.writeGenerated();
4245
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4246
+ },
4247
+ });
4248
+ pi.registerTool({
4249
+ name: "task_checklist_add",
4250
+ label: "Task Checklist Add",
4251
+ 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.",
4252
+ parameters: Type.Object({
4253
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4254
+ title: Type.String({ description: "Checklist item text (a single step)" }),
4255
+ }),
4256
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4257
+ const st = await requirePlan(ctx);
4258
+ if (!st)
4259
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4260
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4261
+ if (!found)
4262
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4263
+ let result = "";
4264
+ await st.updatePhase(found.phase.id, (phase) => {
4265
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4266
+ if (!task) {
4267
+ result = `Task not found: ${params.taskId}`;
4268
+ return phase;
4269
+ }
4270
+ const ck = task.checklist ?? [];
4271
+ const item = addChecklistItem(ck, task.id, params.title);
4272
+ ck.push(item);
4273
+ task.checklist = ck;
4274
+ task.updatedAt = nowISO();
4275
+ phase.updatedAt = nowISO();
4276
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
4277
+ return phase;
4278
+ });
4279
+ await st.writeGenerated();
4280
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4281
+ },
4282
+ });
4283
+ pi.registerTool({
4284
+ name: "task_checklist_remove",
4285
+ label: "Task Checklist Remove",
4286
+ 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.",
4287
+ parameters: Type.Object({
4288
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4289
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4290
+ }),
4291
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4292
+ const st = await requirePlan(ctx);
4293
+ if (!st)
4294
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4295
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4296
+ if (!found)
4297
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4298
+ let result = "";
4299
+ await st.updatePhase(found.phase.id, (phase) => {
4300
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4301
+ if (!task) {
4302
+ result = `Task not found: ${params.taskId}`;
4303
+ return phase;
4304
+ }
4305
+ const ck = task.checklist ?? [];
4306
+ if (ck.length === 0) {
4307
+ result = `Task "${found.task.title}" has no checklist.`;
4308
+ return phase;
4309
+ }
4310
+ const removed = removeChecklistItem(ck, params.item);
4311
+ if (!removed) {
4312
+ result = `No checklist item matching "${params.item}".`;
4313
+ return phase;
4314
+ }
4315
+ task.updatedAt = nowISO();
4316
+ phase.updatedAt = nowISO();
4317
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
4318
+ return phase;
4319
+ });
4320
+ await st.writeGenerated();
4321
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
3537
4322
  },
3538
4323
  });
3539
4324
  // ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
@@ -3610,7 +4395,7 @@ export default function planPiExtension(pi) {
3610
4395
  description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
3611
4396
  parameters: Type.Object({}),
3612
4397
  async execute(_id, _params, _signal, _onUpdate, _ctx) {
3613
- plannerSessionEnabled = false;
4398
+ disablePlannerSession();
3614
4399
  await stopServer().catch(() => { });
3615
4400
  try {
3616
4401
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -3632,6 +4417,7 @@ export default function planPiExtension(pi) {
3632
4417
  // add seconds of latency on every turn if run unconditionally.
3633
4418
  if (!plannerHeavyInitDone) {
3634
4419
  await migrateToUuids(st);
4420
+ await migrateToGlobalSequence(st).catch(() => null);
3635
4421
  await st.ensureShortIdsAndPriority().catch(() => null);
3636
4422
  await ensureProjectLanguagePreferences(st).catch(() => null);
3637
4423
  await maybeHealStatuses(st, ctx);
@@ -3664,8 +4450,9 @@ export default function planPiExtension(pi) {
3664
4450
  const profile = await st.loadCodebaseProfile();
3665
4451
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3666
4452
  const activity = await st.loadActivityLog();
3667
- const recentActivity = activity.entries.slice(-8).reverse();
4453
+ const recentActivity = activity.entries.slice(-3).reverse();
3668
4454
  const handoffs = await st.listHandoffs();
4455
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3669
4456
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3670
4457
  const orderedPhases = [
3671
4458
  ...plan.features.features.flatMap((feature) => {
@@ -3678,21 +4465,9 @@ export default function planPiExtension(pi) {
3678
4465
  ...plan.phases.filter((phase) => !phase.featureId)
3679
4466
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3680
4467
  ];
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;
4468
+ const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
4469
+ || plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
4470
+ || orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
3696
4471
  const currentPhase = hasActiveWork
3697
4472
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3698
4473
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3709,16 +4484,51 @@ export default function planPiExtension(pi) {
3709
4484
  ?? null)
3710
4485
  : null;
3711
4486
  const currentFeature = hasActiveWork && currentPhase?.featureId
3712
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4487
+ ? featureById.get(currentPhase.featureId) ?? null
3713
4488
  : hasActiveWork
3714
4489
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3715
4490
  : 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.");
4491
+ const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
4492
+ const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
4493
+ const currentTaskRef = currentTask && currentPhase
4494
+ ? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
4495
+ : "";
4496
+ const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
4497
+ const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
4498
+ for (const phase of plan.phases) {
4499
+ phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
4500
+ }
4501
+ const phaseCountsSummary = phaseStatusOrder
4502
+ .map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
4503
+ .join(" ");
4504
+ const activeTaskMap = new Map();
4505
+ for (const phase of orderedPhases) {
4506
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4507
+ for (const task of phase.tasks) {
4508
+ if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
4509
+ continue;
4510
+ activeTaskMap.set(task.id, { phase, task, featureNumber });
4511
+ }
4512
+ }
4513
+ const inProgressTaskLines = [...activeTaskMap.values()]
4514
+ .sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
4515
+ .map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
4516
+ const otherActiveOrBlockedPhases = orderedPhases
4517
+ .filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
4518
+ .slice(0, 6)
4519
+ .map((phase) => {
4520
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4521
+ return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
4522
+ });
4523
+ const openQuestions = plan.phases
4524
+ .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
4525
+ .slice(0, 8);
4526
+ const acceptedDecisions = [
4527
+ ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
4528
+ ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
4529
+ ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
4530
+ ...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}`))),
4531
+ ].slice(0, 20);
3722
4532
  const webServer = server;
3723
4533
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3724
4534
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3731,15 +4541,6 @@ export default function planPiExtension(pi) {
3731
4541
  catch { }
3732
4542
  const webUrl = webLocalUrl;
3733
4543
  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
4544
  const pkgSummary = profile?.packageJson
3744
4545
  ? `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
4546
  : "(no package.json)";
@@ -3761,96 +4562,63 @@ export default function planPiExtension(pi) {
3761
4562
  // Build/refresh the context block (slow path: cache miss).
3762
4563
  const contextBlock = [
3763
4564
  `[Plan Context — ${project.name}]`,
3764
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
3765
4565
  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
4566
  "",
3767
- // ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
3768
- "",
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
- "═══════════════════════════════════════════════════════════════",
4567
+ "Operational rules:",
4568
+ "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4569
+ "- BEFORE work: read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true); only then call task_start before any code edit/write. AFTER finishing a task: task_complete.",
4570
+ "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4571
+ "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4572
+ "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4573
+ "- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4574
+ "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
3779
4575
  "",
3780
- // ── Project details ──
3781
4576
  `Goal: ${project.goal || "(not set)"}`,
3782
4577
  project.description ? `Description: ${project.description}` : "",
3783
4578
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
3784
4579
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
3785
4580
  `Ambient: ${ambientSummary}`,
3786
4581
  "",
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)"}`,
4582
+ "Global rules:",
4583
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
3794
4584
  "",
4585
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
3795
4586
  `Scope: ${project.scope.join(", ") || "(none)"}`,
3796
4587
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
3797
4588
  "",
3798
4589
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
3799
- ...acceptedDecisions.map((d) => `- ${d}`),
4590
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
3800
4591
  openQuestions.length ? "" : "",
3801
4592
  openQuestions.length ? "Open questions:" : "",
3802
- ...openQuestions.map((q) => `- ${q}`),
4593
+ ...openQuestions.map((question) => `- ${question}`),
3803
4594
  "",
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)",
4595
+ "Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
4596
+ currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
4597
+ currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
4598
+ currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
4599
+ inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
3807
4600
  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}` : "",
4601
+ handoffs.length === 0 && resume.nextSteps.length
4602
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4603
+ : "",
4604
+ "",
4605
+ "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
4606
  "",
3811
- recentActivity.length ? "Recent activity (most recent first):" : "",
3812
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
4607
+ "Phase overview:",
4608
+ ` counts: ${phaseCountsSummary}`,
4609
+ currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
4610
+ otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
4611
+ ...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
3813
4612
  "",
3814
- `${plan.phases.length} phases defined.`,
3815
- "Current phases:",
3816
- phaseLines || " _none_",
3817
- active ? `Active: ${active}` : "",
4613
+ recentActivity.length ? "Recent activity:" : "",
4614
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
3818
4615
  "",
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.",
4616
+ handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
4617
+ ...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
4618
+ 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
4619
  "",
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",
4620
+ `Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
4621
+ "Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
3854
4622
  ].filter(Boolean).join("\n");
3855
4623
  contextBlockCache = contextBlock;
3856
4624
  contextBlockDirty = false;