@agent-plan/mcp 0.2.19-next.2 → 0.2.19-next.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAm5BA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA2rCA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -4,9 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import * as z from "zod/v4";
5
5
  import { join } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
7
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, migrateToGlobalSequence, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
8
8
  import { serve } from "@agent-plan/server";
9
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
9
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
10
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
11
11
  const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
12
12
  function text(textValue, structuredContent) {
@@ -31,6 +31,7 @@ async function requireStore() {
31
31
  if (!planBackfillDone) {
32
32
  planBackfillDone = true;
33
33
  await st.ensureShortIdsAndPriority().catch(() => { });
34
+ await migrateToGlobalSequence(st).catch(() => { });
34
35
  }
35
36
  return st;
36
37
  }
@@ -56,39 +57,51 @@ async function ensureWebStarted() {
56
57
  }
57
58
  }
58
59
  function findFeatureByRef(features, ref) {
59
- const normalized = ref.trim().toLowerCase();
60
- return features.find((feature) => feature.id.toLowerCase() === normalized)
61
- ?? features.find((feature) => feature.name.toLowerCase() === normalized)
62
- ?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
63
- }
64
- function findTaskByRef(phases, ref) {
65
60
  const normalized = ref.trim().toLowerCase();
66
61
  if (!normalized)
67
62
  return undefined;
68
- // Composite ref like "P00x(F00x)/T00x" or "T00x(P00x/F00x)": match by phase + task number.
69
- const tMatch = normalized.match(/t0*(\d+)/);
70
- const pMatch = normalized.match(/p0*(\d+)/);
71
- if (tMatch && pMatch) {
72
- const tNum = parseInt(tMatch[1], 10);
73
- const pNum = parseInt(pMatch[1], 10);
74
- for (const phase of phases) {
75
- if (phase.number !== pNum)
76
- continue;
77
- const task = phase.tasks.find((t) => t.number === tNum);
78
- if (task)
79
- return { phase, task };
80
- }
81
- }
82
- for (const phase of phases) {
83
- const task = phase.tasks.find((entry) => entry.id.toLowerCase() === normalized
84
- || (entry.shortId && entry.shortId.toLowerCase() === normalized)
85
- || entry.title.toLowerCase() === normalized
86
- || entry.title.toLowerCase().includes(normalized));
87
- if (task)
88
- return { phase, task };
63
+ const fMatch = normalized.match(/^f(\d+)$/);
64
+ if (fMatch) {
65
+ const n = parseInt(fMatch[1], 10);
66
+ const byNum = features.find((feature) => feature.number === n);
67
+ if (byNum)
68
+ return byNum;
89
69
  }
90
- return undefined;
70
+ return features.find((feature) => feature.shortId?.toLowerCase() === normalized)
71
+ ?? features.find((feature) => feature.id.toLowerCase() === normalized)
72
+ ?? features.find((feature) => feature.name.toLowerCase() === normalized)
73
+ ?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
74
+ }
75
+ function resolveFeatureRefStrict(features, ref) {
76
+ const raw = ref.trim();
77
+ if (!raw)
78
+ return { ok: false, error: "Feature ref is required." };
79
+ const normalized = raw.toLowerCase();
80
+ const byNumber = normalized.match(/^f(\d+)$/)
81
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
82
+ : undefined;
83
+ if (byNumber)
84
+ return { ok: true, feature: byNumber };
85
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
86
+ if (byShortId)
87
+ return { ok: true, feature: byShortId };
88
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
89
+ if (byId)
90
+ return { ok: true, feature: byId };
91
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
92
+ if (exactName.length === 1)
93
+ return { ok: true, feature: exactName[0] };
94
+ if (exactName.length > 1)
95
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
96
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
97
+ if (partialName.length === 1)
98
+ return { ok: true, feature: partialName[0] };
99
+ if (partialName.length > 1)
100
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
101
+ return { ok: false, error: `Feature not found: ${raw}` };
91
102
  }
103
+ /** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
104
+ * must still exist in the store before we allocate numbers or write. */
92
105
  function featureNumberOfPhase(phase, features) {
93
106
  return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
94
107
  }
@@ -151,7 +164,7 @@ server.registerTool("planner-export", {
151
164
  const markdown = exportService.exportToMarkdown(plan, full);
152
165
  const fs = await import("node:fs/promises");
153
166
  await fs.writeFile(join(st.root, "EXPORT.md"), markdown, "utf-8");
154
- return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`, { markdown });
167
+ return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`);
155
168
  });
