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

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,13 +1469,13 @@ 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() ?? "",
@@ -1374,6 +1490,7 @@ export default function planPiExtension(pi) {
1374
1490
  acceptedDecisions: [],
1375
1491
  phaseIds: [],
1376
1492
  dependsOn: [],
1493
+ statusLog: [],
1377
1494
  createdAt: now,
1378
1495
  updatedAt: now,
1379
1496
  };
@@ -1411,6 +1528,44 @@ export default function planPiExtension(pi) {
1411
1528
  ].filter(Boolean).join("\n"), "info");
1412
1529
  return;
1413
1530
  }
1531
+ if (b === "discuss") {
1532
+ const featuresDoc = await st.loadFeatures();
1533
+ let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
1534
+ if (!feature) {
1535
+ feature = await pickFeature();
1536
+ if (!feature) {
1537
+ ctx.ui.notify("No features to discuss. Create one first.", "warning");
1538
+ return;
1539
+ }
1540
+ }
1541
+ const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
1542
+ const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
1543
+ const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
1544
+ const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
1545
+ const featureId = feature.id;
1546
+ const updatedDoc = await st.updateFeatures((doc) => {
1547
+ const target = doc.features.find((entry) => entry.id === featureId);
1548
+ if (!target)
1549
+ return doc;
1550
+ if (description !== undefined)
1551
+ target.description = description.trim();
1552
+ if (workDone !== undefined)
1553
+ target.workDone = workDone.trim();
1554
+ if (workRemaining !== undefined)
1555
+ target.workRemaining = workRemaining.trim();
1556
+ if (dependencies !== undefined)
1557
+ target.dependsOn = splitCsv(dependencies);
1558
+ target.discussedAt = nowISO();
1559
+ target.contextReady = true;
1560
+ target.contextReadyReason = "Updated through /planner feature discuss.";
1561
+ target.updatedAt = nowISO();
1562
+ return doc;
1563
+ });
1564
+ feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1565
+ await st.writeGenerated();
1566
+ ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1567
+ return;
1568
+ }
1414
1569
  if (b === "update") {
1415
1570
  const featuresDoc = await st.loadFeatures();
1416
1571
  let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
@@ -1509,7 +1664,7 @@ export default function planPiExtension(pi) {
1509
1664
  ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1510
1665
  return;
1511
1666
  }
1512
- ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
1667
+ ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
1513
1668
  return;
1514
1669
  }
1515
1670
  // ═══════════════════════════════════════════════════════════════
@@ -1536,23 +1691,27 @@ export default function planPiExtension(pi) {
1536
1691
  ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
1537
1692
  return;
1538
1693
  }
1694
+ // This belongs to the creation transaction: Escape before the phase
1695
+ // is persisted cancels the whole command rather than creating a phase
1696
+ // and only then abandoning the follow-up question.
1697
+ const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1539
1698
  let phase;
