@agent-plan/mcp 0.2.25 → 0.2.26

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":";AAw0DA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAi8EA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -5,9 +5,9 @@ import * as z from "zod/v4";
5
5
  import { join } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { pathToFileURL } from "node:url";
8
- import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
8
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, HandoffContractError } from "@agent-plan/core";
9
9
  import { serve } from "@agent-plan/server";
10
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
+ import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, validateResolvedTarget } from "@agent-plan/core/naming";
11
11
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
12
12
  const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
13
13
  const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
@@ -21,6 +21,35 @@ function plannerSessionIdFor(extra) {
21
21
  function text(textValue, structuredContent) {
22
22
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
23
23
  }
24
+ function handoffContractFailure(error) {
25
+ return {
26
+ isError: true,
27
+ content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
28
+ structuredContent: { errorCode: error.code, ...error.details },
29
+ };
30
+ }
31
+ function boundedHandoffForTransport(content) {
32
+ if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
33
+ return { content, fullLength: content.length, truncated: false };
34
+ const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
35
+ return {
36
+ content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
37
+ fullLength: content.length,
38
+ truncated: true,
39
+ };
40
+ }
41
+ function boundedPage(items, page, pageSize) {
42
+ const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
43
+ const totalPages = Math.max(1, Math.ceil(items.length / size));
44
+ const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
45
+ return {
46
+ items: items.slice((currentPage - 1) * size, currentPage * size),
47
+ page: currentPage,
48
+ pageSize: size,
49
+ total: items.length,
50
+ totalPages,
51
+ };
52
+ }
24
53
  function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
25
54
  const childKind = entity === "feature" ? "phases" : "tasks";
26
55
  const retryAction = entity === "feature"
@@ -52,6 +81,23 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
52
81
  },
53
82
  };
54
83
  }
