@agent-plan/mcp 0.2.19-next.8 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAg8BA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA4vCA,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, findTaskByRef, buildRecap, migrateToGlobalSequence } from "@agent-plan/core";
7
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
8
8
  import { serve } from "@agent-plan/server";
9
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef } from "@agent-plan/core/naming";
9
+ import { createChecklistItemId, createFeatureId, createPhaseId, 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) {
@@ -27,15 +27,10 @@ async function requireStore() {
27
27
  const st = store();
28
28
  if (!(await st.exists()))
29
29
  throw new Error(`No .planner/ found at ${st.root}. Use planner-init first.`);
30
- // One-time idempotent backfill of shortId + priority (safe, best-effort).
31
- if (!planBackfillDone) {
32
- planBackfillDone = true;
33
- await st.ensureShortIdsAndPriority().catch(() => { });
34
- await migrateToGlobalSequence(st).catch(() => { });
35
- }
30
+ // Opening a plan is read-only. Global migrations/backfills are explicit
31
+ // maintenance commands; running them here rewrites unrelated feature files.
36
32
  return st;
37
33
  }
38
- let planBackfillDone = false;
39
34
  // In-process web server handle, managed by the planner-web tool and planner-load.
40
35
  // Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
41
36
  let webHandle = null;
@@ -58,10 +53,50 @@ async function ensureWebStarted() {
58
53
  }
59
54
  function findFeatureByRef(features, ref) {
60
55
  const normalized = ref.trim().toLowerCase();
61
- return features.find((feature) => feature.id.toLowerCase() === normalized)
56
+ if (!normalized)
57
+ return undefined;
58
+ const fMatch = normalized.match(/^f(\d+)$/);
59
+ if (fMatch) {
60
+ const n = parseInt(fMatch[1], 10);
61
+ const byNum = features.find((feature) => feature.number === n);
62
+ if (byNum)
63
+ return byNum;
64
+ }
65
+ return features.find((feature) => feature.shortId?.toLowerCase() === normalized)
66
+ ?? features.find((feature) => feature.id.toLowerCase() === normalized)
62
67
  ?? features.find((feature) => feature.name.toLowerCase() === normalized)
63
68
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
64
69
  }
70
+ function resolveFeatureRefStrict(features, ref) {
71
+ const raw = ref.trim();
72
+ if (!raw)
73
+ return { ok: false, error: "Feature ref is required." };
74
+ const normalized = raw.toLowerCase();
75
+ const byNumber = normalized.match(/^f(\d+)$/)
76
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
77
+ : undefined;
78
+ if (byNumber)
79
+ return { ok: true, feature: byNumber };
80
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
81
+ if (byShortId)
82
+ return { ok: true, feature: byShortId };
83
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
84
+ if (byId)
85
+ return { ok: true, feature: byId };
86
+ const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
87
+ if (exactName.length === 1)
88
+ return { ok: true, feature: exactName[0] };
89
+ if (exactName.length > 1)
90
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
91
+ const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
92
+ if (partialName.length === 1)
93
+ return { ok: true, feature: partialName[0] };
94
+ if (partialName.length > 1)
95
+ return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
96
+ return { ok: false, error: `Feature not found: ${raw}` };
97
+ }
98
+ /** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
99
+ * must still exist in the store before we allocate numbers or write. */
65
100
  function featureNumberOfPhase(phase, features) {
66
101
  return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
67
102
  }
@@ -97,16 +132,12 @@ async function resolvePhaseForHandoff(st, ref) {
97
132
  if (ref && ref.trim()) {
98
133
  phase = findPhaseByRef(phases, features, ref.trim());
99
134
  }
100
- else {
101
- phase = phases.find((p) => p.status === "in-progress")
102
- ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
103
- }
104
135
  if (!phase) {
105
136
  return {
106
137
  ok: false,
107
138
  error: ref && ref.trim()
108
139
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
109
- : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
140
+ : "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
110
141
  };
111
142
  }
112
143
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -124,7 +155,7 @@ server.registerTool("planner-export", {
124
155
  const markdown = exportService.exportToMarkdown(plan, full);
125
156
  const fs = await import("node:fs/promises");
126
157
  await fs.writeFile(join(st.root, "EXPORT.md"), markdown, "utf-8");
127
- return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`, { markdown });
158
+ return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`);
128
159
  });
129
160
  server.registerTool("planner-authorize-bypass", {
130
161
  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.",
@@ -165,26 +196,77 @@ server.registerTool("planner-init", {
165
196
  return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
166
197
  });
167
198
  server.registerTool("planner-show", {
168
- description: "Show the current planner overview.",
199
+ 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.",
169
200
  }, async () => {
170
201
  const st = await requireStore();
171
- const plan = await st.loadAll();
172
- return text([
173
- `📋 ${plan.project.name}`,
174
- `Description: ${plan.project.description || "(not set)"}`,
175
- `Goal: ${plan.project.goal || "(not set)"}`,
176
- `Features: ${plan.features.features.length}`,
177
- `Phases: ${plan.phases.length}`,
178
- `Tasks: ${plan.phases.reduce((total, phase) => total + phase.tasks.length, 0)}`,
179
- `Updated: ${plan.manifest.updatedAt}`,
180
- ].join("\n"), { plan });
202
+ const features = (await st.loadFeatures()).features;
203
+ const phases = await st.loadAllPhases();
204
+ const totalTasks = phases.reduce((total, phase) => total + phase.tasks.length, 0);
205
+ const project = await st.loadProject();
206
+ const manifest = await st.loadManifest();
207
+ const featureLines = features.map((feature) => {
208
+ const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
209
+ const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
210
+ return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
211
+ });
212
+ const summary = [
213
+ `📋 ${project.name}`,
214
+ `Description: ${project.description || "(not set)"}`,
215
+ `Goal: ${project.goal || "(not set)"}`,
216
+ `Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
217
+ `Updated: ${manifest.updatedAt || "(unknown)"}`,
218
+ "",
219
+ "Features:",
220
+ featureLines.join("\n") || "(none)",
221
+ ].join("\n");
222
+ // Compact structured overview (no full plan: avoids 1M+ char token overflow).
223
+ const overview = {
224
+ project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
225
+ counts: { features: features.length, phases: phases.length, tasks: totalTasks },
226
+ features: features.map((feature) => ({
227
+ ref: formatFeatureRef(feature.number),
228
+ shortId: feature.shortId || null,
229
+ name: feature.name,
230
+ status: feature.status,
231
+ phases: phases.filter((p) => p.featureId === feature.id).length,
232
+ tasks: phases.filter((p) => p.featureId === feature.id).reduce((t, p) => t + p.tasks.length, 0),
233
+ })),
234
+ };
235
+ return text(summary, { overview });
181
236
  });
182
237
  server.registerTool("planner-repair", {
183
238
  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.",
184
239
  }, async () => {
185
240
  const st = await requireStore();
186
241
  const report = await st.repair();
187
- 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 });
242
+ 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). Handoffs: archived ${report.handoffs.archived} stale completed/canceled handoff(s). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
243
+ });
244
+ server.registerTool("planner-cleanup-orphan-phases", {
245
+ 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.",
246
+ inputSchema: {
247
+ confirm: z.boolean().optional().describe("Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)."),
248
+ },
249
+ }, async ({ confirm }) => {
250
+ const st = await requireStore();
251
+ const found = await st.listOrphanPhases();
252
+ if (!confirm) {
253
+ if (found.length === 0)
254
+ return text("No orphan phases found.", { found: [] });
255
+ const lines = [
256
+ `Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
257
+ ...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
258
+ "Rerun with confirm=true to delete these orphan phase files.",
259
+ ];
260
+ return text(lines.join("\n"), { found, confirmRequired: true });
261
+ }
262
+ const report = await st.cleanupOrphanPhases();
263
+ if (report.removed.length === 0)
264
+ return text("No orphan phases found.", report);
265
+ const lines = [
266
+ `Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
267
+ ...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
268
+ ];
269
+ return text(lines.join("\n"), report);
188
270
  });
189
271
  server.registerTool("planner-project-language", {
190
272
  description: "Persist preferred languages for plan content and chat.",
@@ -311,12 +393,13 @@ server.registerTool("planner-feature-add", {
311
393
  const timestamp = nowISO();
312
394
  const effectiveStatus = status ?? "planned";
313
395
  const existingFeatures = (await st.loadFeatures()).features;
314
- const shortId = createShortId(await st.assignedShortIds());
396
+ const id = createFeatureId();
397
+ const identity = await st.allocateEntityIdentity("feature", id);
315
398
  const priority = await st.nextPriority("feature");
316
399
  const feature = {
317
- id: createFeatureId(),
318
- number: await st.allocFeatureNumber(),
319
- shortId,
400
+ id,
401
+ number: identity.number,
402
+ shortId: identity.shortId,
320
403
  priority,
321
404
  name: name.trim(),
322
405
  description: description?.trim() ?? "",
@@ -331,6 +414,7 @@ server.registerTool("planner-feature-add", {
331
414
  acceptedDecisions: [],
332
415
  phaseIds: [],
333
416
  dependsOn: [],
417
+ statusLog: [],
334
418
  createdAt: timestamp,
335
419
  updatedAt: timestamp,
336
420
  };
@@ -346,13 +430,51 @@ server.registerTool("planner-feature-show", {
346
430
  }, async ({ feature: ref, full }) => {
347
431
  const st = await requireStore();
348
432
  const features = (await st.loadFeatures()).features;
349
- const feature = findFeatureByRef(features, ref);
350
- if (!feature)
351
- return text(`Feature not found: ${ref}`);
433
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
434
+ if (!resolvedFeature.ok)
435
+ return text(resolvedFeature.error);
436
+ const feature = resolvedFeature.feature;
352
437
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
353
438
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
354
439
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
355
440
  });
441
+ server.registerTool("planner-feature-discuss", {
442
+ description: "Persist feature discovery/governance fields and mark the feature context as ready.",
443
+ inputSchema: {
444
+ feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
445
+ description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
446
+ workDone: z.string().optional().describe("What is already implemented / decided."),
447
+ workRemaining: z.string().optional().describe("What still needs to be done."),
448
+ dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
449
+ },
450
+ }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
451
+ const st = await requireStore();
452
+ const features = (await st.loadFeatures()).features;
453
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
454
+ if (!resolvedFeature.ok)
455
+ return text(resolvedFeature.error);
456
+ const feature = resolvedFeature.feature;
457
+ const updated = await st.updateFeatures((doc) => {
458
+ const target = doc.features.find((entry) => entry.id === feature.id);
459
+ if (!target)
460
+ return doc;
461
+ if (description !== undefined)
462
+ target.description = description.trim();
463
+ if (workDone !== undefined)
464
+ target.workDone = workDone.trim();
465
+ if (workRemaining !== undefined)
466
+ target.workRemaining = workRemaining.trim();
467
+ if (dependencies !== undefined)
468
+ target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
469
+ target.discussedAt = nowISO();
470
+ target.contextReady = true;
471
+ target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
472
+ target.updatedAt = nowISO();
473
+ return doc;
474
+ });
475
+ const result = updated.features.find((entry) => entry.id === feature.id);
476
+ return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
477
+ });
356
478
  server.registerTool("planner-feature-update", {
357
479
  description: "Update a feature.",
358
480
  inputSchema: {
@@ -369,9 +491,10 @@ server.registerTool("planner-feature-update", {
369
491
  }, async ({ feature: ref, ...updates }) => {
370
492
  const st = await requireStore();
371
493
  const features = (await st.loadFeatures()).features;
372
- const feature = findFeatureByRef(features, ref);
373
- if (!feature)
374
- return text(`Feature not found: ${ref}`);
494
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
495
+ if (!resolvedFeature.ok)
496
+ return text(resolvedFeature.error);
497
+ const feature = resolvedFeature.feature;
375
498
  const updated = await st.updateFeatures((doc) => {
376
499
  const target = doc.features.find((entry) => entry.id === feature.id);
377
500
  if (!target)
@@ -407,9 +530,10 @@ server.registerTool("planner-feature-delete", {
407
530
  }, async ({ feature: ref, cascade }) => {
408
531
  const st = await requireStore();
409
532
  const features = (await st.loadFeatures()).features;
410
- const feature = findFeatureByRef(features, ref);
411
- if (!feature)
412
- return text(`Feature not found: ${ref}`);
533
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
534
+ if (!resolvedFeature.ok)
535
+ return text(resolvedFeature.error);
536
+ const feature = resolvedFeature.feature;
413
537
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
414
538
  await st.updateFeatures((doc) => {
415
539
  doc.features = doc.features.filter((entry) => entry.id !== feature.id);
@@ -428,31 +552,40 @@ server.registerTool("planner-feature-delete", {
428
552
  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) });
429
553
  });
430
554
  server.registerTool("planner-phase-add", {
431
- 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.",
555
+ 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.",
432
556
  inputSchema: {
433
557
  title: z.string().min(1),
434
- feature: z.string().optional().describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
558
+ feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
435
559
  summary: z.string().optional().describe("One-line summary of the phase"),
436
560
  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."),
437
561
  },
438
562
  }, async ({ title, feature: featureRef, summary, description }) => {
439
563
  const st = await requireStore();
564
+ if (!featureRef?.trim())
565
+ return text("feature is required: a phase must belong to a feature.");
440
566
  const featuresDoc = await st.loadFeatures();
441
- const feature = featureRef ? findFeatureByRef(featuresDoc.features, featureRef) : featuresDoc.features[0];
442
- if (featureRef && !feature)
443
- return text(`Feature not found: ${featureRef}`);
567
+ const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
568
+ if (!resolvedFeature.ok)
569
+ return text(resolvedFeature.error);
570
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
571
+ if (!featureValidation.ok)
572
+ return text(featureValidation.error);
573
+ const feature = resolvedFeature.feature;
574
+ if (!feature)
575
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
444
576
  const lockKey = feature?.id ?? "__unscoped__";
445
577
  let phase;
446
578
  await withFeatureLock(lockKey, async () => {
447
579
  const phases = await st.loadAllPhases();
448
580
  const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
449
581
  const timestamp = nowISO();
450
- const shortId = createShortId(await st.assignedShortIds());
582
+ const id = createPhaseId();
583
+ const identity = await st.allocateEntityIdentity("phase", id);
451
584
  const priority = await st.nextPriority("phase", feature?.id);
452
585
  phase = {
453
- id: createPhaseId(),
454
- number: await st.allocPhaseNumber(),
455
- shortId,
586
+ id,
587
+ number: identity.number,
588
+ shortId: identity.shortId,
456
589
  priority,
457
590
  slug: normalizeSlug(title),
458
591
  title: title.trim(),
@@ -481,6 +614,7 @@ server.registerTool("planner-phase-add", {
481
614
  handoffUpdatedAt: "",
482
615
  handoffReadAt: "",
483
616
  handoffHistory: [],
617
+ statusLog: [],
484
618
  };
485
619
  await st.savePhase(phase);
486
620
  if (feature) {
@@ -498,7 +632,7 @@ server.registerTool("planner-phase-add", {
498
632
  return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
499
633
  });
500
634
  server.registerTool("planner-phase-show", {
501
- description: "Show a phase by id or name.",
635
+ description: "Show a phase by id or name, including derived linked requirements in the full view.",
502
636
  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).") },
503
637
  }, async ({ phase: ref, full }) => {
504
638
  const st = await requireStore();
@@ -506,8 +640,24 @@ server.registerTool("planner-phase-show", {
506
640
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
507
641
  if (!phase)
508
642
  return text(`Phase not found: ${ref}`);
509
- const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
510
- return text(full ? `${summary}\n\n${phase.description || ""}` : summary);
643
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
644
+ const reqCount = linkedRequirements.length;
645
+ 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"}` : ""})`;
646
+ const requirementsBlock = reqCount > 0
647
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
648
+ : "";
649
+ return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
650
+ phase: {
651
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
652
+ shortId: phase.shortId,
653
+ title: phase.title,
654
+ summary: phase.summary,
655
+ status: phase.status,
656
+ taskCount: phase.tasks.length,
657
+ ...(full ? { description: phase.description } : {}),
658
+ },
659
+ linkedRequirements,
660
+ });
511
661
  });
512
662
  server.registerTool("planner-phase-discuss", {
513
663
  description: "Persist phase discovery fields and mark phase planned.",
@@ -603,25 +753,46 @@ server.registerTool("planner-phase-delete", {
603
753
  server.registerTool("planner-task-add", {
604
754
  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.",
605
755
  inputSchema: {
756
+ 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."),
606
757
  phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
607
758
  title: z.string().min(1),
608
759
  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."),
609
760
  checklist: z.array(z.string()).optional(),
610
761
  },
611
- }, async ({ phase: ref, title, description, checklist }) => {
762
+ }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
612
763
  const st = await requireStore();
613
- const found = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
764
+ const features = (await st.loadFeatures()).features;
765
+ if (!featureRef?.trim())
766
+ return text("feature is required: a task must belong to a feature.");
767
+ const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
768
+ if (!resolvedFeature.ok)
769
+ return text(resolvedFeature.error);
770
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
771
+ if (!featureValidation.ok)
772
+ return text(featureValidation.error);
773
+ const feature = resolvedFeature.feature;
774
+ if (!feature)
775
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
776
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
614
777
  if (!found)
615
778
  return text(`Phase not found: ${ref}`);
779
+ if (found.featureId !== feature.id)
780
+ return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
781
+ const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
782
+ if (!phaseValidation.ok)
783
+ return text(phaseValidation.error);
784
+ const existingPhase = await st.loadPhase(found.id);
785
+ if (!existingPhase)
786
+ return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
616
787
  const timestamp = nowISO();
617
788
  const taskId = createTaskId();
618
- const shortId = createShortId(await st.assignedShortIds());
789
+ const identity = await st.allocateEntityIdentity("task", taskId);
619
790
  const priority = await st.nextPriority("task", found.id);
620
791
  const task = {
621
792
  id: taskId,
622
793
  phaseId: found.id,
623
- number: await st.allocTaskNumber(),
624
- shortId,
794
+ number: identity.number,
795
+ shortId: identity.shortId,
625
796
  priority,
626
797
  shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
627
798
  title: title.trim(),
@@ -631,7 +802,7 @@ server.registerTool("planner-task-add", {
631
802
  statusLog: [],
632
803
  decisions: [],
633
804
  acceptedDecisions: [],
634
- checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
805
+ checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
635
806
  subtasks: [],
636
807
  dependsOn: [],
637
808
  startedAt: "",
@@ -645,8 +816,8 @@ server.registerTool("planner-task-add", {
645
816
  entry.updatedAt = timestamp;
646
817
  return entry;
647
818
  });
648
- const features = (await st.loadFeatures()).features;
649
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, features)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
819
+ const finalFeatures = (await st.loadFeatures()).features;
820
+ return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
650
821
  });
651
822
  server.registerTool("planner-task-show", {
652
823
  description: "Show a task by id or name.",
@@ -683,7 +854,9 @@ server.registerTool("planner-task-discuss", {
683
854
  if (description !== undefined)
684
855
  task.description = description.trim();
685
856
  if (checklist !== undefined)
686
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
857
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
858
+ if (checklist !== undefined)
859
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
687
860
  task.updatedAt = nowISO();
688
861
  phase.updatedAt = task.updatedAt;
689
862
  updatedTask = task;
@@ -702,8 +875,9 @@ server.registerTool("planner-task-update", {
702
875
  description: z.string().optional(),
703
876
  motivation: z.string().optional(),
704
877
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
878
+ 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."),
705
879
  },
706
- }, async ({ task: ref, title, status, description, motivation, priority }) => {
880
+ }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
707
881
  const st = await requireStore();
708
882
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
709
883
  if (!found)
@@ -750,6 +924,107 @@ server.registerTool("planner-task-update", {
750
924
  const t = updatedTask ?? found.task;
751
925
  return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
752
926
  });
927
+ server.registerTool("planner-task-checklist-toggle", {
928
+ 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.",
929
+ inputSchema: {
930
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
931
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
932
+ checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
933
+ },
934
+ }, async ({ task: ref, item, checked }) => {
935
+ const st = await requireStore();
936
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
937
+ if (!found)
938
+ return text(`Task not found: ${ref}`);
939
+ let result = "";
940
+ await st.updatePhase(found.phase.id, (phase) => {
941
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
942
+ if (!task) {
943
+ result = `Task not found: ${ref}`;
944
+ return phase;
945
+ }
946
+ const ck = task.checklist ?? [];
947
+ if (ck.length === 0) {
948
+ result = `Task "${found.task.title}" has no checklist.`;
949
+ return phase;
950
+ }
951
+ const target = toggleChecklistItem(ck, item, checked);
952
+ if (!target) {
953
+ result = `No checklist item matching "${item}".`;
954
+ return phase;
955
+ }
956
+ task.updatedAt = nowISO();
957
+ phase.updatedAt = nowISO();
958
+ const doneCount = ck.filter((i) => i.checked).length;
959
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
960
+ return phase;
961
+ });
962
+ return writeAndSummarize(st, `✅ ${result}`);
963
+ });
964
+ server.registerTool("planner-task-checklist-add", {
965
+ 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.",
966
+ inputSchema: {
967
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
968
+ title: z.string().min(1).describe("Checklist item text (a single step)"),
969
+ },
970
+ }, async ({ task: ref, title }) => {
971
+ const st = await requireStore();
972
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
973
+ if (!found)
974
+ return text(`Task not found: ${ref}`);
975
+ let result = "";
976
+ await st.updatePhase(found.phase.id, (phase) => {
977
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
978
+ if (!task) {
979
+ result = `Task not found: ${ref}`;
980
+ return phase;
981
+ }
982
+ const ck = task.checklist ?? [];
983
+ const item = addChecklistItem(ck, task.id, title);
984
+ ck.push(item);
985
+ task.checklist = ck;
986
+ task.updatedAt = nowISO();
987
+ phase.updatedAt = nowISO();
988
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
989
+ return phase;
990
+ });
991
+ return writeAndSummarize(st, `✅ ${result}`);
992
+ });
993
+ server.registerTool("planner-task-checklist-remove", {
994
+ 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.",
995
+ inputSchema: {
996
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
997
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
998
+ },
999
+ }, async ({ task: ref, item }) => {
1000
+ const st = await requireStore();
1001
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1002
+ if (!found)
1003
+ return text(`Task not found: ${ref}`);
1004
+ let result = "";
1005
+ await st.updatePhase(found.phase.id, (phase) => {
1006
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
1007
+ if (!task) {
1008
+ result = `Task not found: ${ref}`;
1009
+ return phase;
1010
+ }
1011
+ const ck = task.checklist ?? [];
1012
+ if (ck.length === 0) {
1013
+ result = `Task "${found.task.title}" has no checklist.`;
1014
+ return phase;
1015
+ }
1016
+ const removed = removeChecklistItem(ck, item);
1017
+ if (!removed) {
1018
+ result = `No checklist item matching "${item}".`;
1019
+ return phase;
1020
+ }
1021
+ task.updatedAt = nowISO();
1022
+ phase.updatedAt = nowISO();
1023
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
1024
+ return phase;
1025
+ });
1026
+ return writeAndSummarize(st, `✅ ${result}`);
1027
+ });
753
1028
  server.registerTool("planner-task-delete", {
754
1029
  description: "Delete a task.",
755
1030
  inputSchema: { task: z.string().min(1) },
@@ -768,27 +1043,79 @@ server.registerTool("planner-task-delete", {
768
1043
  const tdFeatures = (await st.loadFeatures()).features;
769
1044
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
770
1045
  });
1046
+ server.registerTool("planner-task-recommend", {
1047
+ description: "Return the harness-agnostic recommended task: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports dependency, availability, and approved deviation/resume context without starting or blocking work.",
1048
+ inputSchema: {},
1049
+ }, async () => {
1050
+ const st = await requireStore();
1051
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1052
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
1053
+ if (!result.candidate)
1054
+ return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1055
+ const { candidate } = result;
1056
+ const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1057
+ return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1058
+ kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1059
+ });
1060
+ });
1061
+ server.registerTool("planner-task-deviation", {
1062
+ description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task and never starts, pauses, or blocks work; use normal lifecycle tools for those transitions.",
1063
+ inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
1064
+ }, async ({ temporary_task, resume_task, reason }) => {
1065
+ const st = await requireStore();
1066
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1067
+ const temporary = findTaskByRef(phases, features.features, temporary_task);
1068
+ if (!temporary)
1069
+ return text(`Task not found: ${temporary_task}`);
1070
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
1071
+ const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
1072
+ if (!resume)
1073
+ return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
1074
+ if (temporary.task.id === resume.task.id)
1075
+ return text("A temporary task must differ from its resume target.");
1076
+ if (temporary.task.status !== "planned")
1077
+ return text(`Temporary task is not startable: ${temporary.task.status}.`);
1078
+ const timestamp = nowISO();
1079
+ const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
1080
+ await st.addWorkDeviation(record);
1081
+ return writeAndSummarize(st, `✅ Approved deviation: ${taskCompositeRef(temporary.task, temporary.phase, features.features)} temporarily overrides ${taskCompositeRef(resume.task, resume.phase, features.features)}. Resume target retained.`, { deviation: record });
1082
+ });
771
1083
  server.registerTool("planner-task-start", {
772
- description: "Set a task to in-progress.",
1084
+ description: "Set a task to in-progress. BEFORE calling, use planner-task-show with full=true and planner-phase-show with full=true for the parent phase, including linked requirements. Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Returns a phase/requirements briefing.",
773
1085
  inputSchema: { task: z.string().min(1) },
774
1086
  }, async ({ task: ref }) => {
775
1087
  const st = await requireStore();
776
- const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1088
+ const features = (await st.loadFeatures()).features;
1089
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
777
1090
  if (!found)
778
1091
  return text(`Task not found: ${ref}`);
779
- // Auto-archive the handoff on THIS task's phase (reason: task-started) — the
780
- // agent is now actively working, so the captured context is consumed. Other
781
- // phases' handoffs are left untouched (informational, non-blocking).
782
- const allHandoffs = await st.listHandoffs();
783
- let handoffNotice = "";
784
- if ((found.phase.handoff ?? "") !== "") {
785
- await st.clearPhaseHandoff(found.phase.id, "task-started").catch(() => { });
786
- handoffNotice = "📦 Archived handoff for this phase (task started) — recover via planner-handoff-list + .planner/handoff-archive/.\n";
787
- }
788
- const _otherHandoffs = allHandoffs.filter((h) => h.phaseId !== found.phase.id);
789
- if (_otherHandoffs.length > 0) {
790
- handoffNotice += `ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with planner-handoff-list if relevant.\n`;
791
- }
1092
+ if (found.task.status === "in-progress")
1093
+ return text(`Task ${found.task.id} is already in-progress.`);
1094
+ if (found.task.status === "done")
1095
+ return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1096
+ const project = await st.loadProject();
1097
+ const phases = await st.loadAllPhases();
1098
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1099
+ if (!eligibility.eligible)
1100
+ return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1101
+ const selection = recommendNextTask(features, phases, project.workDeviations);
1102
+ // Assemble the mandatory parent context before changing task lifecycle state.
1103
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1104
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
1105
+ st.loadPhaseWithRequirements(found.phase.id),
1106
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1107
+ ]);
1108
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1109
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
1110
+ // archived only when the phase completes (auto) or the user explicitly
1111
+ // clears it.
1112
+ const advisory = selection.kind === "conflict"
1113
+ ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
1114
+ : selection.candidate && selection.candidate.task.id !== found.task.id
1115
+ ? selection.kind === "active"
1116
+ ? `\n\n⚠️ Active-task advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is already in progress. Explicit task request honored.`
1117
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1118
+ : "";
792
1119
  const timestamp = nowISO();
793
1120
  let updatedTask;
794
1121
  await st.updatePhase(found.phase.id, (phase) => {
@@ -813,9 +1140,11 @@ server.registerTool("planner-task-start", {
813
1140
  return phase;
814
1141
  });
815
1142
  await st.syncTaskStatusRollup(found.phase.id);
816
- const features = (await st.loadFeatures()).features;
1143
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1144
+ if (approvedDeviation)
1145
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
817
1146
  const t = updatedTask ?? found.task;
818
- return writeAndSummarize(st, `${handoffNotice}✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}`);
1147
+ return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
819
1148
  });
820
1149
  server.registerTool("planner-task-complete", {
821
1150
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -860,6 +1189,11 @@ server.registerTool("planner-task-complete", {
860
1189
  return phase;
861
1190
  });
862
1191
  const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
1192
+ const completedDeviation = (await st.loadProject()).workDeviations
1193
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
1194
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1195
+ if (completedDeviation)
1196
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
863
1197
  const features = (await st.loadFeatures()).features;
864
1198
  const t = updatedTask ?? found.task;
865
1199
  return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}`);
@@ -875,8 +1209,8 @@ server.registerTool("planner-handoff-list", {
875
1209
  return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
876
1210
  });
877
1211
  server.registerTool("planner-handoff-show", {
878
- description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title. Omit to target the current in-progress phase.",
879
- inputSchema: { phaseRef: z.string().optional().describe("Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase.") },
1212
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
1213
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
880
1214
  }, async ({ phaseRef }) => {
881
1215
  const st = await requireStore();
882
1216
  const r = await resolvePhaseForHandoff(st, phaseRef);
@@ -888,36 +1222,61 @@ server.registerTool("planner-handoff-show", {
888
1222
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
889
1223
  });
890
1224
  server.registerTool("planner-handoff-write", {
891
- 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.",
1225
+ description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Never guess from in-progress status or stale context; completed/canceled phases reject new handoffs. Pass content (markdown) and a meaningful title.",
892
1226
  inputSchema: {
893
- phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase."),
1227
+ phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1228
+ 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'."),
1229
+ confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
894
1230
  content: z.string().min(1).describe("Handoff text (markdown)."),
895
1231
  },
896
- }, async ({ phaseRef, content }) => {
1232
+ }, async ({ phaseRef, title, confirmed, content }) => {
897
1233
  const st = await requireStore();
1234
+ let body = content.trim();
1235
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1236
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1237
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
1238
+ if (!title && isGeneric) {
1239
+ 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.");
1240
+ }
1241
+ if (title && title.trim()) {
1242
+ const t = title.trim();
1243
+ const lines = body.split(/\r?\n/);
1244
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
1245
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
1246
+ lines[firstIdx] = `# ${t}`;
1247
+ body = lines.join("\n");
1248
+ }
1249
+ else {
1250
+ body = `# ${t}\n\n${body}`;
1251
+ }
1252
+ }
898
1253
  const r = await resolvePhaseForHandoff(st, phaseRef);
899
1254
  if (!r.ok)
900
1255
  return text(`❌ ${r.error}`);
901
- await st.setPhaseHandoff(r.phase.id, content);
1256
+ if (!confirmed)
1257
+ return text(`Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.`, { phaseRef: r.compositeRef, confirmationRequired: true });
1258
+ await st.setPhaseHandoff(r.phase.id, body);
902
1259
  return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
903
1260
  });
904
1261
  server.registerTool("planner-handoff-prepare", {
905
- description: "Return instructions for the agent to prepare a canonical handoff, then call planner-handoff-write (entity-scoped, phase.handoff).",
1262
+ description: "Prepare a canonical handoff proposal. Do not write until the agent has identified the exact feature+phase from the conversation and the user has confirmed that target.",
906
1263
  }, async () => text([
907
- "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).",
908
- "Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
909
- "Stored on the phase.handoff field (entity-scoped). .planner/HANDOFF.md is deprecated.",
1264
+ "First identify the exact feature and phase actually discussed/worked on in this session. Do not use the first in-progress phase and do not target a phase that just became done.",
1265
+ "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1266
+ "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1267
+ "Pass a meaningful title. Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
1268
+ "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
910
1269
  ].join("\n")));
911
1270
  server.registerTool("planner-handoff-clear", {
912
- description: "Clear the entity-scoped handoff of a phase (sets handoff to empty, keeps handoffUpdatedAt as an audit trail). phaseRef optional (default: current in-progress phase).",
913
- inputSchema: { phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase.") },
1271
+ description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1272
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
914
1273
  }, async ({ phaseRef }) => {
915
1274
  const st = await requireStore();
916
1275
  const r = await resolvePhaseForHandoff(st, phaseRef);
917
1276
  if (!r.ok)
918
1277
  return text(`❌ ${r.error}`);
919
1278
  await st.clearPhaseHandoff(r.phase.id, "manual");
920
- return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1279
+ return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
921
1280
  });
922
1281
  server.registerTool("planner-web", {
923
1282
  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.",
@@ -960,7 +1319,7 @@ server.registerTool("planner-web", {
960
1319
  return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
961
1320
  });
962
1321
  server.registerTool("planner-load", {
963
- description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap to the user in that reply, including the web URL on a final prominent line. If a pending handoff is included, read it, summarize it to the user, then call planner-handoff-clear. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
1322
+ description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap verbatim in that reply, including its final prominent Web UI line. A pending handoff is read-only context: NEVER call planner-handoff-show or planner-handoff-clear as part of load/recap. Archive it only when every phase task is done/canceled, when replacing it with a new handoff, or after an explicit user handoff-clear request. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
964
1323
  }, async () => {
965
1324
  const st = store();
966
1325
  if (!(await st.exists()))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.19-next.8",
3
+ "version": "0.2.19",
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.8",
45
- "@agent-plan/server": "0.2.19-next.8"
46
+ "@agent-plan/server": "0.2.19",
47
+ "@agent-plan/core": "0.2.19"
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
+ }