1540
1699
  await withFeatureLock(feature.id, async () => {
1541
1700
  const phases = await st.loadAllPhases();
1542
1701
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1543
- const number = await st.allocPhaseNumber();
1544
1702
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1545
1703
  const id = createPhaseId();
1546
- const shortId = createShortId(await st.assignedShortIds());
1704
+ const identity = await st.allocateEntityIdentity("phase", id);
1547
1705
  const priority = await st.nextPriority("phase", feature.id);
1548
1706
  phase = {
1549
- id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
1707
+ id, number: identity.number, shortId: identity.shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
1550
1708
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1551
1709
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1552
1710
  createdAt: nowISO(), updatedAt: nowISO(),
1553
1711
  handoff: "", handoffUpdatedAt: "",
1554
1712
  handoffReadAt: "",
1555
1713
  handoffHistory: [],
1714
+ statusLog: [],
1556
1715
  };
1557
1716
  await st.savePhase(phase);
1558
1717
  await st.updateFeatures((doc) => {
@@ -1568,7 +1727,6 @@ export default function planPiExtension(pi) {
1568
1727
  if (!phase)
1569
1728
  return;
1570
1729
  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
1730
  if (isYes(startDiscuss)) {
1573
1731
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
1574
1732
  }
@@ -1577,7 +1735,7 @@ export default function planPiExtension(pi) {
1577
1735
  if (b === "discuss") {
1578
1736
  let phase = null;
1579
1737
  if (subArgs.trim()) {
1580
- phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
1738
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1581
1739
  }
1582
1740
  if (!phase) {
1583
1741
  phase = await pickPhase();
@@ -1589,9 +1747,6 @@ export default function planPiExtension(pi) {
1589
1747
  if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
1590
1748
  ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
1591
1749
  }
1592
- phase.status = "discovery";
1593
- phase.updatedAt = nowISO();
1594
- await st.savePhase(phase);
1595
1750
  ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1596
1751
  // ── Recap: give full context before planning tasks ──
1597
1752
  const recapPlan = await st.loadAll();
@@ -1663,7 +1818,7 @@ export default function planPiExtension(pi) {
1663
1818
  if (b === "show") {
1664
1819
  let phase = null;
1665
1820
  if (subArgs.trim()) {
1666
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1821
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1667
1822
  }
1668
1823
  if (!phase) {
1669
1824
  phase = await pickPhase();
@@ -1689,7 +1844,7 @@ export default function planPiExtension(pi) {
1689
1844
  if (b === "delete") {
1690
1845
  let phase = null;
1691
1846
  if (subArgs.trim()) {
1692
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1847
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1693
1848
  }
1694
1849
  if (!phase) {
1695
1850
  phase = await pickPhase();
@@ -1711,7 +1866,7 @@ export default function planPiExtension(pi) {
1711
1866
  if (b === "update") {
1712
1867
  let phase = null;
1713
1868
  if (subArgs.trim()) {
1714
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1869
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1715
1870
  }
1716
1871
  if (!phase) {
1717
1872
  phase = await pickPhase();
@@ -1768,7 +1923,7 @@ export default function planPiExtension(pi) {
1768
1923
  // ═══════════════════════════════════════════════════════════════
1769
1924
  if (a === "task") {
1770
1925
  if (!b) {
1771
- ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
1926
+ 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
1927
  return;
1773
1928
  }
1774
1929
  if (b === "add") {
@@ -1782,14 +1937,16 @@ export default function planPiExtension(pi) {
1782
1937
  ctx.ui.notify("Aborted", "warning");
1783
1938
  return;
1784
1939
  }
1940
+ // Keep the optional follow-up inside the creation transaction so
1941
+ // Escape can still cancel before a task is persisted.
1942
+ const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1785
1943
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1786
- const taskNum = await st.allocTaskNumber();
1787
1944
  const taskId = createTaskId();
1788
- const shortId = createShortId(await st.assignedShortIds());
1945
+ const identity = await st.allocateEntityIdentity("task", taskId);
1789
1946
  const priority = await st.nextPriority("task", phase.id);
1790
1947
  const now = nowISO();
1791
1948
  const task = {
1792
- id: taskId, phaseId: phase.id, number: taskNum, shortId, priority, shortName,
1949
+ id: taskId, phaseId: phase.id, number: identity.number, shortId: identity.shortId, priority, shortName,
1793
1950
  title: title.trim(), status: "planned",
1794
1951
  description: "",
1795
1952
  notes: "",
@@ -1807,7 +1964,6 @@ export default function planPiExtension(pi) {
1807
1964
  await st.savePhase(phase);
1808
1965
  await st.writeGenerated();
1809
1966
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
1810
- const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1811
1967
  if (isYes(startDiscuss)) {
1812
1968
  await handlePlanner(`task discuss ${taskId}`, ctx);
1813
1969
  }
@@ -1839,7 +1995,7 @@ export default function planPiExtension(pi) {
1839
1995
  if (description?.trim())
1840
1996
  task.description = description.trim();
1841
1997
  if (checklistSeed?.trim()) {
1842
- task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
1998
+ task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
1843
1999
  id: createChecklistItemId(task.id, index + 1, itemTitle),
1844
2000
  title: itemTitle,
1845
2001
  checked: false,
@@ -1890,7 +2046,7 @@ export default function planPiExtension(pi) {
1890
2046
  if (b === "delete") {
1891
2047
  let phase = null;
1892
2048
  if (subArgs.trim()) {
1893
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2049
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1894
2050
  }
1895
2051
  if (!phase) {
1896
2052
  phase = await pickPhase();
@@ -1924,7 +2080,7 @@ export default function planPiExtension(pi) {
1924
2080
  if (b === "update") {
1925
2081
  let phase = null;
1926
2082
  if (subArgs.trim()) {
1927
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2083
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1928
2084
  }
1929
2085
  if (!phase) {
1930
2086
  phase = await pickPhase();
@@ -1966,6 +2122,7 @@ export default function planPiExtension(pi) {
1966
2122
  task.updatedAt = now;
1967
2123
  phase.updatedAt = now;
1968
2124
  await st.savePhase(phase);
2125
+ await st.syncTaskStatusRollup(phase.id);
1969
2126
  await st.writeGenerated();
1970
2127
  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
2128
  return;
@@ -1998,17 +2155,9 @@ export default function planPiExtension(pi) {
1998
2155
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
1999
2156
  return;
2000
2157
  }
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
- }
2158
+ // A pending handoff is context, not a lock: task start RETAINS it.
2159
+ // It is archived only when the phase completes (auto) or the user
2160
+ // explicitly clears it.
2012
2161
  const now = nowISO();
2013
2162
  // Record status change in the incremental statusLog.
2014
2163
  const prevStatus = task.status;
@@ -2025,6 +2174,7 @@ export default function planPiExtension(pi) {
2025
2174
  task.updatedAt = now;
2026
2175
  phase.updatedAt = now;
2027
2176
  await st.savePhase(phase);
2177
+ await st.syncTaskStatusRollup(phase.id);
2028
2178
  await st.writeGenerated();
2029
2179
  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
2180
  return;
@@ -2079,12 +2229,80 @@ export default function planPiExtension(pi) {
2079
2229
  applyTaskLifecycleDates(task, "done", now);
2080
2230
  task.updatedAt = now;
2081
2231
  phase.updatedAt = now;
2082
- await st.savePhase(phase);
2232
+ const clearedRef = await st.syncTaskStatusRollup(phase.id);
2233
+ await st.writeGenerated();
2234
+ 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");
2235
+ return;
2236
+ }
2237
+ // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
2238
+ if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
2239
+ const [taskRef, ...itemParts] = rest;
2240
+ const itemText = itemParts.join(" ").trim();
2241
+ if (!taskRef || !itemText) {
2242
+ ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
2243
+ return;
2244
+ }
2245
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
2246
+ if (!resolved) {
2247
+ ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
2248
+ return;
2249
+ }
2250
+ let checked;
2251
+ if (b === "checklist-toggle") {
2252
+ const m = itemText.match(/\s+(on|off)$/i);
2253
+ if (m) {
2254
+ checked = m[1].toLowerCase() === "on";
2255
+ }
2256
+ }
2257
+ const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
2258
+ let msg = "";
2259
+ await st.updatePhase(resolved.phase.id, (phase) => {
2260
+ const task = phase.tasks.find((t) => t.id === resolved.task.id);
2261
+ if (!task) {
2262
+ msg = `Task not found: ${taskRef}`;
2263
+ return phase;
2264
+ }
2265
+ const ck = task.checklist ?? [];
2266
+ if (b === "checklist-add") {
2267
+ const item = addChecklistItem(ck, task.id, selector);
2268
+ ck.push(item);
2269
+ task.checklist = ck;
2270
+ msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
2271
+ }
2272
+ else if (b === "checklist-remove") {
2273
+ if (ck.length === 0) {
2274
+ msg = `Task "${task.title}" has no checklist.`;
2275
+ return phase;
2276
+ }
2277
+ const removed = removeChecklistItem(ck, selector);
2278
+ if (!removed) {
2279
+ msg = `No checklist item matching "${selector}".`;
2280
+ return phase;
2281
+ }
2282
+ msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
2283
+ }
2284
+ else {
2285
+ if (ck.length === 0) {
2286
+ msg = `Task "${task.title}" has no checklist.`;
2287
+ return phase;
2288
+ }
2289
+ const target = toggleChecklistItem(ck, selector, checked);
2290
+ if (!target) {
2291
+ msg = `No checklist item matching "${selector}".`;
2292
+ return phase;
2293
+ }
2294
+ const doneCount = ck.filter((i) => i.checked).length;
2295
+ msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
2296
+ }
2297
+ task.updatedAt = nowISO();
2298
+ phase.updatedAt = nowISO();
2299
+ return phase;
2300
+ });
2083
2301
  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");
2302
+ ctx.ui.notify(`✅ ${msg}`, "info");
2085
2303
  return;
2086
2304
  }
2087
- ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
2305
+ ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
2088
2306
  return;
2089
2307
  }
2090
2308
  // ── discuss (legacy alias) ──
@@ -2130,6 +2348,11 @@ export default function planPiExtension(pi) {
2130
2348
  ctx.ui.notify("No text provided; nothing written.", "info");
2131
2349
  return;
2132
2350
  }
2351
+ const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
2352
+ if (!confirmed) {
2353
+ ctx.ui.notify("Handoff write cancelled.", "info");
2354
+ return;
2355
+ }
2133
2356
  await st.setPhaseHandoff(r.phase.id, text);
2134
2357
  ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
2135
2358
  return;
@@ -2139,22 +2362,17 @@ export default function planPiExtension(pi) {
2139
2362
  ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2140
2363
  return;
2141
2364
  }
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" +
2365
+ 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" +
2366
+ "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" +
2367
+ "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" +
2368
+ "After confirmation, the handoff MUST contain at minimum:\n" +
2145
2369
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2146
2370
  "- `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.");
2371
+ "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2372
+ "- `## What was being done`: the concrete decisions/work from this session\n" +
2373
+ "- `## How to resume`: explicit ordered steps\n" +
2374
+ "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2375
+ "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
2376
  ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2159
2377
  return;
2160
2378
  }
@@ -2223,7 +2441,26 @@ export default function planPiExtension(pi) {
2223
2441
  const m = report.migrated;
2224
2442
  const dup = report.integrity.duplicatePhaseIds.length;
2225
2443
  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");
2444
+ 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");
2445
+ return;
2446
+ }
2447
+ if (a === "cleanup-orphans") {
2448
+ const found = await st.listOrphanPhases();
2449
+ if (found.length === 0) {
2450
+ ctx.ui.notify("No orphan phases found.", "info");
2451
+ return;
2452
+ }
2453
+ const lines = [
2454
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
2455
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2456
+ ];
2457
+ const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
2458
+ if (!confirmed) {
2459
+ ctx.ui.notify("Cleanup aborted.", "warning");
2460
+ return;
2461
+ }
2462
+ const report = await st.cleanupOrphanPhases();
2463
+ ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
2227
2464
  return;
2228
2465
  }
2229
2466
  // ── export ──
@@ -2279,6 +2516,10 @@ export default function planPiExtension(pi) {
2279
2516
  startupResumeSummaryPending = false;
2280
2517
  startupResumeSummaryTimer = null;
2281
2518
  }, 60000);
2519
+ // Safety timeout must not keep the Node process alive on its own
2520
+ // (relevant for test hosts that load this entrypoint). Inside Pi the
2521
+ // process is long-lived, so unref is a no-op there.
2522
+ startupResumeSummaryTimer.unref?.();
2282
2523
  // Build the recap and embed it in the trigger content. The trigger
2283
2524
  // message is the channel that reliably reaches the agent on a triggered
2284
2525
  // turn (before_agent_start's systemPrompt injection is NOT applied to
@@ -2303,7 +2544,7 @@ export default function planPiExtension(pi) {
2303
2544
  return;
2304
2545
  }
2305
2546
  if (a === "stop" || a === "disable") {
2306
- plannerSessionEnabled = false;
2547
+ disablePlannerSession();
2307
2548
  await stopServer().catch(() => { });
2308
2549
  try {
2309
2550
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -2319,6 +2560,11 @@ export default function planPiExtension(pi) {
2319
2560
  description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
2320
2561
  getArgumentCompletions: (prefix) => {
2321
2562
  const normalized = prefix.trim().toLowerCase();
2563
+ // Once a complete command path is followed by whitespace, the remaining
2564
+ // text is a free-form human ref/title. Returning the fallback menu here
2565
+ // lets Pi select `init` and replace input such as `feature update F001`.
2566
+ if (hasPlannerCommandArgument(prefix))
2567
+ return null;
2322
2568
  const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
2323
2569
  return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
2324
2570
  },
@@ -2595,7 +2841,7 @@ export default function planPiExtension(pi) {
2595
2841
  pi.registerTool({
2596
2842
  name: "plan_render",
2597
2843
  label: "Plan Render",
2598
- description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
2844
+ description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
2599
2845
  parameters: Type.Object({}),
2600
2846
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2601
2847
  const st = await requirePlan(ctx);
@@ -2621,6 +2867,7 @@ export default function planPiExtension(pi) {
2621
2867
  `Repair complete.`,
2622
2868
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2623
2869
  `Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
2870
+ `Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
2624
2871
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2625
2872
  ];
2626
2873
  if (report.integrity.danglingPhaseIds.length)
@@ -2630,6 +2877,38 @@ export default function planPiExtension(pi) {
2630
2877
  return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2631
2878
  },
2632
2879
  });
2880
+ pi.registerTool({
2881
+ name: "plan_cleanup_orphan_phases",
2882
+ label: "Plan Cleanup Orphan Phases",
2883
+ 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.",
2884
+ parameters: Type.Object({
2885
+ confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
2886
+ }),
2887
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2888
+ const st = await requirePlan(ctx);
2889
+ if (!st)
2890
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2891
+ const found = await st.listOrphanPhases();
2892
+ if (!params.confirm) {
2893
+ if (found.length === 0)
2894
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
2895
+ const lines = [
2896
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
2897
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2898
+ "Rerun with confirm=true to delete these orphan phase files.",
2899
+ ];
2900
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
2901
+ }
2902
+ const report = await st.cleanupOrphanPhases();
2903
+ if (report.removed.length === 0)
2904
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
2905
+ const lines = [
2906
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
2907
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
2908
+ ];
2909
+ return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2910
+ },
2911
+ });
2633
2912
  pi.registerTool({
2634
2913
  name: "plan_get_handoff",
2635
2914
  label: "Plan Get Handoff",
@@ -2649,9 +2928,12 @@ export default function planPiExtension(pi) {
2649
2928
  pi.registerTool({
2650
2929
  name: "plan_write_handoff",
2651
2930
  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.",
2931
+ 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
2932
  parameters: Type.Object({
2933
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
2934
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2654
2935
  reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
2936
+ 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
2937
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
2656
2938
  howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
2657
2939
  extraSections: Type.Optional(Type.Array(Type.Object({
@@ -2663,7 +2945,14 @@ export default function planPiExtension(pi) {
2663
2945
  const st = await requirePlan(ctx);
2664
2946
  if (!st)
2665
2947
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2948
+ const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
2949
+ if (!resolved.ok)
2950
+ 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 } };
2951
+ if (!params.confirmed)
2952
+ 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
2953
  const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2954
+ phaseId: resolved.phase.id,
2955
+ ...(params.title?.trim() ? { title: params.title.trim() } : {}),
2667
2956
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2668
2957
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2669
2958
  ...(params.extraSections && params.extraSections.length > 0
@@ -2674,23 +2963,20 @@ export default function planPiExtension(pi) {
2674
2963
  }
2675
2964
  : {}),
2676
2965
  });
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 } };
2966
+ await st.setPhaseHandoff(resolved.phase.id, markdown);
2967
+ 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
2968
  },
2683
2969
  });
2684
2970
  pi.registerTool({
2685
2971
  name: "plan_delete_handoff",
2686
2972
  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) {
2973
+ description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
2974
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
2975
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2690
2976
  const st = await requirePlan(ctx);
2691
2977
  if (!st)
2692
2978
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2693
- const r = await resolvePhaseForHandoff(st, undefined);
2979
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2694
2980
  if (!r.ok)
2695
2981
  return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
2696
2982
  await st.clearPhaseHandoff(r.phase.id, "manual");
@@ -2698,8 +2984,8 @@ export default function planPiExtension(pi) {
2698
2984
  },
2699
2985
  });
2700
2986
  // 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.
2987
+ // UUID | title. A write target is always explicit: never guess from the
2988
+ // first in-progress phase or a stale resume pointer.
2703
2989
  async function resolvePhaseForHandoff(st, ref) {
2704
2990
  const phases = await st.loadAllPhases();
2705
2991
  const features = (await st.loadFeatures()).features;
@@ -2707,16 +2993,12 @@ export default function planPiExtension(pi) {
2707
2993
  if (ref && ref.trim()) {
2708
2994
  phase = findPhaseByRef(phases, features, ref.trim());
2709
2995
  }
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
2996
  if (!phase) {
2715
2997
  return {
2716
2998
  ok: false,
2717
2999
  error: ref && ref.trim()
2718
3000
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
2719
- : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
3001
+ : "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
2720
3002
  };
2721
3003
  }
2722
3004
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -2741,8 +3023,8 @@ export default function planPiExtension(pi) {
2741
3023
  pi.registerTool({
2742
3024
  name: "handoff_show",
2743
3025
  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." })) }),
3026
+ 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.",
3027
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
2746
3028
  async execute(_id, params, _signal, _onUpdate, ctx) {
2747
3029
  const st = await requirePlan(ctx);
2748
3030
  if (!st)
@@ -2759,9 +3041,11 @@ export default function planPiExtension(pi) {
2759
3041
  pi.registerTool({
2760
3042
  name: "handoff_write",
2761
3043
  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.",
3044
+ 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
3045
  parameters: Type.Object({
2764
- phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
3046
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3047
+ 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'." })),
3048
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2765
3049
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2766
3050
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2767
3051
  }),
@@ -2769,12 +3053,38 @@ export default function planPiExtension(pi) {
2769
3053
  const st = await requirePlan(ctx);
2770
3054
  if (!st)
2771
3055
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2772
- const body = (params.markdown_content ?? params.content ?? "").trim();
3056
+ let body = (params.markdown_content ?? params.content ?? "").trim();
2773
3057
  if (!body)
2774
3058
  return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
3059
+ const title = params.title?.trim();
3060
+ // Derive the current first non-empty line (H1) of the supplied body.
3061
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
3062
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3063
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3064
+ if (!title && isGeneric) {
3065
+ return {
3066
+ 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." }],
3067
+ details: { error: "generic title", firstHeadingText },
3068
+ };
3069
+ }
3070
+ // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
3071
+ if (title) {
3072
+ const lines = body.split(/\r?\n/);
3073
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
3074
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
3075
+ lines[firstIdx] = `# ${title}`;
3076
+ body = lines.join("\n");
3077
+ }
3078
+ else {
3079
+ body = `# ${title}\n\n${body}`;
3080
+ }
3081
+ }
2775
3082
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
2776
3083
  if (!r.ok)
2777
3084
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3085
+ if (!params.confirmed) {
3086
+ 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 } };
3087
+ }
2778
3088
  await st.setPhaseHandoff(r.phase.id, body);
2779
3089
  return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
2780
3090
  },
@@ -2782,8 +3092,8 @@ export default function planPiExtension(pi) {
2782
3092
  pi.registerTool({
2783
3093
  name: "handoff_clear",
2784
3094
  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." })) }),
3095
+ 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.",
3096
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
2787
3097
  async execute(_id, params, _signal, _onUpdate, ctx) {
2788
3098
  const st = await requirePlan(ctx);
2789
3099
  if (!st)
@@ -2792,7 +3102,7 @@ export default function planPiExtension(pi) {
2792
3102
  if (!r.ok)
2793
3103
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
2794
3104
  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 } };
3105
+ 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
3106
  },
2797
3107
  });
2798
3108
  pi.registerTool({
@@ -2849,7 +3159,7 @@ export default function planPiExtension(pi) {
2849
3159
  pi.registerTool({
2850
3160
  name: "feature_get",
2851
3161
  label: "Feature Get",
2852
- description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
3162
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
2853
3163
  parameters: Type.Object({
2854
3164
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
2855
3165
  full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
@@ -2860,12 +3170,17 @@ export default function planPiExtension(pi) {
2860
3170
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2861
3171
  const features = (await st.loadFeatures()).features;
2862
3172
  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: {} };
3173
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
3174
+ if (!resolvedFeature.ok)
3175
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3176
+ const feature = resolvedFeature.feature;
2866
3177
  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: {} };
3178
+ const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3179
+ 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"}` : ""})`;
3180
+ const requirementsBlock = linkedRequirements.length > 0
3181
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3182
+ : "\n\nLinked requirements:\n- None linked to this feature.";
3183
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
2869
3184
  },
2870
3185
  });
2871
3186
  pi.registerTool({
@@ -2884,12 +3199,13 @@ export default function planPiExtension(pi) {
2884
3199
  const now = nowISO();
2885
3200
  const status = params.status ?? "planned";
2886
3201
  const currentFeatures = (await st.loadFeatures()).features;
2887
- const shortId = createShortId(await st.assignedShortIds());
3202
+ const id = createFeatureId();
3203
+ const identity = await st.allocateEntityIdentity("feature", id);
2888
3204
  const priority = await st.nextPriority("feature");
2889
3205
  const feature = {
2890
- id: createFeatureId(),
2891
- number: await st.allocFeatureNumber(),
2892
- shortId,
3206
+ id,
3207
+ number: identity.number,
3208
+ shortId: identity.shortId,
2893
3209
  priority,
2894
3210
  name: params.name,
2895
3211
  description: params.description ?? "",
@@ -2904,6 +3220,7 @@ export default function planPiExtension(pi) {
2904
3220
  acceptedDecisions: [],
2905
3221
  phaseIds: [],
2906
3222
  dependsOn: [],
3223
+ statusLog: [],
2907
3224
  createdAt: now,
2908
3225
  updatedAt: now,
2909
3226
  };
@@ -2917,6 +3234,51 @@ export default function planPiExtension(pi) {
2917
3234
  return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2918
3235
  },
2919
3236
  });
3237
+ pi.registerTool({
3238
+ name: "feature_discuss",
3239
+ label: "Feature Discuss",
3240
+ description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
3241
+ parameters: Type.Object({
3242
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3243
+ description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3244
+ workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3245
+ workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3246
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
3247
+ }),
3248
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3249
+ const st = await requirePlan(ctx);
3250
+ if (!st)
3251
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3252
+ const features = (await st.loadFeatures()).features;
3253
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3254
+ if (!resolvedFeature.ok)
3255
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3256
+ const featureId = resolvedFeature.feature.id;
3257
+ const updatedDoc = await st.updateFeatures((doc) => {
3258
+ const feature = doc.features.find((f) => f.id === featureId);
3259
+ if (!feature)
3260
+ return doc;
3261
+ if (params.description !== undefined)
3262
+ feature.description = params.description;
3263
+ if (params.workDone !== undefined)
3264
+ feature.workDone = params.workDone;
3265
+ if (params.workRemaining !== undefined)
3266
+ feature.workRemaining = params.workRemaining;
3267
+ if (params.dependencies !== undefined)
3268
+ feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3269
+ feature.discussedAt = nowISO();
3270
+ feature.contextReady = true;
3271
+ feature.contextReadyReason = "Updated through feature_discuss tool.";
3272
+ feature.updatedAt = nowISO();
3273
+ return doc;
3274
+ });
3275
+ const feature = updatedDoc.features.find((f) => f.id === featureId);
3276
+ if (!feature)
3277
+ return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3278
+ await st.writeGenerated();
3279
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3280
+ },
3281
+ });
2920
3282
  pi.registerTool({
2921
3283
  name: "feature_update",
2922
3284
  label: "Feature Update",
@@ -2944,10 +3306,36 @@ export default function planPiExtension(pi) {
2944
3306
  const st = await requirePlan(ctx);
2945
3307
  if (!st)
2946
3308
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3309
+ const features = (await st.loadFeatures()).features;
3310
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3311
+ if (!resolvedFeature.ok)
3312
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3313
+ const featureId = resolvedFeature.feature.id;
3314
+ const mutableFields = [
3315
+ "name",
3316
+ "description",
3317
+ "status",
3318
+ "startDate",
3319
+ "endDate",
3320
+ "workDone",
3321
+ "workRemaining",
3322
+ "priority",
3323
+ "acceptedDecisions",
3324
+ ];
3325
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3326
+ if (receivedFields.length === 0) {
3327
+ return {
3328
+ content: [{
3329
+ type: "text",
3330
+ 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.",
3331
+ }],
3332
+ details: { updated: false, reason: "no-mutable-fields" },
3333
+ };
3334
+ }
2947
3335
  let foundDoc;
2948
3336
  try {
2949
3337
  foundDoc = await st.updateFeatures((doc) => {
2950
- const feature = doc.features.find((f) => f.id === params.featureId);
3338
+ const feature = doc.features.find((f) => f.id === featureId);
2951
3339
  if (!feature)
2952
3340
  return doc; // not found: no-op, handled below
2953
3341
  if (params.name !== undefined)
@@ -2985,11 +3373,17 @@ export default function planPiExtension(pi) {
2985
3373
  catch (e) {
2986
3374
  return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
2987
3375
  }
2988
- const feature = foundDoc?.features.find((f) => f.id === params.featureId);
3376
+ const feature = foundDoc?.features.find((f) => f.id === featureId);
2989
3377
  if (!feature)
2990
3378
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
2991
3379
  await st.writeGenerated();
2992
- return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3380
+ return {
3381
+ content: [{
3382
+ type: "text",
3383
+ text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
3384
+ }],
3385
+ details: { ...feature, updated: true, updatedFields: receivedFields },
3386
+ };
2993
3387
  },
2994
3388
  });
2995
3389
  pi.registerTool({
@@ -3004,21 +3398,23 @@ export default function planPiExtension(pi) {
3004
3398
  const st = await requirePlan(ctx);
3005
3399
  if (!st)
3006
3400
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3401
+ const features = (await st.loadFeatures()).features;
3402
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3403
+ if (!resolvedFeature.ok)
3404
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3405
+ const featureId = resolvedFeature.feature.id;
3007
3406
  let deleted = false;
3008
3407
  await st.updateFeatures((doc) => {
3009
3408
  const before = doc.features.length;
3010
- doc.features = doc.features.filter((f) => f.id !== params.featureId);
3409
+ doc.features = doc.features.filter((f) => f.id !== featureId);
3011
3410
  if (doc.features.length !== before)
3012
3411
  deleted = true;
3013
3412
  return doc;
3014
3413
  });
3015
- if (!deleted) {
3016
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3017
- }
3018
3414
  let cascadeCount = 0;
3019
3415
  if (params.cascade) {
3020
3416
  const phases = await st.loadAllPhases();
3021
- for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
3417
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
3022
3418
  await st.deletePhase(phase.id);
3023
3419
  cascadeCount += 1;
3024
3420
  }
@@ -3062,7 +3458,7 @@ export default function planPiExtension(pi) {
3062
3458
  pi.registerTool({
3063
3459
  name: "phase_get",
3064
3460
  label: "Phase Get",
3065
- description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
3461
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3066
3462
  parameters: Type.Object({
3067
3463
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3068
3464
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
@@ -3075,8 +3471,13 @@ export default function planPiExtension(pi) {
3075
3471
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3076
3472
  if (!phase)
3077
3473
  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: {} };
3474
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3475
+ const reqCount = linkedRequirements.length;
3476
+ 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"}` : ""})`;
3477
+ const requirementsBlock = reqCount > 0
3478
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3479
+ : "";
3480
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3080
3481
  },
3081
3482
  });
3082
3483
  pi.registerTool({
@@ -3096,18 +3497,26 @@ export default function planPiExtension(pi) {
3096
3497
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3097
3498
  if (!params.featureId?.trim())
3098
3499
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3500
+ const createPhaseFeatures = (await st.loadFeatures()).features;
3501
+ const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
3502
+ if (!resolvedFeature.ok)
3503
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3504
+ const featureId = resolvedFeature.feature.id;
3505
+ const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
3506
+ if (!featureValidation.ok)
3507
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3508
+ const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
3509
+ if (!existingFeature)
3510
+ return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3099
3511
  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();
3512
+ await withFeatureLock(featureId, async () => {
3104
3513
  const id = createPhaseId();
3105
- const shortId = createShortId(await st.assignedShortIds());
3106
- const priority = await st.nextPriority("phase", params.featureId);
3514
+ const identity = await st.allocateEntityIdentity("phase", id);
3515
+ const priority = await st.nextPriority("phase", featureId);
3107
3516
  const now = nowISO();
3108
3517
  phase = {
3109
- id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3110
- featureId: params.featureId,
3518
+ id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3519
+ featureId,
3111
3520
  status: params.status ?? "draft",
3112
3521
  discussedAt: "",
3113
3522
  contextReady: false,
@@ -3119,11 +3528,12 @@ export default function planPiExtension(pi) {
3119
3528
  handoff: "", handoffUpdatedAt: "",
3120
3529
  handoffReadAt: "",
3121
3530
  handoffHistory: [],
3531
+ statusLog: [],
3122
3532
  };
3123
3533
  await st.savePhase(phase);
3124
3534
  // Atomic: serialize concurrent phase_create linking to the same feature.
3125
3535
  await st.updateFeatures((doc) => {
3126
- const feature = doc.features.find((f) => f.id === params.featureId);
3536
+ const feature = doc.features.find((f) => f.id === featureId);
3127
3537
  if (feature && !feature.phaseIds.includes(phase.id)) {
3128
3538
  feature.phaseIds.push(phase.id);
3129
3539
  feature.updatedAt = now;
@@ -3170,14 +3580,20 @@ export default function planPiExtension(pi) {
3170
3580
  const st = await requirePlan(ctx);
3171
3581
  if (!st)
3172
3582
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3583
+ const features = (await st.loadFeatures()).features;
3584
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3585
+ if (!resolvedPhase)
3586
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3587
+ const phaseId = resolvedPhase.id;
3173
3588
  let phase;
3174
3589
  try {
3175
- phase = await st.loadPhase(params.phaseId);
3590
+ phase = await st.loadPhase(phaseId);
3176
3591
  }
3177
3592
  catch {
3178
3593
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3179
3594
  }
3180
3595
  const prevFeatureId = phase.featureId;
3596
+ let nextFeatureIdForUpdate = prevFeatureId;
3181
3597
  if (params.title !== undefined) {
3182
3598
  phase.title = params.title;
3183
3599
  phase.slug = normalizeSlug(params.title);
@@ -3207,14 +3623,22 @@ export default function planPiExtension(pi) {
3207
3623
  if (params.completionCriteria !== undefined)
3208
3624
  phase.completionCriteria = params.completionCriteria;
3209
3625
  if (params.featureId !== undefined) {
3210
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3211
- phase.featureId = nextFeatureId;
3212
- if (nextFeatureId !== prevFeatureId) {
3626
+ if (params.featureId === "") {
3627
+ nextFeatureIdForUpdate = undefined;
3628
+ }
3629
+ else {
3630
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3631
+ if (!resolvedFeature.ok)
3632
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3633
+ nextFeatureIdForUpdate = resolvedFeature.feature.id;
3634
+ }
3635
+ phase.featureId = nextFeatureIdForUpdate;
3636
+ if (nextFeatureIdForUpdate !== prevFeatureId) {
3213
3637
  await st.updateFeatures((doc) => {
3214
3638
  for (const f of doc.features) {
3215
3639
  if (f.id === prevFeatureId)
3216
3640
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3217
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3641
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3218
3642
  f.phaseIds.push(phase.id);
3219
3643
  }
3220
3644
  return doc;
@@ -3228,6 +3652,77 @@ export default function planPiExtension(pi) {
3228
3652
  return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3229
3653
  },
3230
3654
  });
3655
+ pi.registerTool({
3656
+ name: "decision_record",
3657
+ label: "Decision Record",
3658
+ 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.",
3659
+ parameters: Type.Object({
3660
+ featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
3661
+ phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
3662
+ title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
3663
+ decision: Type.String({ minLength: 1, description: "The agreed decision" }),
3664
+ rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
3665
+ implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
3666
+ }),
3667
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3668
+ const st = await requirePlan(ctx);
3669
+ if (!st)
3670
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3671
+ const features = (await st.loadFeatures()).features;
3672
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3673
+ if (!resolvedFeature.ok)
3674
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3675
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
3676
+ if (!resolvedPhase)
3677
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3678
+ if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
3679
+ return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
3680
+ }
3681
+ const acceptedAt = nowISO();
3682
+ const acceptedDecision = {
3683
+ id: crypto.randomUUID(),
3684
+ title: params.title.trim(),
3685
+ decision: params.decision.trim(),
3686
+ rationale: params.rationale.trim(),
3687
+ implementationNotes: params.implementationNotes.trim(),
3688
+ acceptedAt,
3689
+ };
3690
+ await withFeatureLock(resolvedFeature.feature.id, async () => {
3691
+ let featureWritten = false;
3692
+ try {
3693
+ await st.updateFeatures((doc) => {
3694
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3695
+ if (!feature)
3696
+ throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
3697
+ feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
3698
+ feature.updatedAt = acceptedAt;
3699
+ return doc;
3700
+ });
3701
+ featureWritten = true;
3702
+ await st.updatePhase(resolvedPhase.id, (phase) => {
3703
+ phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
3704
+ phase.updatedAt = acceptedAt;
3705
+ return phase;
3706
+ });
3707
+ }
3708
+ catch (error) {
3709
+ // Best-effort compensation keeps a failed dual-write from reporting
3710
+ // success with a decision persisted only on the feature.
3711
+ if (featureWritten) {
3712
+ await st.updateFeatures((doc) => {
3713
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3714
+ if (feature)
3715
+ feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
3716
+ return doc;
3717
+ }).catch(() => { });
3718
+ }
3719
+ throw error;
3720
+ }
3721
+ });
3722
+ await st.writeGenerated();
3723
+ return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
3724
+ },
3725
+ });
3231
3726
  pi.registerTool({
3232
3727
  name: "phase_delete",
3233
3728
  label: "Phase Delete",
@@ -3239,19 +3734,24 @@ export default function planPiExtension(pi) {
3239
3734
  const st = await requirePlan(ctx);
3240
3735
  if (!st)
3241
3736
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3737
+ const features = (await st.loadFeatures()).features;
3738
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3739
+ if (!resolvedPhase)
3740
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3741
+ const phaseId = resolvedPhase.id;
3242
3742
  let phase;
3243
3743
  try {
3244
- phase = await st.loadPhase(params.phaseId);
3744
+ phase = await st.loadPhase(phaseId);
3245
3745
  }
3246
3746
  catch {
3247
3747
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3248
3748
  }
3249
- await st.deletePhase(params.phaseId);
3749
+ await st.deletePhase(phaseId);
3250
3750
  if (phase.featureId) {
3251
3751
  await st.updateFeatures((doc) => {
3252
3752
  const feature = doc.features.find((f) => f.id === phase.featureId);
3253
3753
  if (feature) {
3254
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
3754
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
3255
3755
  feature.updatedAt = nowISO();
3256
3756
  }
3257
3757
  return doc;
@@ -3330,32 +3830,52 @@ export default function planPiExtension(pi) {
3330
3830
  label: "Task Create",
3331
3831
  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
3832
  parameters: Type.Object({
3833
+ featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
3333
3834
  phaseId: Type.String({ description: "Phase ID the task belongs to" }),
3334
3835
  title: Type.String({ description: "Task title" }),
3335
3836
  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
3837
  status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
3337
3838
  shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
3839
+ checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
3338
3840
  }),
3339
3841
  async execute(_id, params, _signal, _onUpdate, ctx) {
3340
3842
  const st = await requirePlan(ctx);
3341
3843
  if (!st)
3342
3844
  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 {
3845
+ if (!params.featureId?.trim())
3846
+ return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3847
+ const features = (await st.loadFeatures()).features;
3848
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
3849
+ if (!resolvedFeature.ok)
3850
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3851
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
3852
+ if (!featureValidation.ok)
3853
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3854
+ const feature = resolvedFeature.feature;
3855
+ const existingFeature = features.find((f) => f.id === feature.id);
3856
+ if (!existingFeature)
3857
+ return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
3858
+ // Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
3859
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3860
+ if (!resolvedPhase)
3348
3861
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3349
- }
3862
+ if (resolvedPhase.featureId !== feature.id)
3863
+ 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: {} };
3864
+ const phaseId = resolvedPhase.id;
3865
+ const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
3866
+ if (!phaseValidation.ok)
3867
+ return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
3868
+ const existingPhase = await st.loadPhase(phaseId);
3869
+ if (!existingPhase)
3870
+ return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3350
3871
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3351
3872
  const taskId = createTaskId();
3352
- const shortId = createShortId(await st.assignedShortIds());
3353
- const priority = await st.nextPriority("task", params.phaseId);
3873
+ const identity = await st.allocateEntityIdentity("task", taskId);
3874
+ const priority = await st.nextPriority("task", phaseId);
3354
3875
  const now = nowISO();
3355
3876
  const initialStatus = params.status ?? "planned";
3356
- const existingPhase = await st.loadPhase(params.phaseId);
3357
3877
  const task = {
3358
- id: taskId, phaseId: params.phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3878
+ id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
3359
3879
  title: params.title,
3360
3880
  status: initialStatus,
3361
3881
  description: params.description ?? "",
@@ -3363,7 +3883,10 @@ export default function planPiExtension(pi) {
3363
3883
  statusLog: [],
3364
3884
  decisions: [],
3365
3885
  acceptedDecisions: [],
3366
- checklist: [], subtasks: [],
3886
+ checklist: params.checklist
3887
+ ? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
3888
+ : [],
3889
+ subtasks: [],
3367
3890
  dependsOn: [],
3368
3891
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3369
3892
  completedAt: initialStatus === "done" ? now : "",
@@ -3371,14 +3894,15 @@ export default function planPiExtension(pi) {
3371
3894
  };
3372
3895
  // Atomic read-modify-write on the phase file: serializes concurrent
3373
3896
  // task_create calls on the SAME phaseId (batch) so tasks don't get lost.
3374
- await st.updatePhase(params.phaseId, (phase) => {
3897
+ await st.updatePhase(phaseId, (phase) => {
3375
3898
  phase.tasks.push(task);
3376
3899
  phase.taskIds.push(taskId);
3377
3900
  phase.updatedAt = now;
3378
3901
  return phase;
3379
3902
  });
3380
3903
  await st.writeGenerated();
3381
- return { content: [{ type: "text", text: `Task created: ${taskId}` }], details: task };
3904
+ const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
3905
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
3382
3906
  },
3383
3907
  });
3384
3908
  pi.registerTool({
@@ -3409,15 +3933,29 @@ export default function planPiExtension(pi) {
3409
3933
  if (!st)
3410
3934
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3411
3935
  // 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)
3936
+ const features = (await st.loadFeatures()).features;
3937
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3938
+ if (!found)
3415
3939
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3940
+ const hostPhase = found.phase;
3416
3941
  const now = nowISO();
3417
3942
  let updatedTask;
3943
+ // Validate the motivation requirement for restrictive status transitions,
3944
+ // mirroring the MCP adapter and the /planner task update command
3945
+ // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
3946
+ if (params.status !== undefined &&
3947
+ params.status !== found.task.status &&
3948
+ needsMotivation(found.task.status, params.status)) {
3949
+ if (!params.motivation || !params.motivation.trim()) {
3950
+ return {
3951
+ 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.` }],
3952
+ details: {},
3953
+ };
3954
+ }
3955
+ }
3418
3956
  try {
3419
3957
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3420
- const task = phase.tasks.find((t) => t.id === params.taskId);
3958
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3421
3959
  if (!task)
3422
3960
  return phase;
3423
3961
  if (params.title !== undefined)
@@ -3433,7 +3971,7 @@ export default function planPiExtension(pi) {
3433
3971
  if (params.acceptedDecisions !== undefined)
3434
3972
  task.acceptedDecisions = params.acceptedDecisions;
3435
3973
  if (params.checklist !== undefined) {
3436
- task.checklist = params.checklist.map((itemTitle, index) => ({
3974
+ task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
3437
3975
  id: createChecklistItemId(task.id, index + 1, itemTitle),
3438
3976
  title: itemTitle,
3439
3977
  checked: false,
@@ -3465,6 +4003,7 @@ export default function planPiExtension(pi) {
3465
4003
  }
3466
4004
  if (!updatedTask)
3467
4005
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4006
+ await st.syncTaskStatusRollup(found.phase.id);
3468
4007
  await st.writeGenerated();
3469
4008
  return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
3470
4009
  },
@@ -3480,13 +4019,13 @@ export default function planPiExtension(pi) {
3480
4019
  const st = await requirePlan(ctx);
3481
4020
  if (!st)
3482
4021
  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)
4022
+ const features = (await st.loadFeatures()).features;
4023
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4024
+ if (!found)
3486
4025
  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);
4026
+ await st.updatePhase(found.phase.id, (phase) => {
4027
+ phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
4028
+ phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
3490
4029
  phase.updatedAt = nowISO();
3491
4030
  return phase;
3492
4031
  });
@@ -3495,10 +4034,65 @@ export default function planPiExtension(pi) {
3495
4034
  },
3496
4035
  });
3497
4036
  // ── task start / complete tools ──────────────────────────────────
4037
+ pi.registerTool({
4038
+ name: "task_recommend",
4039
+ label: "Task Recommend",
4040
+ 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.",
4041
+ parameters: Type.Object({}),
4042
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
4043
+ const st = await requirePlan(ctx);
4044
+ if (!st)
4045
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4046
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4047
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
4048
+ if (!result.candidate)
4049
+ return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4050
+ const { candidate } = result;
4051
+ const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
4052
+ return {
4053
+ 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}.` : ""}` }],
4054
+ details: result,
4055
+ };
4056
+ },
4057
+ });
4058
+ pi.registerTool({
4059
+ name: "task_deviation",
4060
+ label: "Task Deviation",
4061
+ 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.",
4062
+ parameters: Type.Object({
4063
+ temporary_task: Type.String({ description: "Temporary task ref" }),
4064
+ resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
4065
+ reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
4066
+ }),
4067
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4068
+ const st = await requirePlan(ctx);
4069
+ if (!st)
4070
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4071
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4072
+ const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4073
+ if (!temporary)
4074
+ return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4075
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
4076
+ const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4077
+ if (!resume)
4078
+ return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
4079
+ if (temporary.task.id === resume.task.id)
4080
+ return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
4081
+ if (temporary.task.status !== "planned")
4082
+ return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
4083
+ const timestamp = nowISO();
4084
+ 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: "" };
4085
+ await st.addWorkDeviation(record);
4086
+ await st.writeGenerated();
4087
+ const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
4088
+ const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
4089
+ return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
4090
+ },
4091
+ });
3498
4092
  pi.registerTool({
3499
4093
  name: "task_start",
3500
4094
  label: "Task Start",
3501
- description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
4095
+ 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
4096
  parameters: Type.Object({
3503
4097
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3504
4098
  }),
@@ -3506,19 +4100,40 @@ export default function planPiExtension(pi) {
3506
4100
  const st = await requirePlan(ctx);
3507
4101
  if (!st)
3508
4102
  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)
4103
+ const features = (await st.loadFeatures()).features;
4104
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4105
+ if (!found)
3513
4106
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4107
+ const task = found.task;
3514
4108
  if (task.status === "in-progress")
3515
4109
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3516
4110
  if (task.status === "done")
3517
4111
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4112
+ const project = await st.loadProject();
4113
+ const phases = await st.loadAllPhases();
4114
+ const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4115
+ if (!eligibility.eligible)
4116
+ return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
4117
+ const selection = recommendNextTask(features, phases, project.workDeviations);
4118
+ // Assemble the mandatory parent context before changing task lifecycle state.
4119
+ // The output order is feature + its requirements, then phase + its requirements.
4120
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
4121
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
4122
+ st.loadPhaseWithRequirements(found.phase.id),
4123
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
4124
+ ]);
4125
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4126
+ const advisory = selection.kind === "conflict"
4127
+ ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
4128
+ : selection.candidate && selection.candidate.task.id !== task.id
4129
+ ? selection.kind === "active"
4130
+ ? `\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.`
4131
+ : `\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.`
4132
+ : "";
3518
4133
  const now = nowISO();
3519
4134
  let startedTask;
3520
- await st.updatePhase(hostPhase.id, (phase) => {
3521
- const t = phase.tasks.find((x) => x.id === params.taskId);
4135
+ await st.updatePhase(found.phase.id, (phase) => {
4136
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3522
4137
  if (!t)
3523
4138
  return phase;
3524
4139
  applyTaskLifecycleDates(t, "in-progress", now);
@@ -3527,10 +4142,14 @@ export default function planPiExtension(pi) {
3527
4142
  startedTask = t;
3528
4143
  return phase;
3529
4144
  });
4145
+ await st.syncTaskStatusRollup(found.phase.id);
4146
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
4147
+ if (approvedDeviation)
4148
+ await st.setWorkDeviationState(approvedDeviation.id, "active", now);
3530
4149
  await st.writeGenerated();
3531
4150
  if (!startedTask)
3532
4151
  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 };
4152
+ 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
4153
  },
3535
4154
  });
3536
4155
  pi.registerTool({
@@ -3546,11 +4165,11 @@ export default function planPiExtension(pi) {
3546
4165
  const st = await requirePlan(ctx);
3547
4166
  if (!st)
3548
4167
  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)
4168
+ const features = (await st.loadFeatures()).features;
4169
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4170
+ if (!found)
3553
4171
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4172
+ const task = found.task;
3554
4173
  if (task.status === "done")
3555
4174
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
3556
4175
  const unchecked = task.checklist.filter((item) => !item.checked);
@@ -3562,8 +4181,8 @@ export default function planPiExtension(pi) {
3562
4181
  }
3563
4182
  const now = nowISO();
3564
4183
  let completedTask;
3565
- await st.updatePhase(hostPhase.id, (phase) => {
3566
- const t = phase.tasks.find((x) => x.id === params.taskId);
4184
+ await st.updatePhase(found.phase.id, (phase) => {
4185
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3567
4186
  if (!t)
3568
4187
  return phase;
3569
4188
  applyTaskLifecycleDates(t, "done", now);
@@ -3576,10 +4195,135 @@ export default function planPiExtension(pi) {
3576
4195
  completedTask = t;
3577
4196
  return phase;
3578
4197
  });
4198
+ const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
4199
+ const completedDeviation = (await st.loadProject()).workDeviations
4200
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
4201
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4202
+ if (completedDeviation)
4203
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
3579
4204
  await st.writeGenerated();
3580
4205
  if (!completedTask)
3581
4206
  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 };
4207
+ return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
4208
+ },
4209
+ });
4210
+ pi.registerTool({
4211
+ name: "task_checklist_toggle",
4212
+ label: "Task Checklist Toggle",
4213
+ 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.",
4214
+ parameters: Type.Object({
4215
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4216
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4217
+ checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
4218
+ }),
4219
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4220
+ const st = await requirePlan(ctx);
4221
+ if (!st)
4222
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4223
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4224
+ if (!found)
4225
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4226
+ let result = "";
4227
+ await st.updatePhase(found.phase.id, (phase) => {
4228
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4229
+ if (!task) {
4230
+ result = `Task not found: ${params.taskId}`;
4231
+ return phase;
4232
+ }
4233
+ const ck = task.checklist ?? [];
4234
+ if (ck.length === 0) {
4235
+ result = `Task "${found.task.title}" has no checklist.`;
4236
+ return phase;
4237
+ }
4238
+ const target = toggleChecklistItem(ck, params.item, params.checked);
4239
+ if (!target) {
4240
+ result = `No checklist item matching "${params.item}".`;
4241
+ return phase;
4242
+ }
4243
+ task.updatedAt = nowISO();
4244
+ phase.updatedAt = nowISO();
4245
+ const doneCount = ck.filter((i) => i.checked).length;
4246
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
4247
+ return phase;
4248
+ });
4249
+ await st.writeGenerated();
4250
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4251
+ },
4252
+ });
4253
+ pi.registerTool({
4254
+ name: "task_checklist_add",
4255
+ label: "Task Checklist Add",
4256
+ 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.",
4257
+ parameters: Type.Object({
4258
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4259
+ title: Type.String({ description: "Checklist item text (a single step)" }),
4260
+ }),
4261
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4262
+ const st = await requirePlan(ctx);
4263
+ if (!st)
4264
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4265
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4266
+ if (!found)
4267
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4268
+ let result = "";
4269
+ await st.updatePhase(found.phase.id, (phase) => {
4270
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4271
+ if (!task) {
4272
+ result = `Task not found: ${params.taskId}`;
4273
+ return phase;
4274
+ }
4275
+ const ck = task.checklist ?? [];
4276
+ const item = addChecklistItem(ck, task.id, params.title);
4277
+ ck.push(item);
4278
+ task.checklist = ck;
4279
+ task.updatedAt = nowISO();
4280
+ phase.updatedAt = nowISO();
4281
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
4282
+ return phase;
4283
+ });
4284
+ await st.writeGenerated();
4285
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
4286
+ },
4287
+ });
4288
+ pi.registerTool({
4289
+ name: "task_checklist_remove",
4290
+ label: "Task Checklist Remove",
4291
+ 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.",
4292
+ parameters: Type.Object({
4293
+ taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4294
+ item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
4295
+ }),
4296
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4297
+ const st = await requirePlan(ctx);
4298
+ if (!st)
4299
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4300
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
4301
+ if (!found)
4302
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4303
+ let result = "";
4304
+ await st.updatePhase(found.phase.id, (phase) => {
4305
+ const task = phase.tasks.find((t) => t.id === found.task.id);
4306
+ if (!task) {
4307
+ result = `Task not found: ${params.taskId}`;
4308
+ return phase;
4309
+ }
4310
+ const ck = task.checklist ?? [];
4311
+ if (ck.length === 0) {
4312
+ result = `Task "${found.task.title}" has no checklist.`;
4313
+ return phase;
4314
+ }
4315
+ const removed = removeChecklistItem(ck, params.item);
4316
+ if (!removed) {
4317
+ result = `No checklist item matching "${params.item}".`;
4318
+ return phase;
4319
+ }
4320
+ task.updatedAt = nowISO();
4321
+ phase.updatedAt = nowISO();
4322
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
4323
+ return phase;
4324
+ });
4325
+ await st.writeGenerated();
4326
+ return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
3583
4327
  },
3584
4328
  });
3585
4329
  // ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
@@ -3656,7 +4400,7 @@ export default function planPiExtension(pi) {
3656
4400
  description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
3657
4401
  parameters: Type.Object({}),
3658
4402
  async execute(_id, _params, _signal, _onUpdate, _ctx) {
3659
- plannerSessionEnabled = false;
4403
+ disablePlannerSession();
3660
4404
  await stopServer().catch(() => { });
3661
4405
  try {
3662
4406
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -3673,15 +4417,9 @@ export default function planPiExtension(pi) {
3673
4417
  if (!(await st.exists()))
3674
4418
  return;
3675
4419
  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.
4420
+ // Session initialization must not mutate canonical planner entities.
4421
+ // Migrations/backfills/repairs are explicit tools; resume is `.local`.
3679
4422
  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
4423
  await st.refreshResume();
3686
4424
  plannerHeavyInitDone = true;
3687
4425
  }
@@ -3711,8 +4449,9 @@ export default function planPiExtension(pi) {
3711
4449
  const profile = await st.loadCodebaseProfile();
3712
4450
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3713
4451
  const activity = await st.loadActivityLog();
3714
- const recentActivity = activity.entries.slice(-8).reverse();
4452
+ const recentActivity = activity.entries.slice(-3).reverse();
3715
4453
  const handoffs = await st.listHandoffs();
4454
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3716
4455
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3717
4456
  const orderedPhases = [
3718
4457
  ...plan.features.features.flatMap((feature) => {
@@ -3725,21 +4464,9 @@ export default function planPiExtension(pi) {
3725
4464
  ...plan.phases.filter((phase) => !phase.featureId)
3726
4465
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3727
4466
  ];
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;
4467
+ const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
4468
+ || plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
4469
+ || orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
3743
4470
  const currentPhase = hasActiveWork
3744
4471
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3745
4472
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3756,16 +4483,51 @@ export default function planPiExtension(pi) {
3756
4483
  ?? null)
3757
4484
  : null;
3758
4485
  const currentFeature = hasActiveWork && currentPhase?.featureId
3759
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4486
+ ? featureById.get(currentPhase.featureId) ?? null
3760
4487
  : hasActiveWork
3761
4488
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3762
4489
  : 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.";
4490
+ const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
4491
+ const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
4492
+ const currentTaskRef = currentTask && currentPhase
4493
+ ? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
4494
+ : "";
4495
+ const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
4496
+ const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
4497
+ for (const phase of plan.phases) {
4498
+ phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
4499
+ }
4500
+ const phaseCountsSummary = phaseStatusOrder
4501
+ .map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
4502
+ .join(" ");
4503
+ const activeTaskMap = new Map();
4504
+ for (const phase of orderedPhases) {
4505
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4506
+ for (const task of phase.tasks) {
4507
+ if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
4508
+ continue;
4509
+ activeTaskMap.set(task.id, { phase, task, featureNumber });
4510
+ }
4511
+ }
4512
+ const inProgressTaskLines = [...activeTaskMap.values()]
4513
+ .sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
4514
+ .map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
4515
+ const otherActiveOrBlockedPhases = orderedPhases
4516
+ .filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
4517
+ .slice(0, 6)
4518
+ .map((phase) => {
4519
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4520
+ return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
4521
+ });
4522
+ const openQuestions = plan.phases
4523
+ .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
4524
+ .slice(0, 8);
4525
+ const acceptedDecisions = [
4526
+ ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
4527
+ ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
4528
+ ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
4529
+ ...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}`))),
4530
+ ].slice(0, 20);
3769
4531
  const webServer = server;
3770
4532
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3771
4533
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3778,15 +4540,6 @@ export default function planPiExtension(pi) {
3778
4540
  catch { }
3779
4541
  const webUrl = webLocalUrl;
3780
4542
  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
4543
  const pkgSummary = profile?.packageJson
3791
4544
  ? `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
4545
  : "(no package.json)";
@@ -3808,96 +4561,63 @@ export default function planPiExtension(pi) {
3808
4561
  // Build/refresh the context block (slow path: cache miss).
3809
4562
  const contextBlock = [
3810
4563
  `[Plan Context — ${project.name}]`,
3811
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
3812
4564
  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
4565
  "",
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
- "═══════════════════════════════════════════════════════════════",
4566
+ "Operational rules:",
4567
+ "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4568
+ "- 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.",
4569
+ "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4570
+ "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4571
+ "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4572
+ "- 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).",
4573
+ "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
3826
4574
  "",
3827
- // ── Project details ──
3828
4575
  `Goal: ${project.goal || "(not set)"}`,
3829
4576
  project.description ? `Description: ${project.description}` : "",
3830
4577
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
3831
4578
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
3832
4579
  `Ambient: ${ambientSummary}`,
3833
4580
  "",
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)"}`,
4581
+ "Global rules:",
4582
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
3841
4583
  "",
4584
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
3842
4585
  `Scope: ${project.scope.join(", ") || "(none)"}`,
3843
4586
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
3844
4587
  "",
3845
4588
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
3846
- ...acceptedDecisions.map((d) => `- ${d}`),
4589
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
3847
4590
  openQuestions.length ? "" : "",
3848
4591
  openQuestions.length ? "Open questions:" : "",
3849
- ...openQuestions.map((q) => `- ${q}`),
4592
+ ...openQuestions.map((question) => `- ${question}`),
3850
4593
  "",
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)",
4594
+ "Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
4595
+ currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
4596
+ currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
4597
+ currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
4598
+ inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
3854
4599
  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}` : "",
4600
+ handoffs.length === 0 && resume.nextSteps.length
4601
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4602
+ : "",
4603
+ "",
4604
+ "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
4605
  "",
3858
- recentActivity.length ? "Recent activity (most recent first):" : "",
3859
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
4606
+ "Phase overview:",
4607
+ ` counts: ${phaseCountsSummary}`,
4608
+ currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
4609
+ otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
4610
+ ...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
3860
4611
  "",
3861
- `${plan.phases.length} phases defined.`,
3862
- "Current phases:",
3863
- phaseLines || " _none_",
3864
- active ? `Active: ${active}` : "",
4612
+ recentActivity.length ? "Recent activity:" : "",
4613
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
3865
4614
  "",
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.",
4615
+ handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
4616
+ ...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
4617
+ 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
4618
  "",
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",
4619
+ `Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
4620
+ "Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
3901
4621
  ].filter(Boolean).join("\n");
3902
4622
  contextBlockCache = contextBlock;
3903
4623
  contextBlockDirty = false;