@agent-plan/pi-adapter 0.2.19-next.9 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * - inject context into prompts
10
10
  */
11
11
  import { Type } from "typebox";
12
- import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, 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
- "- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/handoff-archive/) or the phase completes. No need to clear it manually.",
461
+ "- This handoff remains active until every task in this phase is done/canceled, a newer handoff replaces it, or the user explicitly clears it. Reading or starting a task never clears it.",
420
462
  ].join("\n");
421
463
  }
422
464
  async function writeProjectHandoff(st, reason, overrides) {
@@ -620,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
620
662
  return;
621
663
  const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
622
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;
623
668
  try {
624
669
  server = await serve({
625
670
  port,
@@ -632,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
632
677
  });
633
678
  }
634
679
  catch (e) {
635
- // EADDRINUSE or other listen failure: retry on a random free port.
636
- if (e instanceof Error && /EADDRINUSE|EACCES|EADDRNOTAVAIL/.test(e.message)) {
637
- try {
638
- server = await serve({
639
- port: 0,
640
- host,
641
- planRoot: resolvePlanRoot(ctx.cwd),
642
- staticDir: resolveStaticDir(),
643
- quiet: true,
644
- uiConfig: readUiConfig(ctx),
645
- isBusy: () => planBusy,
646
- });
647
- ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
648
- }
649
- catch (e2) {
650
- ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
651
- }
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");
652
697
  }
653
- else {
654
- 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");
655
700
  }
656
701
  }
657
702
  if (server) {
@@ -684,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
684
729
  catch { }
685
730
  }
686
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
+ }
687
743
  async function stopServer() {
688
744
  if (server) {
689
745
  await server.close();
690
746
  server = null;
691
747
  }
692
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;
693
752
  }
694
753
  // ─── Extension factory ──────────────────────────────────────────────────
695
754
  export default function planPiExtension(pi) {
@@ -708,6 +767,9 @@ export default function planPiExtension(pi) {
708
767
  }
709
768
  const argPrefix = match[2];
710
769
  const prefix = argPrefix === undefined ? match[1] : argPrefix;
770
+ if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
771
+ return null;
772
+ }
711
773
  const normalized = (argPrefix ?? "").trimStart().toLowerCase();
712
774
  const items = PLANNER_COMMAND_COMPLETIONS
713
775
  .filter((item) => !normalized || item.value.startsWith(normalized))
@@ -747,9 +809,10 @@ export default function planPiExtension(pi) {
747
809
  // ── Step 1: Enable gating ───────────────────────────────────────
748
810
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
749
811
  // render for NPM-loaded ESM extensions during session_start (they hang
750
- // until the safety-net timeout). The planner is always DISABLED at
751
- // startup; the user enables it with '/planner load' (which also starts
752
- // 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).
753
816
  let enablePlanner = false;
754
817
  if (exists) {
755
818
  ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
@@ -760,8 +823,8 @@ export default function planPiExtension(pi) {
760
823
  const handoffs = await st.listHandoffs().catch(() => []);
761
824
  if (handoffs.length > 0) {
762
825
  ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
763
- // Mark each handoff as read (recap presented). Do NOT clear — content is
764
- // kept until a task in the phase starts (auto-archived) or phase completes.
826
+ // Mark each handoff as read (recap presented). Do NOT clear — reading/load
827
+ // is non-mutating; only phase completion, replacement, or explicit clear archives it.
765
828
  for (const h of handoffs)
766
829
  await st.markHandoffRead(h.phaseId).catch(() => { });
767
830
  }
@@ -931,8 +994,45 @@ export default function planPiExtension(pi) {
931
994
  });
932
995
  // ── Commands ───────────────────────────────────────────────────────
933
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
+ }
934
1025
  async function handlePlanner(args, ctx) {
935
- 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
+ }
936
1036
  }
937
1037
  async function runPlanner(args, ctx) {
938
1038
  const parts = args.trim().split(/\s+/).filter(Boolean);
@@ -942,6 +1042,7 @@ export default function planPiExtension(pi) {
942
1042
  "init",
943
1043
  "show",
944
1044
  "repair",
1045
+ "cleanup-orphans",
945
1046
  "project discuss",
946
1047
  "project language",
947
1048
  "feature list",
@@ -975,9 +1076,9 @@ export default function planPiExtension(pi) {
975
1076
  "load",
976
1077
  "stop",
977
1078
  ];
978
- const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
979
- "Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
980
- "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)";
981
1082
  if (!a) {
982
1083
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
983
1084
  if (!action) {
@@ -997,6 +1098,14 @@ export default function planPiExtension(pi) {
997
1098
  return;
998
1099
  }
999
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 || ""}]`);
1000
1109
  ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
1001
1110
  if (server) {
1002
1111
  ctx.ui.notify("Stopping active web server...", "info");
@@ -1004,12 +1113,6 @@ export default function planPiExtension(pi) {
1004
1113
  server = null;
1005
1114
  }
1006
1115
  await rm(st.root, { recursive: true, force: true });
1007
- const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
1008
- if (!name?.trim()) {
1009
- ctx.ui.notify("Aborted", "warning");
1010
- return;
1011
- }
1012
- const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
1013
1116
  await st.init(name.trim());
1014
1117
  const project = await st.loadProject();
1015
1118
  project.description = description?.trim() ?? oldProject.description;
@@ -1132,22 +1235,33 @@ export default function planPiExtension(pi) {
1132
1235
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1133
1236
  ?? null;
1134
1237
  }
1135
- function findPhaseByRef(phases, ref) {
1136
- const normalized = ref.trim().toLowerCase();
1137
- if (!normalized)
1138
- return null;
1139
- const pMatch = normalized.match(/^p(\d+)$/);
1140
- if (pMatch) {
1141
- const n = parseInt(pMatch[1], 10);
1142
- const byNum = phases.find((p) => p.number === n);
1143
- if (byNum)
1144
- return byNum;
1145
- }
1146
- return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1147
- ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1148
- ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1149
- ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1150
- ?? 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}` };
1151
1265
  }
