@agent-plan/pi-adapter 0.2.19-next.16 → 0.2.19-next.18

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",
@@ -3019,10 +3213,10 @@ export default function planPiExtension(pi) {
3019
3213
  if (!st)
3020
3214
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3021
3215
  const features = (await st.loadFeatures()).features;
3022
- const resolvedFeature = features.find((feat) => formatFeatureRef(feat.number).toLowerCase() === params.featureId.trim().toLowerCase() || feat.shortId === params.featureId.trim() || feat.id === params.featureId.trim() || feat.name.toLowerCase() === params.featureId.trim().toLowerCase());
3023
- if (!resolvedFeature)
3024
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3025
- const featureId = resolvedFeature.id;
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;
3026
3220
  let foundDoc;
3027
3221
  try {
3028
3222
  foundDoc = await st.updateFeatures((doc) => {
@@ -3084,10 +3278,10 @@ export default function planPiExtension(pi) {
3084
3278
  if (!st)
3085
3279
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3086
3280
  const features = (await st.loadFeatures()).features;
3087
- const resolvedFeature = features.find((feat) => formatFeatureRef(feat.number).toLowerCase() === params.featureId.trim().toLowerCase() || feat.shortId === params.featureId.trim() || feat.id === params.featureId.trim() || feat.name.toLowerCase() === params.featureId.trim().toLowerCase());
3088
- if (!resolvedFeature)
3089
- return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3090
- const featureId = resolvedFeature.id;
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;
3091
3285
  let deleted = false;
3092
3286
  await st.updateFeatures((doc) => {
3093
3287
  const before = doc.features.length;
@@ -3143,7 +3337,7 @@ export default function planPiExtension(pi) {
3143
3337
  pi.registerTool({
3144
3338
  name: "phase_get",
3145
3339
  label: "Phase Get",
3146
- 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.",
3147
3341
  parameters: Type.Object({
3148
3342
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3149
3343
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
@@ -3156,8 +3350,13 @@ export default function planPiExtension(pi) {
3156
3350
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3157
3351
  if (!phase)
3158
3352
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3159
- const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
3160
- 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 } };
3161
3360
  },
3162
3361
  });
3163
3362
  pi.registerTool({
@@ -3177,18 +3376,27 @@ export default function planPiExtension(pi) {
3177
3376
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3178
3377
  if (!params.featureId?.trim())
3179
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: {} };
3180
3390
  let phase;
3181
- await withFeatureLock(params.featureId, async () => {
3182
- const phases = await st.loadAllPhases();
3183
- const featurePhases = phases.filter((p) => p.featureId === params.featureId);
3391
+ await withFeatureLock(featureId, async () => {
3184
3392
  const number = await st.allocPhaseNumber();
3185
3393
  const id = createPhaseId();
3186
3394
  const shortId = createShortId(await st.assignedShortIds());
3187
- const priority = await st.nextPriority("phase", params.featureId);
3395
+ const priority = await st.nextPriority("phase", featureId);
3188
3396
  const now = nowISO();
3189
3397
  phase = {
3190
3398
  id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3191
- featureId: params.featureId,
3399
+ featureId,
3192
3400
  status: params.status ?? "draft",
3193
3401
  discussedAt: "",
3194
3402
  contextReady: false,
@@ -3205,7 +3413,7 @@ export default function planPiExtension(pi) {
3205
3413
  await st.savePhase(phase);
3206
3414
  // Atomic: serialize concurrent phase_create linking to the same feature.
3207
3415
  await st.updateFeatures((doc) => {
3208
- const feature = doc.features.find((f) => f.id === params.featureId);
3416
+ const feature = doc.features.find((f) => f.id === featureId);
3209
3417
  if (feature && !feature.phaseIds.includes(phase.id)) {
3210
3418
  feature.phaseIds.push(phase.id);
3211
3419
  feature.updatedAt = now;
@@ -3265,6 +3473,7 @@ export default function planPiExtension(pi) {
3265
3473
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3266
3474
  }
3267
3475
  const prevFeatureId = phase.featureId;
3476
+ let nextFeatureIdForUpdate = prevFeatureId;
3268
3477
  if (params.title !== undefined) {
3269
3478
  phase.title = params.title;
3270
3479
  phase.slug = normalizeSlug(params.title);
@@ -3294,14 +3503,22 @@ export default function planPiExtension(pi) {
3294
3503
  if (params.completionCriteria !== undefined)
3295
3504
  phase.completionCriteria = params.completionCriteria;
3296
3505
  if (params.featureId !== undefined) {
3297
- const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
3298
- phase.featureId = nextFeatureId;
3299
- 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) {
3300
3517
  await st.updateFeatures((doc) => {
3301
3518
  for (const f of doc.features) {
3302
3519
  if (f.id === prevFeatureId)
3303
3520
  f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3304
- if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
3521
+ if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3305
3522
  f.phaseIds.push(phase.id);
3306
3523
  }
3307
3524
  return doc;
@@ -3439,13 +3656,18 @@ export default function planPiExtension(pi) {
3439
3656
  if (!resolvedPhase)
3440
3657
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3441
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: {} };
3442
3665
  const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3443
3666
  const taskId = createTaskId();
3444
3667
  const shortId = createShortId(await st.assignedShortIds());
3445
3668
  const priority = await st.nextPriority("task", phaseId);
3446
3669
  const now = nowISO();
3447
3670
  const initialStatus = params.status ?? "planned";
3448
- const existingPhase = await st.loadPhase(phaseId);
3449
3671
  const task = {
3450
3672
  id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
3451
3673
  title: params.title,
@@ -3926,8 +4148,9 @@ export default function planPiExtension(pi) {
3926
4148
  const profile = await st.loadCodebaseProfile();
3927
4149
  const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
3928
4150
  const activity = await st.loadActivityLog();
3929
- const recentActivity = activity.entries.slice(-8).reverse();
4151
+ const recentActivity = activity.entries.slice(-3).reverse();
3930
4152
  const handoffs = await st.listHandoffs();
4153
+ const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
3931
4154
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
3932
4155
  const orderedPhases = [
3933
4156
  ...plan.features.features.flatMap((feature) => {
@@ -3940,21 +4163,9 @@ export default function planPiExtension(pi) {
3940
4163
  ...plan.phases.filter((phase) => !phase.featureId)
3941
4164
  .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
3942
4165
  ];
3943
- const phaseLines = orderedPhases
3944
- .map((p) => `- ${statusIcon(p.status)} \`${p.id}\` — ${phaseLabel(p)} (${p.status})`)
3945
- .join("\n");
3946
- const active = orderedPhases
3947
- .filter((p) => p.status === "in-progress")
3948
- .map((p) => `\`${p.id}\``)
3949
- .join(", ");
3950
- const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
3951
- const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
3952
- const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
3953
- const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
3954
- const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
3955
- const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
3956
- const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
3957
- 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"));
3958
4169
  const currentPhase = hasActiveWork
3959
4170
  ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
3960
4171
  ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
@@ -3971,16 +4182,51 @@ export default function planPiExtension(pi) {
3971
4182
  ?? null)
3972
4183
  : null;
3973
4184
  const currentFeature = hasActiveWork && currentPhase?.featureId
3974
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
4185
+ ? featureById.get(currentPhase.featureId) ?? null
3975
4186
  : hasActiveWork
3976
4187
  ? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
3977
4188
  : null;
3978
- const nextActivity = handoffs.length > 0
3979
- ? "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.)"
3980
- : hasActiveWork
3981
- ? (resume.nextSteps[0]
3982
- ?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
3983
- : "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);
3984
4230
  const webServer = server;
3985
4231
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
3986
4232
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -3993,15 +4239,6 @@ export default function planPiExtension(pi) {
3993
4239
  catch { }
3994
4240
  const webUrl = webLocalUrl;
3995
4241
  const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
3996
- const openQuestions = plan.phases
3997
- .flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
3998
- .slice(0, 12);
3999
- const acceptedDecisions = [
4000
- ...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
4001
- ...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
4002
- ...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
4003
- ...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
4004
- ].slice(0, 20);
4005
4242
  const pkgSummary = profile?.packageJson
4006
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}`
4007
4244
  : "(no package.json)";
@@ -4023,96 +4260,60 @@ export default function planPiExtension(pi) {
4023
4260
  // Build/refresh the context block (slow path: cache miss).
4024
4261
  const contextBlock = [
4025
4262
  `[Plan Context — ${project.name}]`,
4026
- // ── Web UI status (prominent — agent must surface URL in its summary) ──
4027
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'.`,
4028
4264
  "",
4029
- // ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
4030
- "",
4031
- "═══════════════════════════════════════════════════════════════",
4032
- "MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
4033
- "═══════════════════════════════════════════════════════════════",
4034
- "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.",
4035
- "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.",
4036
- "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.",
4037
- "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.",
4038
- "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
4039
- "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.",
4040
- "═══════════════════════════════════════════════════════════════",
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.",
4041
4272
  "",
4042
- // ── Project details ──
4043
4273
  `Goal: ${project.goal || "(not set)"}`,
4044
4274
  project.description ? `Description: ${project.description}` : "",
4045
4275
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
4046
4276
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
4047
4277
  `Ambient: ${ambientSummary}`,
4048
4278
  "",
4049
- "Global rules (re-read before every phase/task start):",
4050
- project.globalRules.length ? project.globalRules.map((r) => `- ${r}`).join("\n") : "- (none)",
4051
- "",
4052
- "Workflow rules:",
4053
- ` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
4054
- ` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
4055
- ` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4279
+ "Global rules:",
4280
+ project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
4056
4281
  "",
4282
+ `Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
4057
4283
  `Scope: ${project.scope.join(", ") || "(none)"}`,
4058
4284
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
4059
4285
  "",
4060
4286
  acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
4061
- ...acceptedDecisions.map((d) => `- ${d}`),
4287
+ ...acceptedDecisions.map((decision) => `- ${decision}`),
4062
4288
  openQuestions.length ? "" : "",
4063
4289
  openQuestions.length ? "Open questions:" : "",
4064
- ...openQuestions.map((q) => `- ${q}`),
4290
+ ...openQuestions.map((question) => `- ${question}`),
4065
4291
  "",
4066
- "Resume focus (where we are now):",
4067
- resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
4068
- 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)",
4069
4297
  resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
4070
- 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)",
4071
- resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
4298
+ handoffs.length === 0 && resume.nextSteps.length
4299
+ ? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
4300
+ : "",
4072
4301
  "",
4073
- recentActivity.length ? "Recent activity (most recent first):" : "",
4074
- ...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
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}`),
4075
4307
  "",
4076
- `${plan.phases.length} phases defined.`,
4077
- "Current phases:",
4078
- phaseLines || " _none_",
4079
- active ? `Active: ${active}` : "",
4308
+ recentActivity.length ? "Recent activity:" : "",
4309
+ ...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
4080
4310
  "",
4081
- "Primary command UX:",
4082
- "- Use `/planner <TAB>` for grouped subcommand suggestions.",
4083
- "- Use `/planner` with no args to open the navigable planner menu.",
4084
- "- Feature commands: planner feature list|add|show|update|delete.",
4085
- "- Phase commands: planner phase add|show|discuss|update|delete.",
4086
- "- Task commands: planner task add|show|discuss|update|delete|start|complete.",
4087
- "- Handoff commands: planner handoff prepare|show|write|clear.",
4088
- "- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
4089
- "Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
4090
- "Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
4091
- `Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
4092
- "If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
4093
- "Ask for language only if BOTH preferences are unset.",
4094
- "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.",
4095
- "Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
4096
- "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.",
4097
- "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.",
4098
- "Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
4099
- "",
4100
- "TASK STATUS RULES (strict — do not skip):",
4101
- "- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
4102
- "- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
4103
- "- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
4104
- "- 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.",
4105
- "- 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.",
4106
- "- 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.",
4107
- "- 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.",
4108
- "- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
4109
- "- To reopen a completed task, use task_start (recommended) or task_update.",
4110
4311
  handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
4111
- ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
4112
- 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." : "",
4113
- 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." : "",
4114
- 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." : "",
4115
- "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.",
4116
4317
  ].filter(Boolean).join("\n");
4117
4318
  contextBlockCache = contextBlock;
4118
4319
  contextBlockDirty = false;