156
169
  server.registerTool("planner-authorize-bypass", {
157
170
  description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write tools can proceed even when no task is in-progress. Use ONLY after the user explicitly authorizes proceeding without a task. Harness-agnostic: stored in resume.json so all adapters (Pi, Claude Code, Codex, ...) respect it.",
@@ -189,29 +202,80 @@ server.registerTool("planner-init", {
189
202
  if (goal !== undefined)
190
203
  project.goal = goal.trim();
191
204
  await st.saveProject(project);
192
- return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`, { project });
205
+ return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
193
206
  });
194
207
  server.registerTool("planner-show", {
195
- description: "Show the current planner overview.",
208
+ description: "Show a compact planner overview: project metadata, counts, and a one-line summary of each feature (F00x · shortId — name (status; N phases, M tasks)). Does NOT include full descriptions, phases, or tasks — use planner-feature-list / planner-phase-list / planner-task-list for discovery and planner-*-show for full detail of a single entity. Keeps the result small to avoid token overflow.",
196
209
  }, async () => {
197
210
  const st = await requireStore();
198
- const plan = await st.loadAll();
199
- return text([
200
- `📋 ${plan.project.name}`,
201
- `Description: ${plan.project.description || "(not set)"}`,
202
- `Goal: ${plan.project.goal || "(not set)"}`,
203
- `Features: ${plan.features.features.length}`,
204
- `Phases: ${plan.phases.length}`,
205
- `Tasks: ${plan.phases.reduce((total, phase) => total + phase.tasks.length, 0)}`,
206
- `Updated: ${plan.manifest.updatedAt}`,
207
- ].join("\n"), { plan });
211
+ const features = (await st.loadFeatures()).features;
212
+ const phases = await st.loadAllPhases();
213
+ const totalTasks = phases.reduce((total, phase) => total + phase.tasks.length, 0);
214
+ const project = await st.loadProject();
215
+ const manifest = await st.loadManifest();
216
+ const featureLines = features.map((feature) => {
217
+ const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
218
+ const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
219
+ return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
220
+ });
221
+ const summary = [
222
+ `📋 ${project.name}`,
223
+ `Description: ${project.description || "(not set)"}`,
224
+ `Goal: ${project.goal || "(not set)"}`,
225
+ `Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
226
+ `Updated: ${manifest.updatedAt || "(unknown)"}`,
227
+ "",
228
+ "Features:",
229
+ featureLines.join("\n") || "(none)",
230
+ ].join("\n");
231
+ // Compact structured overview (no full plan: avoids 1M+ char token overflow).
232
+ const overview = {
233
+ project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
234
+ counts: { features: features.length, phases: phases.length, tasks: totalTasks },
235
+ features: features.map((feature) => ({
236
+ ref: formatFeatureRef(feature.number),
237
+ shortId: feature.shortId || null,
238
+ name: feature.name,
239
+ status: feature.status,
240
+ phases: phases.filter((p) => p.featureId === feature.id).length,
241
+ tasks: phases.filter((p) => p.featureId === feature.id).reduce((t, p) => t + p.tasks.length, 0),
242
+ })),
243
+ };
244
+ return text(summary, { overview });
208
245
  });
209
246
  server.registerTool("planner-repair", {
210
- description: "Repair dangling feature→phase references and report integrity.",
247
+ description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug where tasks land in the wrong phase file), and report integrity.",
211
248
  }, async () => {
212
249
  const st = await requireStore();
213
250
  const report = await st.repair();
214
- return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
251
+ return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Containment: ${report.containment.changed} phase files rewritten (${report.containment.tasks} tasks scanned, ${report.containment.orphan} orphan). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
252
+ });
253
+ server.registerTool("planner-cleanup-orphan-phases", {
254
+ 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.",
255
+ inputSchema: {
256
+ confirm: z.boolean().optional().describe("Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)."),
257
+ },
258
+ }, async ({ confirm }) => {
259
+ const st = await requireStore();
260
+ const found = await st.listOrphanPhases();
261
+ if (!confirm) {
262
+ if (found.length === 0)
263
+ return text("No orphan phases found.", { found: [] });
264
+ const lines = [
265
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
266
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
267
+ "Rerun with confirm=true to delete these orphan phase files.",
268
+ ];
269
+ return text(lines.join("\n"), { found, confirmRequired: true });
270
+ }
271
+ const report = await st.cleanupOrphanPhases();
272
+ if (report.removed.length === 0)
273
+ return text("No orphan phases found.", report);
274
+ const lines = [
275
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
276
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
277
+ ];
278
+ return text(lines.join("\n"), report);
215
279
  });