84
+ function mutationNoFieldsFailure(input) {
85
+ const outcome = noMutableFieldsReceived(input);
86
+ return {
87
+ isError: true,
88
+ content: [{ type: "text", text: outcome.message }],
89
+ structuredContent: { ...outcome },
90
+ };
91
+ }
92
+ function legacyContextMigrationSummary(preview) {
93
+ return [
94
+ `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
95
+ `Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
96
+ `Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
97
+ `Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
98
+ `Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
99
+ ].join("\n");
100
+ }
55
101
  function taskStartError(outcome, structuredContent = {}) {
56
102
  return {
57
103
  isError: true,
@@ -66,14 +112,15 @@ function taskStartError(outcome, structuredContent = {}) {
66
112
  structuredContent: { ...outcome, ...structuredContent },
67
113
  };
68
114
  }
69
- function mcpContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
70
- const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
115
+ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
116
+ const actions = projectGuidelinesReadRequired ? ["planner-project-guidelines-show"] : [];
117
+ actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
71
118
  if (read.kind === "task")
72
119
  return [`planner-task-show ${refs.task} with full=true`];
73
120
  if (read.kind === "phase")
74
121
  return [`planner-phase-show ${refs.phase} with full=true`];
75
122
  return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
76
- });
123
+ }));
77
124
  if (requirementReadRequired)
78
125
  actions.push("planner-requirement-list");
79
126
  actions.push(retryAction);
@@ -194,6 +241,21 @@ const server = new McpServer({
194
241
  name: "agent-plan-planner",
195
242
  version: MCP_PACKAGE.version,
196
243
  });
244
+ async function resolveRequirementPhaseRefs(st, refs) {
245
+ if (!refs || refs.length === 0)
246
+ return { ok: false, error: "linkedPhaseIds must contain at least one phase reference." };
247
+ const [phases, features] = await Promise.all([st.loadAllPhases(), st.loadFeatures()]);
248
+ const linkedPhaseIds = [];
249
+ for (const rawRef of refs) {
250
+ const ref = rawRef.trim();
251
+ const phase = ref ? findPhaseByRef(phases, features.features, ref) : undefined;
252
+ if (!phase)
253
+ return { ok: false, error: `Linked phase not found: ${rawRef}` };
254
+ if (!linkedPhaseIds.includes(phase.id))
255
+ linkedPhaseIds.push(phase.id);
256
+ }
257
+ return { ok: true, linkedPhaseIds };
258
+ }
197
259
  async function resolvePhaseForHandoff(st, ref) {
198
260
  const phases = await st.loadAllPhases();
199
261
  const features = (await st.loadFeatures()).features;
@@ -294,6 +356,7 @@ server.registerTool("planner-show", {
294
356
  const summary = [
295
357
  `📋 ${project.name}`,
296
358
  `Description: ${project.description || "(not set)"}`,
359
+ ...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
297
360
  `Goal: ${project.goal || "(not set)"}`,
298
361
  `Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
299
362
  `Updated: ${manifest.updatedAt || "(unknown)"}`,
@@ -303,7 +366,7 @@ server.registerTool("planner-show", {
303
366
  ].join("\n");
304
367
  // Compact structured overview (no full plan: avoids 1M+ char token overflow).
305
368
  const overview = {
306
- project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
369
+ project: { name: project.name, description: project.description || null, descriptionRef: project.descriptionRef || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
307
370
  counts: { features: features.length, phases: phases.length, tasks: totalTasks },
308
371
  features: features.map((feature) => ({
309
372
  ref: formatFeatureRef(feature.number),
@@ -369,6 +432,8 @@ server.registerTool("planner-project-language", {
369
432
  server.registerTool("planner-project-discuss", {
370
433
  description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
371
434
  inputSchema: {
435
+ description: z.string().optional(),
436
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
372
437
  goal: z.string().optional(),
373
438
  scope: z.array(z.string()).optional(),
374
439
  outOfScope: z.array(z.string()).optional(),
@@ -380,6 +445,32 @@ server.registerTool("planner-project-discuss", {
380
445
  }, async (params) => {
381
446
  const st = await requireStore();
382
447
  const project = await st.loadProject();
448
+ const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
449
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
450
+ if (receivedFields.length === 0) {
451
+ return mutationNoFieldsFailure({
452
+ entity: "project",
453
+ ref: project.name,
454
+ operation: "discuss",
455
+ mutableFields,
456
+ retryCommand: "planner-project-discuss with a concise description summary and the intended metadata",
457
+ readBackCommand: "planner-show",
458
+ descriptionFallback: {
459
+ inputField: "description",
460
+ storedField: "project description",
461
+ descriptionRefField: "descriptionRef",
462
+ },
463
+ });
464
+ }
465
+ if (params.description !== undefined)
466
+ project.description = params.description.trim();
467
+ if (params.descriptionRef !== undefined) {
468
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
469
+ if (descriptionRef)
470
+ project.descriptionRef = descriptionRef;
471
+ else
472
+ delete project.descriptionRef;
473
+ }
383
474
  if (params.goal !== undefined)
384
475
  project.goal = params.goal.trim();
385
476
  if (params.scope !== undefined)
@@ -395,25 +486,83 @@ server.registerTool("planner-project-discuss", {
395
486
  if (params.decisions !== undefined)
396
487
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
397
488
  await st.saveProject(project);
398
- return writeAndSummarize(st, `Project discussed/updated: ${project.name}`);
489
+ return writeAndSummarize(st, `Project discussed/updated: ${project.name}. Fields saved: ${receivedFields.join(", ")}.`, { project, discussed: true, updatedFields: receivedFields });
490
+ });
491
+ server.registerTool("planner-project-guidelines-show", {
492
+ description: "Read the canonical Project Guidelines / Coding Standard section for this project. Call this when planner-task-start or planner-task-switch nextActions require it, and keep the guidance in working memory while doing task work.",
493
+ }, async (extra) => {
494
+ const plannerSessionId = plannerSessionIdFor(extra);
495
+ const st = await requireStore();
496
+ const project = await st.loadProject();
497
+ if (project.projectGuidelines.content.trim()) {
498
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
499
+ }
500
+ const refreshed = await st.loadProject();
501
+ const content = refreshed.projectGuidelines.content.trim();
502
+ return text(content
503
+ ? ["## Project Guidelines", content].join("\n\n")
504
+ : "No project guidelines set.", { projectGuidelines: refreshed.projectGuidelines, readRecorded: Boolean(content) });
505
+ });
506
+ server.registerTool("planner-project-guidelines-update", {
507
+ description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
508
+ inputSchema: {
509
+ content: z.string().describe("Full Project Guidelines markdown/text. Pass an empty string to clear the section."),
510
+ },
511
+ }, async ({ content }) => {
512
+ const st = await requireStore();
513
+ const project = await st.loadProject();
514
+ project.projectGuidelines = {
515
+ content: content.trim(),
516
+ updatedAt: project.projectGuidelines.updatedAt,
517
+ sessionInfo: project.projectGuidelines.sessionInfo,
518
+ };
519
+ await st.saveProject(project);
520
+ const refreshed = await st.loadProject();
521
+ return writeAndSummarize(st, refreshed.projectGuidelines.content
522
+ ? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
523
+ : "Project Guidelines cleared.", { updated: true, projectGuidelines: refreshed.projectGuidelines });
524
+ });
525
+ server.registerTool("planner-project-context-migrate", {
526
+ description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
527
+ inputSchema: {
528
+ apply: z.boolean().default(false).describe("False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields."),
529
+ },
530
+ }, async ({ apply }) => {
531
+ const st = await requireStore();
532
+ const preview = await st.previewLegacyProjectContextMigration();
533
+ if (!apply) {
534
+ return text([`Project context migration preview (no changes applied).`, legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n"), { applied: false, preview });
535
+ }
536
+ const result = await st.migrateLegacyProjectContext();
537
+ return writeAndSummarize(st, [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n"), { ...result });
399
538
  });
400
539
  server.registerTool("planner-feature-list", {
401
- description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
540
+ description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
402
541
  inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
403
542
  }, async ({ featureRef }) => {
404
543
  const st = await requireStore();
405
544
  const allFeatures = (await st.loadFeatures()).features;
406
545
  const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
407
546
  const phases = await st.loadAllPhases();
408
- const lines = features.map((feature) => {
547
+ const items = features.map((feature) => {
409
548
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
410
549
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
411
- return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
550
+ return {
551
+ id: feature.id,
552
+ ref: formatFeatureRef(feature.number),
553
+ shortId: feature.shortId,
554
+ name: feature.name,
555
+ status: feature.status,
556
+ priority: feature.priority,
557
+ phaseCount: featurePhases.length,
558
+ taskCount,
559
+ };
412
560
  });
413
- return text(lines.join("\n") || "No features");
561
+ const lines = items.map((feature) => `- ${feature.ref}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}${feature.priority > 0 ? `; priority ${feature.priority}` : ""}; ${feature.phaseCount} phases, ${feature.taskCount} tasks)`);
562
+ return text(lines.join("\n") || "No features", { features: items });
414
563
  });
415
564
  server.registerTool("planner-phase-list", {
416
- description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
565
+ description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
417
566
  inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name)."), status: z.string().optional().describe("Optional: filter by status name.") },
418
567
  }, async ({ featureRef, status }) => {
419
568
  const st = await requireStore();
@@ -427,15 +576,27 @@ server.registerTool("planner-phase-list", {
427
576
  }
428
577
  if (status)
429
578
  phases = phases.filter((p) => p.status === status);
430
- const lines = phases.map((phase) => {
579
+ const items = phases.map((phase) => {
431
580
  const fNum = featureNumberOfPhase(phase, features);
432
- const fTag = fNum !== undefined ? ` [F${String(fNum).padStart(3, "0")}]` : "";
433
- return `- ${formatPhaseRef(phase.number, fNum)}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}; ${phase.tasks.length} tasks)${fTag}`;
581
+ return {
582
+ id: phase.id,
583
+ ref: formatPhaseRef(phase.number, fNum),
584
+ shortId: phase.shortId,
585
+ title: phase.title,
586
+ status: phase.status,
587
+ priority: phase.priority,
588
+ taskCount: phase.tasks.length,
589
+ featureNumber: fNum,
590
+ };
591
+ });
592
+ const lines = items.map((phase) => {
593
+ const fTag = phase.featureNumber !== undefined ? ` [F${String(phase.featureNumber).padStart(3, "0")}]` : "";
594
+ return `- ${phase.ref}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}${phase.priority > 0 ? `; priority ${phase.priority}` : ""}; ${phase.taskCount} tasks)${fTag}`;
434
595
  });
435
- return text(lines.join("\n") || "No phases");
596
+ return text(lines.join("\n") || "No phases", { phases: items });
436
597
  });
437
598
  server.registerTool("planner-task-list", {
438
- description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
599
+ description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
439
600
  inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
440
601
  }, async ({ featureRef, phaseRef, status }) => {
441
602
  const st = await requireStore();
@@ -453,15 +614,25 @@ server.registerTool("planner-task-list", {
453
614
  return text(`Phase not found: ${phaseRef}`);
454
615
  phases = [p];
455
616
  }
456
- const out = [];
617
+ const items = [];
457
618
  for (const phase of phases) {
458
619
  for (const task of phase.tasks) {
459
620
  if (status && task.status !== status)
460
621
  continue;
461
- out.push(`- ${taskCompositeRef(task, phase, features)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
622
+ items.push({
623
+ id: task.id,
624
+ ref: taskCompositeRef(task, phase, features),
625
+ shortId: task.shortId,
626
+ title: task.title,
627
+ status: task.status,
628
+ priority: task.priority,
629
+ phaseId: phase.id,
630
+ ...(phase.featureId ? { featureId: phase.featureId } : {}),
631
+ });
462
632
  }
463
633
  }
464
- return text(out.join("\n") || "No tasks");
634
+ const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
635
+ return text(out.join("\n") || "No tasks", { tasks: items });
465
636
  });
466
637
  server.registerTool("planner-feature-add", {
467
638
  description: "Create a feature with a rich description. REQUIRED: description must include code references (file:line), current implementation state (what exists, what is unimplemented), systems/structs/traits involved, concrete goals, and behaviors to preserve. The description is the primary context for future agents resuming this feature; one-liners cause misalignment.",
@@ -523,30 +694,58 @@ server.registerTool("planner-feature-show", {
523
694
  markFeatureReadForSessionId(plannerSessionId, feature.id);
524
695
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
525
696
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
526
- return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
697
+ const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
698
+ return text(full ? `${summary}\n\n${feature.description || ""}${descriptionRefBlock}` : summary, full ? { feature: { ref: formatFeatureRef(feature.number), description: feature.description, descriptionRef: feature.descriptionRef || "" } } : undefined);
527
699
  });
528
700
  server.registerTool("planner-feature-discuss", {
529
701
  description: "Persist feature discovery/governance fields and mark the feature context as ready.",
530
702
  inputSchema: {
531
703
  feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
532
704
  description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
705
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
533
706
  workDone: z.string().optional().describe("What is already implemented / decided."),
534
707
  workRemaining: z.string().optional().describe("What still needs to be done."),
535
708
  dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
536
709
  },
537
- }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
710
+ }, async ({ feature: ref, description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies }) => {
538
711
  const st = await requireStore();
539
712
  const features = (await st.loadFeatures()).features;
540
713
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
541
714
  if (!resolvedFeature.ok)
542
715
  return text(resolvedFeature.error);
543
716
  const feature = resolvedFeature.feature;
717
+ const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
718
+ const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
719
+ const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
720
+ if (receivedFields.length === 0) {
721
+ return mutationNoFieldsFailure({
722
+ entity: "feature",
723
+ ref: formatFeatureRef(feature.number),
724
+ operation: "discuss",
725
+ mutableFields,
726
+ retryCommand: `planner-feature-discuss ${formatFeatureRef(feature.number)}`,
727
+ readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
728
+ descriptionFallback: {
729
+ entityId: feature.id,
730
+ inputField: "description",
731
+ storedField: "feature description",
732
+ descriptionRefField: "descriptionRef",
733
+ },
734
+ });
735
+ }
544
736
  const updated = await st.updateFeatures((doc) => {
545
737
  const target = doc.features.find((entry) => entry.id === feature.id);
546
738
  if (!target)
547
739
  return doc;
548
740
  if (description !== undefined)
549
741
  target.description = description.trim();
742
+ if (rawDescriptionRef !== undefined) {
743
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
744
+ if (descriptionRef)
745
+ target.descriptionRef = descriptionRef;
746
+ else
747
+ delete target.descriptionRef;
748
+ }
550
749
  if (workDone !== undefined)
551
750
  target.workDone = workDone.trim();
552
751
  if (workRemaining !== undefined)
@@ -560,7 +759,7 @@ server.registerTool("planner-feature-discuss", {
560
759
  return doc;
561
760
  });
562
761
  const result = updated.features.find((entry) => entry.id === feature.id);
563
- return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
762
+ return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, discussed: true, updatedFields: receivedFields });
564
763
  });
565
764
  server.registerTool("planner-feature-update", {
566
765
  description: "Update a feature.",
@@ -568,6 +767,7 @@ server.registerTool("planner-feature-update", {
568
767
  feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
569
768
  name: z.string().optional(),
570
769
  description: z.string().optional(),
770
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
571
771
  status: z.enum(STATUS_VALUES).optional(),
572
772
  workDone: z.string().optional(),
573
773
  workRemaining: z.string().optional(),
@@ -585,6 +785,24 @@ server.registerTool("planner-feature-update", {
585
785
  if (updates.status !== undefined) {
586
786
  return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
587
787
  }
788
+ const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
789
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
790
+ if (receivedFields.length === 0) {
791
+ return mutationNoFieldsFailure({
792
+ entity: "feature",
793
+ ref: formatFeatureRef(feature.number),
794
+ operation: "update",
795
+ mutableFields,
796
+ retryCommand: `planner-feature-update ${formatFeatureRef(feature.number)}`,
797
+ readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
798
+ descriptionFallback: {
799
+ entityId: feature.id,
800
+ inputField: "description",
801
+ storedField: "feature description",
802
+ descriptionRefField: "descriptionRef",
803
+ },
804
+ });
805
+ }
588
806
  const updated = await st.updateFeatures((doc) => {
589
807
  const target = doc.features.find((entry) => entry.id === feature.id);
590
808
  if (!target)
@@ -593,6 +811,13 @@ server.registerTool("planner-feature-update", {
593
811
  target.name = updates.name.trim();
594
812
  if (updates.description !== undefined)
595
813
  target.description = updates.description.trim();
814
+ if (updates.descriptionRef !== undefined) {
815
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
816
+ if (descriptionRef)
817
+ target.descriptionRef = descriptionRef;
818
+ else
819
+ delete target.descriptionRef;
820
+ }
596
821
  if (updates.workDone !== undefined)
597
822
  target.workDone = updates.workDone.trim();
598
823
  if (updates.workRemaining !== undefined)
@@ -607,7 +832,7 @@ server.registerTool("planner-feature-update", {
607
832
  return doc;
608
833
  });
609
834
  const result = updated.features.find((entry) => entry.id === feature.id);
610
- return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
835
+ return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, updated: true, updatedFields: receivedFields });
611
836
  });
612
837
  server.registerTool("planner-feature-delete", {
613
838
  description: "Delete a feature. By default, phases are unlinked rather than deleted.",
@@ -701,6 +926,7 @@ server.registerTool("planner-phase-add", {
701
926
  updatedAt: timestamp,
702
927
  handoff: "",
703
928
  handoffUpdatedAt: "",
929
+ handoffAudit: null,
704
930
  handoffReadAt: "",
705
931
  handoffHistory: [],
706
932
  statusLog: [],
@@ -736,10 +962,11 @@ server.registerTool("planner-phase-show", {
736
962
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
737
963
  const reqCount = linkedRequirements.length;
738
964
  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"}` : ""})`;
965
+ const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
739
966
  const requirementsBlock = reqCount > 0
740
967
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
741
968
  : "";
742
- return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
969
+ return text(full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary, {
743
970
  phase: {
744
971
  ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
745
972
  shortId: phase.shortId,
@@ -747,7 +974,7 @@ server.registerTool("planner-phase-show", {
747
974
  summary: phase.summary,
748
975
  status: phase.status,
749
976
  taskCount: phase.tasks.length,
750
- ...(full ? { description: phase.description } : {}),
977
+ ...(full ? { description: phase.description, descriptionRef: phase.descriptionRef || "" } : {}),
751
978
  },
752
979
  linkedRequirements,
753
980
  });
@@ -759,6 +986,7 @@ server.registerTool("planner-phase-discuss", {
759
986
  goal: z.string().optional(),
760
987
  summary: z.string().optional(),
761
988
  scope: z.string().optional(),
989
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
762
990
  nonGoals: z.array(z.string()).optional(),
763
991
  dependencies: z.array(z.string()).optional(),
764
992
  risks: z.array(z.string()).optional(),
@@ -770,6 +998,24 @@ server.registerTool("planner-phase-discuss", {
770
998
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
771
999
  if (!found)
772
1000
  return text(`Phase not found: ${ref}`);
1001
+ const mutableFields = ["goal", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "completionCriteria"];
1002
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1003
+ if (receivedFields.length === 0) {
1004
+ return mutationNoFieldsFailure({
1005
+ entity: "phase",
1006
+ ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1007
+ operation: "discuss",
1008
+ mutableFields,
1009
+ retryCommand: `planner-phase-discuss ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
1010
+ readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
1011
+ descriptionFallback: {
1012
+ entityId: found.id,
1013
+ inputField: "scope",
1014
+ storedField: "phase description",
1015
+ descriptionRefField: "descriptionRef",
1016
+ },
1017
+ });
1018
+ }
773
1019
  const phase = await st.updatePhase(found.id, (entry) => {
774
1020
  if (updates.goal !== undefined)
775
1021
  entry.goals = [updates.goal.trim()].filter(Boolean);
@@ -777,6 +1023,13 @@ server.registerTool("planner-phase-discuss", {
777
1023
  entry.summary = updates.summary.trim();
778
1024
  if (updates.scope !== undefined)
779
1025
  entry.description = updates.scope.trim();
1026
+ if (updates.descriptionRef !== undefined) {
1027
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1028
+ if (descriptionRef)
1029
+ entry.descriptionRef = descriptionRef;
1030
+ else
1031
+ delete entry.descriptionRef;
1032
+ }
780
1033
  if (updates.nonGoals !== undefined)
781
1034
  entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
782
1035
  if (updates.dependencies !== undefined)
@@ -792,7 +1045,7 @@ server.registerTool("planner-phase-discuss", {
792
1045
  entry.updatedAt = nowISO();
793
1046
  return entry;
794
1047
  });
795
- return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
1048
+ return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, discussed: true, updatedFields: receivedFields });
796
1049
  });
797
1050
  server.registerTool("planner-phase-update", {
798
1051
  description: "Update phase fields.",
@@ -802,6 +1055,7 @@ server.registerTool("planner-phase-update", {
802
1055
  status: z.enum(PHASE_STATUS_VALUES).optional(),
803
1056
  summary: z.string().optional(),
804
1057
  description: z.string().optional(),
1058
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
805
1059
  priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
806
1060
  },
807
1061
  }, async ({ phase: ref, ...updates }) => {
@@ -813,6 +1067,24 @@ server.registerTool("planner-phase-update", {
813
1067
  if (updates.status !== undefined) {
814
1068
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
815
1069
  }
1070
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "priority"];
1071
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1072
+ if (receivedFields.length === 0) {
1073
+ return mutationNoFieldsFailure({
1074
+ entity: "phase",
1075
+ ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1076
+ operation: "update",
1077
+ mutableFields,
1078
+ retryCommand: `planner-phase-update ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
1079
+ readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
1080
+ descriptionFallback: {
1081
+ entityId: found.id,
1082
+ inputField: "description",
1083
+ storedField: "phase description",
1084
+ descriptionRefField: "descriptionRef",
1085
+ },
1086
+ });
1087
+ }
816
1088
  const phase = await st.updatePhase(found.id, (entry) => {
817
1089
  if (updates.title !== undefined)
818
1090
  entry.title = updates.title.trim();
@@ -820,12 +1092,19 @@ server.registerTool("planner-phase-update", {
820
1092
  entry.summary = updates.summary.trim();
821
1093
  if (updates.description !== undefined)
822
1094
  entry.description = updates.description.trim();
1095
+ if (updates.descriptionRef !== undefined) {
1096
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1097
+ if (descriptionRef)
1098
+ entry.descriptionRef = descriptionRef;
1099
+ else
1100
+ delete entry.descriptionRef;
1101
+ }
823
1102
  if (updates.priority !== undefined)
824
1103
  entry.priority = updates.priority;
825
1104
  entry.updatedAt = nowISO();
826
1105
  return entry;
827
1106
  });
828
- return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
1107
+ return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, updated: true, updatedFields: receivedFields });
829
1108
  });
830
1109
  server.registerTool("planner-phase-delete", {
831
1110
  description: "Delete a phase and unlink it from features.",
@@ -944,6 +1223,8 @@ server.registerTool("planner-task-show", {
944
1223
  const sections = [summary];
945
1224
  if (found.task.description?.trim())
946
1225
  sections.push(found.task.description.trim());
1226
+ if (found.task.descriptionRef)
1227
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
947
1228
  if (snapshot || pendingDeviation) {
948
1229
  sections.push([
949
1230
  pendingDeviation
@@ -957,20 +1238,40 @@ server.registerTool("planner-task-show", {
957
1238
  }
958
1239
  if (log)
959
1240
  sections.push(`Status log:\n${log}`);
960
- return text(sections.join("\n\n"));
1241
+ return text(sections.join("\n\n"), { task: { ref: taskCompositeRef(found.task, found.phase, features), description: found.task.description, descriptionRef: found.task.descriptionRef || "" } });
961
1242
  });
962
1243
  server.registerTool("planner-task-discuss", {
963
1244
  description: "Persist task execution notes/checklist.",
964
1245
  inputSchema: {
965
1246
  task: z.string().min(1),
966
1247
  description: z.string().optional(),
1248
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
967
1249
  checklist: z.array(z.string()).optional(),
968
1250
  },
969
- }, async ({ task: ref, description, checklist }) => {
1251
+ }, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
970
1252
  const st = await requireStore();
971
1253
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
972
1254
  if (!found)
973
1255
  return text(`Task not found: ${ref}`);
1256
+ const mutableFields = ["description", "descriptionRef", "checklist"];
1257
+ const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
1258
+ const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
1259
+ if (receivedFields.length === 0) {
1260
+ return mutationNoFieldsFailure({
1261
+ entity: "task",
1262
+ ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1263
+ operation: "discuss",
1264
+ mutableFields,
1265
+ retryCommand: `planner-task-discuss ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
1266
+ readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
1267
+ descriptionFallback: {
1268
+ entityId: found.task.id,
1269
+ inputField: "description",
1270
+ storedField: "task description",
1271
+ descriptionRefField: "descriptionRef",
1272
+ },
1273
+ });
1274
+ }
974
1275
  let updatedTask;
975
1276
  await st.updatePhase(found.phase.id, (phase) => {
976
1277
  const task = phase.tasks.find((entry) => entry.id === found.task.id);
@@ -978,8 +1279,13 @@ server.registerTool("planner-task-discuss", {
978
1279
  return phase;
979
1280
  if (description !== undefined)
980
1281
  task.description = description.trim();
981
- if (checklist !== undefined)
982
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
1282
+ if (rawDescriptionRef !== undefined) {
1283
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1284
+ if (descriptionRef)
1285
+ task.descriptionRef = descriptionRef;
1286
+ else
1287
+ delete task.descriptionRef;
1288
+ }
983
1289
  if (checklist !== undefined)
984
1290
  task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
985
1291
  task.updatedAt = nowISO();
@@ -989,7 +1295,7 @@ server.registerTool("planner-task-discuss", {
989
1295
  });
990
1296
  const features = (await st.loadFeatures()).features;
991
1297
  const t = updatedTask ?? found.task;
992
- return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
1298
+ return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { task: t, discussed: true, updatedFields: receivedFields });
993
1299
  });
994
1300
  server.registerTool("planner-task-update", {
995
1301
  description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
@@ -998,11 +1304,12 @@ server.registerTool("planner-task-update", {
998
1304
  title: z.string().optional(),
999
1305
  status: z.enum(STATUS_VALUES).optional(),
1000
1306
  description: z.string().optional(),
1307
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
1001
1308
  motivation: z.string().optional(),
1002
1309
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
1003
1310
  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."),
1004
1311
  },
1005
- }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
1312
+ }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist }) => {
1006
1313
  const st = await requireStore();
1007
1314
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1008
1315
  if (!found)
@@ -1020,6 +1327,25 @@ server.registerTool("planner-task-update", {
1020
1327
  `Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
1021
1328
  }
1022
1329
  }
1330
+ const mutableFields = ["title", "status", "description", "descriptionRef", "motivation", "priority", "checklist"];
1331
+ const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist };
1332
+ const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
1333
+ if (receivedFields.length === 0) {
1334
+ return mutationNoFieldsFailure({
1335
+ entity: "task",
1336
+ ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1337
+ operation: "update",
1338
+ mutableFields,
1339
+ retryCommand: `planner-task-update ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
1340
+ readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
1341
+ descriptionFallback: {
1342
+ entityId: found.task.id,
1343
+ inputField: "description",
1344
+ storedField: "task description",
1345
+ descriptionRefField: "descriptionRef",
1346
+ },
1347
+ });
1348
+ }
1023
1349
  let updatedTask;
1024
1350
  const timestamp = nowISO();
1025
1351
  await st.updatePhase(found.phase.id, (phase) => {
@@ -1032,6 +1358,13 @@ server.registerTool("planner-task-update", {
1032
1358
  task.priority = priority;
1033
1359
  if (description !== undefined)
1034
1360
  task.description = description.trim();
1361
+ if (rawDescriptionRef !== undefined) {
1362
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1363
+ if (descriptionRef)
1364
+ task.descriptionRef = descriptionRef;
1365
+ else
1366
+ delete task.descriptionRef;
1367
+ }
1035
1368
  if (status !== undefined && status !== task.status) {
1036
1369
  // Record status change in the incremental statusLog.
1037
1370
  const entry = {
@@ -1068,7 +1401,7 @@ server.registerTool("planner-task-update", {
1068
1401
  }
1069
1402
  }
1070
1403
  }
1071
- return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
1404
+ return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${resumeNotice}`, resumeRequired ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired } : { task: t, updated: true, updatedFields: receivedFields });
1072
1405
  });
1073
1406
  server.registerTool("planner-task-checklist-toggle", {
1074
1407
  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.",
@@ -1279,7 +1612,7 @@ server.registerTool("planner-task-pause", {
1279
1612
  ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1280
1613
  });
1281
1614
  server.registerTool("planner-task-switch", {
1282
- description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
1615
+ description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
1283
1616
  inputSchema: {
1284
1617
  from_task: z.string().min(1),
1285
1618
  to_task: z.string().min(1),
@@ -1324,11 +1657,17 @@ server.registerTool("planner-task-switch", {
1324
1657
  };
1325
1658
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1326
1659
  const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1660
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1661
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1327
1662
  const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
1328
1663
  const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
1329
1664
  const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
1665
+ if (!projectGuidelinesReady) {
1666
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1667
+ return text(`Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.`, { projectGuidelinesReadState, contextEligibility, nextActions });
1668
+ }
1330
1669
  if (!contextEligibility.eligible) {
1331
- const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1670
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1332
1671
  return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1333
1672
  }
1334
1673
  if (!requirementsReady) {
@@ -1434,7 +1773,7 @@ server.registerTool("planner-task-switch", {
1434
1773
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1435
1774
  });
1436
1775
  server.registerTool("planner-task-start", {
1437
- description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1776
+ description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1438
1777
  inputSchema: { task: z.string().min(1) },
1439
1778
  }, async ({ task: ref }, extra) => {
1440
1779
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -1454,7 +1793,8 @@ server.registerTool("planner-task-start", {
1454
1793
  const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1455
1794
  const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1456
1795
  if (found.task.status === "done")
1457
- return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry planner-task-start ${taskRef}.`], { taskId: found.task.id }));
1796
+ return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Retry planner-task-start ${taskRef}. If it then reports read prerequisites, perform only the missing or stale nextActions before retrying again.`], { taskId: found.task.id }));
1797
+ const project = await st.loadProject();
1458
1798
  const linkedRequirements = [
1459
1799
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
1460
1800
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -1473,8 +1813,12 @@ server.registerTool("planner-task-start", {
1473
1813
  };
1474
1814
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1475
1815
  const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1816
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1817
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1818
+ if (!projectGuidelinesReady)
1819
+ return taskStartError(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { projectGuidelinesReadState, contextEligibility });
1476
1820
  if (!contextEligibility.eligible)
1477
- return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
1821
+ return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
1478
1822
  if (!requirementsReady) {
1479
1823
  return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["planner-requirement-list", `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }));
1480
1824
  }
@@ -1485,7 +1829,7 @@ server.registerTool("planner-task-start", {
1485
1829
  const outcome = taskStartSucceeded(found.task.id, true);
1486
1830
  return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1487
1831
  }
1488
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1832
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
1489
1833
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1490
1834
  if (!eligibility.eligible)
1491
1835
  return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
@@ -1658,14 +2002,20 @@ server.registerTool("planner-task-complete", {
1658
2002
  return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
1659
2003
  });
1660
2004
  server.registerTool("planner-handoff-list", {
1661
- description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
1662
- }, async () => {
2005
+ description: "List active handoffs as compact summary-only pages. Full bodies are never embedded; call planner-handoff-show for one exact phase.",
2006
+ inputSchema: {
2007
+ page: z.number().int().min(1).default(1).describe("1-based page number."),
2008
+ pageSize: z.number().int().min(1).max(25).default(10).describe("Summaries per page; maximum 25."),
2009
+ },
2010
+ }, async ({ page, pageSize }) => {
1663
2011
  const st = await requireStore();
1664
2012
  const list = await st.listHandoffs();
1665
2013
  if (list.length === 0)
1666
- return text("No phase handoffs set.");
1667
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
1668
- return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
2014
+ return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
2015
+ const result = boundedPage(list, page, pageSize);
2016
+ const { items, ...pageInfo } = result;
2017
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
2018
+ return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
1669
2019
  });
1670
2020
  server.registerTool("planner-handoff-show", {
1671
2021
  description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
@@ -1675,10 +2025,11 @@ server.registerTool("planner-handoff-show", {
1675
2025
  const r = await resolvePhaseForHandoff(st, phaseRef);
1676
2026
  if (!r.ok)
1677
2027
  return text(`❌ ${r.error}`);
1678
- const content = await st.getPhaseHandoff(r.phase.id);
1679
- if (!content.trim())
1680
- return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, empty: true });
1681
- return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
2028
+ const phase = await st.loadPhase(r.phase.id);
2029
+ const bounded = boundedHandoffForTransport(phase.handoff);
2030
+ if (!bounded.content.trim())
2031
+ return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, handoffAudit: phase.handoffAudit });
2032
+ return text(`Handoff for ${r.compositeRef}:\n\n${bounded.content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit });
1682
2033
  });
1683
2034
  server.registerTool("planner-requirement-list", {
1684
2035
  description: "List all top-level requirements in requirements.json. Reading this list records the requirements as read for the start/resume read-enforcement (explicit requirement read, required alongside feature/phase reads).",
@@ -1690,8 +2041,228 @@ server.registerTool("planner-requirement-list", {
1690
2041
  requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1691
2042
  return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1692
2043
  });
2044
+ server.registerTool("planner-requirement-create", {
2045
+ description: "Create a top-level requirement with optional nested macro tasks. Requirement and macro-task IDs/timestamps are assigned by the planner.",
2046
+ inputSchema: {
2047
+ title: z.string().min(1),
2048
+ description: z.string().optional(),
2049
+ status: z.enum(STATUS_VALUES).optional(),
2050
+ linkedPhaseIds: z.array(z.string().min(1)),
2051
+ macroTasks: z.array(z.object({
2052
+ title: z.string().min(1),
2053
+ description: z.string().optional(),
2054
+ status: z.enum(STATUS_VALUES).optional(),
2055
+ })).optional(),
2056
+ },
2057
+ }, async ({ title, description, status, linkedPhaseIds, macroTasks }) => {
2058
+ const st = await requireStore();
2059
+ const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2060
+ if (!links.ok)
2061
+ return text(`❌ ${links.error}`, { created: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2062
+ const now = nowISO();
2063
+ let normalizedMacroTasks;
2064
+ try {
2065
+ normalizedMacroTasks = reconcileRequirementMacroTasks([], (macroTasks ?? []).map((task) => ({
2066
+ title: task.title,
2067
+ ...(task.description !== undefined ? { description: task.description } : {}),
2068
+ status: task.status ?? "planned",
2069
+ })), now);
2070
+ }
2071
+ catch (error) {
2072
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2073
+ return text(`❌ ${message}`, { created: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2074
+ }
2075
+ const requirement = {
2076
+ id: createRequirementId(),
2077
+ title: title.trim(),
2078
+ description: description?.trim() ?? "",
2079
+ status: status ?? "planned",
2080
+ macroTasks: normalizedMacroTasks,
2081
+ linkedPhaseIds: links.linkedPhaseIds,
2082
+ createdAt: now,
2083
+ updatedAt: now,
2084
+ sessionInfo: [],
2085
+ };
2086
+ const requirements = await st.loadRequirements();
2087
+ await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
2088
+ await st.writeGenerated();
2089
+ return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
2090
+ });
2091
+ server.registerTool("planner-requirement-update", {
2092
+ description: "Update a requirement’s human-owned title, description, status, linked phases, or complete ordered macro-task list. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
2093
+ inputSchema: {
2094
+ requirementId: z.string().min(1),
2095
+ title: z.string().min(1).optional(),
2096
+ description: z.string().optional(),
2097
+ status: z.enum(STATUS_VALUES).optional(),
2098
+ linkedPhaseIds: z.array(z.string().min(1)).optional(),
2099
+ macroTasks: z.array(z.object({
2100
+ id: z.string().min(1).optional(),
2101
+ title: z.string().min(1),
2102
+ description: z.string().optional(),
2103
+ status: z.enum(STATUS_VALUES),
2104
+ })).optional(),
2105
+ },
2106
+ }, async ({ requirementId, title, description, status, linkedPhaseIds, macroTasks }) => {
2107
+ const st = await requireStore();
2108
+ const requirements = await st.loadRequirements();
2109
+ const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
2110
+ if (!current)
2111
+ return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
2112
+ const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2113
+ if (!links.ok)
2114
+ return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2115
+ const now = nowISO();
2116
+ let normalizedMacroTasks = current.macroTasks;
2117
+ if (macroTasks !== undefined) {
2118
+ try {
2119
+ normalizedMacroTasks = reconcileRequirementMacroTasks(current.macroTasks, macroTasks.map((task) => ({
2120
+ ...(task.id !== undefined ? { id: task.id } : {}),
2121
+ title: task.title,
2122
+ ...(task.description !== undefined ? { description: task.description } : {}),
2123
+ status: task.status,
2124
+ })), now);
2125
+ }
2126
+ catch (error) {
2127
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2128
+ return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2129
+ }
2130
+ }
2131
+ const next = {
2132
+ ...current,
2133
+ ...(title !== undefined ? { title: title.trim() } : {}),
2134
+ ...(description !== undefined ? { description: description.trim() } : {}),
2135
+ ...(status !== undefined ? { status } : {}),
2136
+ linkedPhaseIds: links.linkedPhaseIds,
2137
+ macroTasks: normalizedMacroTasks,
2138
+ updatedAt: now,
2139
+ };
2140
+ await st.saveRequirements({ requirements: requirements.requirements.map((requirement) => requirement.id === current.id ? next : requirement) });
2141
+ await st.writeGenerated();
2142
+ return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
2143
+ });
2144
+ server.registerTool("planner-requirement-delete", {
2145
+ description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
2146
+ inputSchema: { requirementId: z.string().min(1) },
2147
+ }, async ({ requirementId }) => {
2148
+ const st = await requireStore();
2149
+ const requirements = await st.loadRequirements();
2150
+ const next = requirements.requirements.filter((requirement) => requirement.id !== requirementId);
2151
+ if (next.length === requirements.requirements.length)
2152
+ return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
2153
+ await st.saveRequirements({ requirements: next });
2154
+ await st.writeGenerated();
2155
+ return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
2156
+ });
2157
+ function ideaPromotionTarget(st, type, ref) {
2158
+ return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
2159
+ const features = featuresDoc.features;
2160
+ if (type === "feature") {
2161
+ const feature = findFeatureByRef(features, ref);
2162
+ return feature ? { id: feature.id, ref: formatFeatureRef(feature.number) } : undefined;
2163
+ }
2164
+ if (type === "phase") {
2165
+ const phase = findPhaseByRef(phases, features, ref);
2166
+ return phase ? { id: phase.id, ref: formatPhaseRef(phase.number, features.find((item) => item.id === phase.featureId)?.number) } : undefined;
2167
+ }
2168
+ const found = findTaskByRef(phases, features, ref);
2169
+ return found ? { id: found.task.id, ref: `${formatPhaseRef(found.phase.number, features.find((item) => item.id === found.phase.featureId)?.number)}/T${String(found.task.number).padStart(3, "0")}` } : undefined;
2170
+ });
2171
+ }
2172
+ server.registerTool("planner-idea-list", {
2173
+ description: "List top-level Ideas Inbox entries. Ideas never affect feature, phase, or task rollups.",
2174
+ inputSchema: {},
2175
+ }, async () => {
2176
+ const ideas = (await requireStore().then((st) => st.loadIdeas())).ideas;
2177
+ return text(ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas", { ideas });
2178
+ });
2179
+ server.registerTool("planner-idea-show", {
2180
+ description: "Show one Idea Inbox entry by I-number, short ID, UUID, or title.",
2181
+ inputSchema: { idea: z.string().min(1) },
2182
+ }, async ({ idea: ref }) => {
2183
+ const st = await requireStore();
2184
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2185
+ return idea ? text(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}`, { idea }) : text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
2186
+ });
2187
+ server.registerTool("planner-idea-create", {
2188
+ description: "Create a rollup-independent Ideas Inbox entry.",
2189
+ inputSchema: { title: z.string().min(1), description: z.string().optional() },
2190
+ }, async ({ title, description }) => {
2191
+ const st = await requireStore();
2192
+ const idea = await st.createIdea({ title, ...(description !== undefined ? { description } : {}) });
2193
+ await st.writeGenerated();
2194
+ return text(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, { idea, created: true });
2195
+ });
2196
+ server.registerTool("planner-idea-update", {
2197
+ description: "Update an unpromoted or promoted Idea Inbox entry title or description.",
2198
+ inputSchema: { idea: z.string().min(1), title: z.string().min(1).optional(), description: z.string().optional() },
2199
+ }, async ({ idea: ref, title, description }) => {
2200
+ const st = await requireStore();
2201
+ const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2202
+ if (!current)
2203
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
2204
+ if (title === undefined && description === undefined)
2205
+ return text("No mutable idea fields were received.", { errorCode: "NO_MUTABLE_FIELDS_RECEIVED", updated: false });
2206
+ const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
2207
+ await st.writeGenerated();
2208
+ return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
2209
+ });
2210
+ server.registerTool("planner-idea-delete", {
2211
+ description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
2212
+ inputSchema: { idea: z.string().min(1), confirmed: z.boolean() },
2213
+ }, async ({ idea: ref, confirmed }) => {
2214
+ if (!confirmed)
2215
+ return text("Idea deletion requires confirmed=true after explicit user confirmation.", { errorCode: "CONFIRMATION_REQUIRED", deleted: false });
2216
+ const st = await requireStore();
2217
+ const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2218
+ if (!current)
2219
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", deleted: false });
2220
+ await st.deleteIdea(current.id);
2221
+ await st.writeGenerated();
2222
+ return text(`Idea deleted: ${formatIdeaRef(current.number)}.`, { deleted: true, ideaRef: formatIdeaRef(current.number) });
2223
+ });
2224
+ server.registerTool("planner-idea-promotion-begin", {
2225
+ description: "Begin an Idea promotion discussion without persisting a target or promotion. Returns the project-local grill-me instructions; follow them one question at a time, then use planner-idea-promotion-finalize only after target confirmation.",
2226
+ inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]) },
2227
+ }, async ({ idea: ref, targetType }) => {
2228
+ const st = await requireStore();
2229
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2230
+ if (!idea)
2231
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
2232
+ if (idea.promotion)
2233
+ return text(`${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.`, { errorCode: "ALREADY_PROMOTED", idea });
2234
+ const features = (await st.loadFeatures()).features;
2235
+ const phases = await st.loadAllPhases();
2236
+ const recommendedFeature = features[0];
2237
+ const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
2238
+ const recommendation = targetType === "feature"
2239
+ ? "Create a new feature after the discussion."
2240
+ : targetType === "phase"
2241
+ ? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
2242
+ : recommendedPhase
2243
+ ? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.`
2244
+ : "Create and confirm a parent feature and phase before promoting to a task.";
2245
+ return text(`Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}`, { idea, targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target through the normal feature/phase/task creation tool.", "Call planner-idea-promotion-finalize with discussionCompleted=true and confirmed=true."] });
2246
+ });
2247
+ server.registerTool("planner-idea-promotion-finalize", {
2248
+ description: "Record a promotion only after grill-me discussion, explicit target confirmation, and successful target creation. targetRef must be the already-created agreed target; no raw UUID is returned.",
2249
+ inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]), targetRef: z.string().min(1), discussionCompleted: z.boolean(), confirmed: z.boolean() },
2250
+ }, async ({ idea: ref, targetType, targetRef, discussionCompleted, confirmed }) => {
2251
+ if (!discussionCompleted || !confirmed)
2252
+ return text("Promotion requires discussionCompleted=true and confirmed=true; no target or promotion was persisted.", { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false });
2253
+ const st = await requireStore();
2254
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2255
+ if (!idea)
2256
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", promoted: false });
2257
+ const target = await ideaPromotionTarget(st, targetType, targetRef);
2258
+ if (!target)
2259
+ return text(`Confirmed ${targetType} target not found: ${targetRef}; no promotion was persisted.`, { errorCode: "TARGET_NOT_FOUND", promoted: false });
2260
+ const promoted = await st.promoteIdea(idea.id, { targetType, targetId: target.id });
2261
+ await st.writeGenerated();
2262
+ return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
2263
+ });
1693
2264
  server.registerTool("planner-handoff-write", {
1694
- description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run planner-handoff-prepare with the confirmed phaseRef first and pass its exact handoffUpdatedAt token.",
2265
+ description: "Reconcile one active handoff with durable context synchronization and a mandatory versioned completeness audit. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
1695
2266
  inputSchema: {
1696
2267
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1697
2268
  title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
@@ -1699,6 +2270,18 @@ server.registerTool("planner-handoff-write", {
1699
2270
  content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
1700
2271
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
1701
2272
  reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
2273
+ completenessAudit: z.object({
2274
+ version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
2275
+ entries: z.array(z.object({
2276
+ category: z.string().min(1).describe(`One required category id: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}`),
2277
+ status: z.enum(["captured", "not-applicable"]),
2278
+ detail: z.string().min(1),
2279
+ })),
2280
+ }).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
2281
+ supportingDocuments: z.array(z.object({
2282
+ path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
2283
+ description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
2284
+ })).optional(),
1702
2285
  taskUpdates: z.array(z.object({
1703
2286
  taskRef: z.string().min(1),
1704
2287
  completionSummary: z.string().min(1),
@@ -1716,7 +2299,7 @@ server.registerTool("planner-handoff-write", {
1716
2299
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
1717
2300
  featureNoUpdateReason: z.string().min(1).optional(),
1718
2301
  },
1719
- }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2302
+ }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
1720
2303
  const st = await requireStore();
1721
2304
  let body = content.trim();
1722
2305
  const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
@@ -1762,6 +2345,8 @@ server.registerTool("planner-handoff-write", {
1762
2345
  content: body,
1763
2346
  expectedHandoffUpdatedAt,
1764
2347
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2348
+ ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2349
+ ...(supportingDocuments ? { supportingDocuments } : {}),
1765
2350
  contextSync: {
1766
2351
  taskUpdates: resolvedTaskUpdates,
1767
2352
  ...(phaseUpdate ? { phaseUpdate: {
@@ -1775,15 +2360,23 @@ server.registerTool("planner-handoff-write", {
1775
2360
  },
1776
2361
  });
1777
2362
  await st.writeGenerated();
1778
- return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result });
2363
+ return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, {
2364
+ phaseRef: r.compositeRef,
2365
+ phaseId: r.phase.id,
2366
+ updatedTaskIds: result.updatedTaskIds,
2367
+ handoffUpdatedAt: result.handoffUpdatedAt,
2368
+ handoffAudit: result.handoffAudit,
2369
+ });
1779
2370
  }
1780
2371
  catch (error) {
2372
+ if (error instanceof HandoffContractError)
2373
+ return handoffContractFailure(error);
1781
2374
  const message = error instanceof Error ? error.message : String(error);
1782
2375
  return text(`❌ Handoff refresh denied: ${message}`, { error: message });
1783
2376
  }
1784
2377
  });
1785
2378
  server.registerTool("planner-handoff-prepare", {
1786
- description: "Identify or audit the exact handoff target. Without phaseRef, returns the confirmation workflow. With a confirmed phaseRef, returns the current handoff/version and done tasks missing durable completion evidence.",
2379
+ description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the current token, missing task evidence, mandatory completeness categories, and the 24,000-character inline budget.",
1787
2380
  inputSchema: { phaseRef: z.string().min(1).optional() },
1788
2381
  }, async ({ phaseRef }) => {
1789
2382
  if (!phaseRef)
@@ -1805,6 +2398,10 @@ server.registerTool("planner-handoff-prepare", {
1805
2398
  "Done tasks missing durable completion/verification evidence:",
1806
2399
  missing,
1807
2400
  "",
2401
+ `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
2402
+ ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
2403
+ `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
2404
+ "",
1808
2405
  "Existing active handoff (reconcile all still-relevant content):",
1809
2406
  audit.handoff.trim() || "(none)",
1810
2407
  ].join("\n"), { phaseRef: r.compositeRef, ...audit });
@@ -1867,13 +2464,41 @@ server.registerTool("planner-web", {
1867
2464
  });
1868
2465
  server.registerTool("planner-load", {
1869
2466
  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).",
1870
- }, async () => {
2467
+ }, async (extra) => {
1871
2468
  const st = store();
1872
2469
  if (!(await st.exists()))
1873
2470
  return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
2471
+ const plannerSessionId = plannerSessionIdFor(extra);
2472
+ let preparation;
2473
+ try {
2474
+ preparation = await st.preparePlannerSession();
2475
+ }
2476
+ catch (error) {
2477
+ return text(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", loaded: false });
2478
+ }
2479
+ const project = preparation.project;
2480
+ if (project.projectGuidelines.content.trim()) {
2481
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
2482
+ }
2483
+ const plannerSkill = await st.syncPlannerSkill();
2484
+ await st.syncGrillMeSkill();
1874
2485
  const web = await ensureWebStarted();
1875
2486
  const recap = await buildRecap(st, web, { harness: "mcp" });
1876
- return text(recap);
2487
+ const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
2488
+ return {
2489
+ content: [{ type: "text", text: visibleRecap }],
2490
+ structuredContent: {
2491
+ preparation: preparation.legacyProjectContext,
2492
+ agentContext: {
2493
+ kind: "planner-usage-skill",
2494
+ content: plannerSkill.content,
2495
+ status: plannerSkill.status,
2496
+ customized: plannerSkill.customized,
2497
+ message: plannerSkill.message,
2498
+ instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
2499
+ },
2500
+ },
2501
+ };
1877
2502
  });
1878
2503
  server.registerTool("planner-disable", {
1879
2504
  description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "private": false,
5
5
  "description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
6
6
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/sdk": "^1.17.0",
45
45
  "zod": "^3.24.0",
46
- "@agent-plan/core": "0.2.25",
47
- "@agent-plan/server": "0.2.25"
46
+ "@agent-plan/core": "0.2.26",
47
+ "@agent-plan/server": "0.2.26"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",