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

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,7 +9,7 @@
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, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
13
13
  import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
14
14
  import { join, dirname } from "node:path";
15
15
  import { homedir } from "node:os";
@@ -131,9 +131,9 @@ const PLANNER_COMMAND_COMPLETIONS = [
131
131
  { value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
132
132
  { value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
133
133
  { value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
134
- { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
135
- { value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (default: in-progress phase)" },
136
- { 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)" },
137
137
  { value: "web start", label: "web start", description: "Start the web UI" },
138
138
  { value: "web stop", label: "web stop", description: "Stop the web UI" },
139
139
  { value: "web status", label: "web status", description: "Show web UI status" },
@@ -144,6 +144,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
144
144
  { value: "load", label: "load", description: "Enable planner + start web UI (LAN) and show resume summary" },
145
145
  { value: "stop", label: "stop", description: "Disable planner and shut down the web UI" },
146
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
+ }
147
153
  // ─── Helpers ────────────────────────────────────────────────────────────
148
154
  function nowISO() {
149
155
  return new Date().toISOString();
@@ -201,7 +207,7 @@ async function maybeHealStatuses(st, ctx) {
201
207
  function normalizeLanguagePref(value) {
202
208
  return value?.trim() ?? "";
203
209
  }
204
- async function ensureProjectLanguagePreferences(st) {
210
+ async function ensureProjectLanguagePreferences(st, persist = true) {
205
211
  const project = await st.loadProject();
206
212
  const contentLanguage = normalizeLanguagePref(project.contentLanguage);
207
213
  const chatLanguage = normalizeLanguagePref(project.chatLanguage);
@@ -209,7 +215,8 @@ async function ensureProjectLanguagePreferences(st) {
209
215
  if (project.contentLanguage !== contentLanguage || project.chatLanguage !== chatLanguage) {
210
216
  project.contentLanguage = contentLanguage;
211
217
  project.chatLanguage = chatLanguage;
212
- await st.saveProject(project);
218
+ if (persist)
219
+ await st.saveProject(project);
213
220
  }
214
221
  return project;
215
222
  }
@@ -217,7 +224,8 @@ async function ensureProjectLanguagePreferences(st) {
217
224
  const fallback = contentLanguage || chatLanguage;
218
225
  project.contentLanguage = contentLanguage || fallback;
219
226
  project.chatLanguage = chatLanguage || fallback;
220
- await st.saveProject(project);
227
+ if (persist)
228
+ await st.saveProject(project);
221
229
  return project;
222
230
  }
223
231
  return project;
@@ -296,7 +304,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
296
304
  const latestTaskUpdate = [...allTasks].sort((left, right) => right.task.updatedAt.localeCompare(left.task.updatedAt))[0] ?? null;
297
305
  const latestPhaseUpdate = [...plan.phases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
298
306
  const latestFeatureUpdate = [...plan.features.features].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
299
- 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)
300
309
  ?? plan.phases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
301
310
  ?? plan.phases.find((phase) => phase.status === "in-progress")
302
311
  ?? nonTerminalTasks[0]?.phase
@@ -653,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
653
662
  return;
654
663
  const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
655
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;
656
668
  try {
657
669
  server = await serve({
658
670
  port,
@@ -665,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
665
677
  });
666
678
  }
667
679
  catch (e) {
668
- // EADDRINUSE or other listen failure: retry on a random free port.
669
- if (e instanceof Error && /EADDRINUSE|EACCES|EADDRNOTAVAIL/.test(e.message)) {
670
- try {
671
- server = await serve({
672
- port: 0,
673
- host,
674
- planRoot: resolvePlanRoot(ctx.cwd),
675
- staticDir: resolveStaticDir(),
676
- quiet: true,
677
- uiConfig: readUiConfig(ctx),
678
- isBusy: () => planBusy,
679
- });
680
- ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
681
- }
682
- catch (e2) {
683
- ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
684
- }
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");
685
697
  }
686
- else {
687
- 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");
688
700
  }
689
701
  }
690
702
  if (server) {
@@ -717,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
717
729
  catch { }
718
730
  }
719
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
+ }
720
743
  async function stopServer() {
721
744
  if (server) {
722
745
  await server.close();
723
746
  server = null;
724
747
  }
725
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;
726
752
  }
727
753
  // ─── Extension factory ──────────────────────────────────────────────────
728
754
  export default function planPiExtension(pi) {
@@ -741,6 +767,9 @@ export default function planPiExtension(pi) {
741
767
  }
742
768
  const argPrefix = match[2];
743
769
  const prefix = argPrefix === undefined ? match[1] : argPrefix;
770
+ if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
771
+ return null;
772
+ }
744
773
  const normalized = (argPrefix ?? "").trimStart().toLowerCase();
745
774
  const items = PLANNER_COMMAND_COMPLETIONS
746
775
  .filter((item) => !normalized || item.value.startsWith(normalized))
@@ -965,8 +994,45 @@ export default function planPiExtension(pi) {
965
994
  });
966
995
  // ── Commands ───────────────────────────────────────────────────────
967
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
+ }
968
1025
  async function handlePlanner(args, ctx) {
969
- 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
+ }
970
1036
  }
971
1037
  async function runPlanner(args, ctx) {
972
1038
  const parts = args.trim().split(/\s+/).filter(Boolean);
@@ -1012,7 +1078,7 @@ export default function planPiExtension(pi) {
1012
1078
  ];
1013
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" +
1014
1080
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1015
- "Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
1081
+ "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
1016
1082
  if (!a) {
1017
1083
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
1018
1084
  if (!action) {
@@ -1032,6 +1098,14 @@ export default function planPiExtension(pi) {
1032
1098
  return;
1033
1099
  }
1034
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 || ""}]`);
1035
1109
  ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
1036
1110
  if (server) {
1037
1111
  ctx.ui.notify("Stopping active web server...", "info");
@@ -1039,12 +1113,6 @@ export default function planPiExtension(pi) {
1039
1113
  server = null;
1040
1114
  }
1041
1115
  await rm(st.root, { recursive: true, force: true });
1042
- const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
1043
- if (!name?.trim()) {
1044
- ctx.ui.notify("Aborted", "warning");
1045
- return;
1046
- }
1047
- const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
1048
1116
  await st.init(name.trim());
1049
1117
  const project = await st.loadProject();
1050
1118
  project.description = description?.trim() ?? oldProject.description;
@@ -1195,23 +1263,6 @@ export default function planPiExtension(pi) {
1195
1263
  return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
1196
1264
  return { ok: false, error: `Feature not found: ${raw}` };
1197
1265
  }
1198
- function findPhaseByRef(phases, ref) {
1199
- const normalized = ref.trim().toLowerCase();
1200
- if (!normalized)
1201
- return null;
1202
- const pMatch = normalized.match(/^p(\d+)$/);
1203
- if (pMatch) {
1204
- const n = parseInt(pMatch[1], 10);
1205
- const byNum = phases.find((p) => p.number === n);
1206
- if (byNum)
1207
- return byNum;
1208
- }
1209
- return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1210
- ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1211
- ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1212
- ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1213
- ?? null;
1214
- }
1215
1266
  function profile_packageManager(pkg, lockfile) {
1216
1267
  if (pkg?.packageManager)
1217
1268
  return pkg.packageManager;
@@ -1225,7 +1276,7 @@ export default function planPiExtension(pi) {
1225
1276
  return "npm";
1226
1277
  return "";
1227
1278
  }
1228
- async function scanCodebase() {
1279
+ async function scanCodebase(persist = true) {
1229
1280
  const root = ctx.cwd;
1230
1281
  const rootFiles = [];
1231
1282
  const directories = [];
@@ -1307,7 +1358,8 @@ export default function planPiExtension(pi) {
1307
1358
  tree,
1308
1359
  ambient,
1309
1360
  };
1310
- await st.saveCodebaseProfile(profile);
1361
+ if (persist)
1362
+ await st.saveCodebaseProfile(profile);
1311
1363
  return profile;
1312
1364
  }
1313
1365
  // ═══════════════════════════════════════════════════════════════
@@ -1319,8 +1371,8 @@ export default function planPiExtension(pi) {
1319
1371
  return;
1320
1372
  }
1321
1373
  if (b === "discuss") {
1322
- const project = await ensureProjectLanguagePreferences(st);
1323
- const profile = await scanCodebase();
1374
+ const project = await ensureProjectLanguagePreferences(st, false);
1375
+ const profile = await scanCodebase(false);
1324
1376
  ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
1325
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");
1326
1378
  const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
@@ -1347,6 +1399,7 @@ export default function planPiExtension(pi) {
1347
1399
  : project.acceptedDecisions,
1348
1400
  };
1349
1401
  await st.saveProject(draftProject);
1402
+ await st.saveCodebaseProfile(profile);
1350
1403
  await st.writeGenerated();
1351
1404
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
1352
1405
  if (capturedPi) {
@@ -1638,6 +1691,10 @@ export default function planPiExtension(pi) {
1638
1691
  ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
1639
1692
  return;
1640
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");
1641
1698
  let phase;
1642
1699
  await withFeatureLock(feature.id, async () => {
1643
1700
  const phases = await st.loadAllPhases();
@@ -1671,7 +1728,6 @@ export default function planPiExtension(pi) {
1671
1728
  if (!phase)
1672
1729
  return;
1673
1730
  ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
1674
- const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1675
1731
  if (isYes(startDiscuss)) {
1676
1732
  await handlePlanner(`phase discuss ${phase.id}`, ctx);
1677
1733
  }
@@ -1680,7 +1736,7 @@ export default function planPiExtension(pi) {
1680
1736
  if (b === "discuss") {
1681
1737
  let phase = null;
1682
1738
  if (subArgs.trim()) {
1683
- phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
1739
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1684
1740
  }
1685
1741
  if (!phase) {
1686
1742
  phase = await pickPhase();
@@ -1692,9 +1748,6 @@ export default function planPiExtension(pi) {
1692
1748
  if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
1693
1749
  ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
1694
1750
  }
1695
- phase.status = "discovery";
1696
- phase.updatedAt = nowISO();
1697
- await st.savePhase(phase);
1698
1751
  ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
1699
1752
  // ── Recap: give full context before planning tasks ──
1700
1753
  const recapPlan = await st.loadAll();
@@ -1766,7 +1819,7 @@ export default function planPiExtension(pi) {
1766
1819
  if (b === "show") {
1767
1820
  let phase = null;
1768
1821
  if (subArgs.trim()) {
1769
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1822
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1770
1823
  }
1771
1824
  if (!phase) {
1772
1825
  phase = await pickPhase();
@@ -1792,7 +1845,7 @@ export default function planPiExtension(pi) {
1792
1845
  if (b === "delete") {
1793
1846
  let phase = null;
1794
1847
  if (subArgs.trim()) {
1795
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1848
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1796
1849
  }
1797
1850
  if (!phase) {
1798
1851
  phase = await pickPhase();
@@ -1814,7 +1867,7 @@ export default function planPiExtension(pi) {
1814
1867
  if (b === "update") {
1815
1868
  let phase = null;
1816
1869
  if (subArgs.trim()) {
1817
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
1870
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1818
1871
  }
1819
1872
  if (!phase) {
1820
1873
  phase = await pickPhase();
@@ -1885,6 +1938,9 @@ export default function planPiExtension(pi) {
1885
1938
  ctx.ui.notify("Aborted", "warning");
1886
1939
  return;
1887
1940
  }
1941
+ // Keep the optional follow-up inside the creation transaction so
1942
+ // Escape can still cancel before a task is persisted.
1943
+ const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1888
1944
  const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
1889
1945
  const taskNum = await st.allocTaskNumber();
1890
1946
  const taskId = createTaskId();
@@ -1910,7 +1966,6 @@ export default function planPiExtension(pi) {
1910
1966
  await st.savePhase(phase);
1911
1967
  await st.writeGenerated();
1912
1968
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
1913
- const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
1914
1969
  if (isYes(startDiscuss)) {
1915
1970
  await handlePlanner(`task discuss ${taskId}`, ctx);
1916
1971
  }
@@ -1993,7 +2048,7 @@ export default function planPiExtension(pi) {
1993
2048
  if (b === "delete") {
1994
2049
  let phase = null;
1995
2050
  if (subArgs.trim()) {
1996
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2051
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
1997
2052
  }
1998
2053
  if (!phase) {
1999
2054
  phase = await pickPhase();
@@ -2027,7 +2082,7 @@ export default function planPiExtension(pi) {
2027
2082
  if (b === "update") {
2028
2083
  let phase = null;
2029
2084
  if (subArgs.trim()) {
2030
- phase = await st.loadPhase(subArgs.trim()).catch(() => null);
2085
+ phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
2031
2086
  }
2032
2087
  if (!phase) {
2033
2088
  phase = await pickPhase();
@@ -2069,6 +2124,7 @@ export default function planPiExtension(pi) {
2069
2124
  task.updatedAt = now;
2070
2125
  phase.updatedAt = now;
2071
2126
  await st.savePhase(phase);
2127
+ await st.syncTaskStatusRollup(phase.id);
2072
2128
  await st.writeGenerated();
2073
2129
  ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2074
2130
  return;
@@ -2101,17 +2157,9 @@ export default function planPiExtension(pi) {
2101
2157
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2102
2158
  return;
2103
2159
  }
2104
- // Auto-archive the handoff on THIS task's phase (reason: task-started) —
2105
- // the agent is now actively working, so the captured context is consumed.
2106
- // Other phases' handoffs are left untouched (informational, non-blocking).
2107
- if ((phase.handoff ?? "") !== "") {
2108
- await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
2109
- ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/.local/handoff-archive/.", "info");
2110
- }
2111
- const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
2112
- if (_otherHandoffs.length > 0) {
2113
- ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
2114
- }
2160
+ // A pending handoff is context, not a lock: task start RETAINS it.
2161
+ // It is archived only when the phase completes (auto) or the user
2162
+ // explicitly clears it.
2115
2163
  const now = nowISO();
2116
2164
  // Record status change in the incremental statusLog.
2117
2165
  const prevStatus = task.status;
@@ -2128,6 +2176,7 @@ export default function planPiExtension(pi) {
2128
2176
  task.updatedAt = now;
2129
2177
  phase.updatedAt = now;
2130
2178
  await st.savePhase(phase);
2179
+ await st.syncTaskStatusRollup(phase.id);
2131
2180
  await st.writeGenerated();
2132
2181
  ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2133
2182
  return;
@@ -2182,9 +2231,9 @@ export default function planPiExtension(pi) {
2182
2231
  applyTaskLifecycleDates(task, "done", now);
2183
2232
  task.updatedAt = now;
2184
2233
  phase.updatedAt = now;
2185
- await st.savePhase(phase);
2234
+ const clearedRef = await st.syncTaskStatusRollup(phase.id);
2186
2235
  await st.writeGenerated();
2187
- ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2236
+ ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
2188
2237
  return;
2189
2238
  }
2190
2239
  // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
@@ -2301,6 +2350,11 @@ export default function planPiExtension(pi) {
2301
2350
  ctx.ui.notify("No text provided; nothing written.", "info");
2302
2351
  return;
2303
2352
  }
2353
+ const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
2354
+ if (!confirmed) {
2355
+ ctx.ui.notify("Handoff write cancelled.", "info");
2356
+ return;
2357
+ }
2304
2358
  await st.setPhaseHandoff(r.phase.id, text);
2305
2359
  ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
2306
2360
  return;
@@ -2310,24 +2364,17 @@ export default function planPiExtension(pi) {
2310
2364
  ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2311
2365
  return;
2312
2366
  }
2313
- const handoffs = await st.listHandoffs();
2314
- await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the phase the user explicitly identifies as the one just worked on in this session, using the `handoff_write` tool (entity-scoped, stored on phase.handoff — .planner/HANDOFF.md is deprecated).\n\n" +
2315
- "Pass a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring') — it becomes the H1 / first line in handoff lists. Generic titles like 'Handoff' or 'Canonical handoff' are REJECTED.\n\n" +
2316
- "WARNING: Do not default to 'current in-progress phase' or a stale resume pointer. If the user just completed P058, write the handoff on P058 (or its continuation P061), not on P031 just because P031 is marked in-progress or has an existing handoff.\n\n" +
2317
- "The handoff MUST be a structured markdown document containing at minimum:\n" +
2367
+ await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
2368
+ "If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
2369
+ "Before calling `handoff_write`, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. Only after confirmation call `handoff_write` with that exact phaseRef. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
2370
+ "After confirmation, the handoff MUST contain at minimum:\n" +
2318
2371
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2319
2372
  "- `Reason:` why this handoff is being written\n" +
2320
- "- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
2321
- "- `## 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" +
2322
- "- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
2323
- "- `## Files touched`: explicit file paths modified during this session.\n" +
2324
- "- `## Blockers`: any open blockers, or `None recorded`.\n" +
2325
- "- `## Next steps`: the immediate next steps.\n" +
2326
- "- `## Recent decisions`: accepted decisions made this session, if any.\n" +
2327
- "- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
2328
- "The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
2329
- (handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
2330
- "Once written, confirm to me with a one-line summary.");
2373
+ "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2374
+ "- `## What was being done`: the concrete decisions/work from this session\n" +
2375
+ "- `## How to resume`: explicit ordered steps\n" +
2376
+ "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2377
+ "Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
2331
2378
  ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2332
2379
  return;
2333
2380
  }
@@ -2396,7 +2443,7 @@ export default function planPiExtension(pi) {
2396
2443
  const m = report.migrated;
2397
2444
  const dup = report.integrity.duplicatePhaseIds.length;
2398
2445
  const dang = report.integrity.danglingPhaseIds.length;
2399
- ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2446
+ ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}, archived ${report.handoffs.archived} stale handoff(s). Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2400
2447
  return;
2401
2448
  }
2402
2449
  if (a === "cleanup-orphans") {
@@ -2471,6 +2518,10 @@ export default function planPiExtension(pi) {
2471
2518
  startupResumeSummaryPending = false;
2472
2519
  startupResumeSummaryTimer = null;
2473
2520
  }, 60000);
2521
+ // Safety timeout must not keep the Node process alive on its own
2522
+ // (relevant for test hosts that load this entrypoint). Inside Pi the
2523
+ // process is long-lived, so unref is a no-op there.
2524
+ startupResumeSummaryTimer.unref?.();
2474
2525
  // Build the recap and embed it in the trigger content. The trigger
2475
2526
  // message is the channel that reliably reaches the agent on a triggered
2476
2527
  // turn (before_agent_start's systemPrompt injection is NOT applied to
@@ -2495,7 +2546,7 @@ export default function planPiExtension(pi) {
2495
2546
  return;
2496
2547
  }
2497
2548
  if (a === "stop" || a === "disable") {
2498
- plannerSessionEnabled = false;
2549
+ disablePlannerSession();
2499
2550
  await stopServer().catch(() => { });
2500
2551
  try {
2501
2552
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -2511,6 +2562,11 @@ export default function planPiExtension(pi) {
2511
2562
  description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
2512
2563
  getArgumentCompletions: (prefix) => {
2513
2564
  const normalized = prefix.trim().toLowerCase();
2565
+ // Once a complete command path is followed by whitespace, the remaining
2566
+ // text is a free-form human ref/title. Returning the fallback menu here
2567
+ // lets Pi select `init` and replace input such as `feature update F001`.
2568
+ if (hasPlannerCommandArgument(prefix))
2569
+ return null;
2514
2570
  const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
2515
2571
  return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
2516
2572
  },
@@ -2813,6 +2869,7 @@ export default function planPiExtension(pi) {
2813
2869
  `Repair complete.`,
2814
2870
  `Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
2815
2871
  `Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
2872
+ `Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
2816
2873
  `Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
2817
2874
  ];
2818
2875
  if (report.integrity.danglingPhaseIds.length)
@@ -2873,8 +2930,10 @@ export default function planPiExtension(pi) {
2873
2930
  pi.registerTool({
2874
2931
  name: "plan_write_handoff",
2875
2932
  label: "Plan Write Handoff",
2876
- description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. Write or refresh the canonical phase handoff. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made. IMPORTANT: write the handoff on the phase the user explicitly identifies as the one just worked on in this session; do not default to a stale 'current phase' pointer or an existing handoff on a different phase.",
2933
+ description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. phaseRef is required and must be the exact phase confirmed with the user; never infer a target from current status or resume pointers. Completed/canceled phases reject new handoffs.",
2877
2934
  parameters: Type.Object({
2935
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
2936
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2878
2937
  reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
2879
2938
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Example: 'P049 — featureId validation: tests + adapter wiring'." })),
2880
2939
  whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
@@ -2888,7 +2947,13 @@ export default function planPiExtension(pi) {
2888
2947
  const st = await requirePlan(ctx);
2889
2948
  if (!st)
2890
2949
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2950
+ const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
2951
+ if (!resolved.ok)
2952
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${resolved.error}` }], details: { deprecated: true, error: resolved.error } };
2953
+ if (!params.confirmed)
2954
+ return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${resolved.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, confirmationRequired: true } };
2891
2955
  const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2956
+ phaseId: resolved.phase.id,
2892
2957
  ...(params.title?.trim() ? { title: params.title.trim() } : {}),
2893
2958
  ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2894
2959
  ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
@@ -2900,23 +2965,20 @@ export default function planPiExtension(pi) {
2900
2965
  }
2901
2966
  : {}),
2902
2967
  });
2903
- const r = await resolvePhaseForHandoff(st, undefined);
2904
- if (!r.ok)
2905
- 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 } };
2906
- await st.setPhaseHandoff(r.phase.id, markdown);
2907
- return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${r.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
2968
+ await st.setPhaseHandoff(resolved.phase.id, markdown);
2969
+ return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${resolved.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id } };
2908
2970
  },
2909
2971
  });
2910
2972
  pi.registerTool({
2911
2973
  name: "plan_delete_handoff",
2912
2974
  label: "Plan Delete Handoff",
2913
- description: "DEPRECATED — clears the entity-scoped handoff of the current in-progress phase. Prefer handoff_clear.",
2914
- parameters: Type.Object({}),
2915
- async execute(_id, _params, _signal, _onUpdate, ctx) {
2975
+ description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
2976
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
2977
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2916
2978
  const st = await requirePlan(ctx);
2917
2979
  if (!st)
2918
2980
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2919
- const r = await resolvePhaseForHandoff(st, undefined);
2981
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
2920
2982
  if (!r.ok)
2921
2983
  return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
2922
2984
  await st.clearPhaseHandoff(r.phase.id, "manual");
@@ -2924,11 +2986,8 @@ export default function planPiExtension(pi) {
2924
2986
  },
2925
2987
  });
2926
2988
  // Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
2927
- // UUID | title; omitted -> current in-progress phase (phase.status, or the
2928
- // phase containing an in-progress task). Returns the phase + its composite ref.
2929
- // NOTE: callers must NOT treat the resolved phase as a directive; it is a
2930
- // fallback pointer that may be stale if the agent worked on a different
2931
- // workstream in this session.
2989
+ // UUID | title. A write target is always explicit: never guess from the
2990
+ // first in-progress phase or a stale resume pointer.
2932
2991
  async function resolvePhaseForHandoff(st, ref) {
2933
2992
  const phases = await st.loadAllPhases();
2934
2993
  const features = (await st.loadFeatures()).features;
@@ -2936,16 +2995,12 @@ export default function planPiExtension(pi) {
2936
2995
  if (ref && ref.trim()) {
2937
2996
  phase = findPhaseByRef(phases, features, ref.trim());
2938
2997
  }
2939
- else {
2940
- phase = phases.find((p) => p.status === "in-progress")
2941
- ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
2942
- }
2943
2998
  if (!phase) {
2944
2999
  return {
2945
3000
  ok: false,
2946
3001
  error: ref && ref.trim()
2947
3002
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
2948
- : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
3003
+ : "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
2949
3004
  };
2950
3005
  }
2951
3006
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -2970,8 +3025,8 @@ export default function planPiExtension(pi) {
2970
3025
  pi.registerTool({
2971
3026
  name: "handoff_show",
2972
3027
  label: "Handoff Show",
2973
- 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.",
2974
- parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase." })) }),
3028
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
3029
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
2975
3030
  async execute(_id, params, _signal, _onUpdate, ctx) {
2976
3031
  const st = await requirePlan(ctx);
2977
3032
  if (!st)
@@ -2988,10 +3043,11 @@ export default function planPiExtension(pi) {
2988
3043
  pi.registerTool({
2989
3044
  name: "handoff_write",
2990
3045
  label: "Handoff Write",
2991
- description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). The title becomes the H1 and the first line shown in handoff lists; generic titles like 'Handoff' or 'Canonical handoff' are REJECTED. phaseRef optional (default: current in-progress phase). IMPORTANT: defaulting to 'current in-progress phase' is a fallback only; write the handoff on the phase the user explicitly identifies as the one just worked on in this session, and do not update an existing handoff on a different phase unless the user confirms.",
3046
+ description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). Never guess the phase from in-progress status or stale context. Completed/canceled phases reject new handoffs.",
2992
3047
  parameters: Type.Object({
2993
- phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
3048
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
2994
3049
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'." })),
3050
+ confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
2995
3051
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
2996
3052
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
2997
3053
  }),
@@ -3028,6 +3084,9 @@ export default function planPiExtension(pi) {
3028
3084
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
3029
3085
  if (!r.ok)
3030
3086
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3087
+ if (!params.confirmed) {
3088
+ return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
3089
+ }
3031
3090
  await st.setPhaseHandoff(r.phase.id, body);
3032
3091
  return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3033
3092
  },
@@ -3035,8 +3094,8 @@ export default function planPiExtension(pi) {
3035
3094
  pi.registerTool({
3036
3095
  name: "handoff_clear",
3037
3096
  label: "Handoff Clear",
3038
- 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).",
3039
- parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })) }),
3097
+ description: "Clear the entity-scoped handoff of a phase (archives it and keeps handoffUpdatedAt as an audit trail). phaseRef is required; never infer a phase automatically.",
3098
+ parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3040
3099
  async execute(_id, params, _signal, _onUpdate, ctx) {
3041
3100
  const st = await requirePlan(ctx);
3042
3101
  if (!st)
@@ -3102,7 +3161,7 @@ export default function planPiExtension(pi) {
3102
3161
  pi.registerTool({
3103
3162
  name: "feature_get",
3104
3163
  label: "Feature Get",
3105
- description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
3164
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3106
3165
  parameters: Type.Object({
3107
3166
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3108
3167
  full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
@@ -3113,12 +3172,17 @@ export default function planPiExtension(pi) {
3113
3172
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3114
3173
  const features = (await st.loadFeatures()).features;
3115
3174
  const ref = params.featureId.trim();
3116
- const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
3117
- if (!feature)
3118
- return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
3175
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
3176
+ if (!resolvedFeature.ok)
3177
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3178
+ const feature = resolvedFeature.feature;
3119
3179
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3120
- const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
3121
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
3180
+ const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3181
+ const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
3182
+ const requirementsBlock = linkedRequirements.length > 0
3183
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3184
+ : "\n\nLinked requirements:\n- None linked to this feature.";
3185
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3122
3186
  },
3123
3187
  });
3124
3188
  pi.registerTool({
@@ -3248,6 +3312,27 @@ export default function planPiExtension(pi) {
3248
3312
  if (!resolvedFeature.ok)
3249
3313
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3250
3314
  const featureId = resolvedFeature.feature.id;
3315
+ const mutableFields = [
3316
+ "name",
3317
+ "description",
3318
+ "status",
3319
+ "startDate",
3320
+ "endDate",
3321
+ "workDone",
3322
+ "workRemaining",
3323
+ "priority",
3324
+ "acceptedDecisions",
3325
+ ];
3326
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3327
+ if (receivedFields.length === 0) {
3328
+ return {
3329
+ content: [{
3330
+ type: "text",
3331
+ text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
3332
+ }],
3333
+ details: { updated: false, reason: "no-mutable-fields" },
3334
+ };
3335
+ }
3251
3336
  let foundDoc;
3252
3337
  try {
3253
3338
  foundDoc = await st.updateFeatures((doc) => {
@@ -3293,7 +3378,13 @@ export default function planPiExtension(pi) {
3293
3378
  if (!feature)
3294
3379
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3295
3380
  await st.writeGenerated();
3296
- return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3381
+ return {
3382
+ content: [{
3383
+ type: "text",
3384
+ text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
3385
+ }],
3386
+ details: { ...feature, updated: true, updatedFields: receivedFields },
3387
+ };
3297
3388
  },
3298
3389
  });
3299
3390
  pi.registerTool({
@@ -3563,6 +3654,77 @@ export default function planPiExtension(pi) {
3563
3654
  return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3564
3655
  },
3565
3656
  });
3657
+ pi.registerTool({
3658
+ name: "decision_record",
3659
+ label: "Decision Record",
3660
+ description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
3661
+ parameters: Type.Object({
3662
+ featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
3663
+ phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
3664
+ title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
3665
+ decision: Type.String({ minLength: 1, description: "The agreed decision" }),
3666
+ rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
3667
+ implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
3668
+ }),
3669
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3670
+ const st = await requirePlan(ctx);
3671
+ if (!st)
3672
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3673
+ const features = (await st.loadFeatures()).features;
3674
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3675
+ if (!resolvedFeature.ok)
3676
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3677
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
3678
+ if (!resolvedPhase)
3679
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3680
+ if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
3681
+ return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
3682
+ }
3683
+ const acceptedAt = nowISO();
3684
+ const acceptedDecision = {
3685
+ id: crypto.randomUUID(),
3686
+ title: params.title.trim(),
3687
+ decision: params.decision.trim(),
3688
+ rationale: params.rationale.trim(),
3689
+ implementationNotes: params.implementationNotes.trim(),
3690
+ acceptedAt,
3691
+ };
3692
+ await withFeatureLock(resolvedFeature.feature.id, async () => {
3693
+ let featureWritten = false;
3694
+ try {
3695
+ await st.updateFeatures((doc) => {
3696
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3697
+ if (!feature)
3698
+ throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
3699
+ feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
3700
+ feature.updatedAt = acceptedAt;
3701
+ return doc;
3702
+ });
3703
+ featureWritten = true;
3704
+ await st.updatePhase(resolvedPhase.id, (phase) => {
3705
+ phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
3706
+ phase.updatedAt = acceptedAt;
3707
+ return phase;
3708
+ });
3709
+ }
3710
+ catch (error) {
3711
+ // Best-effort compensation keeps a failed dual-write from reporting
3712
+ // success with a decision persisted only on the feature.
3713
+ if (featureWritten) {
3714
+ await st.updateFeatures((doc) => {
3715
+ const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
3716
+ if (feature)
3717
+ feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
3718
+ return doc;
3719
+ }).catch(() => { });
3720
+ }
3721
+ throw error;
3722
+ }
3723
+ });
3724
+ await st.writeGenerated();
3725
+ return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
3726
+ },
3727
+ });
3566
3728
  pi.registerTool({
3567
3729
  name: "phase_delete",
3568
3730
  label: "Phase Delete",
@@ -3780,6 +3942,19 @@ export default function planPiExtension(pi) {
3780
3942
  const hostPhase = found.phase;
3781
3943
  const now = nowISO();
3782
3944
  let updatedTask;
3945
+ // Validate the motivation requirement for restrictive status transitions,
3946
+ // mirroring the MCP adapter and the /planner task update command
3947
+ // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
3948
+ if (params.status !== undefined &&
3949
+ params.status !== found.task.status &&
3950
+ needsMotivation(found.task.status, params.status)) {
3951
+ if (!params.motivation || !params.motivation.trim()) {
3952
+ return {
3953
+ content: [{ type: "text", text: `Status transition "${found.task.status} → ${params.status}" requires a motivation. Provide the "motivation" parameter with a detailed explanation of why this change is needed.` }],
3954
+ details: {},
3955
+ };
3956
+ }
3957
+ }
3783
3958
  try {
3784
3959
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3785
3960
  const task = phase.tasks.find((t) => t.id === found.task.id);
@@ -3830,6 +4005,7 @@ export default function planPiExtension(pi) {
3830
4005
  }
3831
4006
  if (!updatedTask)
3832
4007
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4008
+ await st.syncTaskStatusRollup(found.phase.id);
3833
4009
  await st.writeGenerated();
3834
4010
  return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
3835
4011
  },
@@ -3860,10 +4036,65 @@ export default function planPiExtension(pi) {
3860
4036
  },
3861
4037
  });
3862
4038
  // ── task start / complete tools ──────────────────────────────────
4039
+ pi.registerTool({
4040
+ name: "task_recommend",
4041
+ label: "Task Recommend",
4042
+ description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4043
+ parameters: Type.Object({}),
4044
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
4045
+ const st = await requirePlan(ctx);
4046
+ if (!st)
4047
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4048
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4049
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
4050
+ if (!result.candidate)
4051
+ return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4052
+ const { candidate } = result;
4053
+ const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
4054
+ return {
4055
+ content: [{ type: "text", text: `Recommended (${result.kind}): ${reference} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}` }],
4056
+ details: result,
4057
+ };
4058
+ },
4059
+ });
4060
+ pi.registerTool({
4061
+ name: "task_deviation",
4062
+ label: "Task Deviation",
4063
+ description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task but never starts, pauses, or blocks work; use normal task lifecycle tools for those transitions.",
4064
+ parameters: Type.Object({
4065
+ temporary_task: Type.String({ description: "Temporary task ref" }),
4066
+ resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
4067
+ reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
4068
+ }),
4069
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4070
+ const st = await requirePlan(ctx);
4071
+ if (!st)
4072
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4073
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4074
+ const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4075
+ if (!temporary)
4076
+ return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4077
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
4078
+ const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4079
+ if (!resume)
4080
+ return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
4081
+ if (temporary.task.id === resume.task.id)
4082
+ return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
4083
+ if (temporary.task.status !== "planned")
4084
+ return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
4085
+ const timestamp = nowISO();
4086
+ const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason: params.reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
4087
+ await st.addWorkDeviation(record);
4088
+ await st.writeGenerated();
4089
+ const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
4090
+ const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
4091
+ return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
4092
+ },
4093
+ });
3863
4094
  pi.registerTool({
3864
4095
  name: "task_start",
3865
4096
  label: "Task Start",
3866
- description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
4097
+ description: "Set a task to in-progress. BEFORE calling, read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true). Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Sets startedAt automatically and returns the ordered feature/phase requirements briefing.",
3867
4098
  parameters: Type.Object({
3868
4099
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
3869
4100
  }),
@@ -3880,6 +4111,27 @@ export default function planPiExtension(pi) {
3880
4111
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3881
4112
  if (task.status === "done")
3882
4113
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4114
+ const project = await st.loadProject();
4115
+ const phases = await st.loadAllPhases();
4116
+ const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4117
+ if (!eligibility.eligible)
4118
+ return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
4119
+ const selection = recommendNextTask(features, phases, project.workDeviations);
4120
+ // Assemble the mandatory parent context before changing task lifecycle state.
4121
+ // The output order is feature + its requirements, then phase + its requirements.
4122
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
4123
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
4124
+ st.loadPhaseWithRequirements(found.phase.id),
4125
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
4126
+ ]);
4127
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4128
+ const advisory = selection.kind === "conflict"
4129
+ ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
4130
+ : selection.candidate && selection.candidate.task.id !== task.id
4131
+ ? selection.kind === "active"
4132
+ ? `\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.`
4133
+ : `\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.`
4134
+ : "";
3883
4135
  const now = nowISO();
3884
4136
  let startedTask;
3885
4137
  await st.updatePhase(found.phase.id, (phase) => {
@@ -3892,12 +4144,14 @@ export default function planPiExtension(pi) {
3892
4144
  startedTask = t;
3893
4145
  return phase;
3894
4146
  });
4147
+ await st.syncTaskStatusRollup(found.phase.id);
4148
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
4149
+ if (approvedDeviation)
4150
+ await st.setWorkDeviationState(approvedDeviation.id, "active", now);
3895
4151
  await st.writeGenerated();
3896
4152
  if (!startedTask)
3897
4153
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3898
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
3899
- const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
3900
- return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
4154
+ 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 };
3901
4155
  },
3902
4156
  });
3903
4157
  pi.registerTool({
@@ -3943,10 +4197,16 @@ export default function planPiExtension(pi) {
3943
4197
  completedTask = t;
3944
4198
  return phase;
3945
4199
  });
4200
+ const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
4201
+ const completedDeviation = (await st.loadProject()).workDeviations
4202
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
4203
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4204
+ if (completedDeviation)
4205
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
3946
4206
  await st.writeGenerated();
3947
4207
  if (!completedTask)
3948
4208
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3949
- return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
4209
+ return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
3950
4210
  },
3951
4211
  });
3952
4212
  pi.registerTool({
@@ -4142,7 +4402,7 @@ export default function planPiExtension(pi) {
4142
4402
  description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
4143
4403
  parameters: Type.Object({}),
4144
4404
  async execute(_id, _params, _signal, _onUpdate, _ctx) {
4145
- plannerSessionEnabled = false;
4405
+ disablePlannerSession();
4146
4406
  await stopServer().catch(() => { });
4147
4407
  try {
4148
4408
  capturedPi?.appendEntry("plan-web-state", { running: false });
@@ -4313,10 +4573,11 @@ export default function planPiExtension(pi) {
4313
4573
  "",
4314
4574
  "Operational rules:",
4315
4575
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4316
- "- BEFORE any code edit/write: task_start. AFTER finishing a task: task_complete.",
4576
+ "- 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.",
4577
+ "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4317
4578
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4318
4579
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4319
- "- Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4580
+ "- 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).",
4320
4581
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
4321
4582
  "",
4322
4583
  `Goal: ${project.goal || "(not set)"}`,