@agent-plan/mcp 0.2.29 → 0.3.0

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,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, PlanStoreError, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_INVENTORY_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, HandoffContractError } from "@agent-plan/core";
8
+ import { PlanStore, PlanStoreError, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, replaceChecklist, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, taskStartGateState, taskStartGateStateLine, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_INVENTORY_CATEGORIES, HandoffContractError, buildHandoffShowReply, buildHandoffPrepareReply, buildProjectContextLoadReply, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS, projectContextStaleAdvisory, LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE, LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, buildMutationReply, longTextFieldLimitNotice, buildRecommendationReply, buildTaskOrderContext, taskOrderContextLine, taskCreatedPriorityFragment, findHigherPriorityOpenPhase, higherPriorityOpenPhaseAdvisory, listOpenPhaseWork, openPhaseWorkLines, boundedOpenPhaseWork, deleteFeatureCascade, evaluateTaskCompletionGate, taskCompletionChecklistRefusal, taskCompletionForceMotivationRequired, taskCompletionOverrideNote, taskCompletionMismatchLine, boundedPage, nowISO, resolveFeatureRefStrict, resolveAcceptedDecisionTarget, applyTaskLifecycleDates, handoffContractFailureReply, derivedStatusReadOnlyReply, acceptedDecisionMutationFailureReply, legacyContextMigrationSummary } from "@agent-plan/core";
9
9
  import { serve } from "@agent-plan/server";
10
- import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
+ import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, 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()}`;
@@ -18,73 +18,28 @@ function plannerSessionIdFor(extra) {
18
18
  startReadSession(sessionId);
19
19
  return sessionId;
20
20
  }
21
+ /** Stated on every long-prose input so a caller learns the limit before the
22
+ * call rather than from the rejection. The number itself lives in the core
23
+ * (PLANNER_LONG_TEXT_FIELD_MAX_CHARS) so both adapters state the same one. */
24
+ const LONG_TEXT_LIMIT_NOTICE = longTextFieldLimitNotice();
21
25
  function text(textValue, structuredContent) {
22
26
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
23
27
  }
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
- };
28
+ function mcpHandoffContractFailure(error, action = "refresh") {
29
+ const reply = handoffContractFailureReply(error, action);
30
+ return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
30
31
  }
31
32
  function handoffPreflightFailure(errorCode, message, details = {}) {
32
33
  return { isError: true, content: [{ type: "text", text: `❌ Handoff refresh denied [${errorCode}]: ${message}` }], structuredContent: { errorCode, persisted: false, ...details } };
33
34
  }
34
- function boundedHandoffForTransport(content) {
35
- if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
36
- return { content, fullLength: content.length, truncated: false };
37
- const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
38
- return {
39
- content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
40
- fullLength: content.length,
41
- truncated: true,
42
- };
43
- }
44
- function boundedPage(items, page, pageSize) {
45
- const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
46
- const totalPages = Math.max(1, Math.ceil(items.length / size));
47
- const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
48
- return {
49
- items: items.slice((currentPage - 1) * size, currentPage * size),
50
- page: currentPage,
51
- pageSize: size,
52
- total: items.length,
53
- totalPages,
54
- };
55
- }
56
- function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
57
- const childKind = entity === "feature" ? "phases" : "tasks";
35
+ function mcpDerivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
58
36
  const retryAction = entity === "feature"
59
37
  ? `Retry planner-feature-update ${ref} without the status field if you need to update metadata.`
60
38
  : `Retry planner-phase-update ${ref} without the status field if you need to update metadata.`;
61
- const nextActions = [
62
- `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
63
- retryAction,
64
- ];
65
- return {
66
- isError: true,
67
- content: [{ type: "text", text: [
68
- `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
69
- `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
70
- `Attempted status: ${attemptedStatus}`,
71
- `Effective derived status: ${effectiveStatus}`,
72
- "No planner data was changed.",
73
- "Next required actions:",
74
- ...nextActions.map((action, index) => `${index + 1}. ${action}`),
75
- ].join("\n") }],
76
- structuredContent: {
77
- updated: false,
78
- errorCode: "DERIVED_STATUS_READ_ONLY",
79
- entity,
80
- ref,
81
- attemptedStatus,
82
- effectiveStatus,
83
- nextActions,
84
- },
85
- };
39
+ const reply = derivedStatusReadOnlyReply({ entity, ref, attemptedStatus, effectiveStatus, retryAction });
40
+ return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
86
41
  }