216
280
  server.registerTool("planner-project-language", {
217
281
  description: "Persist preferred languages for plan content and chat.",
@@ -227,7 +291,7 @@ server.registerTool("planner-project-language", {
227
291
  if (chatLanguage !== undefined)
228
292
  project.chatLanguage = chatLanguage.trim();
229
293
  await st.saveProject(project);
230
- return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`, { project });
294
+ return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
231
295
  });
232
296
  server.registerTool("planner-project-discuss", {
233
297
  description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
@@ -258,20 +322,73 @@ server.registerTool("planner-project-discuss", {
258
322
  if (params.decisions !== undefined)
259
323
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
260
324
  await st.saveProject(project);
261
- return writeAndSummarize(st, `Project discussed/updated: ${project.name}`, { project });
325
+ return writeAndSummarize(st, `Project discussed/updated: ${project.name}`);
262
326
  });
263
327
  server.registerTool("planner-feature-list", {
264
- description: "List features with phase/task counts.",
265
- }, async () => {
328
+ description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
329
+ inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
330
+ }, async ({ featureRef }) => {
266
331
  const st = await requireStore();
267
- const features = (await st.loadFeatures()).features;
332
+ const allFeatures = (await st.loadFeatures()).features;
333
+ const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
268
334
  const phases = await st.loadAllPhases();
269
335
  const lines = features.map((feature) => {
270
336
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
271
337
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
272
- return `- ${feature.id} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
338
+ return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
339
+ });
340
+ return text(lines.join("\n") || "No features");
341
+ });
342
+ server.registerTool("planner-phase-list", {
343
+ description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
344
+ inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name)."), status: z.string().optional().describe("Optional: filter by status name.") },
345
+ }, async ({ featureRef, status }) => {
346
+ const st = await requireStore();
347
+ const features = (await st.loadFeatures()).features;
348
+ let phases = await st.loadAllPhases();
349
+ if (featureRef) {
350
+ const f = findFeatureByRef(features, featureRef);
351
+ if (!f)
352
+ return text(`Feature not found: ${featureRef}`);
353
+ phases = phases.filter((p) => p.featureId === f.id);
354
+ }
355
+ if (status)
356
+ phases = phases.filter((p) => p.status === status);
357
+ const lines = phases.map((phase) => {
358
+ const fNum = featureNumberOfPhase(phase, features);
359
+ const fTag = fNum !== undefined ? ` [F${String(fNum).padStart(3, "0")}]` : "";
360
+ return `- ${formatPhaseRef(phase.number, fNum)}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}; ${phase.tasks.length} tasks)${fTag}`;
273
361
  });
274
- return text(lines.join("\n") || "No features", { features });
362
+ return text(lines.join("\n") || "No phases");
363
+ });
364
+ server.registerTool("planner-task-list", {
365
+ description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
366
+ inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
367
+ }, async ({ featureRef, phaseRef, status }) => {
368
+ const st = await requireStore();
369
+ const features = (await st.loadFeatures()).features;
370
+ let phases = await st.loadAllPhases();
371
+ if (featureRef) {
372
+ const f = findFeatureByRef(features, featureRef);
373
+ if (!f)
374
+ return text(`Feature not found: ${featureRef}`);
375
+ phases = phases.filter((p) => p.featureId === f.id);
376
+ }
377
+ if (phaseRef) {
378
+ const p = findPhaseByRef(phases, features, phaseRef);
379
+ if (!p)
380
+ return text(`Phase not found: ${phaseRef}`);
381
+ phases = [p];
382
+ }
383
+ const out = [];
384
+ for (const phase of phases) {
385
+ for (const task of phase.tasks) {
386
+ if (status && task.status !== status)
387
+ continue;
388
+ out.push(`- ${taskCompositeRef(task, phase, features)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
389
+ }
390
+ }
391
+ return text(out.join("\n") || "No tasks");
275
392
  });
276
393
  server.registerTool("planner-feature-add", {
277
394
  description: "Create a feature with a rich description. REQUIRED: description must include code references (file:line), current implementation state (what exists, what is unimplemented), systems/structs/traits involved, concrete goals, and behaviors to preserve. The description is the primary context for future agents resuming this feature; one-liners cause misalignment.",
@@ -289,7 +406,7 @@ server.registerTool("planner-feature-add", {
289
406
  const priority = await st.nextPriority("feature");
290
407
  const feature = {
291
408
  id: createFeatureId(),
292
- number: existingFeatures.length + 1,
409
+ number: await st.allocFeatureNumber(),
293
410
  shortId,
294
411
  priority,
295
412
  name: name.trim(),
@@ -305,6 +422,7 @@ server.registerTool("planner-feature-add", {
305
422
  acceptedDecisions: [],
306
423
  phaseIds: [],
307
424
  dependsOn: [],
425
+ statusLog: [],
308
426
  createdAt: timestamp,
309
427
  updatedAt: timestamp,
310
428
  };
@@ -312,24 +430,63 @@ server.registerTool("planner-feature-add", {
312
430
  doc.features.push(feature);
313
431
  return doc;
314
432
  });
315
- return writeAndSummarize(st, `Feature created: ${feature.id} — ${feature.name}`, { feature });
433
+ return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
316
434
  });
317
435
  server.registerTool("planner-feature-show", {
318
436
  description: "Show a feature by id or name.",
319
- inputSchema: { feature: z.string().min(1).describe("Feature id or name") },
320
- }, async ({ feature: ref }) => {
437
+ inputSchema: { feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the feature description. Default: compact identity only (saves tokens).") },
438
+ }, async ({ feature: ref, full }) => {
321
439
  const st = await requireStore();
322
440
  const features = (await st.loadFeatures()).features;
323
- const feature = findFeatureByRef(features, ref);
324
- if (!feature)
325
- return text(`Feature not found: ${ref}`);
441
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
442
+ if (!resolvedFeature.ok)
443
+ return text(resolvedFeature.error);
444
+ const feature = resolvedFeature.feature;
326
445
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
327
- return text(`${feature.name} (${feature.id}) — ${feature.status}; ${phases.length} phases`, { feature, phases });
446
+ const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
447
+ return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
448
+ });
449
+ server.registerTool("planner-feature-discuss", {
450
+ description: "Persist feature discovery/governance fields and mark the feature context as ready.",
451
+ inputSchema: {
452
+ feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
453
+ description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
454
+ workDone: z.string().optional().describe("What is already implemented / decided."),
455
+ workRemaining: z.string().optional().describe("What still needs to be done."),
456
+ dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
457
+ },
458
+ }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
459
+ const st = await requireStore();
460
+ const features = (await st.loadFeatures()).features;
461
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
462
+ if (!resolvedFeature.ok)
463
+ return text(resolvedFeature.error);
464
+ const feature = resolvedFeature.feature;
465
+ const updated = await st.updateFeatures((doc) => {
466
+ const target = doc.features.find((entry) => entry.id === feature.id);
467
+ if (!target)
468
+ return doc;
469
+ if (description !== undefined)
470
+ target.description = description.trim();
471
+ if (workDone !== undefined)
472
+ target.workDone = workDone.trim();
473
+ if (workRemaining !== undefined)
474
+ target.workRemaining = workRemaining.trim();
475
+ if (dependencies !== undefined)
476
+ target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
477
+ target.discussedAt = nowISO();
478
+ target.contextReady = true;
479
+ target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
480
+ target.updatedAt = nowISO();
481
+ return doc;
482
+ });
483
+ const result = updated.features.find((entry) => entry.id === feature.id);
484
+ return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
328
485
  });
329
486
  server.registerTool("planner-feature-update", {
330
487
  description: "Update a feature.",
331
488
  inputSchema: {
332
- feature: z.string().min(1).describe("Feature id or name"),
489
+ feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
333
490
  name: z.string().optional(),
334
491
  description: z.string().optional(),
335
492
  status: z.enum(STATUS_VALUES).optional(),
@@ -342,9 +499,10 @@ server.registerTool("planner-feature-update", {
342
499
  }, async ({ feature: ref, ...updates }) => {
343
500
  const st = await requireStore();
344
501
  const features = (await st.loadFeatures()).features;
345
- const feature = findFeatureByRef(features, ref);
346
- if (!feature)
347
- return text(`Feature not found: ${ref}`);
502
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
503
+ if (!resolvedFeature.ok)
504
+ return text(resolvedFeature.error);
505
+ const feature = resolvedFeature.feature;
348
506
  const updated = await st.updateFeatures((doc) => {
349
507
  const target = doc.features.find((entry) => entry.id === feature.id);
350
508
  if (!target)
@@ -369,20 +527,21 @@ server.registerTool("planner-feature-update", {
369
527
  return doc;
370
528
  });
371
529
  const result = updated.features.find((entry) => entry.id === feature.id);
372
- return writeAndSummarize(st, `Feature updated: ${result.id} — ${result.name}`, { feature: result });
530
+ return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
373
531
  });
374
532
  server.registerTool("planner-feature-delete", {
375
533
  description: "Delete a feature. By default, phases are unlinked rather than deleted.",
376
534
  inputSchema: {
377
- feature: z.string().min(1).describe("Feature id or name"),
535
+ feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
378
536
  cascade: z.boolean().optional().describe("Also delete phases belonging to this feature"),
379
537
  },
380
538
  }, async ({ feature: ref, cascade }) => {
381
539
  const st = await requireStore();
382
540
  const features = (await st.loadFeatures()).features;
383
- const feature = findFeatureByRef(features, ref);
384
- if (!feature)
385
- return text(`Feature not found: ${ref}`);
541
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
542
+ if (!resolvedFeature.ok)
543
+ return text(resolvedFeature.error);
544
+ const feature = resolvedFeature.feature;
386
545
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
387
546
  await st.updateFeatures((doc) => {
388
547
  doc.features = doc.features.filter((entry) => entry.id !== feature.id);
@@ -401,19 +560,27 @@ server.registerTool("planner-feature-delete", {
401
560
  return writeAndSummarize(st, `Feature deleted: ${feature.id}${cascade ? `; deleted ${phases.length} phases` : `; unlinked ${phases.length} phases`}`, { deleted: feature.id, affectedPhases: phases.length, cascade: Boolean(cascade) });
402
561
  });
403
562
  server.registerTool("planner-phase-add", {
404
- description: "Create a phase with a rich description. REQUIRED: description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
563
+ description: "Create a phase linked to a feature with a rich description. REQUIRED: feature ref and description. The description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
405
564
  inputSchema: {
406
565
  title: z.string().min(1),
407
- feature: z.string().optional().describe("Feature id or name"),
566
+ feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
408
567
  summary: z.string().optional().describe("One-line summary of the phase"),
409
568
  description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner."),
410
569
  },
411
570
  }, async ({ title, feature: featureRef, summary, description }) => {
412
571
  const st = await requireStore();
572
+ if (!featureRef?.trim())
573
+ return text("feature is required: a phase must belong to a feature.");
413
574
  const featuresDoc = await st.loadFeatures();
414
- const feature = featureRef ? findFeatureByRef(featuresDoc.features, featureRef) : featuresDoc.features[0];
415
- if (featureRef && !feature)
416
- return text(`Feature not found: ${featureRef}`);
575
+ const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
576
+ if (!resolvedFeature.ok)
577
+ return text(resolvedFeature.error);
578
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
579
+ if (!featureValidation.ok)
580
+ return text(featureValidation.error);
581
+ const feature = resolvedFeature.feature;
582
+ if (!feature)
583
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
417
584
  const lockKey = feature?.id ?? "__unscoped__";
418
585
  let phase;
419
586
  await withFeatureLock(lockKey, async () => {
@@ -424,7 +591,7 @@ server.registerTool("planner-phase-add", {
424
591
  const priority = await st.nextPriority("phase", feature?.id);
425
592
  phase = {
426
593
  id: createPhaseId(),
427
- number: featurePhases.reduce((max, entry) => Math.max(max, entry.number), 0) + 1,
594
+ number: await st.allocPhaseNumber(),
428
595
  shortId,
429
596
  priority,
430
597
  slug: normalizeSlug(title),
@@ -452,6 +619,9 @@ server.registerTool("planner-phase-add", {
452
619
  updatedAt: timestamp,
453
620
  handoff: "",
454
621
  handoffUpdatedAt: "",
622
+ handoffReadAt: "",
623
+ handoffHistory: [],
624
+ statusLog: [],
455
625
  };
456
626
  await st.savePhase(phase);
457
627
  if (feature) {
@@ -466,22 +636,29 @@ server.registerTool("planner-phase-add", {
466
636
  });
467
637
  if (!phase)
468
638
  return text("Phase creation failed.");
469
- return writeAndSummarize(st, `Phase created: ${phase.id} — ${phase.title}`, { phase });
639
+ return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
470
640
  });
471
641
  server.registerTool("planner-phase-show", {
472
- description: "Show a phase by id or name.",
473
- inputSchema: { phase: z.string().min(1).describe("Phase id or name") },
474
- }, async ({ phase: ref }) => {
642
+ description: "Show a phase by id or name, including derived linked requirements in the full view.",
643
+ inputSchema: { phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the phase description. Default: compact identity only (saves tokens).") },
644
+ }, async ({ phase: ref, full }) => {
475
645
  const st = await requireStore();
476
- const phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
646
+ const features = (await st.loadFeatures()).features;
647
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
477
648
  if (!phase)
478
649
  return text(`Phase not found: ${ref}`);
479
- return text(`${phase.title} (${phase.id}) — ${phase.status}; ${phase.tasks.length} tasks`, { phase });
650
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
651
+ const reqCount = linkedRequirements.length;
652
+ 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"}` : ""})`;
653
+ const requirementsBlock = reqCount > 0
654
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
655
+ : "";
656
+ return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, { linkedRequirements });
480
657
  });
481
658
  server.registerTool("planner-phase-discuss", {
482
659
  description: "Persist phase discovery fields and mark phase planned.",
483
660
  inputSchema: {
484
- phase: z.string().min(1).describe("Phase id or name"),
661
+ phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
485
662
  goal: z.string().optional(),
486
663
  summary: z.string().optional(),
487
664
  scope: z.string().optional(),
@@ -492,7 +669,8 @@ server.registerTool("planner-phase-discuss", {
492
669
  },
493
670
  }, async ({ phase: ref, ...updates }) => {
494
671
  const st = await requireStore();
495
- const found = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
672
+ const features = (await st.loadFeatures()).features;
673
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
496
674
  if (!found)
497
675
  return text(`Phase not found: ${ref}`);
498
676
  const phase = await st.updatePhase(found.id, (entry) => {
@@ -517,7 +695,7 @@ server.registerTool("planner-phase-discuss", {
517
695
  entry.updatedAt = nowISO();
518
696
  return entry;
519
697
  });
520
- return writeAndSummarize(st, `Phase discussed/planned: ${phase.id} — ${phase.title}`, { phase });
698
+ return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
521
699
  });
522
700
  server.registerTool("planner-phase-update", {
523
701
  description: "Update phase fields.",
@@ -531,7 +709,8 @@ server.registerTool("planner-phase-update", {
531
709
  },
532
710
  }, async ({ phase: ref, ...updates }) => {
533
711
  const st = await requireStore();
534
- const found = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
712
+ const features = (await st.loadFeatures()).features;
713
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
535
714
  if (!found)
536
715
  return text(`Phase not found: ${ref}`);
537
716
  const phase = await st.updatePhase(found.id, (entry) => {
@@ -548,7 +727,7 @@ server.registerTool("planner-phase-update", {
548
727
  entry.updatedAt = nowISO();
549
728
  return entry;
550
729
  });
551
- return writeAndSummarize(st, `Phase updated: ${phase.id} — ${phase.title}`, { phase });
730
+ return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
552
731
  });
553
732
  server.registerTool("planner-phase-delete", {
554
733
  description: "Delete a phase and unlink it from features.",
@@ -564,21 +743,43 @@ server.registerTool("planner-phase-delete", {
564
743
  feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
565
744
  return doc;
566
745
  });
567
- return writeAndSummarize(st, `Phase deleted: ${phase.id}`, { deleted: phase.id });
746
+ const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
747
+ return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
568
748
  });
569
749
  server.registerTool("planner-task-add", {
570
750
  description: "Create a task with a rich description. REQUIRED: description must include code references (file:line), what already exists vs what needs to be built, specific structs/traits/systems to modify, concrete implementation steps, and edge cases to handle. The description is the execution context for agents; one-liners cause misalignment.",
571
751
  inputSchema: {
572
- phase: z.string().min(1).describe("Phase id or name"),
752
+ feature: z.string().min(1).optional().describe("Feature ref the task's phase belongs to. Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title. REQUIRED."),
753
+ phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
573
754
  title: z.string().min(1),
574
755
  description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner."),
575
756
  checklist: z.array(z.string()).optional(),
576
757
  },
577
- }, async ({ phase: ref, title, description, checklist }) => {
758
+ }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
578
759
  const st = await requireStore();
579
- const found = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
760
+ const features = (await st.loadFeatures()).features;
761
+ if (!featureRef?.trim())
762
+ return text("feature is required: a task must belong to a feature.");
763
+ const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
764
+ if (!resolvedFeature.ok)
765
+ return text(resolvedFeature.error);
766
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
767
+ if (!featureValidation.ok)
768
+ return text(featureValidation.error);
769
+ const feature = resolvedFeature.feature;
770
+ if (!feature)
771
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
772
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
580
773
  if (!found)
581
774
  return text(`Phase not found: ${ref}`);
775
+ if (found.featureId !== feature.id)
776
+ return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
777
+ const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
778
+ if (!phaseValidation.ok)
779
+ return text(phaseValidation.error);
780
+ const existingPhase = await st.loadPhase(found.id);
781
+ if (!existingPhase)
782
+ return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
582
783
  const timestamp = nowISO();
583
784
  const taskId = createTaskId();
584
785
  const shortId = createShortId(await st.assignedShortIds());
@@ -586,7 +787,7 @@ server.registerTool("planner-task-add", {
586
787
  const task = {
587
788
  id: taskId,
588
789
  phaseId: found.id,
589
- number: found.tasks.length + 1,
790
+ number: await st.allocTaskNumber(),
590
791
  shortId,
591
792
  priority,
592
793
  shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
@@ -597,7 +798,7 @@ server.registerTool("planner-task-add", {
597
798
  statusLog: [],
598
799
  decisions: [],
599
800
  acceptedDecisions: [],
600
- checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
801
+ checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
601
802
  subtasks: [],
602
803
  dependsOn: [],
603
804
  startedAt: "",
@@ -611,18 +812,23 @@ server.registerTool("planner-task-add", {
611
812
  entry.updatedAt = timestamp;
612
813
  return entry;
613
814
  });
614
- const features = (await st.loadFeatures()).features;
615
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, features)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
815
+ const finalFeatures = (await st.loadFeatures()).features;
816
+ return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
616
817
  });
617
818
  server.registerTool("planner-task-show", {
618
819
  description: "Show a task by id or name.",
619
- inputSchema: { task: z.string().min(1).describe("Task id or name") },
620
- }, async ({ task: ref }) => {
820
+ inputSchema: { task: z.string().min(1).describe("Task ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the task description + statusLog. Default: compact identity only (saves tokens).") },
821
+ }, async ({ task: ref, full }) => {
621
822
  const st = await requireStore();
622
- const found = findTaskByRef(await st.loadAllPhases(), ref);
823
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
623
824
  if (!found)
624
825
  return text(`Task not found: ${ref}`);
625
- return text(`${found.task.title} (${found.task.id}) — ${found.task.status}; phase ${found.phase.id}`, { task: found.task, phase: found.phase });
826
+ const features = (await st.loadFeatures()).features;
827
+ const summary = `${found.task.title} — ${taskCompositeRef(found.task, found.phase, features)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status}; phase ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))})`;
828
+ if (!full)
829
+ return text(summary);
830
+ const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
831
+ return text(`${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}`);
626
832
  });
627
833
  server.registerTool("planner-task-discuss", {
628
834
  description: "Persist task execution notes/checklist.",
@@ -633,7 +839,7 @@ server.registerTool("planner-task-discuss", {
633
839
  },
634
840
  }, async ({ task: ref, description, checklist }) => {
635
841
  const st = await requireStore();
636
- const found = findTaskByRef(await st.loadAllPhases(), ref);
842
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
637
843
  if (!found)
638
844
  return text(`Task not found: ${ref}`);
639
845
  let updatedTask;
@@ -644,7 +850,9 @@ server.registerTool("planner-task-discuss", {
644
850
  if (description !== undefined)
645
851
  task.description = description.trim();
646
852
  if (checklist !== undefined)
647
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
853
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
854
+ if (checklist !== undefined)
855
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
648
856
  task.updatedAt = nowISO();
649
857
  phase.updatedAt = task.updatedAt;
650
858
  updatedTask = task;
@@ -663,10 +871,11 @@ server.registerTool("planner-task-update", {
663
871
  description: z.string().optional(),
664
872
  motivation: z.string().optional(),
665
873
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
874
+ checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Agents should tick steps via planner-task-checklist-toggle, not write DONE in titles."),
666
875
  },
667
- }, async ({ task: ref, title, status, description, motivation, priority }) => {
876
+ }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
668
877
  const st = await requireStore();
669
- const found = findTaskByRef(await st.loadAllPhases(), ref);
878
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
670
879
  if (!found)
671
880
  return text(`Task not found: ${ref}`);
672
881
  // Validate motivation requirement for status transitions.
@@ -711,12 +920,113 @@ server.registerTool("planner-task-update", {
711
920
  const t = updatedTask ?? found.task;
712
921
  return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
713
922
  });
923
+ server.registerTool("planner-task-checklist-toggle", {
924
+ description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), the item id, or the title (case-insensitive, first match). Pass checked=true/false to set explicitly, or omit to toggle. Use this instead of writing DONE in step titles. Items are numbered C1/C2… per task.",
925
+ inputSchema: {
926
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
927
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
928
+ checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
929
+ },
930
+ }, async ({ task: ref, item, checked }) => {
931
+ const st = await requireStore();
932
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
933
+ if (!found)
934
+ return text(`Task not found: ${ref}`);
935
+ let result = "";
936
+ await st.updatePhase(found.phase.id, (phase) => {
937
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
938
+ if (!task) {
939
+ result = `Task not found: ${ref}`;
940
+ return phase;
941
+ }
942
+ const ck = task.checklist ?? [];
943
+ if (ck.length === 0) {
944
+ result = `Task "${found.task.title}" has no checklist.`;
945
+ return phase;
946
+ }
947
+ const target = toggleChecklistItem(ck, item, checked);
948
+ if (!target) {
949
+ result = `No checklist item matching "${item}".`;
950
+ return phase;
951
+ }
952
+ task.updatedAt = nowISO();
953
+ phase.updatedAt = nowISO();
954
+ const doneCount = ck.filter((i) => i.checked).length;
955
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
956
+ return phase;
957
+ });
958
+ return writeAndSummarize(st, `✅ ${result}`);
959
+ });
960
+ server.registerTool("planner-task-checklist-add", {
961
+ description: "Add a single checklist item (a 'step') to a task without rewriting the list. The new item is appended as C{n} (next progressive number, stable id, unchecked). Use this to subdivide a task into smaller steps INSTEAD of spawning sub-tasks — the checklist keeps description, notes, statusLog and steps concentrated in one task (sub-tasks disperse context). Also use for granular adds instead of replacing the whole checklist via planner-task-update.",
962
+ inputSchema: {
963
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
964
+ title: z.string().min(1).describe("Checklist item text (a single step)"),
965
+ },
966
+ }, async ({ task: ref, title }) => {
967
+ const st = await requireStore();
968
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
969
+ if (!found)
970
+ return text(`Task not found: ${ref}`);
971
+ let result = "";
972
+ await st.updatePhase(found.phase.id, (phase) => {
973
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
974
+ if (!task) {
975
+ result = `Task not found: ${ref}`;
976
+ return phase;
977
+ }
978
+ const ck = task.checklist ?? [];
979
+ const item = addChecklistItem(ck, task.id, title);
980
+ ck.push(item);
981
+ task.checklist = ck;
982
+ task.updatedAt = nowISO();
983
+ phase.updatedAt = nowISO();
984
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
985
+ return phase;
986
+ });
987
+ return writeAndSummarize(st, `✅ ${result}`);
988
+ });
989
+ server.registerTool("planner-task-checklist-remove", {
990
+ description: "Remove a single checklist item (a 'step') from a task by C{n} (e.g. C2), item id, or title (case-insensitive). Remaining items are renumbered C1..Cn for readability; their stable ids are preserved. Use this for granular removes instead of replacing the whole checklist via planner-task-update.",
991
+ inputSchema: {
992
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
993
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
994
+ },
995
+ }, async ({ task: ref, item }) => {
996
+ const st = await requireStore();
997
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
998
+ if (!found)
999
+ return text(`Task not found: ${ref}`);
1000
+ let result = "";
1001
+ await st.updatePhase(found.phase.id, (phase) => {
1002
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
1003
+ if (!task) {
1004
+ result = `Task not found: ${ref}`;
1005
+ return phase;
1006
+ }
1007
+ const ck = task.checklist ?? [];
1008
+ if (ck.length === 0) {
1009
+ result = `Task "${found.task.title}" has no checklist.`;
1010
+ return phase;
1011
+ }
1012
+ const removed = removeChecklistItem(ck, item);
1013
+ if (!removed) {
1014
+ result = `No checklist item matching "${item}".`;
1015
+ return phase;
1016
+ }
1017
+ task.updatedAt = nowISO();
1018
+ phase.updatedAt = nowISO();
1019
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
1020
+ return phase;
1021
+ });
1022
+ return writeAndSummarize(st, `✅ ${result}`);
1023
+ });
714
1024
  server.registerTool("planner-task-delete", {
715
1025
  description: "Delete a task.",
716
1026
  inputSchema: { task: z.string().min(1) },
717
1027
  }, async ({ task: ref }) => {
718
1028
  const st = await requireStore();
719
- const found = findTaskByRef(await st.loadAllPhases(), ref);
1029
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
720
1030
  if (!found)
721
1031
  return text(`Task not found: ${ref}`);
722
1032
  await st.updatePhase(found.phase.id, (phase) => {
@@ -726,23 +1036,30 @@ server.registerTool("planner-task-delete", {
726
1036
  return phase;
727
1037
  });
728
1038
  await st.syncTaskStatusRollup(found.phase.id);
729
- return writeAndSummarize(st, `Task deleted: ${found.task.id}`, { deleted: found.task.id });
1039
+ const tdFeatures = (await st.loadFeatures()).features;
1040
+ return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
730
1041
  });
731
1042
  server.registerTool("planner-task-start", {
732
1043
  description: "Set a task to in-progress.",
733
1044
  inputSchema: { task: z.string().min(1) },
734
1045
  }, async ({ task: ref }) => {
735
1046
  const st = await requireStore();
736
- // Hygiene notice (non-blocking): surface an existing handoff but never block
737
- // task_start — the handoff is a captured-context artifact, not a lock (e.g. a
738
- // small modification after writing a handoff should start without forcing
739
- // deletion, which would lose context).
740
- const handoffNotice = (await st.listHandoffs()).length > 0
741
- ? "ℹ️ One or more phases have a pending handoff — if relevant to the task you're starting, read it with planner-handoff-show <ref>, then clear with planner-handoff-clear. Proceeding with task start.\n"
742
- : "";
743
- const found = findTaskByRef(await st.loadAllPhases(), ref);
1047
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
744
1048
  if (!found)
745
1049
  return text(`Task not found: ${ref}`);
1050
+ // Auto-archive the handoff on THIS task's phase (reason: task-started) — the
1051
+ // agent is now actively working, so the captured context is consumed. Other
1052
+ // phases' handoffs are left untouched (informational, non-blocking).
1053
+ const allHandoffs = await st.listHandoffs();
1054
+ let handoffNotice = "";
1055
+ if ((found.phase.handoff ?? "") !== "") {
1056
+ await st.clearPhaseHandoff(found.phase.id, "task-started").catch(() => { });
1057
+ handoffNotice = "📦 Archived handoff for this phase (task started) — recover via planner-handoff-list + .planner/.local/handoff-archive/.\n";
1058
+ }
1059
+ const _otherHandoffs = allHandoffs.filter((h) => h.phaseId !== found.phase.id);
1060
+ if (_otherHandoffs.length > 0) {
1061
+ handoffNotice += `ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with planner-handoff-list if relevant.\n`;
1062
+ }
746
1063
  const timestamp = nowISO();
747
1064
  let updatedTask;
748
1065
  await st.updatePhase(found.phase.id, (phase) => {
@@ -769,7 +1086,9 @@ server.registerTool("planner-task-start", {
769
1086
  await st.syncTaskStatusRollup(found.phase.id);
770
1087
  const features = (await st.loadFeatures()).features;
771
1088
  const t = updatedTask ?? found.task;
772
- return writeAndSummarize(st, `${handoffNotice}✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}`);
1089
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1090
+ const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
1091
+ return writeAndSummarize(st, `${handoffNotice}✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}`);
773
1092
  });
774
1093
  server.registerTool("planner-task-complete", {
775
1094
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -780,7 +1099,7 @@ server.registerTool("planner-task-complete", {
780
1099
  },
781
1100
  }, async ({ task: ref, force, description_update }) => {
782
1101
  const st = await requireStore();
783
- const found = findTaskByRef(await st.loadAllPhases(), ref);
1102
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
784
1103
  if (!found)
785
1104
  return text(`Task not found: ${ref}`);
786
1105
  const unchecked = found.task.checklist.filter((item) => !item.checked);
@@ -842,23 +1161,44 @@ server.registerTool("planner-handoff-show", {
842
1161
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
843
1162
  });
844
1163
  server.registerTool("planner-handoff-write", {
845
- description: "Write/refresh the entity-scoped handoff on a phase. Pass content (markdown). phaseRef optional (default: current in-progress phase). Captures design context for a resuming agent on that phase.",
1164
+ description: "Write/refresh the entity-scoped handoff on a phase. Pass content (markdown) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). The title becomes the H1 / first line shown in handoff lists; generic titles like 'Handoff' or 'Canonical handoff' are REJECTED. phaseRef optional (default: current in-progress phase). Captures design context for a resuming agent on that phase.",
846
1165
  inputSchema: {
847
1166
  phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase."),
1167
+ title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'."),
848
1168
  content: z.string().min(1).describe("Handoff text (markdown)."),
849
1169
  },
850
- }, async ({ phaseRef, content }) => {
1170
+ }, async ({ phaseRef, title, content }) => {
851
1171
  const st = await requireStore();
1172
+ let body = content.trim();
1173
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1174
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1175
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
1176
+ if (!title && isGeneric) {
1177
+ return text("❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted.");
1178
+ }
1179
+ if (title && title.trim()) {
1180
+ const t = title.trim();
1181
+ const lines = body.split(/\r?\n/);
1182
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
1183
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
1184
+ lines[firstIdx] = `# ${t}`;
1185
+ body = lines.join("\n");
1186
+ }
1187
+ else {
1188
+ body = `# ${t}\n\n${body}`;
1189
+ }
1190
+ }
852
1191
  const r = await resolvePhaseForHandoff(st, phaseRef);