1152
1266
  function profile_packageManager(pkg, lockfile) {
1153
1267
  if (pkg?.packageManager)
@@ -1162,7 +1276,7 @@ export default function planPiExtension(pi) {
1162
1276
  return "npm";
1163
1277
  return "";
1164
1278
  }
1165
- async function scanCodebase() {
1279
+ async function scanCodebase(persist = true) {
1166
1280
  const root = ctx.cwd;
1167
1281
  const rootFiles = [];
1168
1282
  const directories = [];
@@ -1244,7 +1358,8 @@ export default function planPiExtension(pi) {
1244
1358
  tree,
1245
1359
  ambient,
1246
1360
  };
1247
- await st.saveCodebaseProfile(profile);
1361
+ if (persist)
1362
+ await st.saveCodebaseProfile(profile);
1248
1363
  return profile;
1249
1364
  }
1250
1365
  // ═══════════════════════════════════════════════════════════════
@@ -1256,8 +1371,8 @@ export default function planPiExtension(pi) {
1256
1371
  return;
1257
1372
  }
1258
1373
  if (b === "discuss") {
1259
- const project = await ensureProjectLanguagePreferences(st);
1260
- const profile = await scanCodebase();
1374
+ const project = await ensureProjectLanguagePreferences(st, false);
1375
+ const profile = await scanCodebase(false);
1261
1376
  ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
1262
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");
1263
1378
  const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
@@ -1284,6 +1399,7 @@ export default function planPiExtension(pi) {
1284
1399
  : project.acceptedDecisions,
1285
1400
  };
1286
1401
  await st.saveProject(draftProject);
1402
+ await st.saveCodebaseProfile(profile);
1287
1403
  await st.writeGenerated();
1288
1404
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
1289
1405
  if (capturedPi) {
@@ -1321,7 +1437,7 @@ export default function planPiExtension(pi) {
1321
1437
  // ═══════════════════════════════════════════════════════════════
1322
1438
  if (a === "feature") {
1323
1439
  if (!b) {
1324
- 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");
1325
1441
  return;
1326
1442
  }
1327
1443
  if (b === "list") {
@@ -1353,16 +1469,17 @@ export default function planPiExtension(pi) {
1353
1469
  return;
1354
1470
  }
1355
1471
  const now = nowISO();
1356
- const featureNumber = await st.allocFeatureNumber();
1357
- const shortId = createShortId(await st.assignedShortIds());
1472
+ const id = createFeatureId();
1473
+ const identity = await st.allocateEntityIdentity("feature", id);
1358
1474
  const priority = await st.nextPriority("feature");
1359
1475
  const feature = {
1360
- id: createFeatureId(),
1361
- number: featureNumber,
1362
- shortId,
1476
+ id,
1477
+ number: identity.number,
1478
+ shortId: identity.shortId,
1363
1479
  priority,
1364
1480
  name: nameInput.trim(),
1365
1481
  description: description?.trim() ?? "",
1482
+ descriptionUpdatedAt: now,
1366
1483
  status: status,
1367
1484
  discussedAt: "",
1368
1485
  contextReady: false,
@@ -1374,6 +1491,7 @@ export default function planPiExtension(pi) {
1374
1491
  acceptedDecisions: [],
1375
1492
  phaseIds: [],
1376
1493
  dependsOn: [],
1494
+ statusLog: [],
1377
1495
  createdAt: now,
1378
1496
  updatedAt: now,
1379
1497
  };
@@ -1411,6 +1529,44 @@ export default function planPiExtension(pi) {
1411
1529
  ].filter(Boolean).join("\n"), "info");
1412
1530
  return;
1413
1531
  }
1532
+ if (b === "discuss") {
1533
+ const featuresDoc = await st.loadFeatures();
1534
+ let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
1535
+ if (!feature) {
1536
+ feature = await pickFeature();
1537
+ if (!feature) {
1538
+ ctx.ui.notify("No features to discuss. Create one first.", "warning");
1539
+ return;
1540
+ }
1541
+ }
1542
+ const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
1543
+ const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
1544
+ const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
1545
+ const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
1546
+ const featureId = feature.id;
1547
+ const updatedDoc = await st.updateFeatures((doc) => {
1548
+ const target = doc.features.find((entry) => entry.id === featureId);
1549
+ if (!target)
1550
+ return doc;
1551
+ if (description !== undefined)
1552
+ target.description = description.trim();
1553
+ if (workDone !== undefined)
1554
+ target.workDone = workDone.trim();
1555
+ if (workRemaining !== undefined)
1556
+ target.workRemaining = workRemaining.trim();
1557
+ if (dependencies !== undefined)
1558
+ target.dependsOn = splitCsv(dependencies);
1559
+ target.discussedAt = nowISO();
1560
+ target.contextReady = true;
1561
+ target.contextReadyReason = "Updated through /planner feature discuss.";
1562
+ target.updatedAt = nowISO();
1563
+ return doc;
1564
+ });
1565
+ feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1566
+ await st.writeGenerated();
1567
+ ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1568
+ return;
1569
+ }
1414
1570
  if (b === "update") {
1415
1571
  const featuresDoc = await st.loadFeatures();
1416
1572
  let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
@@ -1509,7 +1665,7 @@ export default function planPiExtension(pi) {
1509
1665
  ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1510
1666
  return;
1511
1667
  }
1512
- ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
1668
+ ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
1513
1669
  return;
1514
1670
  }
1515
1671
  // ═══════════════════════════════════════════════════════════════
@@ -1536,23 +1692,27 @@ export default function planPiExtension(pi) {
1536
1692
  ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
1537
1693
  return;
1538
1694
  }
1695
+ // This belongs to the creation transaction: Escape before the phase
1696
+ // is persisted cancels the whole command rather than creating a phase
1697
+ // and only then abandoning the follow-up question.
1698
+ const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1539
1699
  let phase;
1540
1700
  await withFeatureLock(feature.id, async () => {
1541
1701
  const phases = await st.loadAllPhases();
1542
1702
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1543
- const number = await st.allocPhaseNumber();
1544
1703
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1545
1704
  const id = createPhaseId();
1546
- const shortId = createShortId(await st.assignedShortIds());
1705
+ const identity = await st.allocateEntityIdentity("phase", id);
1547
1706
  const priority = await st.nextPriority("phase", feature.id);
1548
1707
  phase = {
1549
- id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
1708
+ id, number: identity.number, shortId: identity.shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", descriptionUpdatedAt: "", notes: "",
1550
1709
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1551
1710
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1552
1711
  createdAt: nowISO(), updatedAt: nowISO(),
1553
1712
  handoff: "", handoffUpdatedAt: "",
1554
1713
  handoffReadAt: "",
1555
1714
  handoffHistory: [],
1715
+ statusLog: [],
1556
1716
  };
1557
1717
  await st.savePhase(phase);
1558
1718
  await st.updateFeatures((doc) => {
@@ -1568,7 +1728,6 @@ export default function planPiExtension(pi) {
1568
1728
  if (!phase)
1569
1729
  return;
1570
1730
  ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
1571
- const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1572
1731
  if (isYes(startDiscuss)) {
1573
1732
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
1574
1733
  }
@@ -1577,7 +1736,7 @@ export default function planPiExtension(pi) {
1577
1736
  if (b === "discuss") {
1578
1737
  let phase = null;
1579
1738
  if (subArgs.trim()) {
1580
- phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
1739
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1581
1740
  }
1582
1741
  if (!phase) {
1583
1742
  phase = await pickPhase();
@@ -1589,9 +1748,6 @@ export default function planPiExtension(pi) {
1589
1748
  if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
1590
1749
  ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
1591
1750
  }
1592
- phase.status = "discovery";
1593
- phase.updatedAt = nowISO();
1594
- await st.savePhase(phase);
1595
1751
  ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1596
1752
  // ── Recap: give full context before planning tasks ──
1597
1753
  const recapPlan = await st.loadAll();
@@ -1663,7 +1819,7 @@ export default function planPiExtension(pi) {
1663
1819
  if (b === "show") {
1664
1820
  let phase = null;
1665
1821
  if (subArgs.trim()) {
1666
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1822
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1667
1823
  }
1668
1824
  if (!phase) {
1669
1825
  phase = await pickPhase();
@@ -1689,7 +1845,7 @@ export default function planPiExtension(pi) {
1689
1845
  if (b === "delete") {
1690
1846
  let phase = null;
1691
1847
  if (subArgs.trim()) {
1692
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1848
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1693
1849
  }
1694
1850
  if (!phase) {
1695
1851
  phase = await pickPhase();
@@ -1711,7 +1867,7 @@ export default function planPiExtension(pi) {
1711
1867
  if (b === "update") {
1712
1868
  let phase = null;
1713
1869
  if (subArgs.trim()) {
1714
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1870
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1715
1871
  }
1716
1872
  if (!phase) {
1717
1873
  phase = await pickPhase();
@@ -1768,7 +1924,7 @@ export default function planPiExtension(pi) {
1768
1924
  // ═══════════════════════════════════════════════════════════════
1769
1925
  if (a === "task") {
1770
1926
  if (!b) {
1771
- 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");
1772
1928
  return;
1773
1929
  }
1774
1930
  if (b === "add") {
@@ -1782,16 +1938,19 @@ export default function planPiExtension(pi) {
1782
1938
  ctx.ui.notify("Aborted", "warning");
1783
1939
  return;
1784
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");
1785
1944
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1786
- const taskNum = await st.allocTaskNumber();
1787
1945
  const taskId = createTaskId();
1788
- const shortId = createShortId(await st.assignedShortIds());
1946
+ const identity = await st.allocateEntityIdentity("task", taskId);
1789
1947
  const priority = await st.nextPriority("task", phase.id);
1790
1948
  const now = nowISO();
1791
1949
  const task = {
1792
- id: taskId, phaseId: phase.id, number: taskNum, shortId, priority, shortName,
1950
+ id: taskId, phaseId: phase.id, number: identity.number, shortId: identity.shortId, priority, shortName,
1793
1951
  title: title.trim(), status: "planned",
1794
1952
  description: "",
1953
+ descriptionUpdatedAt: "",
1795
1954
  notes: "",
1796
1955
  statusLog: [],
1797
1956
  decisions: [],
@@ -1807,7 +1966,6 @@ export default function planPiExtension(pi) {
1807
1966
  await st.savePhase(phase);
1808
1967
  await st.writeGenerated();
1809
1968
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
1810
- const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1811
1969
  if (isYes(startDiscuss)) {
1812
1970
  await handlePlanner(`task discuss ${taskId}`, ctx);
1813
1971
  }
@@ -1839,7 +1997,7 @@ export default function planPiExtension(pi) {
1839
1997
  if (description?.trim())
1840
1998
  task.description = description.trim();
1841
1999
  if (checklistSeed?.trim()) {
1842
- task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
2000
+ task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
1843
2001
  id: createChecklistItemId(task.id, index + 1, itemTitle),
1844
2002
  title: itemTitle,
1845
2003
  checked: false,
@@ -1890,7 +2048,7 @@ export default function planPiExtension(pi) {
1890
2048
  if (b === "delete") {
1891
2049
  let phase = null;
1892
2050
  if (subArgs.trim()) {
1893
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2051
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1894
2052
  }
1895
2053
  if (!phase) {
1896
2054
  phase = await pickPhase();
@@ -1924,7 +2082,7 @@ export default function planPiExtension(pi) {
1924
2082
  if (b === "update") {
1925
2083
  let phase = null;
1926
2084
  if (subArgs.trim()) {
1927
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2085
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1928
2086
  }
1929
2087
  if (!phase) {
1930
2088
  phase = await pickPhase();
@@ -1966,6 +2124,7 @@ export default function planPiExtension(pi) {
1966
2124
  task.updatedAt = now;
1967
2125
  phase.updatedAt = now;
1968
2126
  await st.savePhase(phase);
2127
+ await st.syncTaskStatusRollup(phase.id);
1969
2128
  await st.writeGenerated();
1970
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");
1971
2130
  return;
@@ -1998,17 +2157,9 @@ export default function planPiExtension(pi) {
1998
2157
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
1999
2158
  return;
2000
2159
  }
2001
- // Auto-archive the handoff on THIS task's phase (reason: task-started) —
2002
- // the agent is now actively working, so the captured context is consumed.
2003
- // Other phases' handoffs are left untouched (informational, non-blocking).
2004
- if ((phase.handoff ?? "") !== "") {
2005
- await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
2006
- ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/handoff-archive/.", "info");
2007
- }
2008
- const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
2009
- if (_otherHandoffs.length > 0) {
2010
- ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
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,6 +2176,7 @@ 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
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;
@@ -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: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}`, "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
  }
@@ -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);
@@ -2621,6 +2869,7 @@ export default function planPiExtension(pi) {
2621
2869
  `Repair complete.`,
2622
2870
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2623
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).`,
2624
2873
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2625
2874
  ];
2626
2875
  if (report.integrity.danglingPhaseIds.length)
@@ -2630,6 +2879,38 @@ export default function planPiExtension(pi) {
2630
2879
  return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2631
2880
  },
2632
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
+ });
2633
2914
  pi.registerTool({
2634
2915
  name: "plan_get_handoff",
2635
2916
  label: "Plan Get Handoff",
@@ -2649,9 +2930,12 @@ export default function planPiExtension(pi) {
2649
2930
  pi.registerTool({
2650
2931
  name: "plan_write_handoff",
2651
2932
  label: "Plan Write Handoff",
2652
- 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.",
2653
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." }),
2654
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'." })),
2655
2939
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
2656
2940
  howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
2657
2941
  extraSections: Type.Optional(Type.Array(Type.Object({
@@ -2663,7 +2947,14 @@ export default function planPiExtension(pi) {
2663
2947
  const st = await requirePlan(ctx);
2664
2948
  if (!st)
2665
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 } };
2666
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() } : {}),
2667
2958
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2668
2959
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2669
2960
  ...(params.extraSections && params.extraSections.length > 0
@@ -2674,23 +2965,20 @@ export default function planPiExtension(pi) {
2674
2965
  }
2675
2966
  : {}),
2676
2967
  });
2677
- const r = await resolvePhaseForHandoff(st, undefined);
2678
- if (!r.ok)
2679
- 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 } };
2680
- await st.setPhaseHandoff(r.phase.id, markdown);
2681
- 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 } };
2682
2970
  },
2683
2971
  });
2684
2972
  pi.registerTool({
2685
2973
  name: "plan_delete_handoff",
2686
2974
  label: "Plan Delete Handoff",
2687
- description: "DEPRECATED — clears the entity-scoped handoff of the current in-progress phase. Prefer handoff_clear.",
2688
- parameters: Type.Object({}),
2689
- 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) {
2690
2978
  const st = await requirePlan(ctx);
2691
2979
  if (!st)
2692
2980
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2693
- const r = await resolvePhaseForHandoff(st, undefined);
2981
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2694
2982
  if (!r.ok)
2695
2983
  return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
2696
2984
  await st.clearPhaseHandoff(r.phase.id, "manual");
@@ -2698,8 +2986,8 @@ export default function planPiExtension(pi) {
2698
2986
  },
2699
2987
  });
2700
2988
  // Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
2701
- // UUID | title; omitted -> current in-progress phase (phase.status, or the
2702
- // 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.
2703
2991
  async function resolvePhaseForHandoff(st, ref) {
2704
2992
  const phases = await st.loadAllPhases();
2705
2993
  const features = (await st.loadFeatures()).features;
@@ -2707,16 +2995,12 @@ export default function planPiExtension(pi) {
2707
2995
  if (ref && ref.trim()) {
2708
2996
  phase = findPhaseByRef(phases, features, ref.trim());
2709
2997
  }
2710
- else {
2711
- phase = phases.find((p) => p.status === "in-progress")
2712
- ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
2713
- }
2714
2998
  if (!phase) {
2715
2999
  return {
2716
3000
  ok: false,
2717
3001
  error: ref && ref.trim()
2718
3002
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
2719
- : "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).",
2720
3004
  };
2721
3005
  }
2722
3006
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -2741,8 +3025,8 @@ export default function planPiExtension(pi) {
2741
3025
  pi.registerTool({
2742
3026
  name: "handoff_show",
2743
3027
  label: "Handoff Show",
2744
- 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.",
2745
- 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." }) }),
2746
3030
  async execute(_id, params, _signal, _onUpdate, ctx) {
2747
3031
  const st = await requirePlan(ctx);
2748
3032
  if (!st)
@@ -2759,9 +3043,11 @@ export default function planPiExtension(pi) {
2759
3043
  pi.registerTool({
2760
3044
  name: "handoff_write",
2761
3045
  label: "Handoff Write",
2762
- 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.",
2763
3047
  parameters: Type.Object({
2764
- 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." }),
2765
3051
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2766
3052
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2767
3053
  }),
@@ -2769,12 +3055,38 @@ export default function planPiExtension(pi) {
2769
3055
  const st = await requirePlan(ctx);
2770
3056
  if (!st)
2771
3057
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2772
- const body = (params.markdown_content ?? params.content ?? "").trim();
3058
+ let body = (params.markdown_content ?? params.content ?? "").trim();
2773
3059
  if (!body)
2774
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
+ }
2775
3084
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2776
3085
  if (!r.ok)
2777
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
+ }
2778
3090
  await st.setPhaseHandoff(r.phase.id, body);
2779
3091
  return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2780
3092
  },
@@ -2782,8 +3094,8 @@ export default function planPiExtension(pi) {
2782
3094
  pi.registerTool({
2783
3095
  name: "handoff_clear",
2784
3096
  label: "Handoff Clear",
2785
- 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).",
2786
- 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." }) }),
2787
3099
  async execute(_id, params, _signal, _onUpdate, ctx) {
2788
3100
  const st = await requirePlan(ctx);
2789
3101
  if (!st)
@@ -2792,7 +3104,7 @@ export default function planPiExtension(pi) {
2792
3104
  if (!r.ok)
2793
3105
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2794
3106
  await st.clearPhaseHandoff(r.phase.id, "manual");
2795
- return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
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 } };
2796
3108
  },
2797
3109
  });
2798
3110
  pi.registerTool({
@@ -2849,7 +3161,7 @@ export default function planPiExtension(pi) {
2849
3161
  pi.registerTool({
2850
3162
  name: "feature_get",
2851
3163
  label: "Feature Get",
2852
- description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
3164
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
2853
3165
  parameters: Type.Object({
2854
3166
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2855
3167
  full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
@@ -2860,12 +3172,17 @@ export default function planPiExtension(pi) {
2860
3172
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2861
3173
  const features = (await st.loadFeatures()).features;
2862
3174
  const ref = params.featureId.trim();
2863
- const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
2864
- if (!feature)
2865
- return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
3175
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
3176
+ if (!resolvedFeature.ok)
3177
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3178
+ const feature = resolvedFeature.feature;
2866
3179
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
2867
- const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
2868
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
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 } };
2869
3186
  },
2870
3187
  });
2871
3188
  pi.registerTool({
@@ -2884,15 +3201,17 @@ export default function planPiExtension(pi) {
2884
3201
  const now = nowISO();
2885
3202
  const status = params.status ?? "planned";
2886
3203
  const currentFeatures = (await st.loadFeatures()).features;
2887
- const shortId = createShortId(await st.assignedShortIds());
3204
+ const id = createFeatureId();
3205
+ const identity = await st.allocateEntityIdentity("feature", id);
2888
3206
  const priority = await st.nextPriority("feature");
2889
3207
  const feature = {
2890
- id: createFeatureId(),
2891
- number: await st.allocFeatureNumber(),
2892
- shortId,
3208
+ id,
3209
+ number: identity.number,
3210
+ shortId: identity.shortId,
2893
3211
  priority,
2894
3212
  name: params.name,
2895
3213
  description: params.description ?? "",
3214
+ descriptionUpdatedAt: now,
2896
3215
  status,
2897
3216
  discussedAt: "",
2898
3217
  contextReady: false,
@@ -2904,6 +3223,7 @@ export default function planPiExtension(pi) {
2904
3223
  acceptedDecisions: [],
2905
3224
  phaseIds: [],
2906
3225
  dependsOn: [],
3226
+ statusLog: [],
2907
3227
  createdAt: now,
2908
3228
  updatedAt: now,
2909
3229
  };
@@ -2917,6 +3237,51 @@ export default function planPiExtension(pi) {
2917
3237
  return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2918
3238
  },
2919
3239
  });
3240
+ pi.registerTool({
3241
+ name: "feature_discuss",
3242
+ label: "Feature Discuss",
3243
+ description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
3244
+ parameters: Type.Object({
3245
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3246
+ description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3247
+ workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3248
+ workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3249
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
3250
+ }),
3251
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3252
+ const st = await requirePlan(ctx);
3253
+ if (!st)
3254
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3255
+ const features = (await st.loadFeatures()).features;
3256
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3257
+ if (!resolvedFeature.ok)
3258
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3259
+ const featureId = resolvedFeature.feature.id;
3260
+ const updatedDoc = await st.updateFeatures((doc) => {
3261
+ const feature = doc.features.find((f) => f.id === featureId);
3262
+ if (!feature)
3263
+ return doc;
3264
+ if (params.description !== undefined)
3265
+ feature.description = params.description;
3266
+ if (params.workDone !== undefined)
3267
+ feature.workDone = params.workDone;
3268
+ if (params.workRemaining !== undefined)
3269
+ feature.workRemaining = params.workRemaining;
3270
+ if (params.dependencies !== undefined)
3271
+ feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3272
+ feature.discussedAt = nowISO();
3273
+ feature.contextReady = true;
3274
+ feature.contextReadyReason = "Updated through feature_discuss tool.";
3275
+ feature.updatedAt = nowISO();
3276
+ return doc;
3277
+ });
3278
+ const feature = updatedDoc.features.find((f) => f.id === featureId);
3279
+ if (!feature)
3280
+ return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3281
+ await st.writeGenerated();
3282
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3283
+ },
3284
+ });
2920
3285
  pi.registerTool({
2921
3286
  name: "feature_update",
2922
3287
  label: "Feature Update",
@@ -2944,10 +3309,36 @@ export default function planPiExtension(pi) {
2944
3309
  const st = await requirePlan(ctx);
2945
3310
  if (!st)
2946
3311
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3312
+ const features = (await st.loadFeatures()).features;
3313
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3314
+ if (!resolvedFeature.ok)
3315
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3316
+ const featureId = resolvedFeature.feature.id;
3317
+ const mutableFields = [
3318
+ "name",
3319
+ "description",
3320
+ "status",
3321
+ "startDate",
3322
+ "endDate",
3323
+ "workDone",
3324
+ "workRemaining",
3325
+ "priority",
3326
+ "acceptedDecisions",
3327
+ ];
3328
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3329
+ if (receivedFields.length === 0) {
3330
+ return {
3331
+ content: [{
3332
+ type: "text",
3333
+ 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.",
3334
+ }],
3335
+ details: { updated: false, reason: "no-mutable-fields" },
3336
+ };
3337
+ }
2947
3338
  let foundDoc;
2948
3339
  try {
2949
3340
  foundDoc = await st.updateFeatures((doc) => {
2950
- const feature = doc.features.find((f) => f.id === params.featureId);
3341
+ const feature = doc.features.find((f) => f.id === featureId);
2951
3342
  if (!feature)
2952
3343
  return doc; // not found: no-op, handled below
2953
3344
  if (params.name !== undefined)
@@ -2985,11 +3376,17 @@ export default function planPiExtension(pi) {
2985
3376
  catch (e) {
2986
3377
  return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
2987
3378
  }
2988
- const feature = foundDoc?.features.find((f) => f.id === params.featureId);
3379
+ const feature = foundDoc?.features.find((f) => f.id === featureId);
2989
3380
  if (!feature)
2990
3381
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
2991
3382
  await st.writeGenerated();
2992
- return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3383
+ return {
3384
+ content: [{
3385
+ type: "text",
3386
+ text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
3387
+ }],
3388
+ details: { ...feature, updated: true, updatedFields: receivedFields },
3389
+ };
2993
3390
  },
2994
3391
  });
2995
3392
  pi.registerTool({
@@ -3004,21 +3401,23 @@ export default function planPiExtension(pi) {
3004
3401
  const st = await requirePlan(ctx);
3005
3402
  if (!st)
3006
3403
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3404
+ const features = (await st.loadFeatures()).features;
3405
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3406
+ if (!resolvedFeature.ok)
3407
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3408
+ const featureId = resolvedFeature.feature.id;
3007
3409
  let deleted = false;
3008
3410
  await st.updateFeatures((doc) => {
3009
3411
  const before = doc.features.length;
3010
- doc.features = doc.features.filter((f) => f.id !== params.featureId);
3412
+ doc.features = doc.features.filter((f) => f.id !== featureId);
3011
3413
  if (doc.features.length !== before)
3012
3414
  deleted = true;
3013
3415
  return doc;
3014
3416
  });
3015
- if (!deleted) {
3016
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3017
- }
3018
3417
  let cascadeCount = 0;
3019
3418
  if (params.cascade) {
3020
3419
  const phases = await st.loadAllPhases();
3021
- for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
3420
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
3022
3421
  await st.deletePhase(phase.id);
3023
3422
  cascadeCount += 1;
3024
3423
  }
@@ -3062,7 +3461,7 @@ export default function planPiExtension(pi) {
3062
3461
  pi.registerTool({
3063
3462
  name: "phase_get",
3064
3463
  label: "Phase Get",
3065
- description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
3464
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3066
3465
  parameters: Type.Object({
3067
3466
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3068
3467
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
@@ -3075,8 +3474,13 @@ export default function planPiExtension(pi) {
3075
3474
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3076
3475
  if (!phase)
3077
3476
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3078
- const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
3079
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}` : summary }], details: {} };
3477
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3478
+ const reqCount = linkedRequirements.length;
3479
+ 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"}` : ""})`;
3480
+ const requirementsBlock = reqCount > 0
3481
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3482
+ : "";
3483
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3080
3484
  },
3081
3485
  });
3082
3486
  pi.registerTool({
@@ -3096,34 +3500,43 @@ export default function planPiExtension(pi) {
3096
3500
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3097
3501
  if (!params.featureId?.trim())
3098
3502
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3503
+ const createPhaseFeatures = (await st.loadFeatures()).features;
3504
+ const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
3505
+ if (!resolvedFeature.ok)
3506
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3507
+ const featureId = resolvedFeature.feature.id;
3508
+ const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
3509
+ if (!featureValidation.ok)
3510
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3511
+ const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
3512
+ if (!existingFeature)
3513
+ return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3099
3514
  let phase;
3100
- await withFeatureLock(params.featureId, async () => {
3101
- const phases = await st.loadAllPhases();
3102
- const featurePhases = phases.filter((p) => p.featureId === params.featureId);
3103
- const number = await st.allocPhaseNumber();
3515
+ await withFeatureLock(featureId, async () => {
3104
3516
  const id = createPhaseId();
3105
- const shortId = createShortId(await st.assignedShortIds());
3106
- const priority = await st.nextPriority("phase", params.featureId);
3517
+ const identity = await st.allocateEntityIdentity("phase", id);
3518
+ const priority = await st.nextPriority("phase", featureId);
3107
3519
  const now = nowISO();
3108
3520
  phase = {
3109
- id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3110
- featureId: params.featureId,
3521
+ id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3522
+ featureId,
3111
3523
  status: params.status ?? "draft",
3112
3524
  discussedAt: "",
3113
3525
  contextReady: false,
3114
3526
  contextReadyReason: "",
3115
- summary: params.summary ?? "", description: params.description ?? "", notes: "",
3527
+ summary: params.summary ?? "", description: params.description ?? "", descriptionUpdatedAt: now, notes: "",
3116
3528
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
3117
3529
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3118
3530
  createdAt: now, updatedAt: now,
3119
3531
  handoff: "", handoffUpdatedAt: "",
3120
3532
  handoffReadAt: "",
3121
3533
  handoffHistory: [],
3534
+ statusLog: [],
3122
3535
  };
3123
3536
  await st.savePhase(phase);
3124
3537
  // Atomic: serialize concurrent phase_create linking to the same feature.
3125
3538
  await st.updateFeatures((doc) => {
3126
- const feature = doc.features.find((f) => f.id === params.featureId);
3539
+ const feature = doc.features.find((f) => f.id === featureId);
3127
3540
  if (feature && !feature.phaseIds.includes(phase.id)) {
3128
3541
  feature.phaseIds.push(phase.id);
3129
3542
  feature.updatedAt = now;
@@ -3170,14 +3583,20 @@ export default function planPiExtension(pi) {
3170
3583
  const st = await requirePlan(ctx);
3171
3584
  if (!st)
3172
3585
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3586
+ const features = (await st.loadFeatures()).features;
3587
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3588
+ if (!resolvedPhase)
3589
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3590
+ const phaseId = resolvedPhase.id;
3173
3591
  let phase;
3174
3592
  try {
3175
- phase = await st.loadPhase(params.phaseId);
3593
+ phase = await st.loadPhase(phaseId);
3176
3594
  }
3177
3595
  catch {
3178
3596
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3179
3597
  }
3180
3598
  const prevFeatureId = phase.featureId;
3599
+ let nextFeatureIdForUpdate = prevFeatureId;
3181
3600
  if (params.title !== undefined) {
3182
3601
  phase.title = params.title;
3183
3602
  phase.slug = normalizeSlug(params.title);
@@ -3207,14 +3626,22 @@ export default function planPiExtension(pi) {
3207
3626
  if (params.completionCriteria !== undefined)
3208
3627
  phase.completionCriteria = params.completionCriteria;
3209
3628
  if (params.featureId !== undefined) {
3210
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3211
- phase.featureId = nextFeatureId;
3212
- if (nextFeatureId !== prevFeatureId) {
3629
+ if (params.featureId === "") {
3630
+ nextFeatureIdForUpdate = undefined;
3631
+ }
3632
+ else {
3633
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3634
+ if (!resolvedFeature.ok)
3635
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3636
+ nextFeatureIdForUpdate = resolvedFeature.feature.id;
3637
+ }
3638
+ phase.featureId = nextFeatureIdForUpdate;
3639
+ if (nextFeatureIdForUpdate !== prevFeatureId) {
3213
3640
  await st.updateFeatures((doc) => {
3214
3641
  for (const f of doc.features) {
3215
3642
  if (f.id === prevFeatureId)
3216
3643
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3217
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3644
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3218
3645
  f.phaseIds.push(phase.id);
3219
3646
  }
3220
3647
  return doc;
@@ -3228,6 +3655,77 @@ export default function planPiExtension(pi) {
3228
3655
  return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3229
3656
  },
3230
3657
  });
3658
+ pi.registerTool({
3659
+ name: "decision_record",
3660
+ label: "Decision Record",
3661
+ 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.",
3662
+ parameters: Type.Object({
3663
+ featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
3664
+ phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
3665
+ title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
3666
+ decision: Type.String({ minLength: 1, description: "The agreed decision" }),
3667
+ rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
3668
+ implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
3669
+ }),
3670
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3671
+ const st = await requirePlan(ctx);
3672
+ if (!st)
3673
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3674
+ const features = (await st.loadFeatures()).features;
3675
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3676
+ if (!resolvedFeature.ok)
3677
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3678
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
3679
+ if (!resolvedPhase)
3680
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3681
+ if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
3682
+ return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
3683
+ }
3684
+ const acceptedAt = nowISO();
3685
+ const acceptedDecision = {
3686
+ id: crypto.randomUUID(),
3687
+ title: params.title.trim(),
3688
+ decision: params.decision.trim(),
3689
+ rationale: params.rationale.trim(),
3690
+ implementationNotes: params.implementationNotes.trim(),
3691
+ acceptedAt,
3692
+ };
3693
+ await withFeatureLock(resolvedFeature.feature.id, async () => {
3694
+ let featureWritten = false;
3695
+ try {
3696
+ await st.updateFeatures((doc) => {
3697
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3698
+ if (!feature)
3699
+ throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
3700
+ feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
3701
+ feature.updatedAt = acceptedAt;
3702
+ return doc;
3703
+ });
3704
+ featureWritten = true;
3705
+ await st.updatePhase(resolvedPhase.id, (phase) => {
3706
+ phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
3707
+ phase.updatedAt = acceptedAt;
3708
+ return phase;
3709
+ });
3710
+ }
3711
+ catch (error) {
3712
+ // Best-effort compensation keeps a failed dual-write from reporting
3713
+ // success with a decision persisted only on the feature.
3714
+ if (featureWritten) {
3715
+ await st.updateFeatures((doc) => {
3716
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3717
+ if (feature)
3718
+ feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
3719
+ return doc;
3720
+ }).catch(() => { });
3721
+ }
3722
+ throw error;
3723
+ }
3724
+ });
3725
+ await st.writeGenerated();
3726
+ return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
3727
+ },
3728
+ });
3231
3729
  pi.registerTool({
3232
3730
  name: "phase_delete",
3233
3731
  label: "Phase Delete",
@@ -3239,19 +3737,24 @@ export default function planPiExtension(pi) {
3239
3737
  const st = await requirePlan(ctx);
3240
3738
  if (!st)
3241
3739
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3740
+ const features = (await st.loadFeatures()).features;
3741
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3742
+ if (!resolvedPhase)
3743
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3744
+ const phaseId = resolvedPhase.id;
3242
3745
  let phase;
3243
3746
  try {
3244
- phase = await st.loadPhase(params.phaseId);
3747
+ phase = await st.loadPhase(phaseId);
3245
3748
  }
3246
3749
  catch {
3247
3750
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3248
3751
  }
3249
- await st.deletePhase(params.phaseId);
3752
+ await st.deletePhase(phaseId);
3250
3753
  if (phase.featureId) {
3251
3754
  await st.updateFeatures((doc) => {
3252
3755
  const feature = doc.features.find((f) => f.id === phase.featureId);
3253
3756
  if (feature) {
3254
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
3757
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
3255
3758
  feature.updatedAt = nowISO();
3256
3759
  }
3257
3760
  return doc;
@@ -3330,40 +3833,64 @@ export default function planPiExtension(pi) {
3330
3833
  label: "Task Create",
3331
3834
  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.",
3332
3835
  parameters: Type.Object({
3836
+ featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
3333
3837
  phaseId: Type.String({ description: "Phase ID the task belongs to" }),
3334
3838
  title: Type.String({ description: "Task title" }),
3335
3839
  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 }),
3336
3840
  status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
3337
3841
  shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
3842
+ checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
3338
3843
  }),
3339
3844
  async execute(_id, params, _signal, _onUpdate, ctx) {
3340
3845
  const st = await requirePlan(ctx);
3341
3846
  if (!st)
3342
3847
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3343
- // Verify phase exists first (fast-fail before generating an id).
3344
- try {
3345
- await st.loadPhase(params.phaseId);
3346
- }
3347
- catch {
3848
+ if (!params.featureId?.trim())
3849
+ return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3850
+ const features = (await st.loadFeatures()).features;
3851
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
3852
+ if (!resolvedFeature.ok)
3853
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3854
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
3855
+ if (!featureValidation.ok)
3856
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3857
+ const feature = resolvedFeature.feature;
3858
+ const existingFeature = features.find((f) => f.id === feature.id);
3859
+ if (!existingFeature)
3860
+ return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
3861
+ // Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
3862
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3863
+ if (!resolvedPhase)
3348
3864
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3349
- }
3865
+ if (resolvedPhase.featureId !== feature.id)
3866
+ 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: {} };
3867
+ const phaseId = resolvedPhase.id;
3868
+ const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
3869
+ if (!phaseValidation.ok)
3870
+ return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
3871
+ const existingPhase = await st.loadPhase(phaseId);
3872
+ if (!existingPhase)
3873
+ return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3350
3874
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3351
3875
  const taskId = createTaskId();
3352
- const shortId = createShortId(await st.assignedShortIds());
3353
- const priority = await st.nextPriority("task", params.phaseId);
3876
+ const identity = await st.allocateEntityIdentity("task", taskId);
3877
+ const priority = await st.nextPriority("task", phaseId);
3354
3878
  const now = nowISO();
3355
3879
  const initialStatus = params.status ?? "planned";
3356
- const existingPhase = await st.loadPhase(params.phaseId);
3357
3880
  const task = {
3358
- id: taskId, phaseId: params.phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3881
+ id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
3359
3882
  title: params.title,
3360
3883
  status: initialStatus,
3361
3884
  description: params.description ?? "",
3885
+ descriptionUpdatedAt: now,
3362
3886
  notes: "",
3363
3887
  statusLog: [],
3364
3888
  decisions: [],
3365
3889
  acceptedDecisions: [],
3366
- checklist: [], subtasks: [],
3890
+ checklist: params.checklist
3891
+ ? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
3892
+ : [],
3893
+ subtasks: [],
3367
3894
  dependsOn: [],
3368
3895
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3369
3896
  completedAt: initialStatus === "done" ? now : "",
@@ -3371,14 +3898,15 @@ export default function planPiExtension(pi) {
3371
3898
  };
3372
3899
  // Atomic read-modify-write on the phase file: serializes concurrent
3373
3900
  // task_create calls on the SAME phaseId (batch) so tasks don't get lost.
3374
- await st.updatePhase(params.phaseId, (phase) => {
3901
+ await st.updatePhase(phaseId, (phase) => {
3375
3902
  phase.tasks.push(task);
3376
3903
  phase.taskIds.push(taskId);
3377
3904
  phase.updatedAt = now;
3378
3905
  return phase;
3379
3906
  });
3380
3907
  await st.writeGenerated();
3381
- return { content: [{ type: "text", text: `Task created: ${taskId}` }], details: task };
3908
+ const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
3909
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
3382
3910
  },
3383
3911
  });
3384
3912
  pi.registerTool({
@@ -3409,15 +3937,29 @@ export default function planPiExtension(pi) {
3409
3937
  if (!st)
3410
3938
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3411
3939
  // Find the phase hosting this task first.
3412
- const allPhases = await st.loadAllPhases();
3413
- const hostPhase = allPhases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3414
- if (!hostPhase)
3940
+ const features = (await st.loadFeatures()).features;
3941
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3942
+ if (!found)
3415
3943
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3944
+ const hostPhase = found.phase;
3416
3945
  const now = nowISO();
3417
3946
  let updatedTask;
3947
+ // Validate the motivation requirement for restrictive status transitions,
3948
+ // mirroring the MCP adapter and the /planner task update command
3949
+ // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
3950
+ if (params.status !== undefined &&
3951
+ params.status !== found.task.status &&
3952
+ needsMotivation(found.task.status, params.status)) {
3953
+ if (!params.motivation || !params.motivation.trim()) {
3954
+ return {
3955
+ 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.` }],
3956
+ details: {},
3957
+ };
3958
+ }
3959
+ }
3418
3960
  try {
3419
3961
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3420
- const task = phase.tasks.find((t) => t.id === params.taskId);
3962
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3421
3963
  if (!task)
3422
3964
  return phase;
3423
3965
  if (params.title !== undefined)
@@ -3433,7 +3975,7 @@ export default function planPiExtension(pi) {
3433
3975
  if (params.acceptedDecisions !== undefined)
3434
3976
  task.acceptedDecisions = params.acceptedDecisions;
3435
3977
  if (params.checklist !== undefined) {
3436
- task.checklist = params.checklist.map((itemTitle, index) => ({
3978
+ task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
3437
3979
  id: createChecklistItemId(task.id, index + 1, itemTitle),
3438
3980
  title: itemTitle,
3439
3981
  checked: false,
@@ -3465,6 +4007,7 @@ export default function planPiExtension(pi) {
3465
4007
  }
3466
4008
  if (!updatedTask)
3467
4009
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4010
+ await st.syncTaskStatusRollup(found.phase.id);
3468
4011
  await st.writeGenerated();
3469
4012
  return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
3470
4013
  },
@@ -3480,13 +4023,13 @@ export default function planPiExtension(pi) {
3480
4023
  const st = await requirePlan(ctx);
3481
4024
  if (!st)
3482
4025
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3483
- const phases = await st.loadAllPhases();
3484
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3485
- if (!hostPhase)
4026
+ const features = (await st.loadFeatures()).features;
4027
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4028
+ if (!found)
3486
4029
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3487
- await st.updatePhase(hostPhase.id, (phase) => {
3488
- phase.tasks = phase.tasks.filter((t) => t.id !== params.taskId);
3489
- phase.taskIds = phase.taskIds.filter((id) => id !== params.taskId);
4030
+ await st.updatePhase(found.phase.id, (phase) => {
4031
+ phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
4032
+ phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
3490
4033
  phase.updatedAt = nowISO();
3491
4034
  return phase;
3492
4035
  });
@@ -3495,10 +4038,65 @@ export default function planPiExtension(pi) {
3495
4038
  },
3496
4039
  });
3497
4040
  // ── task start / complete tools ──────────────────────────────────
4041
+ pi.registerTool({
4042
+ name: "task_recommend",
4043
+ label: "Task Recommend",
4044
+ 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.",
4045
+ parameters: Type.Object({}),
4046
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
4047
+ const st = await requirePlan(ctx);
4048
+ if (!st)
4049
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4050
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4051
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
4052
+ if (!result.candidate)
4053
+ return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4054
+ const { candidate } = result;
4055
+ const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
4056
+ return {
4057
+ 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}.` : ""}` }],
4058
+ details: result,
4059
+ };
4060
+ },
4061
+ });
4062
+ pi.registerTool({
4063
+ name: "task_deviation",
4064
+ label: "Task Deviation",
4065
+ 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.",
4066
+ parameters: Type.Object({
4067
+ temporary_task: Type.String({ description: "Temporary task ref" }),
4068
+ resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
4069
+ reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
4070
+ }),
4071
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4072
+ const st = await requirePlan(ctx);
4073
+ if (!st)
4074
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4075
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4076
+ const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4077
+ if (!temporary)
4078
+ return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4079
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
4080
+ const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4081
+ if (!resume)
4082
+ return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
4083
+ if (temporary.task.id === resume.task.id)
4084
+ return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
4085
+ if (temporary.task.status !== "planned")
4086
+ return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
4087
+ const timestamp = nowISO();
4088
+ 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: "" };
4089
+ await st.addWorkDeviation(record);
4090
+ await st.writeGenerated();
4091
+ const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
4092
+ const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
4093
+ return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
4094
+ },
4095
+ });
3498
4096
  pi.registerTool({
3499
4097
  name: "task_start",
3500
4098
  label: "Task Start",
3501
- description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
4099
+ 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.",
3502
4100
  parameters: Type.Object({
3503
4101
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3504
4102
  }),
@@ -3506,19 +4104,40 @@ export default function planPiExtension(pi) {
3506
4104
  const st = await requirePlan(ctx);
3507
4105
  if (!st)
3508
4106
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3509
- const phases = await st.loadAllPhases();
3510
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3511
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3512
- if (!task || !hostPhase)
4107
+ const features = (await st.loadFeatures()).features;
4108
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4109
+ if (!found)
3513
4110
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4111
+ const task = found.task;
3514
4112
  if (task.status === "in-progress")
3515
4113
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3516
4114
  if (task.status === "done")
3517
4115
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4116
+ const project = await st.loadProject();
4117
+ const phases = await st.loadAllPhases();
4118
+ const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4119
+ if (!eligibility.eligible)
4120
+ return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
4121
+ const selection = recommendNextTask(features, phases, project.workDeviations);
4122
+ // Assemble the mandatory parent context before changing task lifecycle state.
4123
+ // The output order is feature + its requirements, then phase + its requirements.
4124
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
4125
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
4126
+ st.loadPhaseWithRequirements(found.phase.id),
4127
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
4128
+ ]);
4129
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4130
+ const advisory = selection.kind === "conflict"
4131
+ ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
4132
+ : selection.candidate && selection.candidate.task.id !== task.id
4133
+ ? selection.kind === "active"
4134
+ ? `\n\n⚠️ Active-task advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is already in progress. Explicit task request honored.`
4135
+ : `\n\n⚠️ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
4136
+ : "";
3518
4137
  const now = nowISO();
3519
4138
  let startedTask;
3520
- await st.updatePhase(hostPhase.id, (phase) => {
3521
- const t = phase.tasks.find((x) => x.id === params.taskId);
4139
+ await st.updatePhase(found.phase.id, (phase) => {
4140
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3522
4141
  if (!t)
3523
4142
  return phase;
3524
4143
  applyTaskLifecycleDates(t, "in-progress", now);
@@ -3527,10 +4146,14 @@ export default function planPiExtension(pi) {
3527
4146
  startedTask = t;
3528
4147
  return phase;
3529
4148
  });
4149
+ await st.syncTaskStatusRollup(found.phase.id);
4150
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
4151
+ if (approvedDeviation)
4152
+ await st.setWorkDeviationState(approvedDeviation.id, "active", now);
3530
4153
  await st.writeGenerated();
3531
4154
  if (!startedTask)
3532
4155
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3533
- return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)` }], details: startedTask };
4156
+ return { content: [{ type: "text", text: `✅ Task started: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(startedTask.number).padStart(3, "0")} — ${startedTask.title} (in-progress)${startedTask.shortId ? ` · ${startedTask.shortId}` : ""}${phaseContext}${advisory}` }], details: startedTask };
3534
4157
  },
3535
4158
  });
3536
4159
  pi.registerTool({
@@ -3546,11 +4169,11 @@ export default function planPiExtension(pi) {
3546
4169
  const st = await requirePlan(ctx);
3547
4170
  if (!st)
3548
4171
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3549
- const phases = await st.loadAllPhases();
3550
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3551
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3552
- if (!task || !hostPhase)
4172
+ const features = (await st.loadFeatures()).features;
4173
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4174
+ if (!found)
3553
4175
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4176
+ const task = found.task;
3554
4177
  if (task.status === "done")
3555
4178
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
3556
4179
  const unchecked = task.checklist.filter((item) => !item.checked);
@@ -3562,8 +4185,8 @@ export default function planPiExtension(pi) {
3562
4185
  }
3563
4186
  const now = nowISO();
3564
4187
  let completedTask;
3565
- await st.updatePhase(hostPhase.id, (phase) => {
3566
- const t = phase.tasks.find((x) => x.id === params.taskId);
4188
+ await st.updatePhase(found.phase.id, (phase) => {
4189
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3567
4190
  if (!t)
3568
4191
  return phase;
3569
4192
  applyTaskLifecycleDates(t, "done", now);
@@ -3576,10 +4199,135 @@ export default function planPiExtension(pi) {
3576
4199
  completedTask = t;
3577
4200
  return phase;
3578
4201
  });
4202
+ const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
4203
+ const completedDeviation = (await st.loadProject()).workDeviations
4204
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
4205
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4206
+ if (completedDeviation)
4207
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
3579
4208
  await st.writeGenerated();
3580
4209
  if (!completedTask)
3581
4210
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3582
- return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
4211
+ return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
4212
+ },
4213
+ });
4214
+ pi.registerTool({
4215
+ name: "task_checklist_toggle",
4216
+ label: "Task Checklist Toggle",
4217
+ 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.",
4218
+ parameters: Type.Object({
4219
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4220
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4221
+ checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
4222
+ }),
4223
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4224
+ const st = await requirePlan(ctx);
4225
+ if (!st)
4226
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4227
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4228
+ if (!found)
4229
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4230
+ let result = "";
4231
+ await st.updatePhase(found.phase.id, (phase) => {
4232
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4233
+ if (!task) {
4234
+ result = `Task not found: ${params.taskId}`;
4235
+ return phase;
4236
+ }
4237
+ const ck = task.checklist ?? [];
4238
+ if (ck.length === 0) {
4239
+ result = `Task "${found.task.title}" has no checklist.`;
4240
+ return phase;
4241
+ }
4242
+ const target = toggleChecklistItem(ck, params.item, params.checked);
4243
+ if (!target) {
4244
+ result = `No checklist item matching "${params.item}".`;
4245
+ return phase;
4246
+ }
4247
+ task.updatedAt = nowISO();
4248
+ phase.updatedAt = nowISO();
4249
+ const doneCount = ck.filter((i) => i.checked).length;
4250
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
4251
+ return phase;
4252
+ });
4253
+ await st.writeGenerated();
4254
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4255
+ },
4256
+ });
4257
+ pi.registerTool({
4258
+ name: "task_checklist_add",
4259
+ label: "Task Checklist Add",
4260
+ 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.",
4261
+ parameters: Type.Object({
4262
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4263
+ title: Type.String({ description: "Checklist item text (a single step)" }),
4264
+ }),
4265
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4266
+ const st = await requirePlan(ctx);
4267
+ if (!st)
4268
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4269
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4270
+ if (!found)
4271
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4272
+ let result = "";
4273
+ await st.updatePhase(found.phase.id, (phase) => {
4274
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4275
+ if (!task) {
4276
+ result = `Task not found: ${params.taskId}`;
4277
+ return phase;
4278
+ }
4279
+ const ck = task.checklist ?? [];
4280
+ const item = addChecklistItem(ck, task.id, params.title);
4281
+ ck.push(item);
4282
+ task.checklist = ck;
4283
+ task.updatedAt = nowISO();
4284
+ phase.updatedAt = nowISO();
4285
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
4286
+ return phase;
4287
+ });
4288
+ await st.writeGenerated();
4289
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4290
+ },
4291
+ });
4292
+ pi.registerTool({
4293
+ name: "task_checklist_remove",
4294
+ label: "Task Checklist Remove",
4295
+ 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.",
4296
+ parameters: Type.Object({
4297
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4298
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4299
+ }),
4300
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4301
+ const st = await requirePlan(ctx);
4302
+ if (!st)
4303
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4304
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4305
+ if (!found)
4306
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4307
+ let result = "";
4308
+ await st.updatePhase(found.phase.id, (phase) => {
4309
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4310
+ if (!task) {
4311
+ result = `Task not found: ${params.taskId}`;
4312
+ return phase;
4313
+ }
4314
+ const ck = task.checklist ?? [];
4315
+ if (ck.length === 0) {
4316
+ result = `Task "${found.task.title}" has no checklist.`;
4317
+ return phase;
4318
+ }
4319
+ const removed = removeChecklistItem(ck, params.item);
4320
+ if (!removed) {
4321
+ result = `No checklist item matching "${params.item}".`;
4322
+ return phase;
4323
+ }
4324
+ task.updatedAt = nowISO();
4325
+ phase.updatedAt = nowISO();
4326
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
4327
+ return phase;
4328
+ });
4329
+ await st.writeGenerated();
4330
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
3583
4331
  },
3584
4332
  });
3585
4333
  // ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
@@ -3656,7 +4404,7 @@ export default function planPiExtension(pi) {
3656
4404
  description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
3657
4405
  parameters: Type.Object({}),
3658
4406
  async execute(_id, _params, _signal, _onUpdate, _ctx) {
3659
- plannerSessionEnabled = false;
4407
+ disablePlannerSession();
3660
4408
  await stopServer().catch(() => { });
3661
4409
  try {
3662
4410
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -3673,15 +4421,9 @@ export default function planPiExtension(pi) {
3673
4421
  if (!(await st.exists()))
3674
4422
  return;
3675
4423
  try {
3676
- // Heavy one-time-per-session init: migration, language prefs, status
3677
- // healing, and a fresh resume. These write/scan the whole plan and would
3678
- // add seconds of latency on every turn if run unconditionally.
4424
+ // Session initialization must not mutate canonical planner entities.
4425
+ // Migrations/backfills/repairs are explicit tools; resume is `.local`.
3679
4426
  if (!plannerHeavyInitDone) {
3680
- await migrateToUuids(st);
3681
- await migrateToGlobalSequence(st).catch(() => null);
3682
- await st.ensureShortIdsAndPriority().catch(() => null);
3683
- await ensureProjectLanguagePreferences(st).catch(() => null);
3684
- await maybeHealStatuses(st, ctx);
3685
4427
  await st.refreshResume();
3686
4428
  plannerHeavyInitDone = true;
3687
4429
  }
@@ -3711,8 +4453,9 @@ export default function planPiExtension(pi) {
3711
4453
  const profile = await st.loadCodebaseProfile();
3712
4454
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3713
4455
  const activity = await st.loadActivityLog();
3714
- const recentActivity = activity.entries.slice(-8).reverse();
4456
+ const recentActivity = activity.entries.slice(-3).reverse();
3715
4457
  const handoffs = await st.listHandoffs();
4458
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3716
4459
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3717
4460
  const orderedPhases = [
3718
4461
  ...plan.features.features.flatMap((feature) => {
@@ -3725,21 +4468,9 @@ export default function planPiExtension(pi) {
3725
4468
  ...plan.phases.filter((phase) => !phase.featureId)
3726
4469
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3727
4470
  ];
3728
- const phaseLines = orderedPhases
3729
- .map((p) => `- ${statusIcon(p.status)} \`${p.id}\` — ${phaseLabel(p)} (${p.status})`)
3730
- .join("\n");
3731
- const active = orderedPhases
3732
- .filter((p) => p.status === "in-progress")
3733
- .map((p) => `\`${p.id}\``)
3734
- .join(", ");
3735
- const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
3736
- const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
3737
- const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
3738
- const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
3739
- const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
3740
- const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
3741
- const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
3742
- const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
4471
+ const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
4472
+ || plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
4473
+ || orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
3743
4474
  const currentPhase = hasActiveWork
3744
4475
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3745
4476
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3756,16 +4487,51 @@ export default function planPiExtension(pi) {
3756
4487
  ?? null)
3757
4488
  : null;
3758
4489
  const currentFeature = hasActiveWork && currentPhase?.featureId
3759
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4490
+ ? featureById.get(currentPhase.featureId) ?? null
3760
4491
  : hasActiveWork
3761
4492
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3762
4493
  : null;
3763
- const nextActivity = handoffs.length > 0
3764
- ? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task. (Legacy resume.json nextSteps may be stale — the handoff is authoritative.)"
3765
- : hasActiveWork
3766
- ? (resume.nextSteps[0]
3767
- ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3768
- : "Review the current plan and choose the next task.";
4494
+ const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
4495
+ const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
4496
+ const currentTaskRef = currentTask && currentPhase
4497
+ ? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
4498
+ : "";
4499
+ const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
4500
+ const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
4501
+ for (const phase of plan.phases) {
4502
+ phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
4503
+ }
4504
+ const phaseCountsSummary = phaseStatusOrder
4505
+ .map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
4506
+ .join(" ");
4507
+ const activeTaskMap = new Map();
4508
+ for (const phase of orderedPhases) {
4509
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4510
+ for (const task of phase.tasks) {
4511
+ if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
4512
+ continue;
4513
+ activeTaskMap.set(task.id, { phase, task, featureNumber });
4514
+ }
4515
+ }
4516
+ const inProgressTaskLines = [...activeTaskMap.values()]
4517
+ .sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
4518
+ .map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
4519
+ const otherActiveOrBlockedPhases = orderedPhases
4520
+ .filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
4521
+ .slice(0, 6)
4522
+ .map((phase) => {
4523
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4524
+ return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
4525
+ });
4526
+ const openQuestions = plan.phases
4527
+ .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
4528
+ .slice(0, 8);
4529
+ const acceptedDecisions = [
4530
+ ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
4531
+ ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
4532
+ ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
4533
+ ...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}`))),
4534
+ ].slice(0, 20);
3769
4535
  const webServer = server;
3770
4536
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3771
4537
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3778,15 +4544,6 @@ export default function planPiExtension(pi) {
3778
4544
  catch { }
3779
4545
  const webUrl = webLocalUrl;
3780
4546
  const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
3781
- const openQuestions = plan.phases
3782
- .flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
3783
- .slice(0, 12);
3784
- const acceptedDecisions = [
3785
- ...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
3786
- ...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
3787
- ...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
3788
- ...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
3789
- ].slice(0, 20);
3790
4547
  const pkgSummary = profile?.packageJson
3791
4548
  ? `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}`
3792
4549
  : "(no package.json)";
@@ -3808,96 +4565,63 @@ export default function planPiExtension(pi) {
3808
4565
  // Build/refresh the context block (slow path: cache miss).
3809
4566
  const contextBlock = [
3810
4567
  `[Plan Context — ${project.name}]`,
3811
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
3812
4568
  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'.`,
3813
4569
  "",
3814
- // ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
3815
- "",
3816
- "═══════════════════════════════════════════════════════════════",
3817
- "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
3818
- "═══════════════════════════════════════════════════════════════",
3819
- "1. HANDOFF HYGIENE: If one or more phases have a pending handoff (entity-scoped, phase.handoff), present the list, read the relevant one with handoff show <ref> for context. Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — no manual clear needed. They are non-blocking context artifacts, not locks — they never block task_start.",
3820
- "2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'. NOTE: Planner operations (plan_write_handoff / /planner handoff, task_start, task_complete, task_update, status reads) are NOT code edits. They are ALWAYS allowed regardless of task state — you may write/refresh the handoff even when no task is in-progress (e.g., all tasks done, or capturing state mid-flight). Never refuse to write a handoff because 'no task is open'; that is incorrect.",
3821
- "3. IMMEDIATE SYNC: Update task status AT THE EXACT MOMENT of transition. Start = task_start NOW. Done = task_complete NOW. Blocked = task_update with motivation NOW. Never batch status updates.",
3822
- "4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
3823
- "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
3824
- "6. ENTITY REFERENCES & FAST LOOKUP: Feature/Phase/Task numbers are GLOBALLY unique (one P003, one T007 in the whole plan) — bare P00x/T00x is a valid unambiguous ref. PREFER the 5-char shortId (e.g. UUXD1) or composite F00x/P00x/T00x in chat; NEVER a raw UUID. To FIND an entity: call feature_list / phase_list / task_list (compact, ~2K tokens for the whole plan) — do NOT read .planner/*.json files or plan_get full=true (~32K tokens) to locate an entity. To ACT: pass the ref directly to the action tool (task_start/complete/update/phase_update accept F00x/P00x/T00x, bare P00x/T00x, shortId, UUID, title). To read a description: call task_get / phase_get / feature_get with full=true for that ONE entity. Tool responses already show shortId + compositeRef; reuse them.",
3825
- "═══════════════════════════════════════════════════════════════",
4570
+ "Operational rules:",
4571
+ "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4572
+ "- 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.",
4573
+ "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4574
+ "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4575
+ "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4576
+ "- 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).",
4577
+ "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
3826
4578
  "",
3827
- // ── Project details ──
3828
4579
  `Goal: ${project.goal || "(not set)"}`,
3829
4580
  project.description ? `Description: ${project.description}` : "",
3830
4581
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
3831
4582
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
3832
4583
  `Ambient: ${ambientSummary}`,
3833
4584
  "",
3834
- "Global rules (re-read before every phase/task start):",
3835
- project.globalRules.length ? project.globalRules.map((r) => `- ${r}`).join("\n") : "- (none)",
3836
- "",
3837
- "Workflow rules:",
3838
- ` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
3839
- ` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
3840
- ` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4585
+ "Global rules:",
4586
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
3841
4587
  "",
4588
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
3842
4589
  `Scope: ${project.scope.join(", ") || "(none)"}`,
3843
4590
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
3844
4591
  "",
3845
4592
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
3846
- ...acceptedDecisions.map((d) => `- ${d}`),
4593
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
3847
4594
  openQuestions.length ? "" : "",
3848
4595
  openQuestions.length ? "Open questions:" : "",
3849
- ...openQuestions.map((q) => `- ${q}`),
4596
+ ...openQuestions.map((question) => `- ${question}`),
3850
4597
  "",
3851
- "Resume focus (where we are now):",
3852
- resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
3853
- resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
4598
+ "Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
4599
+ currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
4600
+ currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
4601
+ currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
4602
+ inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
3854
4603
  resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
3855
- resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}${handoffs.length > 0 ? " (⚠️ may be stale — handoff is authoritative)" : resume.nextStepsUpdatedAt ? ` (last updated ${resume.nextStepsUpdatedAt}, may be stale)` : ""}` : " next steps: (none)",
3856
- resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
4604
+ handoffs.length === 0 && resume.nextSteps.length
4605
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4606
+ : "",
4607
+ "",
4608
+ "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.",
3857
4609
  "",
3858
- recentActivity.length ? "Recent activity (most recent first):" : "",
3859
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
4610
+ "Phase overview:",
4611
+ ` counts: ${phaseCountsSummary}`,
4612
+ currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
4613
+ otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
4614
+ ...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
3860
4615
  "",
3861
- `${plan.phases.length} phases defined.`,
3862
- "Current phases:",
3863
- phaseLines || " _none_",
3864
- active ? `Active: ${active}` : "",
4616
+ recentActivity.length ? "Recent activity:" : "",
4617
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
3865
4618
  "",
3866
- "Primary command UX:",
3867
- "- Use `/planner <TAB>` for grouped subcommand suggestions.",
3868
- "- Use `/planner` with no args to open the navigable planner menu.",
3869
- "- Feature commands: planner feature list|add|show|update|delete.",
3870
- "- Phase commands: planner phase add|show|discuss|update|delete.",
3871
- "- Task commands: planner task add|show|discuss|update|delete|start|complete.",
3872
- "- Handoff commands: planner handoff prepare|show|write|clear.",
3873
- "- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
3874
- "Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
3875
- "Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
3876
- `Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
3877
- "If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
3878
- "Ask for language only if BOTH preferences are unset.",
3879
- "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.",
3880
- "Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
3881
- "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.",
3882
- "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.",
3883
- "Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
4619
+ handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
4620
+ ...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
4621
+ 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." : "",
3884
4622
  "",
3885
- "TASK STATUS RULES (strict — do not skip):",
3886
- "- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
3887
- "- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
3888
- "- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
3889
- "- 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.",
3890
- "- 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.",
3891
- "- 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.",
3892
- "- 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.",
3893
- "- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
3894
- "- To reopen a completed task, use task_start (recommended) or task_update.",
3895
- handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
3896
- ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3897
- handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
3898
- handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
3899
- handoffs.length > 0 ? "Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — read with handoff show <ref> for context; no manual clear needed." : "",
3900
- "Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
4623
+ `Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
4624
+ "Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
3901
4625
  ].filter(Boolean).join("\n");
3902
4626
  contextBlockCache = contextBlock;
3903
4627
  contextBlockDirty = false;