87
- function mutationNoFieldsFailure(input) {
42
+ function mcpMutationNoFieldsFailure(input) {
88
43
  const outcome = noMutableFieldsReceived(input);
89
44
  return {
90
45
  isError: true,
@@ -92,23 +47,9 @@ function mutationNoFieldsFailure(input) {
92
47
  structuredContent: { ...outcome },
93
48
  };
94
49
  }
95
- function acceptedDecisionMutationFailure(error, outcome) {
96
- if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
97
- throw error;
98
- return {
99
- isError: true,
100
- content: [{ type: "text", text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}` }],
101
- structuredContent: { [outcome]: false, ...error.details },
102
- };
103
- }
104
- function legacyContextMigrationSummary(preview) {
105
- return [
106
- `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
107
- `Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
108
- `Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
109
- `Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
110
- `Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
111
- ].join("\n");
50
+ function mcpAcceptedDecisionMutationFailure(error, outcome) {
51
+ const reply = acceptedDecisionMutationFailureReply(error, outcome);
52
+ return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
112
53
  }
113
54
  function taskStartError(outcome, structuredContent = {}) {
114
55
  return {
@@ -142,9 +83,6 @@ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
142
83
  }
143
84
  const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
144
85
  const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
145
- function nowISO() {
146
- return new Date().toISOString();
147
- }
148
86
  function planRoot() {
149
87
  return process.env.AGENT_PLAN_ROOT || join(process.cwd(), ".planner");
150
88
  }
@@ -220,83 +158,9 @@ function findFeatureByRef(features, ref) {
220
158
  ?? features.find((feature) => feature.name.toLowerCase() === normalized)
221
159
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
222
160
  }
223
- function resolveFeatureRefStrict(features, ref) {
224
- const raw = ref.trim();
225
- if (!raw)
226
- return { ok: false, error: "Feature ref is required." };
227
- const normalized = raw.toLowerCase();
228
- const byNumber = normalized.match(/^f(\d+)$/)
229
- ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
230
- : undefined;
231
- if (byNumber)
232
- return { ok: true, feature: byNumber };
233
- const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
234
- if (byShortId)
235
- return { ok: true, feature: byShortId };
236
- const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
237
- if (byId)
238
- return { ok: true, feature: byId };
239
- const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
240
- if (exactName.length === 1)
241
- return { ok: true, feature: exactName[0] };
242
- if (exactName.length > 1)
243
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
244
- const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
245
- if (partialName.length === 1)
246
- return { ok: true, feature: partialName[0] };
247
- if (partialName.length > 1)
248
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
249
- return { ok: false, error: `Feature not found: ${raw}` };
250
- }
251
- /** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
252
- * must still exist in the store before we allocate numbers or write. */
253
- function featureNumberOfPhase(phase, features) {
254
- return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
255
- }
256
161
  function taskCompositeRef(task, phase, features) {
257
162
  return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
258
163
  }
259
- async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
260
- if (targetType === "project") {
261
- const project = await st.loadProject();
262
- return { ok: true, owner: { kind: "project" }, ref: project.name };
263
- }
264
- const ref = targetRef?.trim();
265
- if (!ref)
266
- return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
267
- const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
268
- const features = featuresDoc.features;
269
- if (targetType === "feature") {
270
- const resolved = resolveFeatureRefStrict(features, ref);
271
- if (!resolved.ok)
272
- return { ok: false, error: resolved.error };
273
- return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
274
- }
275
- if (targetType === "phase") {
276
- const phase = findPhaseByRef(phases, features, ref);
277
- if (!phase)
278
- return { ok: false, error: `Phase not found: ${ref}` };
279
- return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
280
- }
281
- const found = findTaskByRef(phases, features, ref);
282
- if (!found)
283
- return { ok: false, error: `Task not found: ${ref}` };
284
- return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, ref: taskCompositeRef(found.task, found.phase, features) };
285
- }
286
- function applyTaskLifecycleDates(task, nextStatus, now) {
287
- const previousStatus = task.status;
288
- if (nextStatus === "in-progress" && !task.startedAt)
289
- task.startedAt = now;
290
- if (nextStatus === "done") {
291
- if (!task.startedAt)
292
- task.startedAt = now;
293
- task.completedAt = now;
294
- }
295
- else if (previousStatus === "done") {
296
- task.completedAt = "";
297
- }
298
- task.status = nextStatus;
299
- }
300
164
  async function writeAndSummarize(st, message, structuredContent) {
301
165
  await st.writeGenerated();
302
166
  return text(message, structuredContent);
@@ -435,6 +299,12 @@ server.registerTool("planner-show", {
435
299
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
436
300
  return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
437
301
  });
302
+ // P104(F005)/T422: a phase can be left in-progress with no active task and
303
+ // nothing else says so on this cheap read — this renders that fact,
304
+ // priority-ordered, instead of leaving it discoverable only by paying for
305
+ // planner-task-recommend.
306
+ const openPhaseWork = listOpenPhaseWork(phases, features);
307
+ const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
438
308
  const summary = [
439
309
  `📋 ${project.name}`,
440
310
  `Description: ${project.description || "(not set)"}`,
@@ -445,6 +315,7 @@ server.registerTool("planner-show", {
445
315
  activeTasks.length === 0
446
316
  ? "Active tasks: none (verified from all persisted phase task statuses)"
447
317
  : `Active tasks (${activeTasks.length})${activeTaskState === "conflict" ? " — CONFLICT: multiple tasks are in progress" : ""}:\n${activeTasks.map((task) => `- ${task.ref} — ${task.title} (in-progress)`).join("\n")}`,
318
+ ...(openPhaseWorkText ? [`In-progress phases with work left (priority order):`, openPhaseWorkText] : []),
448
319
  `Updated: ${manifest.updatedAt || "(unknown)"}`,
449
320
  "",
450
321
  "Features:",
@@ -457,12 +328,20 @@ server.registerTool("planner-show", {
457
328
  description: project.description || null,
458
329
  descriptionRef: project.descriptionRef || null,
459
330
  goal: project.goal || null,
460
- acceptedDecisions: project.acceptedDecisions,
331
+ // Identity only: `summary` above already carries every canonical field
332
+ // (decision, rationale, implementationNotes) through projectDecisionContext,
333
+ // bounded at MAX_ACCEPTED_DECISION_CONTEXT_CHARS. Echoing the full
334
+ // AcceptedDecision objects here too would duplicate that content in both
335
+ // channels — the discipline handoff-reply.ts and mutation-reply.ts hold
336
+ // to (P104(F005)/T419). Full canonical detail for every decision: planner-load.
337
+ acceptedDecisions: project.acceptedDecisions.map((decision) => ({ id: decision.id, title: decision.title, acceptedAt: decision.acceptedAt })),
461
338
  updatedAt: manifest.updatedAt || null,
462
339
  },
463
340
  counts: { features: features.length, phases: phases.length, tasks: totalTasks, activeTasks: activeTasks.length },
464
341
  activeTaskState,
465
342
  activeTasks,
343
+ openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
344
+ openPhaseWorkTotal: openPhaseWork.length,
466
345
  features: features.map((feature) => ({
467
346
  ref: formatFeatureRef(feature.number),
468
347
  shortId: feature.shortId || null,
@@ -536,7 +415,7 @@ server.registerTool("planner-project-language", {
536
415
  server.registerTool("planner-project-discuss", {
537
416
  description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
538
417
  inputSchema: {
539
- description: z.string().optional(),
418
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
540
419
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
541
420
  goal: z.string().optional(),
542
421
  scope: z.array(z.string()).optional(),
@@ -544,15 +423,18 @@ server.registerTool("planner-project-discuss", {
544
423
  technologies: z.array(z.string()).optional(),
545
424
  tools: z.array(z.string()).optional(),
546
425
  globalRules: z.array(z.string()).optional(),
547
- decisions: z.array(z.string()).optional(),
426
+ decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
548
427
  },
549
428
  }, async (params) => {
550
429
  const st = await requireStore();
430
+ if (params.decisions !== undefined) {
431
+ return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" } };
432
+ }
551
433
  const project = await st.loadProject();
552
- const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
434
+ const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
553
435
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
554
436
  if (receivedFields.length === 0) {
555
- return mutationNoFieldsFailure({
437
+ return mcpMutationNoFieldsFailure({
556
438
  entity: "project",
557
439
  ref: project.name,
558
440
  operation: "discuss",
@@ -587,8 +469,6 @@ server.registerTool("planner-project-discuss", {
587
469
  project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
588
470
  if (params.globalRules !== undefined)
589
471
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
590
- if (params.decisions !== undefined)
591
- project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
592
472
  const persisted = await st.updateProject((current) => {
593
473
  const next = {
594
474
  ...current,
@@ -599,7 +479,6 @@ server.registerTool("planner-project-discuss", {
599
479
  ...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
600
480
  ...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
601
481
  ...(params.globalRules !== undefined ? { globalRules: params.globalRules.map((entry) => entry.trim()).filter(Boolean) } : {}),
602
- ...(params.decisions !== undefined ? { decisions: params.decisions.map((entry) => entry.trim()).filter(Boolean) } : {}),
603
482
  };
604
483
  if (params.descriptionRef !== undefined) {
605
484
  const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
@@ -787,7 +666,7 @@ server.registerTool("planner-accepted-decision-create", {
787
666
  });
788
667
  }
789
668
  catch (error) {
790
- return acceptedDecisionMutationFailure(error, "created");
669
+ return mcpAcceptedDecisionMutationFailure(error, "created");
791
670
  }
792
671
  });
793
672
  server.registerTool("planner-accepted-decision-update", {
@@ -805,7 +684,7 @@ server.registerTool("planner-accepted-decision-update", {
805
684
  const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
806
685
  const receivedFields = mutableFields.filter((field) => ({ title, decision, rationale, implementationNotes })[field] !== undefined);
807
686
  if (receivedFields.length === 0) {
808
- return mutationNoFieldsFailure({
687
+ return mcpMutationNoFieldsFailure({
809
688
  entity: "acceptedDecision",
810
689
  ref: decisionId,
811
690
  operation: "update",
@@ -834,7 +713,7 @@ server.registerTool("planner-accepted-decision-update", {
834
713
  });
835
714
  }
836
715
  catch (error) {
837
- return acceptedDecisionMutationFailure(error, "updated");
716
+ return mcpAcceptedDecisionMutationFailure(error, "updated");
838
717
  }
839
718
  });
840
719
  server.registerTool("planner-accepted-decision-delete", {
@@ -870,14 +749,14 @@ server.registerTool("planner-accepted-decision-delete", {
870
749
  });
871
750
  }
872
751
  catch (error) {
873
- return acceptedDecisionMutationFailure(error, "deleted");
752
+ return mcpAcceptedDecisionMutationFailure(error, "deleted");
874
753
  }
875
754
  });
876
755
  server.registerTool("planner-feature-add", {
877
756
  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.",
878
757
  inputSchema: {
879
758
  name: z.string().min(1),
880
- description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, goals, and behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks without code refs.").describe("Required code references (file:line), current state of the art, structs/traits/systems involved, goals, and behaviors to preserve. Not a one-liner."),
759
+ description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, goals, and behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks without code refs.").describe(`Required code references (file:line), current state of the art, structs/traits/systems involved, goals, and behaviors to preserve. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
881
760
  status: z.enum(STATUS_VALUES).optional(),
882
761
  },
883
762
  }, async ({ name, description, status }) => {
@@ -885,7 +764,6 @@ server.registerTool("planner-feature-add", {
885
764
  return st.runBatch(async () => {
886
765
  const timestamp = nowISO();
887
766
  const effectiveStatus = status ?? "planned";
888
- const existingFeatures = (await st.loadFeatures()).features;
889
767
  const id = createFeatureId();
890
768
  const identity = await st.allocateEntityIdentity("feature", id);
891
769
  const priority = await st.nextPriority("feature");
@@ -952,7 +830,7 @@ server.registerTool("planner-feature-discuss", {
952
830
  description: "Persist feature discovery/governance fields and mark the feature context as ready.",
953
831
  inputSchema: {
954
832
  feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
955
- description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
833
+ description: z.string().optional().describe(`Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}`),
956
834
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
957
835
  workDone: z.string().optional().describe("What is already implemented / decided."),
958
836
  workRemaining: z.string().optional().describe("What still needs to be done."),
@@ -969,7 +847,7 @@ server.registerTool("planner-feature-discuss", {
969
847
  const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
970
848
  const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
971
849
  if (receivedFields.length === 0) {
972
- return mutationNoFieldsFailure({
850
+ return mcpMutationNoFieldsFailure({
973
851
  entity: "feature",
974
852
  ref: formatFeatureRef(feature.number),
975
853
  operation: "discuss",
@@ -1010,14 +888,21 @@ server.registerTool("planner-feature-discuss", {
1010
888
  return doc;
1011
889
  });
1012
890
  const result = updated.features.find((entry) => entry.id === feature.id);
1013
- 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 });
891
+ const featureDiscussReply = buildMutationReply({
892
+ identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
893
+ operation: "discuss",
894
+ updatedFields: receivedFields,
895
+ changedValues: result,
896
+ readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
897
+ });
898
+ return writeAndSummarize(st, featureDiscussReply.text, featureDiscussReply.structured);
1014
899
  });
1015
900
  server.registerTool("planner-feature-update", {
1016
901
  description: "Update a feature.",
1017
902
  inputSchema: {
1018
903
  feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
1019
904
  name: z.string().optional(),
1020
- description: z.string().optional(),
905
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
1021
906
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
1022
907
  status: z.enum(STATUS_VALUES).optional(),
1023
908
  workDone: z.string().optional(),
@@ -1034,12 +919,12 @@ server.registerTool("planner-feature-update", {
1034
919
  return text(resolvedFeature.error);
1035
920
  const feature = resolvedFeature.feature;
1036
921
  if (updates.status !== undefined) {
1037
- return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
922
+ return mcpDerivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
1038
923
  }
1039
924
  const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
1040
925
  const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1041
926
  if (receivedFields.length === 0) {
1042
- return mutationNoFieldsFailure({
927
+ return mcpMutationNoFieldsFailure({
1043
928
  entity: "feature",
1044
929
  ref: formatFeatureRef(feature.number),
1045
930
  operation: "update",
@@ -1083,7 +968,14 @@ server.registerTool("planner-feature-update", {
1083
968
  return doc;
1084
969
  });
1085
970
  const result = updated.features.find((entry) => entry.id === feature.id);
1086
- return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, updated: true, updatedFields: receivedFields });
971
+ const featureUpdateReply = buildMutationReply({
972
+ identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
973
+ operation: "update",
974
+ updatedFields: receivedFields,
975
+ changedValues: result,
976
+ readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
977
+ });
978
+ return writeAndSummarize(st, featureUpdateReply.text, featureUpdateReply.structured);
1087
979
  });
1088
980
  server.registerTool("planner-feature-delete", {
1089
981
  description: "Delete a feature. By default, phases are unlinked rather than deleted.",
@@ -1097,23 +989,11 @@ server.registerTool("planner-feature-delete", {
1097
989
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
1098
990
  if (!resolvedFeature.ok)
1099
991
  return text(resolvedFeature.error);
1100
- const feature = resolvedFeature.feature;
1101
- const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
1102
- await st.runBatch(async () => {
1103
- await st.updateFeatures((doc) => {
1104
- doc.features = doc.features.filter((entry) => entry.id !== feature.id);
1105
- return doc;
1106
- });
1107
- for (const phase of phases) {
1108
- if (cascade) {
1109
- await st.deletePhase(phase.id);
1110
- }
1111
- else {
1112
- await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
1113
- }
1114
- }
1115
- });
1116
- return writeAndSummarize(st, `Feature deleted: ${feature.id}${cascade ? `; deleted ${phases.length} phases` : `; unlinked ${phases.length} phases`}`, { deleted: feature.id, affectedPhases: phases.length, cascade: Boolean(cascade) });
992
+ const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(cascade) });
993
+ if (!outcome.ok)
994
+ return text(`❌ ${outcome.error}`, { deleted: false, errorCode: outcome.errorCode });
995
+ const { result } = outcome;
996
+ return text(`✅ Feature deleted: ${result.ref}${result.cascade ? `; deleted ${result.phaseCount} phases` : `; unlinked ${result.phaseCount} phases`}`, { deleted: true, ref: result.ref, id: result.id, affectedPhases: result.phaseCount, cascade: result.cascade });
1117
997
  });
1118
998
  server.registerTool("planner-phase-add", {
1119
999
  description: "Create a phase linked to a feature with a rich description. REQUIRED: feature ref and description. The description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
@@ -1121,7 +1001,7 @@ server.registerTool("planner-phase-add", {
1121
1001
  title: z.string().min(1),
1122
1002
  feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
1123
1003
  summary: z.string().optional().describe("One-line summary of the phase"),
1124
- description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner."),
1004
+ description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe(`Required code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
1125
1005
  },
1126
1006
  }, async ({ title, feature: featureRef, summary, description }) => {
1127
1007
  const st = await requireStore();
@@ -1140,8 +1020,6 @@ server.registerTool("planner-phase-add", {
1140
1020
  const lockKey = feature?.id ?? "__unscoped__";
1141
1021
  let phase;
1142
1022
  await st.runBatch(() => withFeatureLock(lockKey, async () => {
1143
- const phases = await st.loadAllPhases();
1144
- const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
1145
1023
  const timestamp = nowISO();
1146
1024
  const id = createPhaseId();
1147
1025
  const identity = await st.allocateEntityIdentity("phase", id);
@@ -1269,7 +1147,7 @@ server.registerTool("planner-phase-discuss", {
1269
1147
  const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
1270
1148
  const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1271
1149
  if (receivedFields.length === 0) {
1272
- return mutationNoFieldsFailure({
1150
+ return mcpMutationNoFieldsFailure({
1273
1151
  entity: "phase",
1274
1152
  ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1275
1153
  operation: "discuss",
@@ -1316,7 +1194,16 @@ server.registerTool("planner-phase-discuss", {
1316
1194
  entry.updatedAt = nowISO();
1317
1195
  return entry;
1318
1196
  });
1319
- return writeAndSummarize(st, `✅ Phase discussed/context ready: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, discussed: true, contextReady: true, updatedFields: receivedFields });
1197
+ const phaseDiscussRef = formatPhaseRef(found.number, featureNumberOfPhase(found, features));
1198
+ const phaseDiscussReply = buildMutationReply({
1199
+ identity: { entity: "phase", ref: phaseDiscussRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
1200
+ operation: "discuss",
1201
+ updatedFields: receivedFields,
1202
+ changedValues: phase,
1203
+ extras: { contextReady: true },
1204
+ readBackCommand: `planner-phase-show ${phaseDiscussRef} full=true`,
1205
+ });
1206
+ return writeAndSummarize(st, phaseDiscussReply.text, phaseDiscussReply.structured);
1320
1207
  });
1321
1208
  server.registerTool("planner-phase-update", {
1322
1209
  description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
@@ -1325,7 +1212,7 @@ server.registerTool("planner-phase-update", {
1325
1212
  title: z.string().optional(),
1326
1213
  status: z.enum(PHASE_STATUS_VALUES).optional(),
1327
1214
  summary: z.string().optional(),
1328
- description: z.string().optional(),
1215
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
1329
1216
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
1330
1217
  featureId: z.string().optional().describe("Feature ref to relink this phase to, or an empty string to unlink it."),
1331
1218
  priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
@@ -1334,7 +1221,7 @@ server.registerTool("planner-phase-update", {
1334
1221
  dependencies: z.array(z.string()).optional(),
1335
1222
  risks: z.array(z.string()).optional(),
1336
1223
  openQuestions: z.array(z.string()).optional(),
1337
- decisions: z.array(z.string()).optional(),
1224
+ decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
1338
1225
  completionCriteria: z.array(z.string()).optional(),
1339
1226
  },
1340
1227
  }, async ({ phase: ref, ...updates }) => {
@@ -1344,12 +1231,15 @@ server.registerTool("planner-phase-update", {
1344
1231
  if (!found)
1345
1232
  return text(`Phase not found: ${ref}`);
1346
1233
  if (updates.status !== undefined) {
1347
- return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
1234
+ return mcpDerivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
1235
+ }
1236
+ if (updates.decisions !== undefined) {
1237
+ return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(found.number, featureNumberOfPhase(found, features)) } };
1348
1238
  }
1349
- const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
1239
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
1350
1240
  const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1351
1241
  if (receivedFields.length === 0) {
1352
- return mutationNoFieldsFailure({
1242
+ return mcpMutationNoFieldsFailure({
1353
1243
  entity: "phase",
1354
1244
  ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1355
1245
  operation: "update",
@@ -1409,8 +1299,6 @@ server.registerTool("planner-phase-update", {
1409
1299
  entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
1410
1300
  if (updates.openQuestions !== undefined)
1411
1301
  entry.openQuestions = updates.openQuestions.map((item) => item.trim()).filter(Boolean);
1412
- if (updates.decisions !== undefined)
1413
- entry.decisions = updates.decisions.map((item) => item.trim()).filter(Boolean);
1414
1302
  if (updates.completionCriteria !== undefined)
1415
1303
  entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
1416
1304
  entry.updatedAt = nowISO();
@@ -1437,7 +1325,16 @@ server.registerTool("planner-phase-update", {
1437
1325
  const staleParentRefs = descriptionFreshness?.diagnostics
1438
1326
  .filter((entry) => entry.state === "stale" && entry.ownerId === persisted.featureId)
1439
1327
  .map((entry) => entry.ownerRef) ?? [];
1440
- return text(`✅ Phase updated: ${formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures))} — ${persisted.title}${persisted.shortId ? ` · ${persisted.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase: persisted, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) });
1328
+ const phaseUpdateRef = formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures));
1329
+ const phaseUpdateReply = buildMutationReply({
1330
+ identity: { entity: "phase", ref: phaseUpdateRef, id: persisted.id, shortId: persisted.shortId, title: persisted.title, status: persisted.status },
1331
+ operation: "update",
1332
+ updatedFields: receivedFields,
1333
+ changedValues: persisted,
1334
+ extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
1335
+ readBackCommand: `planner-phase-show ${phaseUpdateRef} full=true`,
1336
+ });
1337
+ return text(phaseUpdateReply.text, phaseUpdateReply.structured);
1441
1338
  });
1442
1339
  server.registerTool("planner-phase-delete", {
1443
1340
  description: "Delete a phase and unlink it from features.",
@@ -1464,7 +1361,7 @@ server.registerTool("planner-task-add", {
1464
1361
  feature: z.string().min(1).optional().describe("Feature ref the task's phase belongs to. Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title. REQUIRED."),
1465
1362
  phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
1466
1363
  title: z.string().min(1),
1467
- description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner."),
1364
+ description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe(`Required code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
1468
1365
  checklist: z.array(z.string()).optional(),
1469
1366
  },
1470
1367
  }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
@@ -1531,7 +1428,7 @@ server.registerTool("planner-task-add", {
1531
1428
  return entry;
1532
1429
  });
1533
1430
  const finalFeatures = (await st.loadFeatures()).features;
1534
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
1431
+ return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned; ${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}`);
1535
1432
  });
1536
1433
  });
1537
1434
  server.registerTool("planner-task-show", {
@@ -1540,10 +1437,11 @@ server.registerTool("planner-task-show", {
1540
1437
  }, async ({ task: ref, full }, extra) => {
1541
1438
  const plannerSessionId = plannerSessionIdFor(extra);
1542
1439
  const st = await requireStore();
1543
- const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1440
+ const features = (await st.loadFeatures()).features;
1441
+ const allPhases = await st.loadAllPhases();
1442
+ const found = findTaskByRef(allPhases, features, ref);
1544
1443
  if (!found)
1545
1444
  return text(`Task not found: ${ref}`);
1546
- const features = (await st.loadFeatures()).features;
1547
1445
  const summary = `${found.task.title} — ${taskCompositeRef(found.task, found.phase, features)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status}; phase ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))})`;
1548
1446
  if (!full)
1549
1447
  return text(summary);
@@ -1556,7 +1454,15 @@ server.registerTool("planner-task-show", {
1556
1454
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1557
1455
  const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
1558
1456
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
1559
- const sections = [summary];
1457
+ // P104(F005)/T421: priority and dependsOn at the point an agent reads
1458
+ // the task and decides what to do next — the fact planner-task-list and
1459
+ // planner-task-recommend already have, made visible here instead of
1460
+ // requiring a second call.
1461
+ const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
1462
+ const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
1463
+ const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
1464
+ if (completionMismatch)
1465
+ sections.push(completionMismatch);
1560
1466
  if (found.task.description?.trim())
1561
1467
  sections.push(found.task.description.trim());
1562
1468
  if (found.task.descriptionRef)
@@ -1581,18 +1487,61 @@ server.registerTool("planner-task-show", {
1581
1487
  description: found.task.description,
1582
1488
  descriptionRef: found.task.descriptionRef || "",
1583
1489
  acceptedDecisions: found.task.acceptedDecisions,
1490
+ priority: orderContext.priority,
1491
+ dependsOn: orderContext.dependsOn,
1492
+ ...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
1493
+ },
1494
+ taskOrder: {
1495
+ readyCount: orderContext.readyCount,
1496
+ readyAheadCount: orderContext.readyAheadCount,
1497
+ nextByPriorityRef: orderContext.nextByPriorityRef,
1584
1498
  },
1585
1499
  });
1586
1500
  markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
1587
- return result;
1501
+ // Advisory only (P104(F005)/T420): report whether planner-task-start
1502
+ // would succeed right now, using the exact checks it runs itself, so a
1503
+ // caller reading the task before starting it learns what else is needed
1504
+ // in this same call instead of from a denial. task-start recomputes this
1505
+ // at call time and remains the sole authority on whether work may begin.
1506
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1507
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1508
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1509
+ const linkedRequirements = [
1510
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
1511
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
1512
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1513
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1514
+ const contextEligibility = contextReadEligibilityForSession({
1515
+ sessionId: plannerSessionId,
1516
+ taskId: found.task.id,
1517
+ phaseId: found.phase.id,
1518
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
1519
+ task: found.task,
1520
+ phase: found.phase,
1521
+ ...(parentFeature ? { feature: parentFeature } : {}),
1522
+ requirements: linkedRequirements,
1523
+ requirementIds: linkedRequirementIds,
1524
+ });
1525
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1526
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1527
+ const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
1528
+ const gateNextActions = gateState.ready ? [] : mcpContextReadActions(contextEligibility, { task: taskCompositeRef(found.task, found.phase, features), phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `planner-task-start ${taskCompositeRef(found.task, found.phase, features)}`);
1529
+ return {
1530
+ content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
1531
+ structuredContent: {
1532
+ ...result.structuredContent,
1533
+ taskStartGateState: gateState,
1534
+ ...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
1535
+ },
1536
+ };
1588
1537
  });
1589
1538
  server.registerTool("planner-task-discuss", {
1590
1539
  description: "Persist task execution notes/checklist.",
1591
1540
  inputSchema: {
1592
1541
  task: z.string().min(1),
1593
- description: z.string().optional(),
1542
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
1594
1543
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
1595
- checklist: z.array(z.string()).optional(),
1544
+ checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick). Use planner-task-checklist-toggle/add/remove for granular edits."),
1596
1545
  },
1597
1546
  }, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
1598
1547
  const st = await requireStore();
@@ -1603,7 +1552,7 @@ server.registerTool("planner-task-discuss", {
1603
1552
  const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
1604
1553
  const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
1605
1554
  if (receivedFields.length === 0) {
1606
- return mutationNoFieldsFailure({
1555
+ return mcpMutationNoFieldsFailure({
1607
1556
  entity: "task",
1608
1557
  ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1609
1558
  operation: "discuss",
@@ -1619,6 +1568,7 @@ server.registerTool("planner-task-discuss", {
1619
1568
  });
1620
1569
  }
1621
1570
  let updatedTask;
1571
+ let checklistLostTicks = [];
1622
1572
  await st.updatePhase(found.phase.id, (phase) => {
1623
1573
  const task = phase.tasks.find((entry) => entry.id === found.task.id);
1624
1574
  if (!task)
@@ -1632,8 +1582,11 @@ server.registerTool("planner-task-discuss", {
1632
1582
  else
1633
1583
  delete task.descriptionRef;
1634
1584
  }
1635
- if (checklist !== undefined)
1636
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
1585
+ if (checklist !== undefined) {
1586
+ const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
1587
+ task.checklist = replacement.items;
1588
+ checklistLostTicks = replacement.lostTicks;
1589
+ }
1637
1590
  task.updatedAt = nowISO();
1638
1591
  phase.updatedAt = task.updatedAt;
1639
1592
  updatedTask = task;
@@ -1641,12 +1594,25 @@ server.registerTool("planner-task-discuss", {
1641
1594
  });
1642
1595
  const features = (await st.loadFeatures()).features;
1643
1596
  const t = updatedTask ?? found.task;
1644
- 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 });
1597
+ const lostTicksNotice = checklistLostTicks.length > 0
1598
+ ? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
1599
+ : "";
1600
+ const taskDiscussRef = taskCompositeRef(t, found.phase, features);
1601
+ const taskDiscussReply = buildMutationReply({
1602
+ identity: { entity: "task", ref: taskDiscussRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
1603
+ operation: "discuss",
1604
+ updatedFields: receivedFields,
1605
+ changedValues: t,
1606
+ extras: checklistLostTicks.length > 0 ? { checklistLostTicks } : {},
1607
+ notices: [lostTicksNotice],
1608
+ readBackCommand: `planner-task-show ${taskDiscussRef} full=true`,
1609
+ });
1610
+ return writeAndSummarize(st, taskDiscussReply.text, taskDiscussReply.structured);
1645
1611
  });
1646
1612
  server.registerTool("planner-task-dependency-add", {
1647
1613
  description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
1648
1614
  inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1) },
1649
- }, async ({ task: ref, dependsOn }, extra) => {
1615
+ }, async ({ task: ref, dependsOn }) => {
1650
1616
  const st = await requireStore();
1651
1617
  const features = (await st.loadFeatures()).features;
1652
1618
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
@@ -1656,7 +1622,8 @@ server.registerTool("planner-task-dependency-add", {
1656
1622
  try {
1657
1623
  const result = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
1658
1624
  await st.writeGenerated();
1659
- return text(`✅ Dependency added: ${taskCompositeRef(found.task, found.phase, features)} depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, { updated: true, task: result });
1625
+ const reply = buildMutationReply({ identity: { entity: "task", ref: taskCompositeRef(found.task, found.phase, features), id: result.id, shortId: result.shortId, title: result.title, status: result.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: result.dependsOn }, readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, features)} full=true` });
1626
+ return text(`${reply.text} Depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, reply.structured);
1660
1627
  }
1661
1628
  catch (error) {
1662
1629
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -1678,7 +1645,8 @@ server.registerTool("planner-task-dependency-delete", {
1678
1645
  try {
1679
1646
  const result = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
1680
1647
  await st.writeGenerated();
1681
- return text("✅ Dependency removed.", { updated: true, task: result });
1648
+ const reply = buildMutationReply({ identity: { entity: "task", ref: taskCompositeRef(found.task, found.phase, features), id: result.id, shortId: result.shortId, title: result.title, status: result.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: result.dependsOn }, readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, features)} full=true` });
1649
+ return text(reply.text, reply.structured);
1682
1650
  }
1683
1651
  catch (error) {
1684
1652
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -1691,13 +1659,13 @@ server.registerTool("planner-task-update", {
1691
1659
  task: z.string().min(1),
1692
1660
  title: z.string().optional(),
1693
1661
  status: z.enum(STATUS_VALUES).optional(),
1694
- description: z.string().optional(),
1662
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
1695
1663
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
1696
1664
  notes: z.string().optional().describe("Implementation notes."),
1697
- decisions: z.array(z.string()).optional().describe("Replace the task decisions list."),
1665
+ decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
1698
1666
  motivation: z.string().optional(),
1699
1667
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
1700
- 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."),
1668
+ checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick); an item that cannot be matched loses its tick and that loss is reported. Agents should tick steps via planner-task-checklist-toggle, not write DONE in titles, and use planner-task-checklist-add/remove for granular edits."),
1701
1669
  subtasks: z.array(z.object({ id: z.string().optional(), title: z.string().min(1), description: z.string().optional(), status: z.enum(STATUS_VALUES).optional() })).optional().describe("Replace subtasks; existing IDs must belong to this task and omitted IDs are planner-generated."),
1702
1670
  },
1703
1671
  }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
@@ -1711,6 +1679,9 @@ server.registerTool("planner-task-update", {
1711
1679
  if (status === "done" && status !== found.task.status) {
1712
1680
  return text("Task completion transitions require planner-task-complete with durable completion and verification evidence.");
1713
1681
  }
1682
+ if (decisions !== undefined) {
1683
+ return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "task", targetRef: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features) } };
1684
+ }
1714
1685
  // Validate motivation requirement for status transitions.
1715
1686
  if (status !== undefined && needsMotivation(found.task.status, status)) {
1716
1687
  if (!motivation || !motivation.trim()) {
@@ -1718,11 +1689,11 @@ server.registerTool("planner-task-update", {
1718
1689
  `Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
1719
1690
  }
1720
1691
  }
1721
- const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "decisions", "motivation", "priority", "checklist", "subtasks"];
1692
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
1722
1693
  const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks };
1723
1694
  const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
1724
1695
  if (receivedFields.length === 0) {
1725
- return mutationNoFieldsFailure({
1696
+ return mcpMutationNoFieldsFailure({
1726
1697
  entity: "task",
1727
1698
  ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1728
1699
  operation: "update",
@@ -1744,6 +1715,7 @@ server.registerTool("planner-task-update", {
1744
1715
  }
1745
1716
  }
1746
1717
  let updatedTask;
1718
+ let checklistLostTicks = [];
1747
1719
  const timestamp = nowISO();
1748
1720
  try {
1749
1721
  await st.updateTask(found.phase.id, found.task.id, (task) => {
@@ -1762,15 +1734,10 @@ server.registerTool("planner-task-update", {
1762
1734
  }
1763
1735
  if (notes !== undefined)
1764
1736
  task.notes = notes.trim();
1765
- if (decisions !== undefined)
1766
- task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
1767
1737
  if (checklist !== undefined) {
1768
- task.checklist = checklist.map((item, index) => ({
1769
- id: createChecklistItemId(task.id, index + 1, item),
1770
- number: index + 1,
1771
- title: item.trim(),
1772
- checked: false,
1773
- }));
1738
+ const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
1739
+ task.checklist = replacement.items;
1740
+ checklistLostTicks = replacement.lostTicks;
1774
1741
  }
1775
1742
  if (subtasks !== undefined) {
1776
1743
  const existingIds = new Set(task.subtasks.map((item) => item.id));
@@ -1826,10 +1793,24 @@ server.registerTool("planner-task-update", {
1826
1793
  const staleParentRefs = descriptionFreshness?.diagnostics
1827
1794
  .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
1828
1795
  .map((entry) => entry.ownerRef) ?? [];
1829
- const details = resumeRequired
1830
- ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
1831
- : { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
1832
- return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${resumeNotice}`, details);
1796
+ const lostTicksNotice = checklistLostTicks.length > 0
1797
+ ? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
1798
+ : "";
1799
+ const taskUpdateRef = taskCompositeRef(t, found.phase, features);
1800
+ const taskUpdateReply = buildMutationReply({
1801
+ identity: { entity: "task", ref: taskUpdateRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
1802
+ operation: "update",
1803
+ updatedFields: receivedFields,
1804
+ changedValues: t,
1805
+ extras: {
1806
+ ...(resumeRequired ? { resumeRequired } : {}),
1807
+ ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
1808
+ ...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
1809
+ },
1810
+ notices: [resumeNotice, lostTicksNotice],
1811
+ readBackCommand: `planner-task-show ${taskUpdateRef} full=true`,
1812
+ });
1813
+ return writeAndSummarize(st, taskUpdateReply.text, taskUpdateReply.structured);
1833
1814
  });
1834
1815
  server.registerTool("planner-task-checklist-toggle", {
1835
1816
  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.",
@@ -1971,36 +1952,8 @@ server.registerTool("planner-task-recommend", {
1971
1952
  })),
1972
1953
  archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
1973
1954
  });
1974
- const { selection } = result;
1975
- if (!selection.candidate)
1976
- return text(`No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}`, {
1977
- kind: selection.kind,
1978
- reason: selection.reason,
1979
- activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1980
- activeTask: result.activeTask,
1981
- nextFeature: result.nextFeature,
1982
- nextPhase: result.nextPhase,
1983
- nextTask: result.nextTask,
1984
- claims: result.claims,
1985
- selection,
1986
- });
1987
- const { candidate } = selection;
1988
- const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1989
- const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
1990
- const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
1991
- return text(`${activeRef}Next work (${selection.kind}): ${ref} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}`, {
1992
- kind: selection.kind,
1993
- taskId: candidate.task.id,
1994
- phaseId: candidate.phase.id,
1995
- featureId: candidate.feature?.id,
1996
- deviation: selection.deviation,
1997
- activeTask: result.activeTask,
1998
- nextFeature: result.nextFeature,
1999
- nextPhase: result.nextPhase,
2000
- nextTask: result.nextTask,
2001
- claims: result.claims,
2002
- selection,
2003
- });
1955
+ const reply = buildRecommendationReply(result, features.features);
1956
+ return text(reply.text, reply.structured);
2004
1957
  });
2005
1958
  server.registerTool("planner-task-deviation", {
2006
1959
  description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task and never starts, pauses, or blocks work; use normal lifecycle tools for those transitions.",
@@ -2278,7 +2231,8 @@ server.registerTool("planner-task-start", {
2278
2231
  }
2279
2232
  return st.runBatch(async () => {
2280
2233
  const features = (await st.loadFeatures()).features;
2281
- const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2234
+ const initialPhases = await st.loadAllPhases();
2235
+ const found = findTaskByRef(initialPhases, features, ref);
2282
2236
  if (!found)
2283
2237
  return taskStartError(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${ref}`, ["Resolve the task with planner-task-list or planner-task-show, then retry planner-task-start."]));
2284
2238
  const taskRef = taskCompositeRef(found.task, found.phase, features);
@@ -2288,6 +2242,12 @@ server.registerTool("planner-task-start", {
2288
2242
  if (found.task.status === "done")
2289
2243
  return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-reopen with task=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry planner-task-start ${taskRef} only if reopening is not the intended operation.`], { taskId: found.task.id }));
2290
2244
  const project = await st.loadProject();
2245
+ // Targeted stale-context refresh (P102(F005)/T404): task-start delivery
2246
+ // stays scoped to feature/phase/task context, but a session that already
2247
+ // did a full planner-load gets a non-blocking advisory when project-level
2248
+ // context has drifted since, instead of silently going stale.
2249
+ const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
2250
+ const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
2291
2251
  const linkedRequirements = [
2292
2252
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
2293
2253
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -2321,7 +2281,9 @@ server.registerTool("planner-task-start", {
2321
2281
  }
2322
2282
  if (found.task.status === "in-progress") {
2323
2283
  const outcome = taskStartSucceeded(found.task.id, true);
2324
- return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
2284
+ const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
2285
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
2286
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true${projectContextAdvisory}${phaseOrderAdvisory}`, { ...outcome, task: found.task, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase });
2325
2287
  }
2326
2288
  const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
2327
2289
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
@@ -2418,21 +2380,26 @@ server.registerTool("planner-task-start", {
2418
2380
  return taskStartError(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect planner-task-show for the persisted state and retry only after resolving the persistence failure."], { taskId: found.task.id }));
2419
2381
  }
2420
2382
  const outcome = taskStartSucceeded(persistedTask.id);
2421
- return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
2383
+ const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
2384
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
2385
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}`, {
2422
2386
  ...outcome,
2423
2387
  task: persistedTask,
2424
2388
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
2389
+ projectContextStale: projectContextReadState === "stale",
2390
+ higherPriorityOpenPhase: higherPriorityPhase,
2425
2391
  });
2426
2392
  });
2427
2393
  });
2428
2394
  server.registerTool("planner-task-complete", {
2429
- description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete unless force=true.",
2395
+ description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete — tick the completed items with planner-task-checklist-toggle first. force=true is the exception for a checklist that no longer matches the work, and requires a motivation; the overridden items are recorded, never auto-ticked.",
2430
2396
  inputSchema: {
2431
2397
  task: z.string().min(1),
2432
2398
  force: z.boolean().optional(),
2399
+ motivation: z.string().optional().describe("Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work."),
2433
2400
  description_update: z.string().min(10).describe("Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references."),
2434
2401
  },
2435
- }, async ({ task: ref, force, description_update }) => {
2402
+ }, async ({ task: ref, force, motivation, description_update }) => {
2436
2403
  const st = await requireStore();
2437
2404
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
2438
2405
  if (!found)
@@ -2442,9 +2409,16 @@ server.registerTool("planner-task-complete", {
2442
2409
  return text("Task completion denied: provide at least 10 characters of durable completion and verification evidence.");
2443
2410
  if (found.task.pauseSnapshot)
2444
2411
  return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
2445
- const unchecked = found.task.checklist.filter((item) => !item.checked);
2446
- if (unchecked.length > 0 && !force)
2447
- return text(`${unchecked.length} checklist item(s) not done. Re-run with force=true to complete anyway.`);
2412
+ const gate = evaluateTaskCompletionGate(found.task.checklist, force === true, motivation);
2413
+ if (!gate.allowed) {
2414
+ const taskRefForToggle = taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features);
2415
+ const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
2416
+ ? taskCompletionForceMotivationRequired(gate.uncheckedItems)
2417
+ : taskCompletionChecklistRefusal(gate.uncheckedItems, `planner-task-checklist-toggle ${taskRefForToggle} <item>`);
2418
+ return text(message, { completed: false, errorCode: gate.errorCode, uncheckedItems: gate.uncheckedItems.map((item) => item.title) });
2419
+ }
2420
+ const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, motivation);
2421
+ const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
2448
2422
  const timestamp = nowISO();
2449
2423
  let updatedTask;
2450
2424
  await st.updatePhase(found.phase.id, (phase) => {
@@ -2459,11 +2433,11 @@ server.registerTool("planner-task-complete", {
2459
2433
  fromStatus: previousStatus,
2460
2434
  toStatus: "done",
2461
2435
  title: `${previousStatus} → done`,
2462
- description: completionSummary,
2436
+ description: completionEvidence,
2463
2437
  };
2464
2438
  task.statusLog = [...(task.statusLog ?? []), entry];
2465
2439
  const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
2466
- task.description = task.description + sep + completionSummary;
2440
+ task.description = task.description + sep + completionEvidence;
2467
2441
  task.descriptionUpdatedAt = timestamp;
2468
2442
  task.updatedAt = timestamp;
2469
2443
  phase.updatedAt = timestamp;
@@ -2522,57 +2496,27 @@ server.registerTool("planner-handoff-show", {
2522
2496
  if (!r.ok)
2523
2497
  return text(`❌ ${r.error}`);
2524
2498
  const phase = await st.loadPhase(r.phase.id);
2525
- const bounded = boundedHandoffForTransport(phase.handoff);
2526
- if (!bounded.content.trim()) {
2499
+ if (!phase.handoff.trim()) {
2527
2500
  const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
2528
2501
  && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
2529
- if (archived) {
2530
- const archivedContent = boundedHandoffForTransport(archived.content);
2531
- const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
2532
- const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
2533
- return text([
2534
- `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
2535
- ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
2536
- "",
2537
- archivedContent.content,
2538
- ].join("\n"), {
2502
+ const reply = archived
2503
+ ? buildHandoffShowReply({
2504
+ kind: "archived",
2539
2505
  phaseRef: r.compositeRef,
2540
2506
  phaseId: phase.id,
2541
- active: false,
2542
- archived: true,
2507
+ content: archived.content,
2543
2508
  archiveReason: archived.reason,
2544
2509
  archivedAt: archived.archivedAt,
2545
2510
  archiveFile: archived.file,
2546
- ...archivedContent,
2547
- supportingDocuments,
2548
- empty: false,
2549
- resumeReady: false,
2550
2511
  handoffAudit: phase.handoffAudit,
2551
- });
2552
- }
2553
- return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
2512
+ })
2513
+ : buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
2514
+ return text(reply.text, reply.structured);
2554
2515
  }
2555
2516
  const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
2556
2517
  const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
2557
- const contentHash = handoffContentHash(phase.handoff);
2558
- const persistenceVerified = Boolean(phase.handoffAudit
2559
- && phase.handoffAudit.contentHash === contentHash
2560
- && phase.handoffAudit.contentLength === phase.handoff.length);
2561
- const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
2562
- const status = resumeReady
2563
- ? "Resume-ready: persisted compact capsule read-back completed."
2564
- : "NOT resume-ready: after reading this persisted capsule, call planner-handoff-verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
2565
- return text(`Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}`, {
2566
- phaseRef: r.compositeRef,
2567
- phaseId: r.phase.id,
2568
- phaseWorkMap,
2569
- ...bounded,
2570
- contentHash,
2571
- persistenceVerified,
2572
- resumeReady,
2573
- verificationRequired: !resumeReady,
2574
- handoffAudit: phase.handoffAudit,
2575
- });
2518
+ const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
2519
+ return text(reply.text, reply.structured);
2576
2520
  });
2577
2521
  server.registerTool("planner-handoff-verify", {
2578
2522
  description: "Finalize one persisted handoff as resume-ready after planner-handoff-show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
@@ -2606,7 +2550,7 @@ server.registerTool("planner-handoff-verify", {
2606
2550
  }
2607
2551
  catch (error) {
2608
2552
  if (error instanceof HandoffContractError)
2609
- return handoffContractFailure(error);
2553
+ return mcpHandoffContractFailure(error);
2610
2554
  const message = error instanceof Error ? error.message : String(error);
2611
2555
  return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
2612
2556
  }
@@ -2654,7 +2598,7 @@ server.registerTool("planner-requirement-create", {
2654
2598
  description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use planner-project-guidelines-update instead. Requirements have no lifecycle status. Requirement and macro-task IDs/timestamps are assigned by the planner.",
2655
2599
  inputSchema: {
2656
2600
  title: z.string().min(1),
2657
- description: z.string().optional(),
2601
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
2658
2602
  linkedPhaseIds: z.array(z.string().min(1)),
2659
2603
  macroTasks: z.array(z.object({
2660
2604
  title: z.string().min(1),
@@ -2699,7 +2643,7 @@ server.registerTool("planner-requirement-update", {
2699
2643
  inputSchema: {
2700
2644
  requirementId: z.string().min(1),
2701
2645
  title: z.string().min(1).optional(),
2702
- description: z.string().optional(),
2646
+ description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
2703
2647
  linkedPhaseIds: z.array(z.string().min(1)).optional(),
2704
2648
  macroTasks: z.array(z.object({
2705
2649
  id: z.string().min(1).optional(),
@@ -2715,7 +2659,7 @@ server.registerTool("planner-requirement-update", {
2715
2659
  if (!current)
2716
2660
  return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
2717
2661
  if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
2718
- return mutationNoFieldsFailure({
2662
+ return mcpMutationNoFieldsFailure({
2719
2663
  entity: "requirement",
2720
2664
  ref: requirementId,
2721
2665
  operation: "update",
@@ -2723,6 +2667,12 @@ server.registerTool("planner-requirement-update", {
2723
2667
  retryCommand: `planner-requirement-update ${requirementId}`,
2724
2668
  });
2725
2669
  }
2670
+ const requirementUpdatedFields = [
2671
+ ...(title !== undefined ? ["title"] : []),
2672
+ ...(description !== undefined ? ["description"] : []),
2673
+ ...(linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
2674
+ ...(macroTasks !== undefined ? ["macroTasks"] : []),
2675
+ ];
2726
2676
  const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2727
2677
  if (!links.ok)
2728
2678
  return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
@@ -2748,7 +2698,14 @@ server.registerTool("planner-requirement-update", {
2748
2698
  return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2749
2699
  }
2750
2700
  await st.writeGenerated();
2751
- return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
2701
+ const requirementUpdateReply = buildMutationReply({
2702
+ identity: { entity: "requirement", ref: next.id, id: next.id, title: next.title },
2703
+ operation: "update",
2704
+ updatedFields: requirementUpdatedFields,
2705
+ changedValues: next,
2706
+ readBackCommand: "planner-requirement-list",
2707
+ });
2708
+ return writeAndSummarize(st, requirementUpdateReply.text, requirementUpdateReply.structured);
2752
2709
  });
2753
2710
  server.registerTool("planner-requirement-delete", {
2754
2711
  description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
@@ -2814,7 +2771,7 @@ server.registerTool("planner-idea-update", {
2814
2771
  if (!current)
2815
2772
  return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
2816
2773
  if (title === undefined && description === undefined) {
2817
- return mutationNoFieldsFailure({
2774
+ return mcpMutationNoFieldsFailure({
2818
2775
  entity: "idea",
2819
2776
  ref,
2820
2777
  operation: "update",
@@ -2822,9 +2779,17 @@ server.registerTool("planner-idea-update", {
2822
2779
  retryCommand: `planner-idea-update ${ref}`,
2823
2780
  });
2824
2781
  }
2782
+ const ideaUpdatedFields = [...(title !== undefined ? ["title"] : []), ...(description !== undefined ? ["description"] : [])];
2825
2783
  const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
2826
2784
  await st.writeGenerated();
2827
- return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
2785
+ const ideaUpdateReply = buildMutationReply({
2786
+ identity: { entity: "idea", ref: formatIdeaRef(idea.number), id: idea.id, title: idea.title },
2787
+ operation: "update",
2788
+ updatedFields: ideaUpdatedFields,
2789
+ changedValues: idea,
2790
+ readBackCommand: `planner-idea-show ${formatIdeaRef(idea.number)}`,
2791
+ });
2792
+ return text(ideaUpdateReply.text, ideaUpdateReply.structured);
2828
2793
  });
2829
2794
  server.registerTool("planner-idea-delete", {
2830
2795
  description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
@@ -2881,15 +2846,19 @@ server.registerTool("planner-idea-promotion-finalize", {
2881
2846
  return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
2882
2847
  });
2883
2848
  server.registerTool("planner-handoff-write", {
2884
- description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown.",
2849
+ description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown. On failure the submitted content is retained against phaseRef + expectedHandoffUpdatedAt: a retry against the exact same token may omit content to reuse it instead of resending the whole body.",
2885
2850
  inputSchema: {
2886
2851
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
2887
- title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
2852
+ title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work. Ignored when content is omitted (the retained title is reused)."),
2888
2853
  reason: z.string().min(1).optional().describe("Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown."),
2889
2854
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
2890
- content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
2855
+ content: z.string().min(1).optional().describe("Full reconciled handoff text (markdown). Omit only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the body retained from that failed attempt."),
2891
2856
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
2892
2857
  reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
2858
+ sectionDispositions: z.array(z.object({
2859
+ section: z.string().min(1).describe("Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal."),
2860
+ disposition: z.string().min(1).describe("What happened to that section: dropped, superseded, or folded into another section."),
2861
+ })).optional().describe("Required only when a prior write was refused with HANDOFF_SECTION_RECONCILIATION_REQUIRED; one entry per section it named. Omit on a first write."),
2893
2862
  completenessAudit: z.object({
2894
2863
  version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
2895
2864
  entries: z.array(z.object({
@@ -2928,23 +2897,29 @@ server.registerTool("planner-handoff-write", {
2928
2897
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
2929
2898
  featureNoUpdateReason: z.string().min(1).optional(),
2930
2899
  },
2931
- }, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2900
+ }, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, sectionDispositions, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2932
2901
  const st = await requireStore();
2933
- let body = content.trim();
2934
- const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
2935
- const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
2936
- const effectiveHeadingText = title?.trim() || firstHeadingText;
2937
- if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
2938
- return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
2939
- }
2940
- if (title?.trim()) {
2941
- const lines = body.split(/\r?\n/);
2942
- const firstIdx = lines.findIndex((line) => line.trim().length > 0);
2943
- if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
2944
- lines[firstIdx] = `# ${title.trim()}`;
2945
- else
2946
- lines.unshift(`# ${title.trim()}`, "");
2947
- body = lines.join("\n");
2902
+ // content is optional so a retry after a failed write can omit it and reuse
2903
+ // the body PlanStore retained against this phase + expectedHandoffUpdatedAt
2904
+ // (see PlanStore.refreshPhaseHandoff). body stays undefined only in that
2905
+ // case; the title/heading checks below apply only to freshly-submitted text.
2906
+ let body = content?.trim() || undefined;
2907
+ if (body !== undefined) {
2908
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
2909
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
2910
+ const effectiveHeadingText = title?.trim() || firstHeadingText;
2911
+ if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
2912
+ return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
2913
+ }
2914
+ if (title?.trim()) {
2915
+ const lines = body.split(/\r?\n/);
2916
+ const firstIdx = lines.findIndex((line) => line.trim().length > 0);
2917
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
2918
+ lines[firstIdx] = `# ${title.trim()}`;
2919
+ else
2920
+ lines.unshift(`# ${title.trim()}`, "");
2921
+ body = lines.join("\n");
2922
+ }
2948
2923
  }
2949
2924
  const r = await resolvePhaseForHandoff(st, phaseRef);
2950
2925
  if (!r.ok)
@@ -2974,9 +2949,10 @@ server.registerTool("planner-handoff-write", {
2974
2949
  try {
2975
2950
  const result = await st.refreshPhaseHandoff(r.phase.id, {
2976
2951
  reason: reason.trim(),
2977
- content: body,
2952
+ ...(body !== undefined ? { content: body } : {}),
2978
2953
  expectedHandoffUpdatedAt,
2979
2954
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2955
+ ...(sectionDispositions ? { sectionDispositions } : {}),
2980
2956
  ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2981
2957
  ...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
2982
2958
  ...(supportingDocuments ? { supportingDocuments } : {}),
@@ -3010,15 +2986,21 @@ server.registerTool("planner-handoff-write", {
3010
2986
  }
3011
2987
  catch (error) {
3012
2988
  if (error instanceof HandoffContractError)
3013
- return handoffContractFailure(error);
2989
+ return mcpHandoffContractFailure(error);
3014
2990
  const message = error instanceof Error ? error.message : String(error);
3015
- return text(`❌ Handoff refresh denied: ${message}`, { error: message });
2991
+ return text(`❌ Handoff refresh denied: ${message} If you retry against the same phaseRef and expectedHandoffUpdatedAt, you may omit content to reuse the body just submitted.`, { error: message });
3016
2992
  }
3017
2993
  });
3018
2994
  server.registerTool("planner-handoff-prepare", {
3019
2995
  description: "Identify or prepare the exact handoff target. With a confirmed phaseRef, returns a compact resume scaffold, bounded priority-ordered phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
3020
- inputSchema: { phaseRef: z.string().min(1).optional() },
3021
- }, async ({ phaseRef }) => {
2996
+ inputSchema: {
2997
+ phaseRef: z.string().min(1).optional(),
2998
+ supportingDocuments: z.array(z.object({
2999
+ path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
3000
+ description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
3001
+ })).optional().describe("Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically."),
3002
+ },
3003
+ }, async ({ phaseRef, supportingDocuments }) => {
3022
3004
  if (!phaseRef)
3023
3005
  return text([
3024
3006
  "First identify the exact feature and phase actually discussed/worked on in this session. Do not use a stale resume pointer or target a phase that just became done.",
@@ -3030,35 +3012,13 @@ server.registerTool("planner-handoff-prepare", {
3030
3012
  if (!r.ok)
3031
3013
  return text(`❌ ${r.error}`);
3032
3014
  try {
3033
- const audit = await st.preparePhaseHandoff(r.phase.id);
3034
- const { completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...compactAudit } = audit;
3035
- const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3036
- return text([
3037
- `Handoff preparation for ${r.compositeRef}`,
3038
- `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
3039
- "Required human inputs before drafting:",
3040
- ...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
3041
- "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
3042
- "Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
3043
- audit.draftTemplate,
3044
- "",
3045
- "Done tasks missing durable completion/verification evidence:",
3046
- missing,
3047
- "",
3048
- "Compact handoff contract: include only the exact focus/resume point, current or partial state, constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
3049
- "No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
3050
- "Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
3051
- `Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
3052
- "",
3053
- "Existing active handoff (reconcile all still-relevant content):",
3054
- audit.handoff.trim() || "(none)",
3055
- ].join("\n"), {
3056
- phaseRef: r.compositeRef,
3057
- ...compactAudit,
3058
- evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
3059
- });
3015
+ const audit = await st.preparePhaseHandoff(r.phase.id, supportingDocuments);
3016
+ const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
3017
+ return text(reply.text, reply.structured);
3060
3018
  }
3061
3019
  catch (error) {
3020
+ if (error instanceof HandoffContractError)
3021
+ return mcpHandoffContractFailure(error, "preparation");
3062
3022
  const message = error instanceof Error ? error.message : String(error);
3063
3023
  return text(`❌ Handoff preparation failed: ${message}`, { error: message });
3064
3024
  }
@@ -3119,8 +3079,11 @@ server.registerTool("planner-web", {
3119
3079
  return text("planner-web not running. Use planner-web with action=start to start the dashboard.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
3120
3080
  });
3121
3081
  server.registerTool("planner-load", {
3122
- description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap plus bounded canonical Accepted Decision agentContext (project, feature, phase, and task scopes). 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).",
3123
- }, async (extra) => {
3082
+ description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated human-facing recap, plus agent-only structuredContent carrying the complete project-level context (description, goal, scope, out-of-scope, technologies, tools, language preferences, Project Guidelines, every Requirement, and every project Accepted Decision, in structuredContent.projectContext and structuredContent.agentContext.projectContext) and bounded canonical Accepted Decision agentContext for feature/phase/task scopes. 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 ONLY structuredContent.recap.text (equivalently, the text content) verbatim to the user, including its final prominent Web UI line — never quote projectContext or agentContext in the human-facing recap. If structuredContent.projectContext.contextComplete is false, the project-level context was NOT delivered this turn — follow structuredContent.projectContext.nextActions and retry before relying on it. 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).",
3083
+ inputSchema: {
3084
+ maxChars: z.number().int().positive().optional().describe(`Single-response bound (chars) for the project-context delivery. Default ${DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS}. Raise this and retry if a prior call returned projectContext.contextComplete=false.`),
3085
+ },
3086
+ }, async ({ maxChars }, extra) => {
3124
3087
  const st = store();
3125
3088
  if (!(await st.exists()))
3126
3089
  return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
@@ -3150,12 +3113,27 @@ server.registerTool("planner-load", {
3150
3113
  decisions: task.acceptedDecisions,
3151
3114
  }))),
3152
3115
  ]);
3116
+ // Explicit planner load must deliver the complete project-level context
3117
+ // (P102(F005)/T404), attested through the same lossless contract used
3118
+ // everywhere else: loadProjectContextDelivery + recordProjectContextRead.
3119
+ // buildProjectContextLoadReply is the single shared shaping for this reply
3120
+ // (Pi's planner-load and /planner load use the identical builder).
3121
+ const boundedMaxChars = maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
3122
+ const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
3123
+ const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
3124
+ if (projectContextReply.structured.contextComplete) {
3125
+ await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
3126
+ }
3153
3127
  const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
3154
3128
  return {
3129
+ // The project-context text (like the planner skill and Accepted Decision
3130
+ // context below) is agent-only: read and retain it, but never quote it
3131
+ // or the Accepted Decisions it contains in the human-facing recap.
3155
3132
  content: [{ type: "text", text: visibleRecap }],
3156
3133
  structuredContent: {
3157
3134
  loaded: true,
3158
3135
  recap: { text: visibleRecap },
3136
+ projectContext: projectContextReply.structured,
3159
3137
  webUi: plannerWebMetadata(web),
3160
3138
  preparation: preparation.legacyProjectContext,
3161
3139
  agentContext: {
@@ -3164,7 +3142,8 @@ server.registerTool("planner-load", {
3164
3142
  status: plannerSkill.status,
3165
3143
  customized: plannerSkill.customized,
3166
3144
  message: plannerSkill.message,
3167
- instruction: "Read and retain this project-local planner usage skill and Accepted Decision context. Do not quote it or the Accepted Decision context in the human-facing recap.",
3145
+ instruction: "Read and retain this project-local planner usage skill, project context, and Accepted Decision context. Do not quote it, the project context, or the Accepted Decision context in the human-facing recap.",
3146
+ projectContext: { text: projectContextReply.text },
3168
3147
  acceptedDecisions: acceptedDecisionContext,
3169
3148
  },
3170
3149
  },