@agent-plan/mcp 0.2.24 → 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.
package/dist/index.js CHANGED
@@ -5,16 +5,99 @@ 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
- const plannerSessionId = randomUUID();
14
- startReadSession(plannerSessionId);
13
+ const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
14
+ startReadSession(fallbackPlannerSessionId);
15
+ function plannerSessionIdFor(extra) {
16
+ const transportSessionId = extra?.sessionId?.trim();
17
+ const sessionId = transportSessionId ? `mcp:${transportSessionId}` : fallbackPlannerSessionId;
18
+ startReadSession(sessionId);
19
+ return sessionId;
20
+ }
15
21
  function text(textValue, structuredContent) {
16
22
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
17
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
+ }
53
+ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
54
+ const childKind = entity === "feature" ? "phases" : "tasks";
55
+ const retryAction = entity === "feature"
56
+ ? `Retry planner-feature-update ${ref} without the status field if you need to update metadata.`
57
+ : `Retry planner-phase-update ${ref} without the status field if you need to update metadata.`;
58
+ const nextActions = [
59
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
60
+ retryAction,
61
+ ];
62
+ return {
63
+ isError: true,
64
+ content: [{ type: "text", text: [
65
+ `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
66
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
67
+ `Attempted status: ${attemptedStatus}`,
68
+ `Effective derived status: ${effectiveStatus}`,
69
+ "No planner data was changed.",
70
+ "Next required actions:",
71
+ ...nextActions.map((action, index) => `${index + 1}. ${action}`),
72
+ ].join("\n") }],
73
+ structuredContent: {
74
+ updated: false,
75
+ errorCode: "DERIVED_STATUS_READ_ONLY",
76
+ entity,
77
+ ref,
78
+ attemptedStatus,
79
+ effectiveStatus,
80
+ nextActions,
81
+ },
82
+ };
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
+ }
18
101
  function taskStartError(outcome, structuredContent = {}) {
19
102
  return {
20
103
  isError: true,
@@ -29,6 +112,20 @@ function taskStartError(outcome, structuredContent = {}) {
29
112
  structuredContent: { ...outcome, ...structuredContent },
30
113
  };
31
114
  }
115
+ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
116
+ const actions = projectGuidelinesReadRequired ? ["planner-project-guidelines-show"] : [];
117
+ actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
118
+ if (read.kind === "task")
119
+ return [`planner-task-show ${refs.task} with full=true`];
120
+ if (read.kind === "phase")
121
+ return [`planner-phase-show ${refs.phase} with full=true`];
122
+ return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
123
+ }));
124
+ if (requirementReadRequired)
125
+ actions.push("planner-requirement-list");
126
+ actions.push(retryAction);
127
+ return actions;
128
+ }
32
129
  const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
33
130
  const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
34
131
  function nowISO() {
@@ -144,6 +241,21 @@ const server = new McpServer({
144
241
  name: "agent-plan-planner",
145
242
  version: MCP_PACKAGE.version,
146
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
+ }
147
259
  async function resolvePhaseForHandoff(st, ref) {
148
260
  const phases = await st.loadAllPhases();
149
261
  const features = (await st.loadFeatures()).features;
@@ -244,6 +356,7 @@ server.registerTool("planner-show", {
244
356
  const summary = [
245
357
  `📋 ${project.name}`,
246
358
  `Description: ${project.description || "(not set)"}`,
359
+ ...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
247
360
  `Goal: ${project.goal || "(not set)"}`,
248
361
  `Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
249
362
  `Updated: ${manifest.updatedAt || "(unknown)"}`,
@@ -253,7 +366,7 @@ server.registerTool("planner-show", {
253
366
  ].join("\n");
254
367
  // Compact structured overview (no full plan: avoids 1M+ char token overflow).
255
368
  const overview = {
256
- 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 },
257
370
  counts: { features: features.length, phases: phases.length, tasks: totalTasks },
258
371
  features: features.map((feature) => ({
259
372
  ref: formatFeatureRef(feature.number),
@@ -319,6 +432,8 @@ server.registerTool("planner-project-language", {
319
432
  server.registerTool("planner-project-discuss", {
320
433
  description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
321
434
  inputSchema: {
435
+ description: z.string().optional(),
436
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
322
437
  goal: z.string().optional(),
323
438
  scope: z.array(z.string()).optional(),
324
439
  outOfScope: z.array(z.string()).optional(),
@@ -330,6 +445,32 @@ server.registerTool("planner-project-discuss", {
330
445
  }, async (params) => {
331
446
  const st = await requireStore();
332
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
+ }
333
474
  if (params.goal !== undefined)
334
475
  project.goal = params.goal.trim();
335
476
  if (params.scope !== undefined)
@@ -345,25 +486,83 @@ server.registerTool("planner-project-discuss", {
345
486
  if (params.decisions !== undefined)
346
487
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
347
488
  await st.saveProject(project);
348
- 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 });
349
538
  });
350
539
  server.registerTool("planner-feature-list", {
351
- 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.",
352
541
  inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
353
542
  }, async ({ featureRef }) => {
354
543
  const st = await requireStore();
355
544
  const allFeatures = (await st.loadFeatures()).features;
356
545
  const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
357
546
  const phases = await st.loadAllPhases();
358
- const lines = features.map((feature) => {
547
+ const items = features.map((feature) => {
359
548
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
360
549
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
361
- 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
+ };
362
560
  });
363
- 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 });
364
563
  });
365
564
  server.registerTool("planner-phase-list", {
366
- 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.",
367
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.") },
368
567
  }, async ({ featureRef, status }) => {
369
568
  const st = await requireStore();
@@ -377,15 +576,27 @@ server.registerTool("planner-phase-list", {
377
576
  }
378
577
  if (status)
379
578
  phases = phases.filter((p) => p.status === status);
380
- const lines = phases.map((phase) => {
579
+ const items = phases.map((phase) => {
381
580
  const fNum = featureNumberOfPhase(phase, features);
382
- const fTag = fNum !== undefined ? ` [F${String(fNum).padStart(3, "0")}]` : "";
383
- 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}`;
384
595
  });
385
- return text(lines.join("\n") || "No phases");
596
+ return text(lines.join("\n") || "No phases", { phases: items });
386
597
  });
387
598
  server.registerTool("planner-task-list", {
388
- 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.",
389
600
  inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
390
601
  }, async ({ featureRef, phaseRef, status }) => {
391
602
  const st = await requireStore();
@@ -403,15 +614,25 @@ server.registerTool("planner-task-list", {
403
614
  return text(`Phase not found: ${phaseRef}`);
404
615
  phases = [p];
405
616
  }
406
- const out = [];
617
+ const items = [];
407
618
  for (const phase of phases) {
408
619
  for (const task of phase.tasks) {
409
620
  if (status && task.status !== status)
410
621
  continue;
411
- 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
+ });
412
632
  }
413
633
  }
414
- 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 });
415
636
  });
416
637
  server.registerTool("planner-feature-add", {
417
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.",
@@ -461,7 +682,8 @@ server.registerTool("planner-feature-add", {
461
682
  server.registerTool("planner-feature-show", {
462
683
  description: "Show a feature by id or name.",
463
684
  inputSchema: { feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the feature description. Default: compact identity only (saves tokens).") },
464
- }, async ({ feature: ref, full }) => {
685
+ }, async ({ feature: ref, full }, extra) => {
686
+ const plannerSessionId = plannerSessionIdFor(extra);
465
687
  const st = await requireStore();
466
688
  const features = (await st.loadFeatures()).features;
467
689
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
@@ -472,30 +694,58 @@ server.registerTool("planner-feature-show", {
472
694
  markFeatureReadForSessionId(plannerSessionId, feature.id);
473
695
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
474
696
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
475
- 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);
476
699
  });
477
700
  server.registerTool("planner-feature-discuss", {
478
701
  description: "Persist feature discovery/governance fields and mark the feature context as ready.",
479
702
  inputSchema: {
480
703
  feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
481
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."),
482
706
  workDone: z.string().optional().describe("What is already implemented / decided."),
483
707
  workRemaining: z.string().optional().describe("What still needs to be done."),
484
708
  dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
485
709
  },
486
- }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
710
+ }, async ({ feature: ref, description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies }) => {
487
711
  const st = await requireStore();
488
712
  const features = (await st.loadFeatures()).features;
489
713
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
490
714
  if (!resolvedFeature.ok)
491
715
  return text(resolvedFeature.error);
492
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
+ }
493
736
  const updated = await st.updateFeatures((doc) => {
494
737
  const target = doc.features.find((entry) => entry.id === feature.id);
495
738
  if (!target)
496
739
  return doc;
497
740
  if (description !== undefined)
498
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
+ }
499
749
  if (workDone !== undefined)
500
750
  target.workDone = workDone.trim();
501
751
  if (workRemaining !== undefined)
@@ -509,7 +759,7 @@ server.registerTool("planner-feature-discuss", {
509
759
  return doc;
510
760
  });
511
761
  const result = updated.features.find((entry) => entry.id === feature.id);
512
- 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 });
513
763
  });
514
764
  server.registerTool("planner-feature-update", {
515
765
  description: "Update a feature.",
@@ -517,6 +767,7 @@ server.registerTool("planner-feature-update", {
517
767
  feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
518
768
  name: z.string().optional(),
519
769
  description: z.string().optional(),
770
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
520
771
  status: z.enum(STATUS_VALUES).optional(),
521
772
  workDone: z.string().optional(),
522
773
  workRemaining: z.string().optional(),
@@ -531,6 +782,27 @@ server.registerTool("planner-feature-update", {
531
782
  if (!resolvedFeature.ok)
532
783
  return text(resolvedFeature.error);
533
784
  const feature = resolvedFeature.feature;
785
+ if (updates.status !== undefined) {
786
+ return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
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
+ }
534
806
  const updated = await st.updateFeatures((doc) => {
535
807
  const target = doc.features.find((entry) => entry.id === feature.id);
536
808
  if (!target)
@@ -539,8 +811,13 @@ server.registerTool("planner-feature-update", {
539
811
  target.name = updates.name.trim();
540
812
  if (updates.description !== undefined)
541
813
  target.description = updates.description.trim();
542
- if (updates.status !== undefined)
543
- target.status = updates.status;
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
+ }
544
821
  if (updates.workDone !== undefined)
545
822
  target.workDone = updates.workDone.trim();
546
823
  if (updates.workRemaining !== undefined)
@@ -555,7 +832,7 @@ server.registerTool("planner-feature-update", {
555
832
  return doc;
556
833
  });
557
834
  const result = updated.features.find((entry) => entry.id === feature.id);
558
- 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 });
559
836
  });
560
837
  server.registerTool("planner-feature-delete", {
561
838
  description: "Delete a feature. By default, phases are unlinked rather than deleted.",
@@ -649,6 +926,7 @@ server.registerTool("planner-phase-add", {
649
926
  updatedAt: timestamp,
650
927
  handoff: "",
651
928
  handoffUpdatedAt: "",
929
+ handoffAudit: null,
652
930
  handoffReadAt: "",
653
931
  handoffHistory: [],
654
932
  statusLog: [],
@@ -672,7 +950,8 @@ server.registerTool("planner-phase-add", {
672
950
  server.registerTool("planner-phase-show", {
673
951
  description: "Show a phase by id or name, including derived linked requirements in the full view.",
674
952
  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).") },
675
- }, async ({ phase: ref, full }) => {
953
+ }, async ({ phase: ref, full }, extra) => {
954
+ const plannerSessionId = plannerSessionIdFor(extra);
676
955
  const st = await requireStore();
677
956
  const features = (await st.loadFeatures()).features;
678
957
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
@@ -683,10 +962,11 @@ server.registerTool("planner-phase-show", {
683
962
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
684
963
  const reqCount = linkedRequirements.length;
685
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}` : "";
686
966
  const requirementsBlock = reqCount > 0
687
967
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
688
968
  : "";
689
- return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
969
+ return text(full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary, {
690
970
  phase: {
691
971
  ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
692
972
  shortId: phase.shortId,
@@ -694,7 +974,7 @@ server.registerTool("planner-phase-show", {
694
974
  summary: phase.summary,
695
975
  status: phase.status,
696
976
  taskCount: phase.tasks.length,
697
- ...(full ? { description: phase.description } : {}),
977
+ ...(full ? { description: phase.description, descriptionRef: phase.descriptionRef || "" } : {}),
698
978
  },
699
979
  linkedRequirements,
700
980
  });
@@ -706,6 +986,7 @@ server.registerTool("planner-phase-discuss", {
706
986
  goal: z.string().optional(),
707
987
  summary: z.string().optional(),
708
988
  scope: z.string().optional(),
989
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
709
990
  nonGoals: z.array(z.string()).optional(),
710
991
  dependencies: z.array(z.string()).optional(),
711
992
  risks: z.array(z.string()).optional(),
@@ -717,6 +998,24 @@ server.registerTool("planner-phase-discuss", {
717
998
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
718
999
  if (!found)
719
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
+ }
720
1019
  const phase = await st.updatePhase(found.id, (entry) => {
721
1020
  if (updates.goal !== undefined)
722
1021
  entry.goals = [updates.goal.trim()].filter(Boolean);
@@ -724,6 +1023,13 @@ server.registerTool("planner-phase-discuss", {
724
1023
  entry.summary = updates.summary.trim();
725
1024
  if (updates.scope !== undefined)
726
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
+ }
727
1033
  if (updates.nonGoals !== undefined)
728
1034
  entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
729
1035
  if (updates.dependencies !== undefined)
@@ -739,7 +1045,7 @@ server.registerTool("planner-phase-discuss", {
739
1045
  entry.updatedAt = nowISO();
740
1046
  return entry;
741
1047
  });
742
- 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 });
743
1049
  });
744
1050
  server.registerTool("planner-phase-update", {
745
1051
  description: "Update phase fields.",
@@ -749,6 +1055,7 @@ server.registerTool("planner-phase-update", {
749
1055
  status: z.enum(PHASE_STATUS_VALUES).optional(),
750
1056
  summary: z.string().optional(),
751
1057
  description: z.string().optional(),
1058
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
752
1059
  priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
753
1060
  },
754
1061
  }, async ({ phase: ref, ...updates }) => {
@@ -757,21 +1064,47 @@ server.registerTool("planner-phase-update", {
757
1064
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
758
1065
  if (!found)
759
1066
  return text(`Phase not found: ${ref}`);
1067
+ if (updates.status !== undefined) {
1068
+ return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
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
+ }
760
1088
  const phase = await st.updatePhase(found.id, (entry) => {
761
1089
  if (updates.title !== undefined)
762
1090
  entry.title = updates.title.trim();
763
- if (updates.status !== undefined)
764
- entry.status = updates.status;
765
1091
  if (updates.summary !== undefined)
766
1092
  entry.summary = updates.summary.trim();
767
1093
  if (updates.description !== undefined)
768
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
+ }
769
1102
  if (updates.priority !== undefined)
770
1103
  entry.priority = updates.priority;
771
1104
  entry.updatedAt = nowISO();
772
1105
  return entry;
773
1106
  });
774
- 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 });
775
1108
  });
776
1109
  server.registerTool("planner-phase-delete", {
777
1110
  description: "Delete a phase and unlink it from features.",
@@ -866,7 +1199,8 @@ server.registerTool("planner-task-add", {
866
1199
  server.registerTool("planner-task-show", {
867
1200
  description: "Show a task by id or name.",
868
1201
  inputSchema: { task: z.string().min(1).describe("Task ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only (saves tokens).") },
869
- }, async ({ task: ref, full }) => {
1202
+ }, async ({ task: ref, full }, extra) => {
1203
+ const plannerSessionId = plannerSessionIdFor(extra);
870
1204
  const st = await requireStore();
871
1205
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
872
1206
  if (!found)
@@ -889,6 +1223,8 @@ server.registerTool("planner-task-show", {
889
1223
  const sections = [summary];
890
1224
  if (found.task.description?.trim())
891
1225
  sections.push(found.task.description.trim());
1226
+ if (found.task.descriptionRef)
1227
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
892
1228
  if (snapshot || pendingDeviation) {
893
1229
  sections.push([
894
1230
  pendingDeviation
@@ -902,20 +1238,40 @@ server.registerTool("planner-task-show", {
902
1238
  }
903
1239
  if (log)
904
1240
  sections.push(`Status log:\n${log}`);
905
- 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 || "" } });
906
1242
  });
907
1243
  server.registerTool("planner-task-discuss", {
908
1244
  description: "Persist task execution notes/checklist.",
909
1245
  inputSchema: {
910
1246
  task: z.string().min(1),
911
1247
  description: z.string().optional(),
1248
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
912
1249
  checklist: z.array(z.string()).optional(),
913
1250
  },
914
- }, async ({ task: ref, description, checklist }) => {
1251
+ }, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
915
1252
  const st = await requireStore();
916
1253
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
917
1254
  if (!found)
918
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
+ }
919
1275
  let updatedTask;
920
1276
  await st.updatePhase(found.phase.id, (phase) => {
921
1277
  const task = phase.tasks.find((entry) => entry.id === found.task.id);
@@ -923,8 +1279,13 @@ server.registerTool("planner-task-discuss", {
923
1279
  return phase;
924
1280
  if (description !== undefined)
925
1281
  task.description = description.trim();
926
- if (checklist !== undefined)
927
- 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
+ }
928
1289
  if (checklist !== undefined)
929
1290
  task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
930
1291
  task.updatedAt = nowISO();
@@ -934,7 +1295,7 @@ server.registerTool("planner-task-discuss", {
934
1295
  });
935
1296
  const features = (await st.loadFeatures()).features;
936
1297
  const t = updatedTask ?? found.task;
937
- 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 });
938
1299
  });
939
1300
  server.registerTool("planner-task-update", {
940
1301
  description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
@@ -943,11 +1304,12 @@ server.registerTool("planner-task-update", {
943
1304
  title: z.string().optional(),
944
1305
  status: z.enum(STATUS_VALUES).optional(),
945
1306
  description: z.string().optional(),
1307
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
946
1308
  motivation: z.string().optional(),
947
1309
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
948
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."),
949
1311
  },
950
- }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
1312
+ }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist }) => {
951
1313
  const st = await requireStore();
952
1314
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
953
1315
  if (!found)
@@ -965,6 +1327,25 @@ server.registerTool("planner-task-update", {
965
1327
  `Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
966
1328
  }
967
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
+ }
968
1349
  let updatedTask;
969
1350
  const timestamp = nowISO();
970
1351
  await st.updatePhase(found.phase.id, (phase) => {
@@ -977,6 +1358,13 @@ server.registerTool("planner-task-update", {
977
1358
  task.priority = priority;
978
1359
  if (description !== undefined)
979
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
+ }
980
1368
  if (status !== undefined && status !== task.status) {
981
1369
  // Record status change in the incremental statusLog.
982
1370
  const entry = {
@@ -1013,7 +1401,7 @@ server.registerTool("planner-task-update", {
1013
1401
  }
1014
1402
  }
1015
1403
  }
1016
- 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 });
1017
1405
  });
1018
1406
  server.registerTool("planner-task-checklist-toggle", {
1019
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.",
@@ -1186,7 +1574,8 @@ server.registerTool("planner-task-pause", {
1186
1574
  how_to_resume: z.string().min(1),
1187
1575
  paused_by: z.string().optional(),
1188
1576
  },
1189
- }, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }) => {
1577
+ }, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }, extra) => {
1578
+ const plannerSessionId = plannerSessionIdFor(extra);
1190
1579
  const st = await requireStore();
1191
1580
  const features = (await st.loadFeatures()).features;
1192
1581
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
@@ -1223,7 +1612,7 @@ server.registerTool("planner-task-pause", {
1223
1612
  ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1224
1613
  });
1225
1614
  server.registerTool("planner-task-switch", {
1226
- description: "Atomically switch from active work to a temporary task, even outside normal priority order. Reuse the target's valid sessionInfo context or require the exact full tree when missing or stale. 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.",
1227
1616
  inputSchema: {
1228
1617
  from_task: z.string().min(1),
1229
1618
  to_task: z.string().min(1),
@@ -1233,7 +1622,8 @@ server.registerTool("planner-task-switch", {
1233
1622
  how_to_resume: z.string().min(1),
1234
1623
  switched_by: z.string().optional(),
1235
1624
  },
1236
- }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }) => {
1625
+ }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }, extra) => {
1626
+ const plannerSessionId = plannerSessionIdFor(extra);
1237
1627
  const st = await requireStore();
1238
1628
  const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1239
1629
  const features = featuresDoc.features;
@@ -1266,10 +1656,22 @@ server.registerTool("planner-task-switch", {
1266
1656
  requirementIds: linkedRequirementIds,
1267
1657
  };
1268
1658
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1269
- if (!contextEligibility.eligible)
1270
- return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, contextEligibility);
1271
- if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
1272
- return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds });
1659
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1660
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1661
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1662
+ const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
1663
+ const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
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
+ }
1669
+ if (!contextEligibility.eligible) {
1670
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1671
+ return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1672
+ }
1673
+ if (!requirementsReady) {
1674
+ return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds, nextActions: ["planner-requirement-list", `Retry planner-task-switch to ${targetTaskRef}`] });
1273
1675
  }
1274
1676
  if (!hasValidSessionAttestation(contextInput)) {
1275
1677
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -1371,9 +1773,10 @@ server.registerTool("planner-task-switch", {
1371
1773
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1372
1774
  });
1373
1775
  server.registerTool("planner-task-start", {
1374
- description: "Set a task to in-progress or resume checkpointed work. Read the exact full task → phase → feature tree and linked requirements once per harness session; subsequent starts reuse persisted sessionInfo while entity updatedAt values remain unchanged. Every stale or denied start is an MCP isError result with started=false and exact retry actions; 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.",
1375
1777
  inputSchema: { task: z.string().min(1) },
1376
- }, async ({ task: ref }) => {
1778
+ }, async ({ task: ref }, extra) => {
1779
+ const plannerSessionId = plannerSessionIdFor(extra);
1377
1780
  let st;
1378
1781
  try {
1379
1782
  st = await requireStore();
@@ -1389,12 +1792,9 @@ server.registerTool("planner-task-start", {
1389
1792
  const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1390
1793
  const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1391
1794
  const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1392
- if (found.task.status === "in-progress") {
1393
- const outcome = taskStartSucceeded(found.task.id, true);
1394
- return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1395
- }
1396
1795
  if (found.task.status === "done")
1397
- 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();
1398
1798
  const linkedRequirements = [
1399
1799
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
1400
1800
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -1412,21 +1812,24 @@ server.registerTool("planner-task-start", {
1412
1812
  requirementIds: linkedRequirementIds,
1413
1813
  };
1414
1814
  const contextEligibility = contextReadEligibilityForSession(contextInput);
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 });
1415
1820
  if (!contextEligibility.eligible)
1416
- return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, [
1417
- `planner-task-show ${taskRef} with full=true`,
1418
- `planner-phase-show ${phaseRef} with full=true`,
1419
- ...(featureRef ? [`planner-feature-show ${featureRef} with full=true`] : []),
1420
- ...(linkedRequirementIds.length > 0 ? ["planner-requirement-list"] : []),
1421
- `Retry planner-task-start ${taskRef}`,
1422
- ], { taskId: found.task.id }), { contextEligibility });
1423
- if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
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 });
1822
+ if (!requirementsReady) {
1424
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 }));
1425
1824
  }
1426
1825
  if (!hasValidSessionAttestation(contextInput)) {
1427
1826
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1428
1827
  }
1429
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1828
+ if (found.task.status === "in-progress") {
1829
+ const outcome = taskStartSucceeded(found.task.id, true);
1830
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1831
+ }
1832
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
1430
1833
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1431
1834
  if (!eligibility.eligible)
1432
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 });
@@ -1599,14 +2002,20 @@ server.registerTool("planner-task-complete", {
1599
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);
1600
2003
  });
1601
2004
  server.registerTool("planner-handoff-list", {
1602
- 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.",
1603
- }, 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 }) => {
1604
2011
  const st = await requireStore();
1605
2012
  const list = await st.listHandoffs();
1606
2013
  if (list.length === 0)
1607
- return text("No phase handoffs set.");
1608
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
1609
- 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 });
1610
2019
  });
1611
2020
  server.registerTool("planner-handoff-show", {
1612
2021
  description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
@@ -1616,22 +2025,244 @@ server.registerTool("planner-handoff-show", {
1616
2025
  const r = await resolvePhaseForHandoff(st, phaseRef);
1617
2026
  if (!r.ok)
1618
2027
  return text(`❌ ${r.error}`);
1619
- const content = await st.getPhaseHandoff(r.phase.id);
1620
- if (!content.trim())
1621
- return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, empty: true });
1622
- 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 });
1623
2033
  });
1624
2034
  server.registerTool("planner-requirement-list", {
1625
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).",
1626
2036
  inputSchema: {},
1627
- }, async () => {
2037
+ }, async (_args, extra) => {
2038
+ const plannerSessionId = plannerSessionIdFor(extra);
1628
2039
  const st = await requireStore();
1629
2040
  const requirements = await st.loadRequirements();
1630
2041
  requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1631
2042
  return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1632
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
+ });
1633
2264
  server.registerTool("planner-handoff-write", {
1634
- 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.",
1635
2266
  inputSchema: {
1636
2267
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1637
2268
  title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
@@ -1639,6 +2270,18 @@ server.registerTool("planner-handoff-write", {
1639
2270
  content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
1640
2271
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
1641
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(),
1642
2285
  taskUpdates: z.array(z.object({
1643
2286
  taskRef: z.string().min(1),
1644
2287
  completionSummary: z.string().min(1),
@@ -1656,7 +2299,7 @@ server.registerTool("planner-handoff-write", {
1656
2299
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
1657
2300
  featureNoUpdateReason: z.string().min(1).optional(),
1658
2301
  },
1659
- }, 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 }) => {
1660
2303
  const st = await requireStore();
1661
2304
  let body = content.trim();
1662
2305
  const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
@@ -1702,6 +2345,8 @@ server.registerTool("planner-handoff-write", {
1702
2345
  content: body,
1703
2346
  expectedHandoffUpdatedAt,
1704
2347
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2348
+ ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2349
+ ...(supportingDocuments ? { supportingDocuments } : {}),
1705
2350
  contextSync: {
1706
2351
  taskUpdates: resolvedTaskUpdates,
1707
2352
  ...(phaseUpdate ? { phaseUpdate: {
@@ -1715,15 +2360,23 @@ server.registerTool("planner-handoff-write", {
1715
2360
  },
1716
2361
  });
1717
2362
  await st.writeGenerated();
1718
- 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
+ });
1719
2370
  }
1720
2371
  catch (error) {
2372
+ if (error instanceof HandoffContractError)
2373
+ return handoffContractFailure(error);
1721
2374
  const message = error instanceof Error ? error.message : String(error);
1722
2375
  return text(`❌ Handoff refresh denied: ${message}`, { error: message });
1723
2376
  }
1724
2377
  });
1725
2378
  server.registerTool("planner-handoff-prepare", {
1726
- 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.",
1727
2380
  inputSchema: { phaseRef: z.string().min(1).optional() },
1728
2381
  }, async ({ phaseRef }) => {
1729
2382
  if (!phaseRef)
@@ -1745,6 +2398,10 @@ server.registerTool("planner-handoff-prepare", {
1745
2398
  "Done tasks missing durable completion/verification evidence:",
1746
2399
  missing,
1747
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
+ "",
1748
2405
  "Existing active handoff (reconcile all still-relevant content):",
1749
2406
  audit.handoff.trim() || "(none)",
1750
2407
  ].join("\n"), { phaseRef: r.compositeRef, ...audit });
@@ -1807,13 +2464,41 @@ server.registerTool("planner-web", {
1807
2464
  });
1808
2465
  server.registerTool("planner-load", {
1809
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).",
1810
- }, async () => {
2467
+ }, async (extra) => {
1811
2468
  const st = store();
1812
2469
  if (!(await st.exists()))
1813
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();
1814
2485
  const web = await ensureWebStarted();
1815
2486
  const recap = await buildRecap(st, web, { harness: "mcp" });
1816
- 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
+ };
1817
2502
  });
1818
2503
  server.registerTool("planner-disable", {
1819
2504
  description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",