@agent-plan/pi-adapter 0.2.19-next.15 → 0.2.19-next.17

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
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Type } from "typebox";
12
12
  import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
14
14
  import { join, dirname } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -105,11 +105,13 @@ const PLANNER_COMMAND_COMPLETIONS = [
105
105
  { value: "init", label: "init", description: "Initialize planner in this project" },
106
106
  { value: "show", label: "show", description: "Show planner overview" },
107
107
  { value: "repair", label: "repair", description: "Repair planner integrity" },
108
+ { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
108
109
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
109
110
  { value: "project language", label: "project language", description: "Set persistent language preferences" },
110
111
  { value: "feature list", label: "feature list", description: "List features" },
111
112
  { value: "feature add", label: "feature add", description: "Create a feature" },
112
113
  { value: "feature show", label: "feature show", description: "Show a feature" },
114
+ { value: "feature discuss", label: "feature discuss", description: "Discuss a feature" },
113
115
  { value: "feature update", label: "feature update", description: "Update a feature" },
114
116
  { value: "feature delete", label: "feature delete", description: "Delete a feature" },
115
117
  { value: "phase add", label: "phase add", description: "Add a phase" },
@@ -253,6 +255,34 @@ function phaseLabel(phase) {
253
255
  function taskLabel(task) {
254
256
  return `T${formatSequence(task.number)} — ${task.title}`;
255
257
  }
258
+ function resolveFeatureRefStrict(features, ref) {
259
+ const raw = ref.trim();
260
+ if (!raw)
261
+ return { ok: false, error: "Feature ref is required." };
262
+ const normalized = raw.toLowerCase();
263
+ const byNumber = normalized.match(/^f(\d+)$/)
264
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
265
+ : undefined;
266
+ if (byNumber)
267
+ return { ok: true, feature: byNumber };
268
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
269
+ if (byShortId)
270
+ return { ok: true, feature: byShortId };
271
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
272
+ if (byId)
273
+ return { ok: true, feature: byId };
274
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
275
+ if (exactName.length === 1)
276
+ return { ok: true, feature: exactName[0] };
277
+ if (exactName.length > 1)
278
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
279
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
280
+ if (partialName.length === 1)
281
+ return { ok: true, feature: partialName[0] };
282
+ if (partialName.length > 1)
283
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
284
+ return { ok: false, error: `Feature not found: ${raw}` };
285
+ }
256
286
  async function buildHandoffMarkdown(st, reason, overrides) {
257
287
  const [plan, resume, activity] = await Promise.all([
258
288
  st.loadAll(),
@@ -750,9 +780,10 @@ export default function planPiExtension(pi) {
750
780
  // ── Step 1: Enable gating ───────────────────────────────────────
751
781
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
752
782
  // render for NPM-loaded ESM extensions during session_start (they hang
753
- // until the safety-net timeout). The planner is always DISABLED at
754
- // startup; the user enables it with '/planner load' (which also starts
755
- // the web UI on LAN and triggers the resume summary showing the URL).
783
+ // until the safety-net timeout). Tracked upstream via planner task
784
+ // T151 · EBRST. The planner is always DISABLED at startup; the user
785
+ // enables it with '/planner load' (which also starts the web UI on LAN
786
+ // and triggers the resume summary showing the URL).
756
787
  let enablePlanner = false;
757
788
  if (exists) {
758
789
  ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
@@ -945,6 +976,7 @@ export default function planPiExtension(pi) {
945
976
  "init",
946
977
  "show",
947
978
  "repair",
979
+ "cleanup-orphans",
948
980
  "project discuss",
949
981
  "project language",
950
982
  "feature list",
@@ -978,8 +1010,8 @@ export default function planPiExtension(pi) {
978
1010
  "load",
979
1011
  "stop",
980
1012
  ];
981
- const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
982
- "Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
1013
+ const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1014
+ "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
983
1015
  "Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
984
1016
  if (!a) {
985
1017
  const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
@@ -1135,6 +1167,34 @@ export default function planPiExtension(pi) {
1135
1167
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1136
1168
  ?? null;
1137
1169
  }
1170
+ function resolveFeatureRef(features, ref) {
1171
+ const raw = ref.trim();
1172
+ if (!raw)
1173
+ return { ok: false, error: "Feature ref is required." };
1174
+ const normalized = raw.toLowerCase();
1175
+ const byNumber = normalized.match(/^f(\d+)$/)
1176
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
1177
+ : undefined;
1178
+ if (byNumber)
1179
+ return { ok: true, feature: byNumber };
1180
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
1181
+ if (byShortId)
1182
+ return { ok: true, feature: byShortId };
1183
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
1184
+ if (byId)
1185
+ return { ok: true, feature: byId };
1186
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
1187
+ if (exactName.length == 1)
1188
+ return { ok: true, feature: exactName[0] };
1189
+ if (exactName.length > 1)
1190
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
1191
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
1192
+ if (partialName.length == 1)
1193
+ return { ok: true, feature: partialName[0] };
1194
+ if (partialName.length > 1)
1195
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
1196
+ return { ok: false, error: `Feature not found: ${raw}` };
1197
+ }
1138
1198
  function findPhaseByRef(phases, ref) {
1139
1199
  const normalized = ref.trim().toLowerCase();
1140
1200
  if (!normalized)
@@ -1324,7 +1384,7 @@ export default function planPiExtension(pi) {
1324
1384
  // ═══════════════════════════════════════════════════════════════
1325
1385
  if (a === "feature") {
1326
1386
  if (!b) {
1327
- ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
1387
+ ctx.ui.notify("feature actions: list | add [name] | show [id|name] | discuss [id|name] | update [id|name] | delete [id|name]", "info");
1328
1388
  return;
1329
1389
  }
1330
1390
  if (b === "list") {
@@ -1415,6 +1475,44 @@ export default function planPiExtension(pi) {
1415
1475
  ].filter(Boolean).join("\n"), "info");
1416
1476
  return;
1417
1477
  }
1478
+ if (b === "discuss") {
1479
+ const featuresDoc = await st.loadFeatures();
1480
+ let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
1481
+ if (!feature) {
1482
+ feature = await pickFeature();
1483
+ if (!feature) {
1484
+ ctx.ui.notify("No features to discuss. Create one first.", "warning");
1485
+ return;
1486
+ }
1487
+ }
1488
+ const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
1489
+ const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
1490
+ const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
1491
+ const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
1492
+ const featureId = feature.id;
1493
+ const updatedDoc = await st.updateFeatures((doc) => {
1494
+ const target = doc.features.find((entry) => entry.id === featureId);
1495
+ if (!target)
1496
+ return doc;
1497
+ if (description !== undefined)
1498
+ target.description = description.trim();
1499
+ if (workDone !== undefined)
1500
+ target.workDone = workDone.trim();
1501
+ if (workRemaining !== undefined)
1502
+ target.workRemaining = workRemaining.trim();
1503
+ if (dependencies !== undefined)
1504
+ target.dependsOn = splitCsv(dependencies);
1505
+ target.discussedAt = nowISO();
1506
+ target.contextReady = true;
1507
+ target.contextReadyReason = "Updated through /planner feature discuss.";
1508
+ target.updatedAt = nowISO();
1509
+ return doc;
1510
+ });
1511
+ feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
1512
+ await st.writeGenerated();
1513
+ ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
1514
+ return;
1515
+ }
1418
1516
  if (b === "update") {
1419
1517
  const featuresDoc = await st.loadFeatures();
1420
1518
  let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
@@ -1513,7 +1611,7 @@ export default function planPiExtension(pi) {
1513
1611
  ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1514
1612
  return;
1515
1613
  }
1516
- ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
1614
+ ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
1517
1615
  return;
1518
1616
  }
1519
1617
  // ═══════════════════════════════════════════════════════════════
@@ -2299,6 +2397,25 @@ export default function planPiExtension(pi) {
2299
2397
  ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
2300
2398
  return;
2301
2399
  }
2400
+ if (a === "cleanup-orphans") {
2401
+ const found = await st.listOrphanPhases();
2402
+ if (found.length === 0) {
2403
+ ctx.ui.notify("No orphan phases found.", "info");
2404
+ return;
2405
+ }
2406
+ const lines = [
2407
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
2408
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2409
+ ];
2410
+ const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
2411
+ if (!confirmed) {
2412
+ ctx.ui.notify("Cleanup aborted.", "warning");
2413
+ return;
2414
+ }
2415
+ const report = await st.cleanupOrphanPhases();
2416
+ ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
2417
+ return;
2418
+ }
2302
2419
  // ── export ──
2303
2420
  if (a === "export" || a === "export-full") {
2304
2421
  const isFull = a === "export-full" || subArgs.includes("--full");
@@ -2703,6 +2820,38 @@ export default function planPiExtension(pi) {
2703
2820
  return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2704
2821
  },
2705
2822
  });
2823
+ pi.registerTool({
2824
+ name: "plan_cleanup_orphan_phases",
2825
+ label: "Plan Cleanup Orphan Phases",
2826
+ 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.",
2827
+ parameters: Type.Object({
2828
+ confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
2829
+ }),
2830
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2831
+ const st = await requirePlan(ctx);
2832
+ if (!st)
2833
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2834
+ const found = await st.listOrphanPhases();
2835
+ if (!params.confirm) {
2836
+ if (found.length === 0)
2837
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
2838
+ const lines = [
2839
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
2840
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
2841
+ "Rerun with confirm=true to delete these orphan phase files.",
2842
+ ];
2843
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
2844
+ }
2845
+ const report = await st.cleanupOrphanPhases();
2846
+ if (report.removed.length === 0)
2847
+ return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
2848
+ const lines = [
2849
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
2850
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
2851
+ ];
2852
+ return { content: [{ type: "text", text: lines.join("\n") }], details: report };
2853
+ },
2854
+ });
2706
2855
  pi.registerTool({
2707
2856
  name: "plan_get_handoff",
2708
2857
  label: "Plan Get Handoff",
@@ -2991,6 +3140,51 @@ export default function planPiExtension(pi) {
2991
3140
  return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
2992
3141
  },
2993
3142
  });
3143
+ pi.registerTool({
3144
+ name: "feature_discuss",
3145
+ label: "Feature Discuss",
3146
+ description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
3147
+ parameters: Type.Object({
3148
+ featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3149
+ description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3150
+ workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3151
+ workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3152
+ dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
3153
+ }),
3154
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3155
+ const st = await requirePlan(ctx);
3156
+ if (!st)
3157
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3158
+ const features = (await st.loadFeatures()).features;
3159
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3160
+ if (!resolvedFeature.ok)
3161
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3162
+ const featureId = resolvedFeature.feature.id;
3163
+ const updatedDoc = await st.updateFeatures((doc) => {
3164
+ const feature = doc.features.find((f) => f.id === featureId);
3165
+ if (!feature)
3166
+ return doc;
3167
+ if (params.description !== undefined)
3168
+ feature.description = params.description;
3169
+ if (params.workDone !== undefined)
3170
+ feature.workDone = params.workDone;
3171
+ if (params.workRemaining !== undefined)
3172
+ feature.workRemaining = params.workRemaining;
3173
+ if (params.dependencies !== undefined)
3174
+ feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3175
+ feature.discussedAt = nowISO();
3176
+ feature.contextReady = true;
3177
+ feature.contextReadyReason = "Updated through feature_discuss tool.";
3178
+ feature.updatedAt = nowISO();
3179
+ return doc;
3180
+ });
3181
+ const feature = updatedDoc.features.find((f) => f.id === featureId);
3182
+ if (!feature)
3183
+ return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3184
+ await st.writeGenerated();
3185
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3186
+ },
3187
+ });
2994
3188
  pi.registerTool({
2995
3189
  name: "feature_update",
2996
3190
  label: "Feature Update",
@@ -3018,10 +3212,15 @@ export default function planPiExtension(pi) {
3018
3212
  const st = await requirePlan(ctx);
3019
3213
  if (!st)
3020
3214
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3215
+ const features = (await st.loadFeatures()).features;
3216
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3217
+ if (!resolvedFeature.ok)
3218
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3219
+ const featureId = resolvedFeature.feature.id;
3021
3220
  let foundDoc;
3022
3221
  try {
3023
3222
  foundDoc = await st.updateFeatures((doc) => {
3024
- const feature = doc.features.find((f) => f.id === params.featureId);
3223
+ const feature = doc.features.find((f) => f.id === featureId);
3025
3224
  if (!feature)
3026
3225
  return doc; // not found: no-op, handled below
3027
3226
  if (params.name !== undefined)
@@ -3059,7 +3258,7 @@ export default function planPiExtension(pi) {
3059
3258
  catch (e) {
3060
3259
  return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
3061
3260
  }
3062
- const feature = foundDoc?.features.find((f) => f.id === params.featureId);
3261
+ const feature = foundDoc?.features.find((f) => f.id === featureId);
3063
3262
  if (!feature)
3064
3263
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3065
3264
  await st.writeGenerated();
@@ -3078,21 +3277,23 @@ export default function planPiExtension(pi) {
3078
3277
  const st = await requirePlan(ctx);
3079
3278
  if (!st)
3080
3279
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3280
+ const features = (await st.loadFeatures()).features;
3281
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3282
+ if (!resolvedFeature.ok)
3283
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3284
+ const featureId = resolvedFeature.feature.id;
3081
3285
  let deleted = false;
3082
3286
  await st.updateFeatures((doc) => {
3083
3287
  const before = doc.features.length;
3084
- doc.features = doc.features.filter((f) => f.id !== params.featureId);
3288
+ doc.features = doc.features.filter((f) => f.id !== featureId);
3085
3289
  if (doc.features.length !== before)
3086
3290
  deleted = true;
3087
3291
  return doc;
3088
3292
  });
3089
- if (!deleted) {
3090
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3091
- }
3092
3293
  let cascadeCount = 0;
3093
3294
  if (params.cascade) {
3094
3295
  const phases = await st.loadAllPhases();
3095
- for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
3296
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
3096
3297
  await st.deletePhase(phase.id);
3097
3298
  cascadeCount += 1;
3098
3299
  }
@@ -3136,7 +3337,7 @@ export default function planPiExtension(pi) {
3136
3337
  pi.registerTool({
3137
3338
  name: "phase_get",
3138
3339
  label: "Phase Get",
3139
- description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
3340
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
3140
3341
  parameters: Type.Object({
3141
3342
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3142
3343
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
@@ -3149,8 +3350,13 @@ export default function planPiExtension(pi) {
3149
3350
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3150
3351
  if (!phase)
3151
3352
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3152
- const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
3153
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}` : summary }], details: {} };
3353
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3354
+ const reqCount = linkedRequirements.length;
3355
+ 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"}` : ""})`;
3356
+ const requirementsBlock = reqCount > 0
3357
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3358
+ : "";
3359
+ return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3154
3360
  },
3155
3361
  });
3156
3362
  pi.registerTool({
@@ -3170,18 +3376,27 @@ export default function planPiExtension(pi) {
3170
3376
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3171
3377
  if (!params.featureId?.trim())
3172
3378
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3379
+ const createPhaseFeatures = (await st.loadFeatures()).features;
3380
+ const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
3381
+ if (!resolvedFeature.ok)
3382
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3383
+ const featureId = resolvedFeature.feature.id;
3384
+ const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
3385
+ if (!featureValidation.ok)
3386
+ return { content: [{ type: "text", text: featureValidation.error }], details: {} };
3387
+ const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
3388
+ if (!existingFeature)
3389
+ return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3173
3390
  let phase;
3174
- await withFeatureLock(params.featureId, async () => {
3175
- const phases = await st.loadAllPhases();
3176
- const featurePhases = phases.filter((p) => p.featureId === params.featureId);
3391
+ await withFeatureLock(featureId, async () => {
3177
3392
  const number = await st.allocPhaseNumber();
3178
3393
  const id = createPhaseId();
3179
3394
  const shortId = createShortId(await st.assignedShortIds());
3180
- const priority = await st.nextPriority("phase", params.featureId);
3395
+ const priority = await st.nextPriority("phase", featureId);
3181
3396
  const now = nowISO();
3182
3397
  phase = {
3183
3398
  id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3184
- featureId: params.featureId,
3399
+ featureId,
3185
3400
  status: params.status ?? "draft",
3186
3401
  discussedAt: "",
3187
3402
  contextReady: false,
@@ -3198,7 +3413,7 @@ export default function planPiExtension(pi) {
3198
3413
  await st.savePhase(phase);
3199
3414
  // Atomic: serialize concurrent phase_create linking to the same feature.
3200
3415
  await st.updateFeatures((doc) => {
3201
- const feature = doc.features.find((f) => f.id === params.featureId);
3416
+ const feature = doc.features.find((f) => f.id === featureId);
3202
3417
  if (feature && !feature.phaseIds.includes(phase.id)) {
3203
3418
  feature.phaseIds.push(phase.id);
3204
3419
  feature.updatedAt = now;
@@ -3245,14 +3460,20 @@ export default function planPiExtension(pi) {
3245
3460
  const st = await requirePlan(ctx);
3246
3461
  if (!st)
3247
3462
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3463
+ const features = (await st.loadFeatures()).features;
3464
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3465
+ if (!resolvedPhase)
3466
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3467
+ const phaseId = resolvedPhase.id;
3248
3468
  let phase;
3249
3469
  try {
3250
- phase = await st.loadPhase(params.phaseId);
3470
+ phase = await st.loadPhase(phaseId);
3251
3471
  }
3252
3472
  catch {
3253
3473
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3254
3474
  }
3255
3475
  const prevFeatureId = phase.featureId;
3476
+ let nextFeatureIdForUpdate = prevFeatureId;
3256
3477
  if (params.title !== undefined) {
3257
3478
  phase.title = params.title;
3258
3479
  phase.slug = normalizeSlug(params.title);
@@ -3282,14 +3503,22 @@ export default function planPiExtension(pi) {
3282
3503
  if (params.completionCriteria !== undefined)
3283
3504
  phase.completionCriteria = params.completionCriteria;
3284
3505
  if (params.featureId !== undefined) {
3285
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3286
- phase.featureId = nextFeatureId;
3287
- if (nextFeatureId !== prevFeatureId) {
3506
+ if (params.featureId === "") {
3507
+ nextFeatureIdForUpdate = undefined;
3508
+ }
3509
+ else {
3510
+ const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
3511
+ if (!resolvedFeature.ok)
3512
+ return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3513
+ nextFeatureIdForUpdate = resolvedFeature.feature.id;
3514
+ }
3515
+ phase.featureId = nextFeatureIdForUpdate;
3516
+ if (nextFeatureIdForUpdate !== prevFeatureId) {
3288
3517
  await st.updateFeatures((doc) => {
3289
3518
  for (const f of doc.features) {
3290
3519
  if (f.id === prevFeatureId)
3291
3520
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3292
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3521
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3293
3522
  f.phaseIds.push(phase.id);
3294
3523
  }
3295
3524
  return doc;
@@ -3314,19 +3543,24 @@ export default function planPiExtension(pi) {
3314
3543
  const st = await requirePlan(ctx);
3315
3544
  if (!st)
3316
3545
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3546
+ const features = (await st.loadFeatures()).features;
3547
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3548
+ if (!resolvedPhase)
3549
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3550
+ const phaseId = resolvedPhase.id;
3317
3551
  let phase;
3318
3552
  try {
3319
- phase = await st.loadPhase(params.phaseId);
3553
+ phase = await st.loadPhase(phaseId);
3320
3554
  }
3321
3555
  catch {
3322
3556
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3323
3557
  }
3324
- await st.deletePhase(params.phaseId);
3558
+ await st.deletePhase(phaseId);
3325
3559
  if (phase.featureId) {
3326
3560
  await st.updateFeatures((doc) => {
3327
3561
  const feature = doc.features.find((f) => f.id === phase.featureId);
3328
3562
  if (feature) {
3329
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
3563
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
3330
3564
  feature.updatedAt = nowISO();
3331
3565
  }
3332
3566
  return doc;
@@ -3416,22 +3650,26 @@ export default function planPiExtension(pi) {
3416
3650
  const st = await requirePlan(ctx);
3417
3651
  if (!st)
3418
3652
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3419
- // Verify phase exists first (fast-fail before generating an id).
3420
- try {
3421
- await st.loadPhase(params.phaseId);
3422
- }
3423
- catch {
3653
+ // Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
3654
+ const features = (await st.loadFeatures()).features;
3655
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3656
+ if (!resolvedPhase)
3424
3657
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3425
- }
3658
+ const phaseId = resolvedPhase.id;
3659
+ const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
3660
+ if (!phaseValidation.ok)
3661
+ return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
3662
+ const existingPhase = await st.loadPhase(phaseId);
3663
+ if (!existingPhase)
3664
+ return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3426
3665
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3427
3666
  const taskId = createTaskId();
3428
3667
  const shortId = createShortId(await st.assignedShortIds());
3429
- const priority = await st.nextPriority("task", params.phaseId);
3668
+ const priority = await st.nextPriority("task", phaseId);
3430
3669
  const now = nowISO();
3431
3670
  const initialStatus = params.status ?? "planned";
3432
- const existingPhase = await st.loadPhase(params.phaseId);
3433
3671
  const task = {
3434
- id: taskId, phaseId: params.phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3672
+ id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3435
3673
  title: params.title,
3436
3674
  status: initialStatus,
3437
3675
  description: params.description ?? "",
@@ -3450,7 +3688,7 @@ export default function planPiExtension(pi) {
3450
3688
  };
3451
3689
  // Atomic read-modify-write on the phase file: serializes concurrent
3452
3690
  // task_create calls on the SAME phaseId (batch) so tasks don't get lost.
3453
- await st.updatePhase(params.phaseId, (phase) => {
3691
+ await st.updatePhase(phaseId, (phase) => {
3454
3692
  phase.tasks.push(task);
3455
3693
  phase.taskIds.push(taskId);
3456
3694
  phase.updatedAt = now;
@@ -3488,15 +3726,16 @@ export default function planPiExtension(pi) {
3488
3726
  if (!st)
3489
3727
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3490
3728
  // Find the phase hosting this task first.
3491
- const allPhases = await st.loadAllPhases();
3492
- const hostPhase = allPhases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3493
- if (!hostPhase)
3729
+ const features = (await st.loadFeatures()).features;
3730
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3731
+ if (!found)
3494
3732
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3733
+ const hostPhase = found.phase;
3495
3734
  const now = nowISO();
3496
3735
  let updatedTask;
3497
3736
  try {
3498
3737
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
3499
- const task = phase.tasks.find((t) => t.id === params.taskId);
3738
+ const task = phase.tasks.find((t) => t.id === found.task.id);
3500
3739
  if (!task)
3501
3740
  return phase;
3502
3741
  if (params.title !== undefined)
@@ -3559,13 +3798,13 @@ export default function planPiExtension(pi) {
3559
3798
  const st = await requirePlan(ctx);
3560
3799
  if (!st)
3561
3800
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3562
- const phases = await st.loadAllPhases();
3563
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3564
- if (!hostPhase)
3801
+ const features = (await st.loadFeatures()).features;
3802
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3803
+ if (!found)
3565
3804
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3566
- await st.updatePhase(hostPhase.id, (phase) => {
3567
- phase.tasks = phase.tasks.filter((t) => t.id !== params.taskId);
3568
- phase.taskIds = phase.taskIds.filter((id) => id !== params.taskId);
3805
+ await st.updatePhase(found.phase.id, (phase) => {
3806
+ phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
3807
+ phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
3569
3808
  phase.updatedAt = nowISO();
3570
3809
  return phase;
3571
3810
  });
@@ -3585,19 +3824,19 @@ export default function planPiExtension(pi) {
3585
3824
  const st = await requirePlan(ctx);
3586
3825
  if (!st)
3587
3826
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3588
- const phases = await st.loadAllPhases();
3589
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3590
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3591
- if (!task || !hostPhase)
3827
+ const features = (await st.loadFeatures()).features;
3828
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3829
+ if (!found)
3592
3830
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3831
+ const task = found.task;
3593
3832
  if (task.status === "in-progress")
3594
3833
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
3595
3834
  if (task.status === "done")
3596
3835
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
3597
3836
  const now = nowISO();
3598
3837
  let startedTask;
3599
- await st.updatePhase(hostPhase.id, (phase) => {
3600
- const t = phase.tasks.find((x) => x.id === params.taskId);
3838
+ await st.updatePhase(found.phase.id, (phase) => {
3839
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3601
3840
  if (!t)
3602
3841
  return phase;
3603
3842
  applyTaskLifecycleDates(t, "in-progress", now);
@@ -3625,11 +3864,11 @@ export default function planPiExtension(pi) {
3625
3864
  const st = await requirePlan(ctx);
3626
3865
  if (!st)
3627
3866
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3628
- const phases = await st.loadAllPhases();
3629
- const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
3630
- const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
3631
- if (!task || !hostPhase)
3867
+ const features = (await st.loadFeatures()).features;
3868
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3869
+ if (!found)
3632
3870
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3871
+ const task = found.task;
3633
3872
  if (task.status === "done")
3634
3873
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
3635
3874
  const unchecked = task.checklist.filter((item) => !item.checked);
@@ -3641,8 +3880,8 @@ export default function planPiExtension(pi) {
3641
3880
  }
3642
3881
  const now = nowISO();
3643
3882
  let completedTask;
3644
- await st.updatePhase(hostPhase.id, (phase) => {
3645
- const t = phase.tasks.find((x) => x.id === params.taskId);
3883
+ await st.updatePhase(found.phase.id, (phase) => {
3884
+ const t = phase.tasks.find((x) => x.id === found.task.id);
3646
3885
  if (!t)
3647
3886
  return phase;
3648
3887
  applyTaskLifecycleDates(t, "done", now);
@@ -3909,8 +4148,9 @@ export default function planPiExtension(pi) {
3909
4148
  const profile = await st.loadCodebaseProfile();
3910
4149
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3911
4150
  const activity = await st.loadActivityLog();
3912
- const recentActivity = activity.entries.slice(-8).reverse();
4151
+ const recentActivity = activity.entries.slice(-3).reverse();
3913
4152
  const handoffs = await st.listHandoffs();
4153
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3914
4154
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3915
4155
  const orderedPhases = [
3916
4156
  ...plan.features.features.flatMap((feature) => {
@@ -3923,21 +4163,9 @@ export default function planPiExtension(pi) {
3923
4163
  ...plan.phases.filter((phase) => !phase.featureId)
3924
4164
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3925
4165
  ];
3926
- const phaseLines = orderedPhases
3927
- .map((p) => `- ${statusIcon(p.status)} \`${p.id}\` — ${phaseLabel(p)} (${p.status})`)
3928
- .join("\n");
3929
- const active = orderedPhases
3930
- .filter((p) => p.status === "in-progress")
3931
- .map((p) => `\`${p.id}\``)
3932
- .join(", ");
3933
- const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
3934
- const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
3935
- const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
3936
- const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
3937
- const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
3938
- const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
3939
- const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
3940
- const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
4166
+ const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
4167
+ || plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
4168
+ || orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
3941
4169
  const currentPhase = hasActiveWork
3942
4170
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3943
4171
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3954,16 +4182,51 @@ export default function planPiExtension(pi) {
3954
4182
  ?? null)
3955
4183
  : null;
3956
4184
  const currentFeature = hasActiveWork && currentPhase?.featureId
3957
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4185
+ ? featureById.get(currentPhase.featureId) ?? null
3958
4186
  : hasActiveWork
3959
4187
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3960
4188
  : null;
3961
- const nextActivity = handoffs.length > 0
3962
- ? "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.)"
3963
- : hasActiveWork
3964
- ? (resume.nextSteps[0]
3965
- ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3966
- : "Review the current plan and choose the next task.";
4189
+ const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
4190
+ const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
4191
+ const currentTaskRef = currentTask && currentPhase
4192
+ ? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
4193
+ : "";
4194
+ const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
4195
+ const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
4196
+ for (const phase of plan.phases) {
4197
+ phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
4198
+ }
4199
+ const phaseCountsSummary = phaseStatusOrder
4200
+ .map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
4201
+ .join(" ");
4202
+ const activeTaskMap = new Map();
4203
+ for (const phase of orderedPhases) {
4204
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4205
+ for (const task of phase.tasks) {
4206
+ if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
4207
+ continue;
4208
+ activeTaskMap.set(task.id, { phase, task, featureNumber });
4209
+ }
4210
+ }
4211
+ const inProgressTaskLines = [...activeTaskMap.values()]
4212
+ .sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
4213
+ .map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
4214
+ const otherActiveOrBlockedPhases = orderedPhases
4215
+ .filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
4216
+ .slice(0, 6)
4217
+ .map((phase) => {
4218
+ const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
4219
+ return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
4220
+ });
4221
+ const openQuestions = plan.phases
4222
+ .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
4223
+ .slice(0, 8);
4224
+ const acceptedDecisions = [
4225
+ ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
4226
+ ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
4227
+ ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
4228
+ ...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}`))),
4229
+ ].slice(0, 20);
3967
4230
  const webServer = server;
3968
4231
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3969
4232
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3976,15 +4239,6 @@ export default function planPiExtension(pi) {
3976
4239
  catch { }
3977
4240
  const webUrl = webLocalUrl;
3978
4241
  const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
3979
- const openQuestions = plan.phases
3980
- .flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
3981
- .slice(0, 12);
3982
- const acceptedDecisions = [
3983
- ...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
3984
- ...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
3985
- ...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
3986
- ...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
3987
- ].slice(0, 20);
3988
4242
  const pkgSummary = profile?.packageJson
3989
4243
  ? `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}`
3990
4244
  : "(no package.json)";
@@ -4006,96 +4260,60 @@ export default function planPiExtension(pi) {
4006
4260
  // Build/refresh the context block (slow path: cache miss).
4007
4261
  const contextBlock = [
4008
4262
  `[Plan Context — ${project.name}]`,
4009
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
4010
4263
  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'.`,
4011
4264
  "",
4012
- // ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
4013
- "",
4014
- "═══════════════════════════════════════════════════════════════",
4015
- "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
4016
- "═══════════════════════════════════════════════════════════════",
4017
- "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.",
4018
- "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.",
4019
- "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.",
4020
- "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.",
4021
- "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
4022
- "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.",
4023
- "═══════════════════════════════════════════════════════════════",
4265
+ "Operational rules:",
4266
+ "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4267
+ "- BEFORE any code edit/write: task_start. AFTER finishing a task: task_complete.",
4268
+ "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4269
+ "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4270
+ "- Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4271
+ "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
4024
4272
  "",
4025
- // ── Project details ──
4026
4273
  `Goal: ${project.goal || "(not set)"}`,
4027
4274
  project.description ? `Description: ${project.description}` : "",
4028
4275
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
4029
4276
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
4030
4277
  `Ambient: ${ambientSummary}`,
4031
4278
  "",
4032
- "Global rules (re-read before every phase/task start):",
4033
- project.globalRules.length ? project.globalRules.map((r) => `- ${r}`).join("\n") : "- (none)",
4034
- "",
4035
- "Workflow rules:",
4036
- ` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
4037
- ` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
4038
- ` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4279
+ "Global rules:",
4280
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
4039
4281
  "",
4282
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4040
4283
  `Scope: ${project.scope.join(", ") || "(none)"}`,
4041
4284
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
4042
4285
  "",
4043
4286
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
4044
- ...acceptedDecisions.map((d) => `- ${d}`),
4287
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
4045
4288
  openQuestions.length ? "" : "",
4046
4289
  openQuestions.length ? "Open questions:" : "",
4047
- ...openQuestions.map((q) => `- ${q}`),
4290
+ ...openQuestions.map((question) => `- ${question}`),
4048
4291
  "",
4049
- "Resume focus (where we are now):",
4050
- resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
4051
- resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
4292
+ "Resume focus:",
4293
+ currentFeatureRef ? ` current feature: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature: (none)",
4294
+ currentPhaseRef ? ` current phase: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase: (none)",
4295
+ currentTaskRef ? ` current task: ${currentTaskRef} — ${currentTask?.title}` : " current task: (none)",
4296
+ inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
4052
4297
  resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
4053
- 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)",
4054
- resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
4055
- "",
4056
- recentActivity.length ? "Recent activity (most recent first):" : "",
4057
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
4298
+ handoffs.length === 0 && resume.nextSteps.length
4299
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4300
+ : "",
4058
4301
  "",
4059
- `${plan.phases.length} phases defined.`,
4060
- "Current phases:",
4061
- phaseLines || " _none_",
4062
- active ? `Active: ${active}` : "",
4302
+ "Phase overview:",
4303
+ ` counts: ${phaseCountsSummary}`,
4304
+ currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
4305
+ otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
4306
+ ...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
4063
4307
  "",
4064
- "Primary command UX:",
4065
- "- Use `/planner <TAB>` for grouped subcommand suggestions.",
4066
- "- Use `/planner` with no args to open the navigable planner menu.",
4067
- "- Feature commands: planner feature list|add|show|update|delete.",
4068
- "- Phase commands: planner phase add|show|discuss|update|delete.",
4069
- "- Task commands: planner task add|show|discuss|update|delete|start|complete.",
4070
- "- Handoff commands: planner handoff prepare|show|write|clear.",
4071
- "- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
4072
- "Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
4073
- "Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
4074
- `Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
4075
- "If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
4076
- "Ask for language only if BOTH preferences are unset.",
4077
- "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.",
4078
- "Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
4079
- "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.",
4080
- "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.",
4081
- "Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
4308
+ recentActivity.length ? "Recent activity:" : "",
4309
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
4082
4310
  "",
4083
- "TASK STATUS RULES (strict — do not skip):",
4084
- "- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
4085
- "- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
4086
- "- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
4087
- "- 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.",
4088
- "- 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.",
4089
- "- 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.",
4090
- "- 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.",
4091
- "- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
4092
- "- To reopen a completed task, use task_start (recommended) or task_update.",
4093
4311
  handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
4094
- ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
4095
- 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." : "",
4096
- 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." : "",
4097
- 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." : "",
4098
- "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",
4312
+ ...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
4313
+ handoffs.length > 0 ? "Use handoff show <ref>; if no task is active, treat the handoff target as a candidate to validate, not the current focus." : "",
4314
+ "",
4315
+ `Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
4316
+ "Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
4099
4317
  ].filter(Boolean).join("\n");
4100
4318
  contextBlockCache = contextBlock;
4101
4319
  contextBlockDirty = false;