@agent-plan/mcp 0.2.19-next.9 → 0.2.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":";AAg8BA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA+vCA,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,15 +393,17 @@ 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() ?? "",
406
+ descriptionUpdatedAt: timestamp,
323
407
  status: effectiveStatus,
324
408
  discussedAt: "",
325
409
  contextReady: false,
@@ -331,6 +415,7 @@ server.registerTool("planner-feature-add", {
331
415
  acceptedDecisions: [],
332
416
  phaseIds: [],
333
417
  dependsOn: [],
418
+ statusLog: [],
334
419
  createdAt: timestamp,
335
420
  updatedAt: timestamp,
336
421
  };
@@ -346,13 +431,51 @@ server.registerTool("planner-feature-show", {
346
431
  }, async ({ feature: ref, full }) => {
347
432
  const st = await requireStore();
348
433
  const features = (await st.loadFeatures()).features;
349
- const feature = findFeatureByRef(features, ref);
350
- if (!feature)
351
- return text(`Feature not found: ${ref}`);
434
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
435
+ if (!resolvedFeature.ok)
436
+ return text(resolvedFeature.error);
437
+ const feature = resolvedFeature.feature;
352
438
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
353
439
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
354
440
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
355
441
  });
442
+ server.registerTool("planner-feature-discuss", {
443
+ description: "Persist feature discovery/governance fields and mark the feature context as ready.",
444
+ inputSchema: {
445
+ feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
446
+ description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
447
+ workDone: z.string().optional().describe("What is already implemented / decided."),
448
+ workRemaining: z.string().optional().describe("What still needs to be done."),
449
+ dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
450
+ },
451
+ }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
452
+ const st = await requireStore();
453
+ const features = (await st.loadFeatures()).features;
454
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
455
+ if (!resolvedFeature.ok)
456
+ return text(resolvedFeature.error);
457
+ const feature = resolvedFeature.feature;
458
+ const updated = await st.updateFeatures((doc) => {
459
+ const target = doc.features.find((entry) => entry.id === feature.id);
460
+ if (!target)
461
+ return doc;
462
+ if (description !== undefined)
463
+ target.description = description.trim();
464
+ if (workDone !== undefined)
465
+ target.workDone = workDone.trim();
466
+ if (workRemaining !== undefined)
467
+ target.workRemaining = workRemaining.trim();
468
+ if (dependencies !== undefined)
469
+ target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
470
+ target.discussedAt = nowISO();
471
+ target.contextReady = true;
472
+ target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
473
+ target.updatedAt = nowISO();
474
+ return doc;
475
+ });
476
+ const result = updated.features.find((entry) => entry.id === feature.id);
477
+ return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
478
+ });
356
479
  server.registerTool("planner-feature-update", {
357
480
  description: "Update a feature.",
358
481
  inputSchema: {
@@ -369,9 +492,10 @@ server.registerTool("planner-feature-update", {
369
492
  }, async ({ feature: ref, ...updates }) => {
370
493
  const st = await requireStore();
371
494
  const features = (await st.loadFeatures()).features;
372
- const feature = findFeatureByRef(features, ref);
373
- if (!feature)
374
- return text(`Feature not found: ${ref}`);
495
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
496
+ if (!resolvedFeature.ok)
497
+ return text(resolvedFeature.error);
498
+ const feature = resolvedFeature.feature;
375
499
  const updated = await st.updateFeatures((doc) => {
376
500
  const target = doc.features.find((entry) => entry.id === feature.id);
377
501
  if (!target)
@@ -407,9 +531,10 @@ server.registerTool("planner-feature-delete", {
407
531
  }, async ({ feature: ref, cascade }) => {
408
532
  const st = await requireStore();
409
533
  const features = (await st.loadFeatures()).features;
410
- const feature = findFeatureByRef(features, ref);
411
- if (!feature)
412
- return text(`Feature not found: ${ref}`);
534
+ const resolvedFeature = resolveFeatureRefStrict(features, ref);
535
+ if (!resolvedFeature.ok)
536
+ return text(resolvedFeature.error);
537
+ const feature = resolvedFeature.feature;
413
538
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
414
539
  await st.updateFeatures((doc) => {
415
540
  doc.features = doc.features.filter((entry) => entry.id !== feature.id);
@@ -428,31 +553,40 @@ server.registerTool("planner-feature-delete", {
428
553
  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
554
  });
430
555
  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.",
556
+ 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
557
  inputSchema: {
433
558
  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."),
559
+ 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
560
  summary: z.string().optional().describe("One-line summary of the phase"),
436
561
  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
562
  },
438
563
  }, async ({ title, feature: featureRef, summary, description }) => {
439
564
  const st = await requireStore();
565
+ if (!featureRef?.trim())
566
+ return text("feature is required: a phase must belong to a feature.");
440
567
  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}`);
568
+ const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
569
+ if (!resolvedFeature.ok)
570
+ return text(resolvedFeature.error);
571
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
572
+ if (!featureValidation.ok)
573
+ return text(featureValidation.error);
574
+ const feature = resolvedFeature.feature;
575
+ if (!feature)
576
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
444
577
  const lockKey = feature?.id ?? "__unscoped__";
445
578
  let phase;
446
579
  await withFeatureLock(lockKey, async () => {
447
580
  const phases = await st.loadAllPhases();
448
581
  const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
449
582
  const timestamp = nowISO();
450
- const shortId = createShortId(await st.assignedShortIds());
583
+ const id = createPhaseId();
584
+ const identity = await st.allocateEntityIdentity("phase", id);
451
585
  const priority = await st.nextPriority("phase", feature?.id);
452
586
  phase = {
453
- id: createPhaseId(),
454
- number: await st.allocPhaseNumber(),
455
- shortId,
587
+ id,
588
+ number: identity.number,
589
+ shortId: identity.shortId,
456
590
  priority,
457
591
  slug: normalizeSlug(title),
458
592
  title: title.trim(),
@@ -463,6 +597,7 @@ server.registerTool("planner-phase-add", {
463
597
  contextReadyReason: "",
464
598
  summary: summary?.trim() ?? "",
465
599
  description: description?.trim() ?? "",
600
+ descriptionUpdatedAt: timestamp,
466
601
  notes: "",
467
602
  goals: [],
468
603
  nonGoals: [],
@@ -481,6 +616,7 @@ server.registerTool("planner-phase-add", {
481
616
  handoffUpdatedAt: "",
482
617
  handoffReadAt: "",
483
618
  handoffHistory: [],
619
+ statusLog: [],
484
620
  };
485
621
  await st.savePhase(phase);
486
622
  if (feature) {
@@ -498,7 +634,7 @@ server.registerTool("planner-phase-add", {
498
634
  return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
499
635
  });
500
636
  server.registerTool("planner-phase-show", {
501
- description: "Show a phase by id or name.",
637
+ description: "Show a phase by id or name, including derived linked requirements in the full view.",
502
638
  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
639
  }, async ({ phase: ref, full }) => {
504
640
  const st = await requireStore();
@@ -506,8 +642,24 @@ server.registerTool("planner-phase-show", {
506
642
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
507
643
  if (!phase)
508
644
  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);
645
+ const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
646
+ const reqCount = linkedRequirements.length;
647
+ 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"}` : ""})`;
648
+ const requirementsBlock = reqCount > 0
649
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
650
+ : "";
651
+ return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
652
+ phase: {
653
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
654
+ shortId: phase.shortId,
655
+ title: phase.title,
656
+ summary: phase.summary,
657
+ status: phase.status,
658
+ taskCount: phase.tasks.length,
659
+ ...(full ? { description: phase.description } : {}),
660
+ },
661
+ linkedRequirements,
662
+ });
511
663
  });
512
664
  server.registerTool("planner-phase-discuss", {
513
665
  description: "Persist phase discovery fields and mark phase planned.",
@@ -603,35 +755,57 @@ server.registerTool("planner-phase-delete", {
603
755
  server.registerTool("planner-task-add", {
604
756
  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
757
  inputSchema: {
758
+ 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
759
  phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
607
760
  title: z.string().min(1),
608
761
  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
762
  checklist: z.array(z.string()).optional(),
610
763
  },
611
- }, async ({ phase: ref, title, description, checklist }) => {
764
+ }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
612
765
  const st = await requireStore();
613
- const found = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
766
+ const features = (await st.loadFeatures()).features;
767
+ if (!featureRef?.trim())
768
+ return text("feature is required: a task must belong to a feature.");
769
+ const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
770
+ if (!resolvedFeature.ok)
771
+ return text(resolvedFeature.error);
772
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
773
+ if (!featureValidation.ok)
774
+ return text(featureValidation.error);
775
+ const feature = resolvedFeature.feature;
776
+ if (!feature)
777
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
778
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
614
779
  if (!found)
615
780
  return text(`Phase not found: ${ref}`);
781
+ if (found.featureId !== feature.id)
782
+ return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
783
+ const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
784
+ if (!phaseValidation.ok)
785
+ return text(phaseValidation.error);
786
+ const existingPhase = await st.loadPhase(found.id);
787
+ if (!existingPhase)
788
+ return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
616
789
  const timestamp = nowISO();
617
790
  const taskId = createTaskId();
618
- const shortId = createShortId(await st.assignedShortIds());
791
+ const identity = await st.allocateEntityIdentity("task", taskId);
619
792
  const priority = await st.nextPriority("task", found.id);
620
793
  const task = {
621
794
  id: taskId,
622
795
  phaseId: found.id,
623
- number: await st.allocTaskNumber(),
624
- shortId,
796
+ number: identity.number,
797
+ shortId: identity.shortId,
625
798
  priority,
626
799
  shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
627
800
  title: title.trim(),
628
801
  status: "planned",
629
802
  description: description?.trim() ?? "",
803
+ descriptionUpdatedAt: timestamp,
630
804
  notes: "",
631
805
  statusLog: [],
632
806
  decisions: [],
633
807
  acceptedDecisions: [],
634
- checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
808
+ checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
635
809
  subtasks: [],
636
810
  dependsOn: [],
637
811
  startedAt: "",
@@ -645,8 +819,8 @@ server.registerTool("planner-task-add", {
645
819
  entry.updatedAt = timestamp;
646
820
  return entry;
647
821
  });
648
- const features = (await st.loadFeatures()).features;
649
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, features)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
822
+ const finalFeatures = (await st.loadFeatures()).features;
823
+ return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
650
824
  });
651
825
  server.registerTool("planner-task-show", {
652
826
  description: "Show a task by id or name.",
@@ -683,7 +857,9 @@ server.registerTool("planner-task-discuss", {
683
857
  if (description !== undefined)
684
858
  task.description = description.trim();
685
859
  if (checklist !== undefined)
686
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
860
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
861
+ if (checklist !== undefined)
862
+ task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
687
863
  task.updatedAt = nowISO();
688
864
  phase.updatedAt = task.updatedAt;
689
865
  updatedTask = task;
@@ -702,8 +878,9 @@ server.registerTool("planner-task-update", {
702
878
  description: z.string().optional(),
703
879
  motivation: z.string().optional(),
704
880
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
881
+ 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
882
  },
706
- }, async ({ task: ref, title, status, description, motivation, priority }) => {
883
+ }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
707
884
  const st = await requireStore();
708
885
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
709
886
  if (!found)
@@ -750,6 +927,107 @@ server.registerTool("planner-task-update", {
750
927
  const t = updatedTask ?? found.task;
751
928
  return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
752
929
  });
930
+ server.registerTool("planner-task-checklist-toggle", {
931
+ 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.",
932
+ inputSchema: {
933
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
934
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
935
+ checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
936
+ },
937
+ }, async ({ task: ref, item, checked }) => {
938
+ const st = await requireStore();
939
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
940
+ if (!found)
941
+ return text(`Task not found: ${ref}`);
942
+ let result = "";
943
+ await st.updatePhase(found.phase.id, (phase) => {
944
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
945
+ if (!task) {
946
+ result = `Task not found: ${ref}`;
947
+ return phase;
948
+ }
949
+ const ck = task.checklist ?? [];
950
+ if (ck.length === 0) {
951
+ result = `Task "${found.task.title}" has no checklist.`;
952
+ return phase;
953
+ }
954
+ const target = toggleChecklistItem(ck, item, checked);
955
+ if (!target) {
956
+ result = `No checklist item matching "${item}".`;
957
+ return phase;
958
+ }
959
+ task.updatedAt = nowISO();
960
+ phase.updatedAt = nowISO();
961
+ const doneCount = ck.filter((i) => i.checked).length;
962
+ result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
963
+ return phase;
964
+ });
965
+ return writeAndSummarize(st, `✅ ${result}`);
966
+ });
967
+ server.registerTool("planner-task-checklist-add", {
968
+ 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.",
969
+ inputSchema: {
970
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
971
+ title: z.string().min(1).describe("Checklist item text (a single step)"),
972
+ },
973
+ }, async ({ task: ref, title }) => {
974
+ const st = await requireStore();
975
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
976
+ if (!found)
977
+ return text(`Task not found: ${ref}`);
978
+ let result = "";
979
+ await st.updatePhase(found.phase.id, (phase) => {
980
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
981
+ if (!task) {
982
+ result = `Task not found: ${ref}`;
983
+ return phase;
984
+ }
985
+ const ck = task.checklist ?? [];
986
+ const item = addChecklistItem(ck, task.id, title);
987
+ ck.push(item);
988
+ task.checklist = ck;
989
+ task.updatedAt = nowISO();
990
+ phase.updatedAt = nowISO();
991
+ result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
992
+ return phase;
993
+ });
994
+ return writeAndSummarize(st, `✅ ${result}`);
995
+ });
996
+ server.registerTool("planner-task-checklist-remove", {
997
+ 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.",
998
+ inputSchema: {
999
+ task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
1000
+ item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
1001
+ },
1002
+ }, async ({ task: ref, item }) => {
1003
+ const st = await requireStore();
1004
+ const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1005
+ if (!found)
1006
+ return text(`Task not found: ${ref}`);
1007
+ let result = "";
1008
+ await st.updatePhase(found.phase.id, (phase) => {
1009
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
1010
+ if (!task) {
1011
+ result = `Task not found: ${ref}`;
1012
+ return phase;
1013
+ }
1014
+ const ck = task.checklist ?? [];
1015
+ if (ck.length === 0) {
1016
+ result = `Task "${found.task.title}" has no checklist.`;
1017
+ return phase;
1018
+ }
1019
+ const removed = removeChecklistItem(ck, item);
1020
+ if (!removed) {
1021
+ result = `No checklist item matching "${item}".`;
1022
+ return phase;
1023
+ }
1024
+ task.updatedAt = nowISO();
1025
+ phase.updatedAt = nowISO();
1026
+ result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
1027
+ return phase;
1028
+ });
1029
+ return writeAndSummarize(st, `✅ ${result}`);
1030
+ });
753
1031
  server.registerTool("planner-task-delete", {
754
1032
  description: "Delete a task.",
755
1033
  inputSchema: { task: z.string().min(1) },
@@ -768,27 +1046,79 @@ server.registerTool("planner-task-delete", {
768
1046
  const tdFeatures = (await st.loadFeatures()).features;
769
1047
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
770
1048
  });
1049
+ server.registerTool("planner-task-recommend", {
1050
+ 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.",
1051
+ inputSchema: {},
1052
+ }, async () => {
1053
+ const st = await requireStore();
1054
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1055
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
1056
+ if (!result.candidate)
1057
+ return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1058
+ const { candidate } = result;
1059
+ const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1060
+ return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1061
+ kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1062
+ });
1063
+ });
1064
+ server.registerTool("planner-task-deviation", {
1065
+ 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.",
1066
+ inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
1067
+ }, async ({ temporary_task, resume_task, reason }) => {
1068
+ const st = await requireStore();
1069
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1070
+ const temporary = findTaskByRef(phases, features.features, temporary_task);
1071
+ if (!temporary)
1072
+ return text(`Task not found: ${temporary_task}`);
1073
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
1074
+ const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
1075
+ if (!resume)
1076
+ return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
1077
+ if (temporary.task.id === resume.task.id)
1078
+ return text("A temporary task must differ from its resume target.");
1079
+ if (temporary.task.status !== "planned")
1080
+ return text(`Temporary task is not startable: ${temporary.task.status}.`);
1081
+ const timestamp = nowISO();
1082
+ 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: "" };
1083
+ await st.addWorkDeviation(record);
1084
+ 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 });
1085
+ });
771
1086
  server.registerTool("planner-task-start", {
772
- description: "Set a task to in-progress.",
1087
+ 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
1088
  inputSchema: { task: z.string().min(1) },
774
1089
  }, async ({ task: ref }) => {
775
1090
  const st = await requireStore();
776
- const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1091
+ const features = (await st.loadFeatures()).features;
1092
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
777
1093
  if (!found)
778
1094
  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
- }
1095
+ if (found.task.status === "in-progress")
1096
+ return text(`Task ${found.task.id} is already in-progress.`);
1097
+ if (found.task.status === "done")
1098
+ return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1099
+ const project = await st.loadProject();
1100
+ const phases = await st.loadAllPhases();
1101
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1102
+ if (!eligibility.eligible)
1103
+ return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1104
+ const selection = recommendNextTask(features, phases, project.workDeviations);
1105
+ // Assemble the mandatory parent context before changing task lifecycle state.
1106
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1107
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
1108
+ st.loadPhaseWithRequirements(found.phase.id),
1109
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1110
+ ]);
1111
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1112
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
1113
+ // archived only when the phase completes (auto) or the user explicitly
1114
+ // clears it.
1115
+ const advisory = selection.kind === "conflict"
1116
+ ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
1117
+ : selection.candidate && selection.candidate.task.id !== found.task.id
1118
+ ? selection.kind === "active"
1119
+ ? `\n\n⚠️ Active-task advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is already in progress. Explicit task request honored.`
1120
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1121
+ : "";
792
1122
  const timestamp = nowISO();
793
1123
  let updatedTask;
794
1124
  await st.updatePhase(found.phase.id, (phase) => {
@@ -813,9 +1143,11 @@ server.registerTool("planner-task-start", {
813
1143
  return phase;
814
1144
  });
815
1145
  await st.syncTaskStatusRollup(found.phase.id);
816
- const features = (await st.loadFeatures()).features;
1146
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1147
+ if (approvedDeviation)
1148
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
817
1149
  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}` : ""}`);
1150
+ return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
819
1151
  });
820
1152
  server.registerTool("planner-task-complete", {
821
1153
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -860,6 +1192,11 @@ server.registerTool("planner-task-complete", {
860
1192
  return phase;
861
1193
  });
862
1194
  const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
1195
+ const completedDeviation = (await st.loadProject()).workDeviations
1196
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
1197
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1198
+ if (completedDeviation)
1199
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
863
1200
  const features = (await st.loadFeatures()).features;
864
1201
  const t = updatedTask ?? found.task;
865
1202
  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 +1212,8 @@ server.registerTool("planner-handoff-list", {
875
1212
  return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
876
1213
  });
877
1214
  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.") },
1215
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
1216
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
880
1217
  }, async ({ phaseRef }) => {
881
1218
  const st = await requireStore();
882
1219
  const r = await resolvePhaseForHandoff(st, phaseRef);
@@ -888,36 +1225,61 @@ server.registerTool("planner-handoff-show", {
888
1225
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
889
1226
  });
890
1227
  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.",
1228
+ 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
1229
  inputSchema: {
893
- phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase."),
1230
+ phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1231
+ 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'."),
1232
+ confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
894
1233
  content: z.string().min(1).describe("Handoff text (markdown)."),
895
1234
  },
896
- }, async ({ phaseRef, content }) => {
1235
+ }, async ({ phaseRef, title, confirmed, content }) => {
897
1236
  const st = await requireStore();
1237
+ let body = content.trim();
1238
+ const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1239
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1240
+ const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
1241
+ if (!title && isGeneric) {
1242
+ 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.");
1243
+ }
1244
+ if (title && title.trim()) {
1245
+ const t = title.trim();
1246
+ const lines = body.split(/\r?\n/);
1247
+ const firstIdx = lines.findIndex((l) => l.trim().length > 0);
1248
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
1249
+ lines[firstIdx] = `# ${t}`;
1250
+ body = lines.join("\n");
1251
+ }
1252
+ else {
1253
+ body = `# ${t}\n\n${body}`;
1254
+ }
1255
+ }
898
1256
  const r = await resolvePhaseForHandoff(st, phaseRef);
899
1257
  if (!r.ok)
900
1258
  return text(`❌ ${r.error}`);
901
- await st.setPhaseHandoff(r.phase.id, content);
1259
+ if (!confirmed)
1260
+ 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 });
1261
+ await st.setPhaseHandoff(r.phase.id, body);
902
1262
  return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
903
1263
  });
904
1264
  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).",
1265
+ 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
1266
  }, 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.",
1267
+ "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.",
1268
+ "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1269
+ "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1270
+ "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.",
1271
+ "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
910
1272
  ].join("\n")));
911
1273
  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.") },
1274
+ description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1275
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
914
1276
  }, async ({ phaseRef }) => {
915
1277
  const st = await requireStore();
916
1278
  const r = await resolvePhaseForHandoff(st, phaseRef);
917
1279
  if (!r.ok)
918
1280
  return text(`❌ ${r.error}`);
919
1281
  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 });
1282
+ 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
1283
  });
922
1284
  server.registerTool("planner-web", {
923
1285
  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 +1322,7 @@ server.registerTool("planner-web", {
960
1322
  return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
961
1323
  });
962
1324
  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).",
1325
+ 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
1326
  }, async () => {
965
1327
  const st = store();
966
1328
  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.9",
3
+ "version": "0.2.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.9",
45
- "@agent-plan/server": "0.2.19-next.9"
46
+ "@agent-plan/core": "0.2.20",
47
+ "@agent-plan/server": "0.2.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
+ }