853
1192
  if (!r.ok)
854
1193
  return text(`❌ ${r.error}`);
855
- await st.setPhaseHandoff(r.phase.id, content);
1194
+ await st.setPhaseHandoff(r.phase.id, body);
856
1195
  return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
857
1196
  });
858
1197
  server.registerTool("planner-handoff-prepare", {
859
1198
  description: "Return instructions for the agent to prepare a canonical handoff, then call planner-handoff-write (entity-scoped, phase.handoff).",
860
1199
  }, async () => text([
861
1200
  "Prepare the canonical session handoff and write it on the current in-progress phase with planner-handoff-write (omit phaseRef to target the in-progress phase).",
1201
+ "Pass a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring') — it becomes the H1 / first line in handoff lists. Generic titles like 'Handoff' or 'Canonical handoff' are REJECTED.",
862
1202
  "Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
863
1203
  "Stored on the phase.handoff field (entity-scoped). .planner/HANDOFF.md is deprecated.",
864
1204
  ].join("\n")));
@@ -870,8 +1210,8 @@ server.registerTool("planner-handoff-clear", {
870
1210
  const r = await resolvePhaseForHandoff(st, phaseRef);
871
1211
  if (!r.ok)
872
1212
  return text(`❌ ${r.error}`);
873
- await st.clearPhaseHandoff(r.phase.id);
874
- return text(`✅ Cleared handoff on ${r.compositeRef}. (handoffUpdatedAt preserved as audit)`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1213
+ await st.clearPhaseHandoff(r.phase.id, "manual");
1214
+ return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
875
1215
  });
876
1216
  server.registerTool("planner-web", {
877
1217
  description: "Manage the planner web dashboard (start/status/stop) from MCP stdio. Runs an in-process HTTP+WS server bound to LAN; it lives as long as this MCP process. plan-root comes from AGENT_PLAN_ROOT or cwd()/.planner.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.19-next.2",
3
+ "version": "0.2.19-next.20",
4
4
  "private": false,
5
5
  "description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
6
6
  "license": "MIT",
@@ -17,9 +17,11 @@
17
17
  "dist/**/*.js",
18
18
  "dist/**/*.d.ts",
19
19
  "dist/**/*.d.ts.map",
20
+ "server.json",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
24
+ "mcpName": "io.github.ovidius72/agent-plan",
23
25
  "publishConfig": {
24
26
  "access": "public"
25
27
  },
@@ -41,8 +43,8 @@
41
43
  "dependencies": {
42
44
  "@modelcontextprotocol/sdk": "^1.17.0",
43
45
  "zod": "^3.24.0",
44
- "@agent-plan/core": "0.2.19-next.2",
45
- "@agent-plan/server": "0.2.19-next.2"
46
+ "@agent-plan/core": "0.2.19-next.20",
47
+ "@agent-plan/server": "0.2.19-next.20"
46
48
  },
47
49
  "scripts": {
48
50
  "build": "tsc -p tsconfig.json",
package/server.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "io.github.ovidius72/agent-plan",
3
+ "version": "0.2.19-next.18",
4
+ "description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ovidius72/agent-planner.git"
9
+ },
10
+ "homepage": "https://github.com/ovidius72/agent-planner#readme",
11
+ "package": {
12
+ "type": "npm",
13
+ "name": "@agent-plan/mcp"
14
+ }
15
+ }