@agent-plan/mcp 0.2.25 → 0.2.27

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, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
8
+ import { PlanStore, 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";
9
9
  import { serve } from "@agent-plan/server";
10
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
+ import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, validateResolvedTarget } from "@agent-plan/core/naming";
11
11
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
12
12
  const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
13
13
  const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
@@ -21,6 +21,38 @@ function plannerSessionIdFor(extra) {
21
21
  function text(textValue, structuredContent) {
22
22
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
23
23
  }
24
+ function handoffContractFailure(error) {
25
+ return {
26
+ isError: true,
27
+ content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
28
+ structuredContent: { errorCode: error.code, ...error.details },
29
+ };
30
+ }
31
+ function handoffPreflightFailure(errorCode, message, details = {}) {
32
+ return { isError: true, content: [{ type: "text", text: `❌ Handoff refresh denied [${errorCode}]: ${message}` }], structuredContent: { errorCode, persisted: false, ...details } };
33
+ }
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
+ }
24
56
  function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
25
57
  const childKind = entity === "feature" ? "phases" : "tasks";
26
58
  const retryAction = entity === "feature"
@@ -52,6 +84,32 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
52
84
  },
53
85
  };
54
86
  }
87
+ function mutationNoFieldsFailure(input) {
88
+ const outcome = noMutableFieldsReceived(input);
89
+ return {
90
+ isError: true,
91
+ content: [{ type: "text", text: outcome.message }],
92
+ structuredContent: { ...outcome },
93
+ };
94
+ }
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");
112
+ }
55
113
  function taskStartError(outcome, structuredContent = {}) {
56
114
  return {
57
115
  isError: true,
@@ -66,16 +124,19 @@ function taskStartError(outcome, structuredContent = {}) {
66
124
  structuredContent: { ...outcome, ...structuredContent },
67
125
  };
68
126
  }
69
- function mcpContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
70
- const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
127
+ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
128
+ const actions = projectGuidelinesReadRequired ? ["planner-project-guidelines-show"] : [];
129
+ actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
71
130
  if (read.kind === "task")
72
131
  return [`planner-task-show ${refs.task} with full=true`];
73
132
  if (read.kind === "phase")
74
133
  return [`planner-phase-show ${refs.phase} with full=true`];
75
- return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
76
- });
134
+ if (read.kind === "feature")
135
+ return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
136
+ return [];
137
+ }));
77
138
  if (requirementReadRequired)
78
- actions.push("planner-requirement-list");
139
+ actions.push(`planner-requirement-list with phaseRef=${refs.phase}`);
79
140
  actions.push(retryAction);
80
141
  return actions;
81
142
  }
@@ -103,21 +164,44 @@ async function requireStore() {
103
164
  // In-process web server handle, managed by the planner-web tool and planner-load.
104
165
  // Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
105
166
  let webHandle = null;
106
- /** Start the web dashboard on LAN (0.0.0.0:0, OS-assigned port) if not already
107
- * running. Returns the local/LAN URLs. No-op (empty url) when no .planner/ exists. */
167
+ function plannerWebMetadata(web) {
168
+ const address = web.lanUrl || web.localUrl;
169
+ let host = web.bindHost ?? "";
170
+ let port = 0;
171
+ if (address) {
172
+ try {
173
+ const parsed = new URL(address);
174
+ host = parsed.hostname;
175
+ port = Number(parsed.port);
176
+ }
177
+ catch {
178
+ // Preserve the original address when a custom server returns a non-standard URL.
179
+ }
180
+ }
181
+ return {
182
+ running: Boolean(web.localUrl),
183
+ address,
184
+ localUrl: web.localUrl,
185
+ lanUrl: web.lanUrl ?? "",
186
+ host,
187
+ port,
188
+ mode: web.mode ?? "",
189
+ ...(web.error ? { errorCode: "PLANNER_WEB_START_FAILED", error: web.error } : {}),
190
+ };
191
+ }
108
192
  async function ensureWebStarted() {
109
193
  if (webHandle)
110
- return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
194
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
111
195
  const root = planRoot();
112
196
  const st = new PlanStore(root);
113
197
  if (!(await st.exists()))
114
- return { localUrl: "" };
198
+ return { localUrl: "", error: "Planner workspace does not exist." };
115
199
  try {
116
200
  webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
117
- return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
201
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
118
202
  }
119
- catch {
120
- return { localUrl: "" };
203
+ catch (error) {
204
+ return { localUrl: "", error: error instanceof Error ? error.message : String(error) };
121
205
  }
122
206
  }
123
207
  function findFeatureByRef(features, ref) {
@@ -172,6 +256,33 @@ function featureNumberOfPhase(phase, features) {
172
256
  function taskCompositeRef(task, phase, features) {
173
257
  return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
174
258
  }
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
+ }
175
286
  function applyTaskLifecycleDates(task, nextStatus, now) {
176
287
  const previousStatus = task.status;
177
288
  if (nextStatus === "in-progress" && !task.startedAt)
@@ -194,6 +305,21 @@ const server = new McpServer({
194
305
  name: "agent-plan-planner",
195
306
  version: MCP_PACKAGE.version,
196
307
  });
308
+ async function resolveRequirementPhaseRefs(st, refs) {
309
+ if (!refs || refs.length === 0)
310
+ return { ok: false, error: "linkedPhaseIds must contain at least one phase reference." };
311
+ const [phases, features] = await Promise.all([st.loadAllPhases(), st.loadFeatures()]);
312
+ const linkedPhaseIds = [];
313
+ for (const rawRef of refs) {
314
+ const ref = rawRef.trim();
315
+ const phase = ref ? findPhaseByRef(phases, features.features, ref) : undefined;
316
+ if (!phase)
317
+ return { ok: false, error: `Linked phase not found: ${rawRef}` };
318
+ if (!linkedPhaseIds.includes(phase.id))
319
+ linkedPhaseIds.push(phase.id);
320
+ }
321
+ return { ok: true, linkedPhaseIds };
322
+ }
197
323
  async function resolvePhaseForHandoff(st, ref) {
198
324
  const phases = await st.loadAllPhases();
199
325
  const features = (await st.loadFeatures()).features;
@@ -213,17 +339,22 @@ async function resolvePhaseForHandoff(st, ref) {
213
339
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
214
340
  }
215
341
  server.registerTool("planner-version", {
216
- description: "Report the versions of the Agent Plan MCP server and core package actually loaded by this process. Available without an initialized .planner/ workspace.",
342
+ description: "Report loaded Agent Plan MCP/core runtime provenance plus plan schema and allocation compatibility. Available without an initialized .planner/ workspace.",
217
343
  }, async () => {
344
+ const packages = runtimePackagesDiagnostic([MCP_PACKAGE, CORE_PACKAGE]);
345
+ const capabilities = runtimeCapabilities();
218
346
  const versions = {
219
347
  [MCP_PACKAGE.name]: MCP_PACKAGE.version,
220
348
  [CORE_PACKAGE.name]: CORE_PACKAGE.version,
221
349
  };
222
350
  return text([
223
- "Agent Plan runtime versions",
224
- `${MCP_PACKAGE.name}: ${MCP_PACKAGE.version}`,
225
- `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
226
- ].join("\n"), { versions });
351
+ "Agent Plan runtime diagnostics",
352
+ "Runtime packages (loaded package manifests):",
353
+ `- ${MCP_PACKAGE.name}: loaded ${MCP_PACKAGE.version}`,
354
+ `- ${CORE_PACKAGE.name}: loaded ${CORE_PACKAGE.version}`,
355
+ `Plan schema: manifest schemaVersion ${capabilities.planSchema.manifestSchemaVersion}`,
356
+ `Allocation registry: v${capabilities.allocationRegistry.version}; supported kinds: ${capabilities.allocationRegistry.supportedKinds.join(", ")}`,
357
+ ].join("\n"), { versions, packages, capabilities });
227
358
  });
228
359
  server.registerTool("planner-export", {
229
360
  description: "Export the project plan as a Markdown report. Supports a concise summary or full hierarchical detail.",
@@ -278,13 +409,26 @@ server.registerTool("planner-init", {
278
409
  return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
279
410
  });
280
411
  server.registerTool("planner-show", {
281
- description: "Show a compact planner overview: project metadata, counts, and a one-line summary of each feature (F00x · shortId — name (status; N phases, M tasks)). Does NOT include full descriptions, phases, or tasks — use planner-feature-list / planner-phase-list / planner-task-list for discovery and planner-*-show for full detail of a single entity. Keeps the result small to avoid token overflow.",
412
+ description: "Show a compact planner overview with explicit active-task evidence: project metadata, counts, authoritative active task refs (or a verified-empty state), and a one-line summary of each feature. Never infer that no task is active from omitted task detail. Use planner-feature-list / planner-phase-list / planner-task-list for discovery and planner-*-show for full entity detail.",
282
413
  }, async () => {
283
414
  const st = await requireStore();
284
415
  const features = (await st.loadFeatures()).features;
285
416
  const phases = await st.loadAllPhases();
286
- const totalTasks = phases.reduce((total, phase) => total + phase.tasks.length, 0);
417
+ const allTasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
418
+ const totalTasks = allTasks.length;
419
+ const activeTasks = allTasks
420
+ .filter(({ task }) => task.status === "in-progress")
421
+ .map(({ phase, task }) => ({
422
+ id: task.id,
423
+ ref: taskCompositeRef(task, phase, features),
424
+ title: task.title,
425
+ phaseId: phase.id,
426
+ featureId: phase.featureId ?? null,
427
+ status: task.status,
428
+ }));
429
+ const activeTaskState = activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict";
287
430
  const project = await st.loadProject();
431
+ const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: project.acceptedDecisions }]);
288
432
  const manifest = await st.loadManifest();
289
433
  const featureLines = features.map((feature) => {
290
434
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
@@ -294,8 +438,13 @@ server.registerTool("planner-show", {
294
438
  const summary = [
295
439
  `📋 ${project.name}`,
296
440
  `Description: ${project.description || "(not set)"}`,
441
+ ...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
297
442
  `Goal: ${project.goal || "(not set)"}`,
443
+ projectDecisionContext.content,
298
444
  `Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
445
+ activeTasks.length === 0
446
+ ? "Active tasks: none (verified from all persisted phase task statuses)"
447
+ : `Active tasks (${activeTasks.length})${activeTaskState === "conflict" ? " — CONFLICT: multiple tasks are in progress" : ""}:\n${activeTasks.map((task) => `- ${task.ref} — ${task.title} (in-progress)`).join("\n")}`,
299
448
  `Updated: ${manifest.updatedAt || "(unknown)"}`,
300
449
  "",
301
450
  "Features:",
@@ -303,8 +452,17 @@ server.registerTool("planner-show", {
303
452
  ].join("\n");
304
453
  // Compact structured overview (no full plan: avoids 1M+ char token overflow).
305
454
  const overview = {
306
- project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
307
- counts: { features: features.length, phases: phases.length, tasks: totalTasks },
455
+ project: {
456
+ name: project.name,
457
+ description: project.description || null,
458
+ descriptionRef: project.descriptionRef || null,
459
+ goal: project.goal || null,
460
+ acceptedDecisions: project.acceptedDecisions,
461
+ updatedAt: manifest.updatedAt || null,
462
+ },
463
+ counts: { features: features.length, phases: phases.length, tasks: totalTasks, activeTasks: activeTasks.length },
464
+ activeTaskState,
465
+ activeTasks,
308
466
  features: features.map((feature) => ({
309
467
  ref: formatFeatureRef(feature.number),
310
468
  shortId: feature.shortId || null,
@@ -316,6 +474,16 @@ server.registerTool("planner-show", {
316
474
  };
317
475
  return text(summary, { overview });
318
476
  });
477
+ server.registerTool("planner-description-freshness", {
478
+ description: "Preview deterministic child-to-parent description drift. Returns exact stale phase/feature refs and leaf-to-root reconciliation steps without rewriting user-authored descriptions.",
479
+ }, async () => {
480
+ const st = await requireStore();
481
+ const freshness = await st.previewDescriptionReconciliation();
482
+ if (!freshness.reconciliationRequired)
483
+ return text("Description hierarchy is fresh; no parent reconciliation is required.", { ...freshness });
484
+ const lines = freshness.reconciliationPreview.map((step) => `- ${step.ownerRef} after ${step.causedByRef}: ${step.action}`);
485
+ return text(`Parent description reconciliation required (${freshness.staleParentRefs.length}):\n${lines.join("\n")}`, { ...freshness });
486
+ });
319
487
  server.registerTool("planner-repair", {
320
488
  description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug where tasks land in the wrong phase file), and report integrity.",
321
489
  }, async () => {
@@ -358,17 +526,18 @@ server.registerTool("planner-project-language", {
358
526
  },
359
527
  }, async ({ contentLanguage, chatLanguage }) => {
360
528
  const st = await requireStore();
361
- const project = await st.loadProject();
362
- if (contentLanguage !== undefined)
363
- project.contentLanguage = contentLanguage.trim();
364
- if (chatLanguage !== undefined)
365
- project.chatLanguage = chatLanguage.trim();
366
- await st.saveProject(project);
529
+ const project = await st.updateProject((current) => ({
530
+ ...current,
531
+ ...(contentLanguage !== undefined ? { contentLanguage: contentLanguage.trim() } : {}),
532
+ ...(chatLanguage !== undefined ? { chatLanguage: chatLanguage.trim() } : {}),
533
+ }));
367
534
  return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
368
535
  });
369
536
  server.registerTool("planner-project-discuss", {
370
537
  description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
371
538
  inputSchema: {
539
+ description: z.string().optional(),
540
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
372
541
  goal: z.string().optional(),
373
542
  scope: z.array(z.string()).optional(),
374
543
  outOfScope: z.array(z.string()).optional(),
@@ -380,6 +549,32 @@ server.registerTool("planner-project-discuss", {
380
549
  }, async (params) => {
381
550
  const st = await requireStore();
382
551
  const project = await st.loadProject();
552
+ const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
553
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
554
+ if (receivedFields.length === 0) {
555
+ return mutationNoFieldsFailure({
556
+ entity: "project",
557
+ ref: project.name,
558
+ operation: "discuss",
559
+ mutableFields,
560
+ retryCommand: "planner-project-discuss with a concise description summary and the intended metadata",
561
+ readBackCommand: "planner-show",
562
+ descriptionFallback: {
563
+ inputField: "description",
564
+ storedField: "project description",
565
+ descriptionRefField: "descriptionRef",
566
+ },
567
+ });
568
+ }
569
+ if (params.description !== undefined)
570
+ project.description = params.description.trim();
571
+ if (params.descriptionRef !== undefined) {
572
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
573
+ if (descriptionRef)
574
+ project.descriptionRef = descriptionRef;
575
+ else
576
+ delete project.descriptionRef;
577
+ }
383
578
  if (params.goal !== undefined)
384
579
  project.goal = params.goal.trim();
385
580
  if (params.scope !== undefined)
@@ -394,26 +589,102 @@ server.registerTool("planner-project-discuss", {
394
589
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
395
590
  if (params.decisions !== undefined)
396
591
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
397
- await st.saveProject(project);
398
- return writeAndSummarize(st, `Project discussed/updated: ${project.name}`);
592
+ const persisted = await st.updateProject((current) => {
593
+ const next = {
594
+ ...current,
595
+ ...(params.description !== undefined ? { description: params.description.trim() } : {}),
596
+ ...(params.goal !== undefined ? { goal: params.goal.trim() } : {}),
597
+ ...(params.scope !== undefined ? { scope: params.scope.map((entry) => entry.trim()).filter(Boolean) } : {}),
598
+ ...(params.outOfScope !== undefined ? { outOfScope: params.outOfScope.map((entry) => entry.trim()).filter(Boolean) } : {}),
599
+ ...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
600
+ ...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
601
+ ...(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
+ };
604
+ if (params.descriptionRef !== undefined) {
605
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
606
+ if (descriptionRef)
607
+ next.descriptionRef = descriptionRef;
608
+ else
609
+ delete next.descriptionRef;
610
+ }
611
+ return next;
612
+ });
613
+ return writeAndSummarize(st, `Project discussed/updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.`, { project: persisted, discussed: true, updatedFields: receivedFields });
614
+ });
615
+ server.registerTool("planner-project-guidelines-show", {
616
+ description: "Read the canonical Project Guidelines / Coding Standard section for this project. Call this when planner-task-start or planner-task-switch nextActions require it, and keep the guidance in working memory while doing task work.",
617
+ }, async (extra) => {
618
+ const plannerSessionId = plannerSessionIdFor(extra);
619
+ const st = await requireStore();
620
+ const project = await st.loadProject();
621
+ if (project.projectGuidelines.content.trim()) {
622
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
623
+ }
624
+ const refreshed = await st.loadProject();
625
+ const content = refreshed.projectGuidelines.content.trim();
626
+ return text(content
627
+ ? ["## Project Guidelines", content].join("\n\n")
628
+ : "No project guidelines set.", { projectGuidelines: refreshed.projectGuidelines, readRecorded: Boolean(content) });
629
+ });
630
+ server.registerTool("planner-project-guidelines-update", {
631
+ description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
632
+ inputSchema: {
633
+ content: z.string().describe("Full Project Guidelines markdown/text. Pass an empty string to clear the section."),
634
+ },
635
+ }, async ({ content }) => {
636
+ const st = await requireStore();
637
+ const project = await st.loadProject();
638
+ await st.updateProject((current) => ({
639
+ ...current,
640
+ projectGuidelines: { ...current.projectGuidelines, content: content.trim() },
641
+ }), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
642
+ const refreshed = await st.loadProject();
643
+ return writeAndSummarize(st, refreshed.projectGuidelines.content
644
+ ? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
645
+ : "Project Guidelines cleared.", { updated: true, projectGuidelines: refreshed.projectGuidelines });
646
+ });
647
+ server.registerTool("planner-project-context-migrate", {
648
+ description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
649
+ inputSchema: {
650
+ apply: z.boolean().default(false).describe("False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields."),
651
+ },
652
+ }, async ({ apply }) => {
653
+ const st = await requireStore();
654
+ const preview = await st.previewLegacyProjectContextMigration();
655
+ if (!apply) {
656
+ return text([`Project context migration preview (no changes applied).`, legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n"), { applied: false, preview });
657
+ }
658
+ const result = await st.migrateLegacyProjectContext();
659
+ return writeAndSummarize(st, [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n"), { ...result });
399
660
  });
400
661
  server.registerTool("planner-feature-list", {
401
- description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
662
+ description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
402
663
  inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
403
664
  }, async ({ featureRef }) => {
404
665
  const st = await requireStore();
405
666
  const allFeatures = (await st.loadFeatures()).features;
406
667
  const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
407
668
  const phases = await st.loadAllPhases();
408
- const lines = features.map((feature) => {
669
+ const items = features.map((feature) => {
409
670
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
410
671
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
411
- return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
672
+ return {
673
+ id: feature.id,
674
+ ref: formatFeatureRef(feature.number),
675
+ shortId: feature.shortId,
676
+ name: feature.name,
677
+ status: feature.status,
678
+ priority: feature.priority,
679
+ phaseCount: featurePhases.length,
680
+ taskCount,
681
+ };
412
682
  });
413
- return text(lines.join("\n") || "No features");
683
+ const lines = items.map((feature) => `- ${feature.ref}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}${feature.priority > 0 ? `; priority ${feature.priority}` : ""}; ${feature.phaseCount} phases, ${feature.taskCount} tasks)`);
684
+ return text(lines.join("\n") || "No features", { features: items });
414
685
  });
415
686
  server.registerTool("planner-phase-list", {
416
- description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
687
+ description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
417
688
  inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name)."), status: z.string().optional().describe("Optional: filter by status name.") },
418
689
  }, async ({ featureRef, status }) => {
419
690
  const st = await requireStore();
@@ -427,15 +698,27 @@ server.registerTool("planner-phase-list", {
427
698
  }
428
699
  if (status)
429
700
  phases = phases.filter((p) => p.status === status);
430
- const lines = phases.map((phase) => {
701
+ const items = phases.map((phase) => {
431
702
  const fNum = featureNumberOfPhase(phase, features);
432
- const fTag = fNum !== undefined ? ` [F${String(fNum).padStart(3, "0")}]` : "";
433
- return `- ${formatPhaseRef(phase.number, fNum)}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}; ${phase.tasks.length} tasks)${fTag}`;
703
+ return {
704
+ id: phase.id,
705
+ ref: formatPhaseRef(phase.number, fNum),
706
+ shortId: phase.shortId,
707
+ title: phase.title,
708
+ status: phase.status,
709
+ priority: phase.priority,
710
+ taskCount: phase.tasks.length,
711
+ featureNumber: fNum,
712
+ };
434
713
  });
435
- return text(lines.join("\n") || "No phases");
714
+ const lines = items.map((phase) => {
715
+ const fTag = phase.featureNumber !== undefined ? ` [F${String(phase.featureNumber).padStart(3, "0")}]` : "";
716
+ return `- ${phase.ref}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}${phase.priority > 0 ? `; priority ${phase.priority}` : ""}; ${phase.taskCount} tasks)${fTag}`;
717
+ });
718
+ return text(lines.join("\n") || "No phases", { phases: items });
436
719
  });
437
720
  server.registerTool("planner-task-list", {
438
- description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
721
+ description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
439
722
  inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
440
723
  }, async ({ featureRef, phaseRef, status }) => {
441
724
  const st = await requireStore();
@@ -453,15 +736,142 @@ server.registerTool("planner-task-list", {
453
736
  return text(`Phase not found: ${phaseRef}`);
454
737
  phases = [p];
455
738
  }
456
- const out = [];
739
+ const items = [];
457
740
  for (const phase of phases) {
458
741
  for (const task of phase.tasks) {
459
742
  if (status && task.status !== status)
460
743
  continue;
461
- out.push(`- ${taskCompositeRef(task, phase, features)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
744
+ items.push({
745
+ id: task.id,
746
+ ref: taskCompositeRef(task, phase, features),
747
+ shortId: task.shortId,
748
+ title: task.title,
749
+ status: task.status,
750
+ priority: task.priority,
751
+ phaseId: phase.id,
752
+ ...(phase.featureId ? { featureId: phase.featureId } : {}),
753
+ });
462
754
  }
463
755
  }
464
- return text(out.join("\n") || "No tasks");
756
+ const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
757
+ return text(out.join("\n") || "No tasks", { tasks: items });
758
+ });
759
+ server.registerTool("planner-accepted-decision-create", {
760
+ description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
761
+ inputSchema: {
762
+ targetType: z.enum(["project", "feature", "phase", "task"]),
763
+ targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
764
+ title: z.string().min(1),
765
+ decision: z.string().optional(),
766
+ rationale: z.string().optional(),
767
+ implementationNotes: z.string().optional(),
768
+ },
769
+ }, async ({ targetType, targetRef, title, decision, rationale, implementationNotes }) => {
770
+ const st = await requireStore();
771
+ const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
772
+ if (!target.ok)
773
+ return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
774
+ const createInput = {
775
+ title,
776
+ ...(decision !== undefined ? { decision } : {}),
777
+ ...(rationale !== undefined ? { rationale } : {}),
778
+ ...(implementationNotes !== undefined ? { implementationNotes } : {}),
779
+ };
780
+ try {
781
+ const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
782
+ return writeAndSummarize(st, `Accepted decision created on ${target.ref}: ${acceptedDecision.title}`, {
783
+ created: true,
784
+ targetType,
785
+ targetRef: target.ref,
786
+ acceptedDecision,
787
+ });
788
+ }
789
+ catch (error) {
790
+ return acceptedDecisionMutationFailure(error, "created");
791
+ }
792
+ });
793
+ server.registerTool("planner-accepted-decision-update", {
794
+ description: "Update one accepted decision in place while preserving its id and acceptedAt timestamp.",
795
+ inputSchema: {
796
+ targetType: z.enum(["project", "feature", "phase", "task"]),
797
+ targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
798
+ decisionId: z.string().min(1),
799
+ title: z.string().optional(),
800
+ decision: z.string().optional(),
801
+ rationale: z.string().optional(),
802
+ implementationNotes: z.string().optional(),
803
+ },
804
+ }, async ({ targetType, targetRef, decisionId, title, decision, rationale, implementationNotes }) => {
805
+ const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
806
+ const receivedFields = mutableFields.filter((field) => ({ title, decision, rationale, implementationNotes })[field] !== undefined);
807
+ if (receivedFields.length === 0) {
808
+ return mutationNoFieldsFailure({
809
+ entity: "acceptedDecision",
810
+ ref: decisionId,
811
+ operation: "update",
812
+ mutableFields,
813
+ retryCommand: "planner-accepted-decision-update with title, decision, rationale, or implementationNotes",
814
+ readBackCommand: "planner-show or the owning entity show tool",
815
+ });
816
+ }
817
+ const st = await requireStore();
818
+ const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
819
+ if (!target.ok)
820
+ return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
821
+ const updateInput = {
822
+ ...(title !== undefined ? { title } : {}),
823
+ ...(decision !== undefined ? { decision } : {}),
824
+ ...(rationale !== undefined ? { rationale } : {}),
825
+ ...(implementationNotes !== undefined ? { implementationNotes } : {}),
826
+ };
827
+ try {
828
+ const acceptedDecision = await st.updateAcceptedDecision(target.owner, decisionId, updateInput);
829
+ return writeAndSummarize(st, `Accepted decision updated on ${target.ref}: ${acceptedDecision.title}`, {
830
+ updated: true,
831
+ targetType,
832
+ targetRef: target.ref,
833
+ acceptedDecision,
834
+ });
835
+ }
836
+ catch (error) {
837
+ return acceptedDecisionMutationFailure(error, "updated");
838
+ }
839
+ });
840
+ server.registerTool("planner-accepted-decision-delete", {
841
+ description: "Delete one accepted decision from the project, feature, phase, or task without replacing the full acceptedDecisions array.",
842
+ inputSchema: {
843
+ targetType: z.enum(["project", "feature", "phase", "task"]),
844
+ targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
845
+ decisionId: z.string().min(1),
846
+ confirmed: z.boolean().describe("Must be true to confirm deleting this accepted decision."),
847
+ },
848
+ }, async ({ targetType, targetRef, decisionId, confirmed }) => {
849
+ const st = await requireStore();
850
+ const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
851
+ if (!target.ok)
852
+ return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
853
+ if (!confirmed) {
854
+ return text(`Delete not confirmed for accepted decision ${decisionId} on ${target.ref}. Retry with confirmed=true.`, {
855
+ deleted: false,
856
+ confirmRequired: true,
857
+ targetType,
858
+ targetRef: target.ref,
859
+ decisionId,
860
+ });
861
+ }
862
+ try {
863
+ const acceptedDecision = await st.deleteAcceptedDecision(target.owner, decisionId);
864
+ return writeAndSummarize(st, `Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}`, {
865
+ deleted: true,
866
+ targetType,
867
+ targetRef: target.ref,
868
+ decisionId,
869
+ acceptedDecision,
870
+ });
871
+ }
872
+ catch (error) {
873
+ return acceptedDecisionMutationFailure(error, "deleted");
874
+ }
465
875
  });
466
876
  server.registerTool("planner-feature-add", {
467
877
  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.",
@@ -472,44 +882,46 @@ server.registerTool("planner-feature-add", {
472
882
  },
473
883
  }, async ({ name, description, status }) => {
474
884
  const st = await requireStore();
475
- const timestamp = nowISO();
476
- const effectiveStatus = status ?? "planned";
477
- const existingFeatures = (await st.loadFeatures()).features;
478
- const id = createFeatureId();
479
- const identity = await st.allocateEntityIdentity("feature", id);
480
- const priority = await st.nextPriority("feature");
481
- const feature = {
482
- id,
483
- number: identity.number,
484
- shortId: identity.shortId,
485
- priority,
486
- name: name.trim(),
487
- description: description?.trim() ?? "",
488
- descriptionUpdatedAt: timestamp,
489
- status: effectiveStatus,
490
- discussedAt: "",
491
- contextReady: false,
492
- contextReadyReason: "",
493
- startDate: effectiveStatus === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
494
- endDate: effectiveStatus === "done" ? new Date().toISOString().slice(0, 10) : "",
495
- workDone: "",
496
- workRemaining: "",
497
- acceptedDecisions: [],
498
- phaseIds: [],
499
- dependsOn: [],
500
- statusLog: [],
501
- sessionInfo: [],
502
- createdAt: timestamp,
503
- updatedAt: timestamp,
504
- };
505
- await st.updateFeatures((doc) => {
506
- doc.features.push(feature);
507
- return doc;
885
+ return st.runBatch(async () => {
886
+ const timestamp = nowISO();
887
+ const effectiveStatus = status ?? "planned";
888
+ const existingFeatures = (await st.loadFeatures()).features;
889
+ const id = createFeatureId();
890
+ const identity = await st.allocateEntityIdentity("feature", id);
891
+ const priority = await st.nextPriority("feature");
892
+ const feature = {
893
+ id,
894
+ number: identity.number,
895
+ shortId: identity.shortId,
896
+ priority,
897
+ name: name.trim(),
898
+ description: description?.trim() ?? "",
899
+ descriptionUpdatedAt: timestamp,
900
+ status: effectiveStatus,
901
+ discussedAt: "",
902
+ contextReady: false,
903
+ contextReadyReason: "",
904
+ startDate: effectiveStatus === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
905
+ endDate: effectiveStatus === "done" ? new Date().toISOString().slice(0, 10) : "",
906
+ workDone: "",
907
+ workRemaining: "",
908
+ acceptedDecisions: [],
909
+ phaseIds: [],
910
+ dependsOn: [],
911
+ statusLog: [],
912
+ sessionInfo: [],
913
+ createdAt: timestamp,
914
+ updatedAt: timestamp,
915
+ };
916
+ await st.updateFeatures((doc) => {
917
+ doc.features.push(feature);
918
+ return doc;
919
+ });
920
+ return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
508
921
  });
509
- return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
510
922
  });
511
923
  server.registerTool("planner-feature-show", {
512
- description: "Show a feature by id or name.",
924
+ description: "Show a feature by id or name. Pass full=true for the description and every canonical Accepted Decision field.",
513
925
  inputSchema: { feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the feature description. Default: compact identity only (saves tokens).") },
514
926
  }, async ({ feature: ref, full }, extra) => {
515
927
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -519,34 +931,72 @@ server.registerTool("planner-feature-show", {
519
931
  if (!resolvedFeature.ok)
520
932
  return text(resolvedFeature.error);
521
933
  const feature = resolvedFeature.feature;
522
- if (full)
523
- markFeatureReadForSessionId(plannerSessionId, feature.id);
524
934
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
525
935
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
526
- return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
936
+ const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
937
+ if (!full)
938
+ return text(summary);
939
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
940
+ const result = text(`${summary}\n\n${feature.description || ""}${descriptionRefBlock}\n\n${decisionBlock}`, {
941
+ feature: {
942
+ ref: formatFeatureRef(feature.number),
943
+ description: feature.description,
944
+ descriptionRef: feature.descriptionRef || "",
945
+ acceptedDecisions: feature.acceptedDecisions,
946
+ },
947
+ });
948
+ markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
949
+ return result;
527
950
  });
528
951
  server.registerTool("planner-feature-discuss", {
529
952
  description: "Persist feature discovery/governance fields and mark the feature context as ready.",
530
953
  inputSchema: {
531
954
  feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
532
955
  description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
956
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
533
957
  workDone: z.string().optional().describe("What is already implemented / decided."),
534
958
  workRemaining: z.string().optional().describe("What still needs to be done."),
535
959
  dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
536
960
  },
537
- }, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
961
+ }, async ({ feature: ref, description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies }) => {
538
962
  const st = await requireStore();
539
963
  const features = (await st.loadFeatures()).features;
540
964
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
541
965
  if (!resolvedFeature.ok)
542
966
  return text(resolvedFeature.error);
543
967
  const feature = resolvedFeature.feature;
968
+ const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
969
+ const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
970
+ const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
971
+ if (receivedFields.length === 0) {
972
+ return mutationNoFieldsFailure({
973
+ entity: "feature",
974
+ ref: formatFeatureRef(feature.number),
975
+ operation: "discuss",
976
+ mutableFields,
977
+ retryCommand: `planner-feature-discuss ${formatFeatureRef(feature.number)}`,
978
+ readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
979
+ descriptionFallback: {
980
+ entityId: feature.id,
981
+ inputField: "description",
982
+ storedField: "feature description",
983
+ descriptionRefField: "descriptionRef",
984
+ },
985
+ });
986
+ }
544
987
  const updated = await st.updateFeatures((doc) => {
545
988
  const target = doc.features.find((entry) => entry.id === feature.id);
546
989
  if (!target)
547
990
  return doc;
548
991
  if (description !== undefined)
549
992
  target.description = description.trim();
993
+ if (rawDescriptionRef !== undefined) {
994
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
995
+ if (descriptionRef)
996
+ target.descriptionRef = descriptionRef;
997
+ else
998
+ delete target.descriptionRef;
999
+ }
550
1000
  if (workDone !== undefined)
551
1001
  target.workDone = workDone.trim();
552
1002
  if (workRemaining !== undefined)
@@ -560,7 +1010,7 @@ server.registerTool("planner-feature-discuss", {
560
1010
  return doc;
561
1011
  });
562
1012
  const result = updated.features.find((entry) => entry.id === feature.id);
563
- return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
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 });
564
1014
  });
565
1015
  server.registerTool("planner-feature-update", {
566
1016
  description: "Update a feature.",
@@ -568,6 +1018,7 @@ server.registerTool("planner-feature-update", {
568
1018
  feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
569
1019
  name: z.string().optional(),
570
1020
  description: z.string().optional(),
1021
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
571
1022
  status: z.enum(STATUS_VALUES).optional(),
572
1023
  workDone: z.string().optional(),
573
1024
  workRemaining: z.string().optional(),
@@ -585,6 +1036,24 @@ server.registerTool("planner-feature-update", {
585
1036
  if (updates.status !== undefined) {
586
1037
  return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
587
1038
  }
1039
+ const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
1040
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1041
+ if (receivedFields.length === 0) {
1042
+ return mutationNoFieldsFailure({
1043
+ entity: "feature",
1044
+ ref: formatFeatureRef(feature.number),
1045
+ operation: "update",
1046
+ mutableFields,
1047
+ retryCommand: `planner-feature-update ${formatFeatureRef(feature.number)}`,
1048
+ readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
1049
+ descriptionFallback: {
1050
+ entityId: feature.id,
1051
+ inputField: "description",
1052
+ storedField: "feature description",
1053
+ descriptionRefField: "descriptionRef",
1054
+ },
1055
+ });
1056
+ }
588
1057
  const updated = await st.updateFeatures((doc) => {
589
1058
  const target = doc.features.find((entry) => entry.id === feature.id);
590
1059
  if (!target)
@@ -593,6 +1062,13 @@ server.registerTool("planner-feature-update", {
593
1062
  target.name = updates.name.trim();
594
1063
  if (updates.description !== undefined)
595
1064
  target.description = updates.description.trim();
1065
+ if (updates.descriptionRef !== undefined) {
1066
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1067
+ if (descriptionRef)
1068
+ target.descriptionRef = descriptionRef;
1069
+ else
1070
+ delete target.descriptionRef;
1071
+ }
596
1072
  if (updates.workDone !== undefined)
597
1073
  target.workDone = updates.workDone.trim();
598
1074
  if (updates.workRemaining !== undefined)
@@ -607,7 +1083,7 @@ server.registerTool("planner-feature-update", {
607
1083
  return doc;
608
1084
  });
609
1085
  const result = updated.features.find((entry) => entry.id === feature.id);
610
- return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
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 });
611
1087
  });
612
1088
  server.registerTool("planner-feature-delete", {
613
1089
  description: "Delete a feature. By default, phases are unlinked rather than deleted.",
@@ -623,20 +1099,20 @@ server.registerTool("planner-feature-delete", {
623
1099
  return text(resolvedFeature.error);
624
1100
  const feature = resolvedFeature.feature;
625
1101
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
626
- await st.updateFeatures((doc) => {
627
- doc.features = doc.features.filter((entry) => entry.id !== feature.id);
628
- return doc;
629
- });
630
- for (const phase of phases) {
631
- if (cascade) {
632
- await st.deletePhase(phase.id);
633
- }
634
- else {
635
- phase.featureId = undefined;
636
- phase.updatedAt = nowISO();
637
- await st.savePhase(phase);
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
+ }
638
1114
  }
639
- }
1115
+ });
640
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) });
641
1117
  });
642
1118
  server.registerTool("planner-phase-add", {
@@ -663,7 +1139,7 @@ server.registerTool("planner-phase-add", {
663
1139
  return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
664
1140
  const lockKey = feature?.id ?? "__unscoped__";
665
1141
  let phase;
666
- await withFeatureLock(lockKey, async () => {
1142
+ await st.runBatch(() => withFeatureLock(lockKey, async () => {
667
1143
  const phases = await st.loadAllPhases();
668
1144
  const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
669
1145
  const timestamp = nowISO();
@@ -701,6 +1177,7 @@ server.registerTool("planner-phase-add", {
701
1177
  updatedAt: timestamp,
702
1178
  handoff: "",
703
1179
  handoffUpdatedAt: "",
1180
+ handoffAudit: null,
704
1181
  handoffReadAt: "",
705
1182
  handoffHistory: [],
706
1183
  statusLog: [],
@@ -716,13 +1193,13 @@ server.registerTool("planner-phase-add", {
716
1193
  });
717
1194
  }
718
1195
  await st.writeGenerated();
719
- });
1196
+ }));
720
1197
  if (!phase)
721
1198
  return text("Phase creation failed.");
722
1199
  return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
723
1200
  });
724
1201
  server.registerTool("planner-phase-show", {
725
- description: "Show a phase by id or name, including derived linked requirements in the full view.",
1202
+ description: "Show a phase by id or name, including every canonical Accepted Decision field and derived linked requirements in the full view.",
726
1203
  inputSchema: { phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the phase description. Default: compact identity only (saves tokens).") },
727
1204
  }, async ({ phase: ref, full }, extra) => {
728
1205
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -731,15 +1208,27 @@ server.registerTool("planner-phase-show", {
731
1208
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
732
1209
  if (!phase)
733
1210
  return text(`Phase not found: ${ref}`);
734
- if (full)
735
- markPhaseReadForSessionId(plannerSessionId, phase.id);
736
1211
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
737
1212
  const reqCount = linkedRequirements.length;
738
1213
  const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
1214
+ const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
739
1215
  const requirementsBlock = reqCount > 0
740
- ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
1216
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
741
1217
  : "";
742
- return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
1218
+ if (!full)
1219
+ return text(summary, {
1220
+ phase: {
1221
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
1222
+ shortId: phase.shortId,
1223
+ title: phase.title,
1224
+ summary: phase.summary,
1225
+ status: phase.status,
1226
+ taskCount: phase.tasks.length,
1227
+ },
1228
+ linkedRequirements,
1229
+ });
1230
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
1231
+ const result = text(`${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}`, {
743
1232
  phase: {
744
1233
  ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
745
1234
  shortId: phase.shortId,
@@ -747,21 +1236,28 @@ server.registerTool("planner-phase-show", {
747
1236
  summary: phase.summary,
748
1237
  status: phase.status,
749
1238
  taskCount: phase.tasks.length,
750
- ...(full ? { description: phase.description } : {}),
1239
+ description: phase.description,
1240
+ descriptionRef: phase.descriptionRef || "",
1241
+ acceptedDecisions: phase.acceptedDecisions,
751
1242
  },
752
1243
  linkedRequirements,
753
1244
  });
1245
+ markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
1246
+ return result;
754
1247
  });
755
1248
  server.registerTool("planner-phase-discuss", {
756
- description: "Persist phase discovery fields and mark phase planned.",
1249
+ description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
757
1250
  inputSchema: {
758
1251
  phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
759
- goal: z.string().optional(),
1252
+ goal: z.string().optional().describe("Backward-compatible single main goal."),
1253
+ goals: z.array(z.string()).optional().describe("Replace the phase goals list."),
760
1254
  summary: z.string().optional(),
761
1255
  scope: z.string().optional(),
1256
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
762
1257
  nonGoals: z.array(z.string()).optional(),
763
1258
  dependencies: z.array(z.string()).optional(),
764
1259
  risks: z.array(z.string()).optional(),
1260
+ openQuestions: z.array(z.string()).optional(),
765
1261
  completionCriteria: z.array(z.string()).optional(),
766
1262
  },
767
1263
  }, async ({ phase: ref, ...updates }) => {
@@ -770,39 +1266,76 @@ server.registerTool("planner-phase-discuss", {
770
1266
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
771
1267
  if (!found)
772
1268
  return text(`Phase not found: ${ref}`);
1269
+ const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
1270
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1271
+ if (receivedFields.length === 0) {
1272
+ return mutationNoFieldsFailure({
1273
+ entity: "phase",
1274
+ ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1275
+ operation: "discuss",
1276
+ mutableFields,
1277
+ retryCommand: `planner-phase-discuss ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
1278
+ readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
1279
+ descriptionFallback: {
1280
+ entityId: found.id,
1281
+ inputField: "scope",
1282
+ storedField: "phase description",
1283
+ descriptionRefField: "descriptionRef",
1284
+ },
1285
+ });
1286
+ }
773
1287
  const phase = await st.updatePhase(found.id, (entry) => {
774
- if (updates.goal !== undefined)
1288
+ if (updates.goals !== undefined)
1289
+ entry.goals = updates.goals.map((item) => item.trim()).filter(Boolean);
1290
+ else if (updates.goal !== undefined)
775
1291
  entry.goals = [updates.goal.trim()].filter(Boolean);
776
1292
  if (updates.summary !== undefined)
777
1293
  entry.summary = updates.summary.trim();
778
1294
  if (updates.scope !== undefined)
779
1295
  entry.description = updates.scope.trim();
1296
+ if (updates.descriptionRef !== undefined) {
1297
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1298
+ if (descriptionRef)
1299
+ entry.descriptionRef = descriptionRef;
1300
+ else
1301
+ delete entry.descriptionRef;
1302
+ }
780
1303
  if (updates.nonGoals !== undefined)
781
1304
  entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
782
1305
  if (updates.dependencies !== undefined)
783
1306
  entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
784
1307
  if (updates.risks !== undefined)
785
1308
  entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
1309
+ if (updates.openQuestions !== undefined)
1310
+ entry.openQuestions = updates.openQuestions.map((item) => item.trim()).filter(Boolean);
786
1311
  if (updates.completionCriteria !== undefined)
787
1312
  entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
788
- entry.status = "planned";
789
1313
  entry.discussedAt = nowISO();
790
1314
  entry.contextReady = true;
791
1315
  entry.contextReadyReason = "Updated through planner-phase-discuss MCP tool.";
792
1316
  entry.updatedAt = nowISO();
793
1317
  return entry;
794
1318
  });
795
- return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
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 });
796
1320
  });
797
1321
  server.registerTool("planner-phase-update", {
798
- description: "Update phase fields.",
1322
+ description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
799
1323
  inputSchema: {
800
1324
  phase: z.string().min(1),
801
1325
  title: z.string().optional(),
802
1326
  status: z.enum(PHASE_STATUS_VALUES).optional(),
803
1327
  summary: z.string().optional(),
804
1328
  description: z.string().optional(),
1329
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
1330
+ featureId: z.string().optional().describe("Feature ref to relink this phase to, or an empty string to unlink it."),
805
1331
  priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
1332
+ goals: z.array(z.string()).optional(),
1333
+ nonGoals: z.array(z.string()).optional(),
1334
+ dependencies: z.array(z.string()).optional(),
1335
+ risks: z.array(z.string()).optional(),
1336
+ openQuestions: z.array(z.string()).optional(),
1337
+ decisions: z.array(z.string()).optional(),
1338
+ completionCriteria: z.array(z.string()).optional(),
806
1339
  },
807
1340
  }, async ({ phase: ref, ...updates }) => {
808
1341
  const st = await requireStore();
@@ -813,19 +1346,101 @@ server.registerTool("planner-phase-update", {
813
1346
  if (updates.status !== undefined) {
814
1347
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
815
1348
  }
816
- const phase = await st.updatePhase(found.id, (entry) => {
817
- if (updates.title !== undefined)
818
- entry.title = updates.title.trim();
819
- if (updates.summary !== undefined)
820
- entry.summary = updates.summary.trim();
821
- if (updates.description !== undefined)
822
- entry.description = updates.description.trim();
823
- if (updates.priority !== undefined)
824
- entry.priority = updates.priority;
825
- entry.updatedAt = nowISO();
826
- return entry;
1349
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
1350
+ const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1351
+ if (receivedFields.length === 0) {
1352
+ return mutationNoFieldsFailure({
1353
+ entity: "phase",
1354
+ ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
1355
+ operation: "update",
1356
+ mutableFields,
1357
+ retryCommand: `planner-phase-update ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
1358
+ readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
1359
+ descriptionFallback: {
1360
+ entityId: found.id,
1361
+ inputField: "description",
1362
+ storedField: "phase description",
1363
+ descriptionRefField: "descriptionRef",
1364
+ },
1365
+ });
1366
+ }
1367
+ const previousFeatureId = found.featureId;
1368
+ let nextFeatureId = previousFeatureId;
1369
+ if (updates.featureId !== undefined) {
1370
+ if (updates.featureId.trim() === "") {
1371
+ nextFeatureId = undefined;
1372
+ }
1373
+ else {
1374
+ const resolvedFeature = resolveFeatureRefStrict(features, updates.featureId);
1375
+ if (!resolvedFeature.ok)
1376
+ return text(resolvedFeature.error);
1377
+ nextFeatureId = resolvedFeature.feature.id;
1378
+ }
1379
+ }
1380
+ let phase;
1381
+ await st.runBatch(async () => {
1382
+ phase = await st.updatePhase(found.id, (entry) => {
1383
+ if (updates.title !== undefined) {
1384
+ entry.title = updates.title.trim();
1385
+ entry.slug = normalizeSlug(updates.title);
1386
+ }
1387
+ if (updates.summary !== undefined)
1388
+ entry.summary = updates.summary.trim();
1389
+ if (updates.description !== undefined)
1390
+ entry.description = updates.description.trim();
1391
+ if (updates.descriptionRef !== undefined) {
1392
+ const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1393
+ if (descriptionRef)
1394
+ entry.descriptionRef = descriptionRef;
1395
+ else
1396
+ delete entry.descriptionRef;
1397
+ }
1398
+ if (updates.featureId !== undefined)
1399
+ entry.featureId = nextFeatureId;
1400
+ if (updates.priority !== undefined)
1401
+ entry.priority = updates.priority;
1402
+ if (updates.goals !== undefined)
1403
+ entry.goals = updates.goals.map((item) => item.trim()).filter(Boolean);
1404
+ if (updates.nonGoals !== undefined)
1405
+ entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
1406
+ if (updates.dependencies !== undefined)
1407
+ entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
1408
+ if (updates.risks !== undefined)
1409
+ entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
1410
+ if (updates.openQuestions !== undefined)
1411
+ 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
+ if (updates.completionCriteria !== undefined)
1415
+ entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
1416
+ entry.updatedAt = nowISO();
1417
+ return entry;
1418
+ });
1419
+ if (updates.featureId !== undefined && nextFeatureId !== previousFeatureId) {
1420
+ await st.updateFeatures((document) => {
1421
+ for (const feature of document.features) {
1422
+ if (feature.id === previousFeatureId)
1423
+ feature.phaseIds = feature.phaseIds.filter((phaseId) => phaseId !== found.id);
1424
+ if (feature.id === nextFeatureId && !feature.phaseIds.includes(found.id))
1425
+ feature.phaseIds.push(found.id);
1426
+ }
1427
+ return document;
1428
+ });
1429
+ }
1430
+ await st.writeGenerated();
827
1431
  });
828
- return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
1432
+ const persisted = phase;
1433
+ const refreshedFeatures = (await st.loadFeatures()).features;
1434
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
1435
+ ? await st.previewDescriptionReconciliation()
1436
+ : null;
1437
+ const staleParentRefs = descriptionFreshness?.diagnostics
1438
+ .filter((entry) => entry.state === "stale" && entry.ownerId === persisted.featureId)
1439
+ .map((entry) => entry.ownerRef) ?? [];
1440
+ const freshnessNotice = staleParentRefs.length > 0
1441
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
1442
+ : "";
1443
+ return text(`✅ Phase updated: ${formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures))} — ${persisted.title}${persisted.shortId ? ` · ${persisted.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}`, { phase: persisted, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) });
829
1444
  });
830
1445
  server.registerTool("planner-phase-delete", {
831
1446
  description: "Delete a phase and unlink it from features.",
@@ -835,11 +1450,13 @@ server.registerTool("planner-phase-delete", {
835
1450
  const phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
836
1451
  if (!phase)
837
1452
  return text(`Phase not found: ${ref}`);
838
- await st.deletePhase(phase.id);
839
- await st.updateFeatures((doc) => {
840
- for (const feature of doc.features)
841
- feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
842
- return doc;
1453
+ await st.runBatch(async () => {
1454
+ await st.deletePhase(phase.id);
1455
+ await st.updateFeatures((doc) => {
1456
+ for (const feature of doc.features)
1457
+ feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
1458
+ return doc;
1459
+ });
843
1460
  });
844
1461
  const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
845
1462
  return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
@@ -855,70 +1472,73 @@ server.registerTool("planner-task-add", {
855
1472
  },
856
1473
  }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
857
1474
  const st = await requireStore();
858
- const features = (await st.loadFeatures()).features;
859
- if (!featureRef?.trim())
860
- return text("feature is required: a task must belong to a feature.");
861
- const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
862
- if (!resolvedFeature.ok)
863
- return text(resolvedFeature.error);
864
- const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
865
- if (!featureValidation.ok)
866
- return text(featureValidation.error);
867
- const feature = resolvedFeature.feature;
868
- if (!feature)
869
- return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
870
- const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
871
- if (!found)
872
- return text(`Phase not found: ${ref}`);
873
- if (found.featureId !== feature.id)
874
- return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
875
- const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
876
- if (!phaseValidation.ok)
877
- return text(phaseValidation.error);
878
- const existingPhase = await st.loadPhase(found.id);
879
- if (!existingPhase)
880
- return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
881
- const timestamp = nowISO();
882
- const taskId = createTaskId();
883
- const identity = await st.allocateEntityIdentity("task", taskId);
884
- const priority = await st.nextPriority("task", found.id);
885
- const task = {
886
- id: taskId,
887
- phaseId: found.id,
888
- number: identity.number,
889
- shortId: identity.shortId,
890
- priority,
891
- shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
892
- title: title.trim(),
893
- status: "planned",
894
- description: description?.trim() ?? "",
895
- descriptionUpdatedAt: timestamp,
896
- notes: "",
897
- statusLog: [],
898
- sessionInfo: [],
899
- decisions: [],
900
- acceptedDecisions: [],
901
- checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
902
- subtasks: [],
903
- dependsOn: [],
904
- pauseSnapshot: null,
905
- pauseHistory: [],
906
- startedAt: "",
907
- completedAt: "",
908
- createdAt: timestamp,
909
- updatedAt: timestamp,
910
- };
911
- await st.updatePhase(found.id, (entry) => {
912
- entry.tasks.push(task);
913
- entry.taskIds.push(task.id);
914
- entry.updatedAt = timestamp;
915
- return entry;
1475
+ return st.runBatch(async () => {
1476
+ const features = (await st.loadFeatures()).features;
1477
+ if (!featureRef?.trim())
1478
+ return text("feature is required: a task must belong to a feature.");
1479
+ const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
1480
+ if (!resolvedFeature.ok)
1481
+ return text(resolvedFeature.error);
1482
+ const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
1483
+ if (!featureValidation.ok)
1484
+ return text(featureValidation.error);
1485
+ const feature = resolvedFeature.feature;
1486
+ if (!feature)
1487
+ return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
1488
+ const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
1489
+ if (!found)
1490
+ return text(`Phase not found: ${ref}`);
1491
+ if (found.featureId !== feature.id)
1492
+ return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
1493
+ const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
1494
+ if (!phaseValidation.ok)
1495
+ return text(phaseValidation.error);
1496
+ const existingPhase = await st.loadPhase(found.id);
1497
+ if (!existingPhase)
1498
+ return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
1499
+ const timestamp = nowISO();
1500
+ const taskId = createTaskId();
1501
+ const identity = await st.allocateEntityIdentity("task", taskId);
1502
+ const priority = await st.nextPriority("task", found.id);
1503
+ const task = {
1504
+ id: taskId,
1505
+ phaseId: found.id,
1506
+ number: identity.number,
1507
+ shortId: identity.shortId,
1508
+ priority,
1509
+ shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
1510
+ title: title.trim(),
1511
+ status: "planned",
1512
+ description: description?.trim() ?? "",
1513
+ descriptionUpdatedAt: timestamp,
1514
+ notes: "",
1515
+ statusLog: [],
1516
+ sessionInfo: [],
1517
+ decisions: [],
1518
+ acceptedDecisions: [],
1519
+ checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
1520
+ subtasks: [],
1521
+ dependsOn: [],
1522
+ pauseSnapshot: null,
1523
+ pauseHistory: [],
1524
+ startedAt: "",
1525
+ completedAt: "",
1526
+ activeOwnerSession: "",
1527
+ createdAt: timestamp,
1528
+ updatedAt: timestamp,
1529
+ };
1530
+ await st.updatePhase(found.id, (entry) => {
1531
+ entry.tasks.push(task);
1532
+ entry.taskIds.push(task.id);
1533
+ entry.updatedAt = timestamp;
1534
+ return entry;
1535
+ });
1536
+ const finalFeatures = (await st.loadFeatures()).features;
1537
+ return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
916
1538
  });
917
- const finalFeatures = (await st.loadFeatures()).features;
918
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
919
1539
  });
920
1540
  server.registerTool("planner-task-show", {
921
- description: "Show a task by id or name.",
1541
+ description: "Show a task by id or name. Pass full=true for every canonical Accepted Decision field plus execution and resume context.",
922
1542
  inputSchema: { task: z.string().min(1).describe("Task ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only (saves tokens).") },
923
1543
  }, async ({ task: ref, full }, extra) => {
924
1544
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -926,8 +1546,6 @@ server.registerTool("planner-task-show", {
926
1546
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
927
1547
  if (!found)
928
1548
  return text(`Task not found: ${ref}`);
929
- if (full)
930
- markTaskReadForSessionId(plannerSessionId, found.task.id);
931
1549
  const features = (await st.loadFeatures()).features;
932
1550
  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))})`;
933
1551
  if (!full)
@@ -944,6 +1562,8 @@ server.registerTool("planner-task-show", {
944
1562
  const sections = [summary];
945
1563
  if (found.task.description?.trim())
946
1564
  sections.push(found.task.description.trim());
1565
+ if (found.task.descriptionRef)
1566
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
947
1567
  if (snapshot || pendingDeviation) {
948
1568
  sections.push([
949
1569
  pendingDeviation
@@ -957,20 +1577,50 @@ server.registerTool("planner-task-show", {
957
1577
  }
958
1578
  if (log)
959
1579
  sections.push(`Status log:\n${log}`);
960
- return text(sections.join("\n\n"));
1580
+ sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
1581
+ const result = text(sections.join("\n\n"), {
1582
+ task: {
1583
+ ref: taskCompositeRef(found.task, found.phase, features),
1584
+ description: found.task.description,
1585
+ descriptionRef: found.task.descriptionRef || "",
1586
+ acceptedDecisions: found.task.acceptedDecisions,
1587
+ },
1588
+ });
1589
+ markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
1590
+ return result;
961
1591
  });
962
1592
  server.registerTool("planner-task-discuss", {
963
1593
  description: "Persist task execution notes/checklist.",
964
1594
  inputSchema: {
965
1595
  task: z.string().min(1),
966
1596
  description: z.string().optional(),
1597
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
967
1598
  checklist: z.array(z.string()).optional(),
968
1599
  },
969
- }, async ({ task: ref, description, checklist }) => {
1600
+ }, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
970
1601
  const st = await requireStore();
971
1602
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
972
1603
  if (!found)
973
1604
  return text(`Task not found: ${ref}`);
1605
+ const mutableFields = ["description", "descriptionRef", "checklist"];
1606
+ const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
1607
+ const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
1608
+ if (receivedFields.length === 0) {
1609
+ return mutationNoFieldsFailure({
1610
+ entity: "task",
1611
+ ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1612
+ operation: "discuss",
1613
+ mutableFields,
1614
+ retryCommand: `planner-task-discuss ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
1615
+ readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
1616
+ descriptionFallback: {
1617
+ entityId: found.task.id,
1618
+ inputField: "description",
1619
+ storedField: "task description",
1620
+ descriptionRefField: "descriptionRef",
1621
+ },
1622
+ });
1623
+ }
974
1624
  let updatedTask;
975
1625
  await st.updatePhase(found.phase.id, (phase) => {
976
1626
  const task = phase.tasks.find((entry) => entry.id === found.task.id);
@@ -978,8 +1628,13 @@ server.registerTool("planner-task-discuss", {
978
1628
  return phase;
979
1629
  if (description !== undefined)
980
1630
  task.description = description.trim();
981
- if (checklist !== undefined)
982
- task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
1631
+ if (rawDescriptionRef !== undefined) {
1632
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1633
+ if (descriptionRef)
1634
+ task.descriptionRef = descriptionRef;
1635
+ else
1636
+ delete task.descriptionRef;
1637
+ }
983
1638
  if (checklist !== undefined)
984
1639
  task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
985
1640
  task.updatedAt = nowISO();
@@ -989,7 +1644,49 @@ server.registerTool("planner-task-discuss", {
989
1644
  });
990
1645
  const features = (await st.loadFeatures()).features;
991
1646
  const t = updatedTask ?? found.task;
992
- return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
1647
+ 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 });
1648
+ });
1649
+ server.registerTool("planner-task-dependency-add", {
1650
+ description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
1651
+ inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1) },
1652
+ }, async ({ task: ref, dependsOn }, extra) => {
1653
+ const st = await requireStore();
1654
+ const features = (await st.loadFeatures()).features;
1655
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1656
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, dependsOn);
1657
+ if (!found || !dependency)
1658
+ return { ...text("Dependency target not found.", { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }), isError: true };
1659
+ try {
1660
+ const result = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
1661
+ await st.writeGenerated();
1662
+ return text(`✅ Dependency added: ${taskCompositeRef(found.task, found.phase, features)} depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, { updated: true, task: result });
1663
+ }
1664
+ catch (error) {
1665
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1666
+ return { ...text(error instanceof Error ? error.message : "Dependency update failed.", { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }), isError: true };
1667
+ }
1668
+ });
1669
+ server.registerTool("planner-task-dependency-delete", {
1670
+ description: "Remove a task dependency atomically after explicit confirmation.",
1671
+ inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1), confirmed: z.boolean() },
1672
+ }, async ({ task: ref, dependsOn, confirmed }) => {
1673
+ if (!confirmed)
1674
+ return { ...text("Explicit confirmation is required to remove a dependency.", { updated: false, errorCode: "CONFIRMATION_REQUIRED" }), isError: true };
1675
+ const st = await requireStore();
1676
+ const features = (await st.loadFeatures()).features;
1677
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1678
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, dependsOn);
1679
+ if (!found || !dependency)
1680
+ return { ...text("Dependency target not found.", { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }), isError: true };
1681
+ try {
1682
+ const result = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
1683
+ await st.writeGenerated();
1684
+ return text("✅ Dependency removed.", { updated: true, task: result });
1685
+ }
1686
+ catch (error) {
1687
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1688
+ return { ...text(error instanceof Error ? error.message : "Dependency deletion failed.", { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }), isError: true };
1689
+ }
993
1690
  });
994
1691
  server.registerTool("planner-task-update", {
995
1692
  description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
@@ -998,11 +1695,15 @@ server.registerTool("planner-task-update", {
998
1695
  title: z.string().optional(),
999
1696
  status: z.enum(STATUS_VALUES).optional(),
1000
1697
  description: z.string().optional(),
1698
+ descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
1699
+ notes: z.string().optional().describe("Implementation notes."),
1700
+ decisions: z.array(z.string()).optional().describe("Replace the task decisions list."),
1001
1701
  motivation: z.string().optional(),
1002
1702
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
1003
1703
  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."),
1704
+ 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."),
1004
1705
  },
1005
- }, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
1706
+ }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
1006
1707
  const st = await requireStore();
1007
1708
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1008
1709
  if (!found)
@@ -1020,6 +1721,31 @@ server.registerTool("planner-task-update", {
1020
1721
  `Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
1021
1722
  }
1022
1723
  }
1724
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "decisions", "motivation", "priority", "checklist", "subtasks"];
1725
+ const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks };
1726
+ const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
1727
+ if (receivedFields.length === 0) {
1728
+ return mutationNoFieldsFailure({
1729
+ entity: "task",
1730
+ ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
1731
+ operation: "update",
1732
+ mutableFields,
1733
+ retryCommand: `planner-task-update ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
1734
+ readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
1735
+ descriptionFallback: {
1736
+ entityId: found.task.id,
1737
+ inputField: "description",
1738
+ storedField: "task description",
1739
+ descriptionRefField: "descriptionRef",
1740
+ },
1741
+ });
1742
+ }
1743
+ if (subtasks !== undefined) {
1744
+ const existingIds = new Set(found.task.subtasks.map((item) => item.id));
1745
+ if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
1746
+ return { ...text("Subtask IDs must belong to the target task.", { updated: false, errorCode: "SUBTASK_ID_INVALID" }), isError: true };
1747
+ }
1748
+ }
1023
1749
  let updatedTask;
1024
1750
  const timestamp = nowISO();
1025
1751
  await st.updatePhase(found.phase.id, (phase) => {
@@ -1032,6 +1758,32 @@ server.registerTool("planner-task-update", {
1032
1758
  task.priority = priority;
1033
1759
  if (description !== undefined)
1034
1760
  task.description = description.trim();
1761
+ if (rawDescriptionRef !== undefined) {
1762
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1763
+ if (descriptionRef)
1764
+ task.descriptionRef = descriptionRef;
1765
+ else
1766
+ delete task.descriptionRef;
1767
+ }
1768
+ if (notes !== undefined)
1769
+ task.notes = notes.trim();
1770
+ if (decisions !== undefined)
1771
+ task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
1772
+ if (checklist !== undefined) {
1773
+ task.checklist = checklist.map((item, index) => ({
1774
+ id: createChecklistItemId(task.id, index + 1, item),
1775
+ number: index + 1,
1776
+ title: item.trim(),
1777
+ checked: false,
1778
+ }));
1779
+ }
1780
+ if (subtasks !== undefined) {
1781
+ const existingIds = new Set(task.subtasks.map((item) => item.id));
1782
+ if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
1783
+ throw new PlanStoreError("Subtask IDs must belong to the target task.", undefined, { errorCode: "SUBTASK_ID_INVALID" });
1784
+ }
1785
+ task.subtasks = subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: item.status ?? "planned", createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? timestamp, updatedAt: timestamp }));
1786
+ }
1035
1787
  if (status !== undefined && status !== task.status) {
1036
1788
  // Record status change in the incremental statusLog.
1037
1789
  const entry = {
@@ -1068,7 +1820,20 @@ server.registerTool("planner-task-update", {
1068
1820
  }
1069
1821
  }
1070
1822
  }
1071
- return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
1823
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
1824
+ ? await st.previewDescriptionReconciliation()
1825
+ : null;
1826
+ const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
1827
+ const staleParentRefs = descriptionFreshness?.diagnostics
1828
+ .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
1829
+ .map((entry) => entry.ownerRef) ?? [];
1830
+ const freshnessNotice = staleParentRefs.length > 0
1831
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
1832
+ : "";
1833
+ const details = resumeRequired
1834
+ ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
1835
+ : { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
1836
+ return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}${resumeNotice}`, details);
1072
1837
  });
1073
1838
  server.registerTool("planner-task-checklist-toggle", {
1074
1839
  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.",
@@ -1190,18 +1955,55 @@ server.registerTool("planner-task-delete", {
1190
1955
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1191
1956
  });
1192
1957
  server.registerTool("planner-task-recommend", {
1193
- description: "Return the harness-agnostic recommended task: continue one active task or the current phase, otherwise choose ready work by feature → phase → task priority. Reports dependency, availability, and approved deviation/resume context without starting or blocking work.",
1958
+ description: "Return the harness-agnostic next-work chain: active task if present, otherwise the lowest-priority ready feature → phase → task, plus bounded typed claims. Only persisted resume-ready handoffs are actionable; Markdown prose and terminal archives are not authority. Does not start or block work.",
1194
1959
  inputSchema: {},
1195
1960
  }, async () => {
1196
1961
  const st = await requireStore();
1197
- const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1198
- const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
1199
- if (!result.candidate)
1200
- return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1201
- const { candidate } = result;
1962
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
1963
+ st.loadFeatures(),
1964
+ st.loadAllPhases(),
1965
+ st.loadProject(),
1966
+ st.loadResume(),
1967
+ st.listHandoffs(),
1968
+ st.listArchivedHandoffs(),
1969
+ ]);
1970
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
1971
+ handoffs: handoffs.map((handoff) => ({
1972
+ compositeRef: handoff.compositeRef,
1973
+ firstLine: handoff.firstLine,
1974
+ resumeReady: handoff.resumeReady,
1975
+ })),
1976
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
1977
+ });
1978
+ const { selection } = result;
1979
+ if (!selection.candidate)
1980
+ 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")}` : ""}`, {
1981
+ kind: selection.kind,
1982
+ reason: selection.reason,
1983
+ activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1984
+ activeTask: result.activeTask,
1985
+ nextFeature: result.nextFeature,
1986
+ nextPhase: result.nextPhase,
1987
+ nextTask: result.nextTask,
1988
+ claims: result.claims,
1989
+ selection,
1990
+ });
1991
+ const { candidate } = selection;
1202
1992
  const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1203
- return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1204
- kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1993
+ const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
1994
+ const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
1995
+ 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}`, {
1996
+ kind: selection.kind,
1997
+ taskId: candidate.task.id,
1998
+ phaseId: candidate.phase.id,
1999
+ featureId: candidate.feature?.id,
2000
+ deviation: selection.deviation,
2001
+ activeTask: result.activeTask,
2002
+ nextFeature: result.nextFeature,
2003
+ nextPhase: result.nextPhase,
2004
+ nextTask: result.nextTask,
2005
+ claims: result.claims,
2006
+ selection,
1205
2007
  });
1206
2008
  });
1207
2009
  server.registerTool("planner-task-deviation", {
@@ -1279,7 +2081,7 @@ server.registerTool("planner-task-pause", {
1279
2081
  ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1280
2082
  });
1281
2083
  server.registerTool("planner-task-switch", {
1282
- description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
2084
+ description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
1283
2085
  inputSchema: {
1284
2086
  from_task: z.string().min(1),
1285
2087
  to_task: z.string().min(1),
@@ -1305,6 +2107,9 @@ server.registerTool("planner-task-switch", {
1305
2107
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1306
2108
  return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1307
2109
  }
2110
+ if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
2111
+ return text(`Task switch denied: source task is owned by another session.`);
2112
+ }
1308
2113
  const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
1309
2114
  const linkedRequirements = [
1310
2115
  ...(await st.linkedRequirementsForPhase(target.phase.id)),
@@ -1323,16 +2128,23 @@ server.registerTool("planner-task-switch", {
1323
2128
  requirementIds: linkedRequirementIds,
1324
2129
  };
1325
2130
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1326
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2131
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2132
+ const requirementsReady = requirementEligibility.eligible;
2133
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2134
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1327
2135
  const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
1328
2136
  const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
1329
2137
  const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
2138
+ if (!projectGuidelinesReady) {
2139
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
2140
+ return text(`Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.`, { projectGuidelinesReadState, contextEligibility, nextActions });
2141
+ }
1330
2142
  if (!contextEligibility.eligible) {
1331
- const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
2143
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1332
2144
  return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1333
2145
  }
1334
2146
  if (!requirementsReady) {
1335
- return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds, nextActions: ["planner-requirement-list", `Retry planner-task-switch to ${targetTaskRef}`] });
2147
+ return text(`Task switch denied: ${requirementEligibility.reason}`, { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`planner-requirement-list with phaseRef=${targetPhaseRef}`, `Retry planner-task-switch to ${targetTaskRef}`] });
1336
2148
  }
1337
2149
  if (!hasValidSessionAttestation(contextInput)) {
1338
2150
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -1346,7 +2158,7 @@ server.registerTool("planner-task-switch", {
1346
2158
  resumeLocation: resume_location, howToResume: how_to_resume,
1347
2159
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
1348
2160
  };
1349
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
2161
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
1350
2162
  const record = {
1351
2163
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
1352
2164
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
@@ -1357,74 +2169,77 @@ server.registerTool("planner-task-switch", {
1357
2169
  const sourceWasActive = source.task.status === "in-progress";
1358
2170
  let sourceCheckpointed = false;
1359
2171
  let targetStarted = false;
1360
- try {
1361
- if (sourceWasActive) {
1362
- await st.pauseTask(source.phase.id, source.task.id, snapshot);
1363
- }
1364
- else {
1365
- await st.updatePhase(source.phase.id, (phase) => {
1366
- const task = phase.tasks.find((entry) => entry.id === source.task.id);
1367
- if (!task)
1368
- throw new Error(`Checkpointed source task disappeared: ${from_task}`);
1369
- task.pauseSnapshot = snapshot;
1370
- task.pauseHistory = [...task.pauseHistory, snapshot];
1371
- task.updatedAt = timestamp;
1372
- return phase;
1373
- });
1374
- }
1375
- sourceCheckpointed = true;
1376
- if (target.task.pauseSnapshot) {
1377
- await st.resumeTask(target.phase.id, target.task.id, timestamp);
1378
- }
1379
- else {
1380
- await st.updatePhase(target.phase.id, (phase) => {
1381
- const task = phase.tasks.find((entry) => entry.id === target.task.id);
1382
- if (!task)
1383
- throw new Error(`Temporary task disappeared: ${to_task}`);
1384
- const previousStatus = task.status;
1385
- applyTaskLifecycleDates(task, "in-progress", timestamp);
1386
- task.statusLog = [...task.statusLog, {
1387
- id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
1388
- date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
1389
- title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
1390
- }];
1391
- task.updatedAt = timestamp;
1392
- phase.updatedAt = timestamp;
1393
- return phase;
1394
- });
1395
- }
1396
- targetStarted = true;
1397
- await st.addWorkDeviation(record);
1398
- if (target.task.pauseSnapshot) {
1399
- const resumedDeviation = project.workDeviations
1400
- .filter((deviation) => deviation.resumeTaskId === target.task.id
1401
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
1402
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1403
- if (resumedDeviation)
1404
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1405
- }
1406
- }
1407
- catch (error) {
1408
- if (targetStarted) {
1409
- await st.updatePhase(target.phase.id, (phase) => {
1410
- phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1411
- return phase;
1412
- }).catch(() => { });
1413
- }
1414
- if (sourceCheckpointed) {
1415
- if (sourceWasActive)
1416
- await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
1417
- else
2172
+ await st.runBatch(async () => {
2173
+ try {
2174
+ if (sourceWasActive) {
2175
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
2176
+ }
2177
+ else {
1418
2178
  await st.updatePhase(source.phase.id, (phase) => {
1419
- phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2179
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
2180
+ if (!task)
2181
+ throw new Error(`Checkpointed source task disappeared: ${from_task}`);
2182
+ task.pauseSnapshot = snapshot;
2183
+ task.pauseHistory = [...task.pauseHistory, snapshot];
2184
+ task.updatedAt = timestamp;
2185
+ return phase;
2186
+ });
2187
+ }
2188
+ sourceCheckpointed = true;
2189
+ if (target.task.pauseSnapshot) {
2190
+ await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
2191
+ }
2192
+ else {
2193
+ await st.updatePhase(target.phase.id, (phase) => {
2194
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
2195
+ if (!task)
2196
+ throw new Error(`Temporary task disappeared: ${to_task}`);
2197
+ const previousStatus = task.status;
2198
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2199
+ task.activeOwnerSession = plannerSessionId;
2200
+ task.statusLog = [...task.statusLog, {
2201
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
2202
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
2203
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
2204
+ }];
2205
+ task.updatedAt = timestamp;
2206
+ phase.updatedAt = timestamp;
2207
+ return phase;
2208
+ });
2209
+ }
2210
+ targetStarted = true;
2211
+ await st.addWorkDeviation(record);
2212
+ if (target.task.pauseSnapshot) {
2213
+ const resumedDeviation = project.workDeviations
2214
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
2215
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2216
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2217
+ if (resumedDeviation)
2218
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2219
+ }
2220
+ }
2221
+ catch (error) {
2222
+ if (targetStarted) {
2223
+ await st.updatePhase(target.phase.id, (phase) => {
2224
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1420
2225
  return phase;
1421
2226
  }).catch(() => { });
2227
+ }
2228
+ if (sourceCheckpointed) {
2229
+ if (sourceWasActive)
2230
+ await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
2231
+ else
2232
+ await st.updatePhase(source.phase.id, (phase) => {
2233
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2234
+ return phase;
2235
+ }).catch(() => { });
2236
+ }
2237
+ throw error;
1422
2238
  }
1423
- throw error;
1424
- }
1425
- await st.syncTaskStatusRollup(source.phase.id);
1426
- if (target.phase.id !== source.phase.id)
1427
- await st.syncTaskStatusRollup(target.phase.id);
2239
+ await st.syncTaskStatusRollup(source.phase.id);
2240
+ if (target.phase.id !== source.phase.id)
2241
+ await st.syncTaskStatusRollup(target.phase.id);
2242
+ });
1428
2243
  invalidateReads(plannerSessionId);
1429
2244
  return writeAndSummarize(st, [
1430
2245
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
@@ -1433,8 +2248,28 @@ server.registerTool("planner-task-switch", {
1433
2248
  `Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
1434
2249
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1435
2250
  });
2251
+ server.registerTool("planner-task-reopen", {
2252
+ description: "Atomically reopen a completed task. Requires confirmed=true; retains completion evidence and never pauses another task.",
2253
+ inputSchema: { task: z.string().min(1), confirmed: z.boolean().optional().default(false) },
2254
+ }, async ({ task: ref, confirmed }, extra) => {
2255
+ const st = await requireStore();
2256
+ const features = (await st.loadFeatures()).features;
2257
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2258
+ if (!found)
2259
+ return { ...text(`Task not found: ${ref}`, { reopened: false, errorCode: "TASK_NOT_FOUND" }), isError: true };
2260
+ try {
2261
+ const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed, ownerSessionId: plannerSessionIdFor(extra) });
2262
+ await st.syncTaskStatusRollup(found.phase.id);
2263
+ await st.writeGenerated();
2264
+ return text(`✅ Task reopened: ${taskCompositeRef(reopened, found.phase, features)} — ${reopened.title} (in-progress)`, { reopened: true, task: reopened });
2265
+ }
2266
+ catch (error) {
2267
+ const details = error instanceof PlanStoreError ? error.details : undefined;
2268
+ return { ...text(`Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}`, { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }), isError: true };
2269
+ }
2270
+ });
1436
2271
  server.registerTool("planner-task-start", {
1437
- description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
2272
+ description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1438
2273
  inputSchema: { task: z.string().min(1) },
1439
2274
  }, async ({ task: ref }, extra) => {
1440
2275
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -1445,144 +2280,153 @@ server.registerTool("planner-task-start", {
1445
2280
  catch {
1446
2281
  return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
1447
2282
  }
1448
- const features = (await st.loadFeatures()).features;
1449
- const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1450
- if (!found)
1451
- 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."]));
1452
- const taskRef = taskCompositeRef(found.task, found.phase, features);
1453
- const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1454
- const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1455
- const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1456
- if (found.task.status === "done")
1457
- return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry planner-task-start ${taskRef}.`], { taskId: found.task.id }));
1458
- const linkedRequirements = [
1459
- ...(await st.linkedRequirementsForPhase(found.phase.id)),
1460
- ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
1461
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1462
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1463
- const contextInput = {
1464
- sessionId: plannerSessionId,
1465
- taskId: found.task.id,
1466
- phaseId: found.phase.id,
1467
- ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
1468
- task: found.task,
1469
- phase: found.phase,
1470
- ...(parentFeature ? { feature: parentFeature } : {}),
1471
- requirements: linkedRequirements,
1472
- requirementIds: linkedRequirementIds,
1473
- };
1474
- const contextEligibility = contextReadEligibilityForSession(contextInput);
1475
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1476
- if (!contextEligibility.eligible)
1477
- return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
1478
- if (!requirementsReady) {
1479
- return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["planner-requirement-list", `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }));
1480
- }
1481
- if (!hasValidSessionAttestation(contextInput)) {
1482
- await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1483
- }
1484
- if (found.task.status === "in-progress") {
1485
- const outcome = taskStartSucceeded(found.task.id, true);
1486
- return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1487
- }
1488
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1489
- const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1490
- if (!eligibility.eligible)
1491
- return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
1492
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1493
- if (selection.kind === "conflict") {
1494
- return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1495
- }
1496
- if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
1497
- const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
1498
- return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use planner-task-switch to snapshot and pause ${activeRef}.`, `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1499
- }
1500
- let resumeProposal;
1501
- if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
1502
- // Advisory only: a pending resume must be surfaced loudly, but it must
1503
- // not hard-block an explicit start of a different task. Capture a
1504
- // structured proposal so the resume-first guidance is explicit and
1505
- // machine-readable for the host/agent.
1506
- const resumeTask = selection.candidate;
1507
- const resumeSnapshot = resumeTask.task.pauseSnapshot
1508
- ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
1509
- && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
1510
- ?? null;
1511
- resumeProposal = buildResumeRequiredProposal({
1512
- ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
1513
- title: resumeTask.task.title,
1514
- taskId: resumeTask.task.id,
1515
- phaseId: resumeTask.phase.id,
1516
- snapshot: resumeSnapshot
1517
- ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
1518
- : null,
1519
- });
1520
- }
1521
- // Assemble the mandatory parent context before changing task lifecycle state.
1522
- const [phaseWithRequirements, featureRequirements] = await Promise.all([
1523
- st.loadPhaseWithRequirements(found.phase.id),
1524
- parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1525
- ]);
1526
- const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1527
- // A pending handoff is context, not a lock: task start RETAINS it. It is
1528
- // archived only when the phase completes (auto) or the user explicitly
1529
- // clears it.
1530
- const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
1531
- ? selection.kind === "resume"
1532
- ? `\n\n${(() => {
1533
- if (resumeProposal)
1534
- return resumeProposal.text;
1535
- return `⚠️ Resume advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`;
1536
- })()}`
1537
- : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1538
- : "";
1539
- const timestamp = nowISO();
1540
- let updatedTask;
1541
- if (found.task.pauseSnapshot) {
1542
- updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp);
1543
- }
1544
- else {
1545
- await st.updatePhase(found.phase.id, (phase) => {
1546
- const task = phase.tasks.find((entry) => entry.id === found.task.id);
1547
- if (!task)
2283
+ return st.runBatch(async () => {
2284
+ const features = (await st.loadFeatures()).features;
2285
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2286
+ if (!found)
2287
+ 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."]));
2288
+ const taskRef = taskCompositeRef(found.task, found.phase, features);
2289
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
2290
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
2291
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
2292
+ if (found.task.status === "done")
2293
+ 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 }));
2294
+ const project = await st.loadProject();
2295
+ const linkedRequirements = [
2296
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
2297
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
2298
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2299
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2300
+ const contextInput = {
2301
+ sessionId: plannerSessionId,
2302
+ taskId: found.task.id,
2303
+ phaseId: found.phase.id,
2304
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
2305
+ task: found.task,
2306
+ phase: found.phase,
2307
+ ...(parentFeature ? { feature: parentFeature } : {}),
2308
+ requirements: linkedRequirements,
2309
+ requirementIds: linkedRequirementIds,
2310
+ };
2311
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
2312
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2313
+ const requirementsReady = requirementEligibility.eligible;
2314
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2315
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2316
+ if (!projectGuidelinesReady)
2317
+ return taskStartError(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { projectGuidelinesReadState, contextEligibility });
2318
+ if (!contextEligibility.eligible)
2319
+ return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
2320
+ if (!requirementsReady) {
2321
+ return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", `Linked requirements were not read; the task remains unchanged. ${requirementEligibility.reason}`, [`planner-requirement-list with phaseRef=${phaseRef}`, `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }), { requirementEligibility });
2322
+ }
2323
+ if (!hasValidSessionAttestation(contextInput)) {
2324
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
2325
+ }
2326
+ if (found.task.status === "in-progress") {
2327
+ const outcome = taskStartSucceeded(found.task.id, true);
2328
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
2329
+ }
2330
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
2331
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
2332
+ if (!eligibility.eligible)
2333
+ return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
2334
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
2335
+ if (selection.kind === "conflict") {
2336
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
2337
+ }
2338
+ if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
2339
+ const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
2340
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use planner-task-switch to snapshot and pause ${activeRef}.`, `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
2341
+ }
2342
+ let resumeProposal;
2343
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
2344
+ // Advisory only: a pending resume must be surfaced loudly, but it must
2345
+ // not hard-block an explicit start of a different task. Capture a
2346
+ // structured proposal so the resume-first guidance is explicit and
2347
+ // machine-readable for the host/agent.
2348
+ const resumeTask = selection.candidate;
2349
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
2350
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
2351
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
2352
+ ?? null;
2353
+ resumeProposal = buildResumeRequiredProposal({
2354
+ ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
2355
+ title: resumeTask.task.title,
2356
+ taskId: resumeTask.task.id,
2357
+ phaseId: resumeTask.phase.id,
2358
+ snapshot: resumeSnapshot
2359
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
2360
+ : null,
2361
+ });
2362
+ }
2363
+ // Assemble the mandatory parent context before changing task lifecycle state.
2364
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
2365
+ st.loadPhaseWithRequirements(found.phase.id),
2366
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
2367
+ ]);
2368
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
2369
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
2370
+ // archived only when the phase completes (auto) or the user explicitly
2371
+ // clears it.
2372
+ const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
2373
+ ? selection.kind === "resume"
2374
+ ? `\n\n${(() => {
2375
+ if (resumeProposal)
2376
+ return resumeProposal.text;
2377
+ return `⚠️ Resume advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`;
2378
+ })()}`
2379
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
2380
+ : "";
2381
+ const timestamp = nowISO();
2382
+ let updatedTask;
2383
+ if (found.task.pauseSnapshot) {
2384
+ updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp, plannerSessionId);
2385
+ }
2386
+ else {
2387
+ await st.updatePhase(found.phase.id, (phase) => {
2388
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
2389
+ if (!task)
2390
+ return phase;
2391
+ const previousStatus = task.status;
2392
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2393
+ const entry = {
2394
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
2395
+ date: timestamp,
2396
+ fromStatus: previousStatus,
2397
+ toStatus: "in-progress",
2398
+ title: `${previousStatus} → in-progress`,
2399
+ description: "",
2400
+ };
2401
+ task.activeOwnerSession = plannerSessionId;
2402
+ task.statusLog = [...(task.statusLog ?? []), entry];
2403
+ task.updatedAt = timestamp;
2404
+ phase.updatedAt = timestamp;
2405
+ updatedTask = task;
1548
2406
  return phase;
1549
- const previousStatus = task.status;
1550
- applyTaskLifecycleDates(task, "in-progress", timestamp);
1551
- const entry = {
1552
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
1553
- date: timestamp,
1554
- fromStatus: previousStatus,
1555
- toStatus: "in-progress",
1556
- title: `${previousStatus} → in-progress`,
1557
- description: "",
1558
- };
1559
- task.statusLog = [...(task.statusLog ?? []), entry];
1560
- task.updatedAt = timestamp;
1561
- phase.updatedAt = timestamp;
1562
- updatedTask = task;
1563
- return phase;
2407
+ });
2408
+ }
2409
+ await st.syncTaskStatusRollup(found.phase.id);
2410
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
2411
+ if (approvedDeviation)
2412
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
2413
+ const resumedDeviation = project.workDeviations
2414
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
2415
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2416
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2417
+ if (resumedDeviation)
2418
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2419
+ await st.writeGenerated();
2420
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
2421
+ if (!updatedTask || persistedTask?.status !== "in-progress") {
2422
+ 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 }));
2423
+ }
2424
+ const outcome = taskStartSucceeded(persistedTask.id);
2425
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
2426
+ ...outcome,
2427
+ task: persistedTask,
2428
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1564
2429
  });
1565
- }
1566
- await st.syncTaskStatusRollup(found.phase.id);
1567
- const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1568
- if (approvedDeviation)
1569
- await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
1570
- const resumedDeviation = project.workDeviations
1571
- .filter((deviation) => deviation.resumeTaskId === found.task.id
1572
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
1573
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1574
- if (resumedDeviation)
1575
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1576
- await st.writeGenerated();
1577
- const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
1578
- if (!updatedTask || persistedTask?.status !== "in-progress") {
1579
- 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 }));
1580
- }
1581
- const outcome = taskStartSucceeded(persistedTask.id);
1582
- return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
1583
- ...outcome,
1584
- task: persistedTask,
1585
- ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1586
2430
  });
1587
2431
  });
1588
2432
  server.registerTool("planner-task-complete", {
@@ -1658,47 +2502,415 @@ server.registerTool("planner-task-complete", {
1658
2502
  return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
1659
2503
  });
1660
2504
  server.registerTool("planner-handoff-list", {
1661
- description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
1662
- }, async () => {
2505
+ description: "List active handoffs as compact summary-only pages. Full bodies are never embedded; call planner-handoff-show for one exact phase.",
2506
+ inputSchema: {
2507
+ page: z.number().int().min(1).default(1).describe("1-based page number."),
2508
+ pageSize: z.number().int().min(1).max(25).default(10).describe("Summaries per page; maximum 25."),
2509
+ },
2510
+ }, async ({ page, pageSize }) => {
1663
2511
  const st = await requireStore();
1664
2512
  const list = await st.listHandoffs();
1665
2513
  if (list.length === 0)
1666
- return text("No phase handoffs set.");
1667
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
1668
- return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
2514
+ return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
2515
+ const result = boundedPage(list, page, pageSize);
2516
+ const { items, ...pageInfo } = result;
2517
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
2518
+ return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
1669
2519
  });
1670
2520
  server.registerTool("planner-handoff-show", {
1671
- description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
2521
+ description: "Read the active entity-scoped handoff of a phase, or its latest terminal archive, together with a bounded priority-ordered phase work map. Reread the canonical phase and relevant sibling tasks before proposing work so capability ownership is not duplicated. phaseRef is required; never infer a phase from in-progress status or stale context.",
1672
2522
  inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
1673
2523
  }, async ({ phaseRef }) => {
1674
2524
  const st = await requireStore();
1675
2525
  const r = await resolvePhaseForHandoff(st, phaseRef);
1676
2526
  if (!r.ok)
1677
2527
  return text(`❌ ${r.error}`);
1678
- const content = await st.getPhaseHandoff(r.phase.id);
1679
- if (!content.trim())
1680
- return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, empty: true });
1681
- return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
2528
+ const phase = await st.loadPhase(r.phase.id);
2529
+ const bounded = boundedHandoffForTransport(phase.handoff);
2530
+ if (!bounded.content.trim()) {
2531
+ const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
2532
+ && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
2533
+ if (archived) {
2534
+ const archivedContent = boundedHandoffForTransport(archived.content);
2535
+ const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
2536
+ const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
2537
+ return text([
2538
+ `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
2539
+ ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
2540
+ "",
2541
+ archivedContent.content,
2542
+ ].join("\n"), {
2543
+ phaseRef: r.compositeRef,
2544
+ phaseId: phase.id,
2545
+ active: false,
2546
+ archived: true,
2547
+ archiveReason: archived.reason,
2548
+ archivedAt: archived.archivedAt,
2549
+ archiveFile: archived.file,
2550
+ ...archivedContent,
2551
+ supportingDocuments,
2552
+ empty: false,
2553
+ resumeReady: false,
2554
+ handoffAudit: phase.handoffAudit,
2555
+ });
2556
+ }
2557
+ return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
2558
+ }
2559
+ const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
2560
+ const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
2561
+ const contentHash = handoffContentHash(phase.handoff);
2562
+ const persistenceVerified = Boolean(phase.handoffAudit
2563
+ && phase.handoffAudit.contentHash === contentHash
2564
+ && phase.handoffAudit.contentLength === phase.handoff.length);
2565
+ const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
2566
+ const status = resumeReady
2567
+ ? "Resume-ready: persisted read-back and source reconciliation completed."
2568
+ : "NOT resume-ready: after reading this entire persisted body, call planner-handoff-verify with its contentHash and a fresh comparison against every required source. If any gap exists, rewrite instead of verifying.";
2569
+ 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}`, {
2570
+ phaseRef: r.compositeRef,
2571
+ phaseId: r.phase.id,
2572
+ phaseWorkMap,
2573
+ ...bounded,
2574
+ contentHash,
2575
+ persistenceVerified,
2576
+ resumeReady,
2577
+ verificationRequired: !resumeReady,
2578
+ handoffAudit: phase.handoffAudit,
2579
+ });
2580
+ });
2581
+ server.registerTool("planner-handoff-verify", {
2582
+ description: "Finalize one persisted handoff as resume-ready only after planner-handoff-show has displayed the entire body and the agent has compared it again with conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer output. Any discovered omission blocks verification and requires prepare+rewrite.",
2583
+ inputSchema: {
2584
+ phaseRef: z.string().min(1),
2585
+ expectedContentHash: z.string().regex(/^[a-f0-9]{64}$/),
2586
+ sourceReviews: z.array(z.object({
2587
+ source: z.enum(["conversation", "planner-entities", "working-tree", "verification-runtime", "peer-agent-output"]),
2588
+ detail: z.string().min(12),
2589
+ })),
2590
+ omissionsFound: z.array(z.string().min(1)),
2591
+ },
2592
+ }, async ({ phaseRef, expectedContentHash, sourceReviews, omissionsFound }) => {
2593
+ const st = await requireStore();
2594
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2595
+ if (!r.ok)
2596
+ return text(`❌ ${r.error}`, { resumeReady: false });
2597
+ try {
2598
+ const result = await st.verifyPhaseHandoffReadBack(r.phase.id, { expectedContentHash, sourceReviews, omissionsFound });
2599
+ await st.writeGenerated();
2600
+ return text(`✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.`, {
2601
+ phaseRef: r.compositeRef,
2602
+ ...result,
2603
+ resumeReady: true,
2604
+ verificationRequired: false,
2605
+ });
2606
+ }
2607
+ catch (error) {
2608
+ if (error instanceof HandoffContractError)
2609
+ return handoffContractFailure(error);
2610
+ const message = error instanceof Error ? error.message : String(error);
2611
+ return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
2612
+ }
1682
2613
  });
1683
2614
  server.registerTool("planner-requirement-list", {
1684
- description: "List all top-level requirements in requirements.json. Reading this list records the requirements as read for the start/resume read-enforcement (explicit requirement read, required alongside feature/phase reads).",
1685
- inputSchema: {},
1686
- }, async (_args, extra) => {
2615
+ description: "List declarative product Requirements: user, business, or system outcomes. Requirements never contain coding standards, best practices, formatting, verification process, or other Project Guidelines. Pass phaseRef to deliver and attest only the Requirements linked to that phase or its owning feature; an unscoped inventory does not satisfy task-start read gates.",
2616
+ inputSchema: {
2617
+ phaseRef: z.string().min(1).optional().describe("Optional target phase ref. When supplied, return full target-relevant requirements and record only those delivered requirements as read."),
2618
+ },
2619
+ }, async ({ phaseRef }, extra) => {
1687
2620
  const plannerSessionId = plannerSessionIdFor(extra);
1688
2621
  const st = await requireStore();
1689
2622
  const requirements = await st.loadRequirements();
1690
- requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1691
- return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
2623
+ if (!phaseRef?.trim()) {
2624
+ return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements", {
2625
+ requirements,
2626
+ attestedRequirementIds: [],
2627
+ readScope: "inventory",
2628
+ });
2629
+ }
2630
+ const features = (await st.loadFeatures()).features;
2631
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, phaseRef.trim());
2632
+ if (!phase)
2633
+ return { ...text(`Phase not found: ${phaseRef}`, { attestedRequirementIds: [] }), isError: true };
2634
+ const delivered = [
2635
+ ...(await st.linkedRequirementsForPhase(phase.id)),
2636
+ ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2637
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2638
+ delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
2639
+ const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2640
+ const body = delivered.map((requirement) => [
2641
+ `- ${requirement.id} — ${requirement.title}`,
2642
+ ` Description: ${requirement.description || "None"}`,
2643
+ ` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
2644
+ ` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
2645
+ ].join("\n")).join("\n");
2646
+ return text(body || `No requirements linked to ${targetRef}`, {
2647
+ requirements: { ...requirements, requirements: delivered },
2648
+ attestedRequirementIds: delivered.map((requirement) => requirement.id),
2649
+ readScope: "target",
2650
+ target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
2651
+ });
2652
+ });
2653
+ server.registerTool("planner-requirement-create", {
2654
+ 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
+ inputSchema: {
2656
+ title: z.string().min(1),
2657
+ description: z.string().optional(),
2658
+ linkedPhaseIds: z.array(z.string().min(1)),
2659
+ macroTasks: z.array(z.object({
2660
+ title: z.string().min(1),
2661
+ description: z.string().optional(),
2662
+ status: z.enum(STATUS_VALUES).optional(),
2663
+ })).optional(),
2664
+ },
2665
+ }, async ({ title, description, linkedPhaseIds, macroTasks }) => {
2666
+ const st = await requireStore();
2667
+ const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2668
+ if (!links.ok)
2669
+ return text(`❌ ${links.error}`, { created: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2670
+ const now = nowISO();
2671
+ let normalizedMacroTasks;
2672
+ try {
2673
+ normalizedMacroTasks = reconcileRequirementMacroTasks([], (macroTasks ?? []).map((task) => ({
2674
+ title: task.title,
2675
+ ...(task.description !== undefined ? { description: task.description } : {}),
2676
+ status: task.status ?? "planned",
2677
+ })), now);
2678
+ }
2679
+ catch (error) {
2680
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2681
+ return text(`❌ ${message}`, { created: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2682
+ }
2683
+ const requirement = {
2684
+ id: createRequirementId(),
2685
+ title: title.trim(),
2686
+ description: description?.trim() ?? "",
2687
+ macroTasks: normalizedMacroTasks,
2688
+ linkedPhaseIds: links.linkedPhaseIds,
2689
+ createdAt: now,
2690
+ updatedAt: now,
2691
+ sessionInfo: [],
2692
+ };
2693
+ await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
2694
+ await st.writeGenerated();
2695
+ return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
2696
+ });
2697
+ server.registerTool("planner-requirement-update", {
2698
+ description: "Update a Requirement’s product outcome, linked phases, or complete ordered macro-task list. 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. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
2699
+ inputSchema: {
2700
+ requirementId: z.string().min(1),
2701
+ title: z.string().min(1).optional(),
2702
+ description: z.string().optional(),
2703
+ linkedPhaseIds: z.array(z.string().min(1)).optional(),
2704
+ macroTasks: z.array(z.object({
2705
+ id: z.string().min(1).optional(),
2706
+ title: z.string().min(1),
2707
+ description: z.string().optional(),
2708
+ status: z.enum(STATUS_VALUES),
2709
+ })).optional(),
2710
+ },
2711
+ }, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
2712
+ const st = await requireStore();
2713
+ const requirements = await st.loadRequirements();
2714
+ const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
2715
+ if (!current)
2716
+ return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
2717
+ if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
2718
+ return mutationNoFieldsFailure({
2719
+ entity: "requirement",
2720
+ ref: requirementId,
2721
+ operation: "update",
2722
+ mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
2723
+ retryCommand: `planner-requirement-update ${requirementId}`,
2724
+ });
2725
+ }
2726
+ const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2727
+ if (!links.ok)
2728
+ return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2729
+ const now = nowISO();
2730
+ let next;
2731
+ try {
2732
+ next = await st.updateRequirement(current.id, (canonical) => ({
2733
+ ...canonical,
2734
+ ...(title !== undefined ? { title: title.trim() } : {}),
2735
+ ...(description !== undefined ? { description: description.trim() } : {}),
2736
+ ...(linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
2737
+ ...(macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, macroTasks.map((task) => ({
2738
+ ...(task.id !== undefined ? { id: task.id } : {}),
2739
+ title: task.title,
2740
+ ...(task.description !== undefined ? { description: task.description } : {}),
2741
+ status: task.status,
2742
+ })), now) } : {}),
2743
+ updatedAt: now,
2744
+ }));
2745
+ }
2746
+ catch (error) {
2747
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2748
+ return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2749
+ }
2750
+ await st.writeGenerated();
2751
+ return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
2752
+ });
2753
+ server.registerTool("planner-requirement-delete", {
2754
+ description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
2755
+ inputSchema: { requirementId: z.string().min(1) },
2756
+ }, async ({ requirementId }) => {
2757
+ const st = await requireStore();
2758
+ let deleted = false;
2759
+ await st.updateRequirements((document) => {
2760
+ const requirements = document.requirements.filter((requirement) => requirement.id !== requirementId);
2761
+ deleted = requirements.length !== document.requirements.length;
2762
+ return deleted ? { requirements } : document;
2763
+ });
2764
+ if (!deleted)
2765
+ return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
2766
+ await st.writeGenerated();
2767
+ return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
2768
+ });
2769
+ function ideaPromotionTarget(st, type, ref) {
2770
+ return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
2771
+ const features = featuresDoc.features;
2772
+ if (type === "feature") {
2773
+ const feature = findFeatureByRef(features, ref);
2774
+ return feature ? { id: feature.id, ref: formatFeatureRef(feature.number) } : undefined;
2775
+ }
2776
+ if (type === "phase") {
2777
+ const phase = findPhaseByRef(phases, features, ref);
2778
+ return phase ? { id: phase.id, ref: formatPhaseRef(phase.number, features.find((item) => item.id === phase.featureId)?.number) } : undefined;
2779
+ }
2780
+ const found = findTaskByRef(phases, features, ref);
2781
+ return found ? { id: found.task.id, ref: `${formatPhaseRef(found.phase.number, features.find((item) => item.id === found.phase.featureId)?.number)}/T${String(found.task.number).padStart(3, "0")}` } : undefined;
2782
+ });
2783
+ }
2784
+ server.registerTool("planner-idea-list", {
2785
+ description: "List top-level Ideas Inbox entries. Ideas never affect feature, phase, or task rollups.",
2786
+ inputSchema: {},
2787
+ }, async () => {
2788
+ const ideas = (await requireStore().then((st) => st.loadIdeas())).ideas;
2789
+ return text(ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas", { ideas });
2790
+ });
2791
+ server.registerTool("planner-idea-show", {
2792
+ description: "Show one Idea Inbox entry by I-number, short ID, UUID, or title.",
2793
+ inputSchema: { idea: z.string().min(1) },
2794
+ }, async ({ idea: ref }) => {
2795
+ const st = await requireStore();
2796
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2797
+ return idea ? text(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}`, { idea }) : text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
2798
+ });
2799
+ server.registerTool("planner-idea-create", {
2800
+ description: "Create a rollup-independent Ideas Inbox entry.",
2801
+ inputSchema: { title: z.string().min(1), description: z.string().optional() },
2802
+ }, async ({ title, description }) => {
2803
+ const st = await requireStore();
2804
+ const idea = await st.createIdea({ title, ...(description !== undefined ? { description } : {}) });
2805
+ await st.writeGenerated();
2806
+ return text(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, { idea, created: true });
2807
+ });
2808
+ server.registerTool("planner-idea-update", {
2809
+ description: "Update an unpromoted or promoted Idea Inbox entry title or description.",
2810
+ inputSchema: { idea: z.string().min(1), title: z.string().min(1).optional(), description: z.string().optional() },
2811
+ }, async ({ idea: ref, title, description }) => {
2812
+ const st = await requireStore();
2813
+ const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2814
+ if (!current)
2815
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
2816
+ if (title === undefined && description === undefined) {
2817
+ return mutationNoFieldsFailure({
2818
+ entity: "idea",
2819
+ ref,
2820
+ operation: "update",
2821
+ mutableFields: ["title", "description"],
2822
+ retryCommand: `planner-idea-update ${ref}`,
2823
+ });
2824
+ }
2825
+ const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
2826
+ await st.writeGenerated();
2827
+ return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
2828
+ });
2829
+ server.registerTool("planner-idea-delete", {
2830
+ description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
2831
+ inputSchema: { idea: z.string().min(1), confirmed: z.boolean() },
2832
+ }, async ({ idea: ref, confirmed }) => {
2833
+ if (!confirmed)
2834
+ return text("Idea deletion requires confirmed=true after explicit user confirmation.", { errorCode: "CONFIRMATION_REQUIRED", deleted: false });
2835
+ const st = await requireStore();
2836
+ const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2837
+ if (!current)
2838
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", deleted: false });
2839
+ await st.deleteIdea(current.id);
2840
+ await st.writeGenerated();
2841
+ return text(`Idea deleted: ${formatIdeaRef(current.number)}.`, { deleted: true, ideaRef: formatIdeaRef(current.number) });
2842
+ });
2843
+ server.registerTool("planner-idea-promotion-begin", {
2844
+ description: "Begin an Idea promotion discussion without persisting a target or promotion. Returns the project-local grill-me instructions; follow them one question at a time, then use planner-idea-promotion-finalize only after target confirmation.",
2845
+ inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]) },
2846
+ }, async ({ idea: ref, targetType }) => {
2847
+ const st = await requireStore();
2848
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2849
+ if (!idea)
2850
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
2851
+ if (idea.promotion)
2852
+ return text(`${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.`, { errorCode: "ALREADY_PROMOTED", idea });
2853
+ const features = (await st.loadFeatures()).features;
2854
+ const phases = await st.loadAllPhases();
2855
+ const recommendedFeature = features[0];
2856
+ const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
2857
+ const recommendation = targetType === "feature"
2858
+ ? "Create a new feature after the discussion."
2859
+ : targetType === "phase"
2860
+ ? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
2861
+ : recommendedPhase
2862
+ ? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.`
2863
+ : "Create and confirm a parent feature and phase before promoting to a task.";
2864
+ return text(`Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}`, { idea, targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target through the normal feature/phase/task creation tool.", "Call planner-idea-promotion-finalize with discussionCompleted=true and confirmed=true."] });
2865
+ });
2866
+ server.registerTool("planner-idea-promotion-finalize", {
2867
+ description: "Record a promotion only after grill-me discussion, explicit target confirmation, and successful target creation. targetRef must be the already-created agreed target; no raw UUID is returned.",
2868
+ inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]), targetRef: z.string().min(1), discussionCompleted: z.boolean(), confirmed: z.boolean() },
2869
+ }, async ({ idea: ref, targetType, targetRef, discussionCompleted, confirmed }) => {
2870
+ if (!discussionCompleted || !confirmed)
2871
+ return text("Promotion requires discussionCompleted=true and confirmed=true; no target or promotion was persisted.", { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false });
2872
+ const st = await requireStore();
2873
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2874
+ if (!idea)
2875
+ return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", promoted: false });
2876
+ const target = await ideaPromotionTarget(st, targetType, targetRef);
2877
+ if (!target)
2878
+ return text(`Confirmed ${targetType} target not found: ${targetRef}; no promotion was persisted.`, { errorCode: "TARGET_NOT_FOUND", promoted: false });
2879
+ const promoted = await st.promoteIdea(idea.id, { targetType, targetId: target.id });
2880
+ await st.writeGenerated();
2881
+ return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
1692
2882
  });
1693
2883
  server.registerTool("planner-handoff-write", {
1694
- description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run planner-handoff-prepare with the confirmed phaseRef first and pass its exact handoffUpdatedAt token.",
2884
+ description: "Persist a reconciled handoff candidate with durable context synchronization and mandatory audits. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify source reconciliation succeeds. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
1695
2885
  inputSchema: {
1696
2886
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1697
2887
  title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
2888
+ 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."),
1698
2889
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
1699
2890
  content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
1700
2891
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
1701
2892
  reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
2893
+ completenessAudit: z.object({
2894
+ version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
2895
+ entries: z.array(z.object({
2896
+ category: z.string().min(1).describe(`One required category id: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}`),
2897
+ status: z.enum(["captured", "not-applicable"]),
2898
+ detail: z.string().min(1),
2899
+ })),
2900
+ }).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
2901
+ coldStartInventory: z.object({
2902
+ version: z.literal(HANDOFF_COLD_START_INVENTORY_VERSION),
2903
+ sourceReviews: z.array(z.object({ source: z.string().min(1), detail: z.string().min(1) })),
2904
+ entries: z.array(z.object({
2905
+ category: z.string().min(1).describe(`One required cold-start category id: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}`),
2906
+ items: z.array(z.string().min(1)),
2907
+ notApplicableReason: z.string().min(1).optional().describe("Optional explanation only; every category still requires a concrete item, using an explicit verified-negative statement when nothing exists."),
2908
+ })),
2909
+ }).optional().describe("Mandatory for confirmed writes. Every concrete item must appear verbatim in the handoff or a validated supporting document."),
2910
+ supportingDocuments: z.array(z.object({
2911
+ path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
2912
+ description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
2913
+ })).optional(),
1702
2914
  taskUpdates: z.array(z.object({
1703
2915
  taskRef: z.string().min(1),
1704
2916
  completionSummary: z.string().min(1),
@@ -1716,7 +2928,7 @@ server.registerTool("planner-handoff-write", {
1716
2928
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
1717
2929
  featureNoUpdateReason: z.string().min(1).optional(),
1718
2930
  },
1719
- }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2931
+ }, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
1720
2932
  const st = await requireStore();
1721
2933
  let body = content.trim();
1722
2934
  const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
@@ -1740,7 +2952,9 @@ server.registerTool("planner-handoff-write", {
1740
2952
  if (!confirmed)
1741
2953
  return text(`Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run planner-handoff-prepare with the exact phaseRef.`, { phaseRef: r.compositeRef, confirmationRequired: true });
1742
2954
  if (expectedHandoffUpdatedAt === undefined)
1743
- return text("❌ Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2955
+ return handoffPreflightFailure("HANDOFF_PREFLIGHT_REQUIRED", "Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2956
+ if (!reason?.trim())
2957
+ return handoffPreflightFailure("HANDOFF_REASON_REQUIRED", "Handoff reason is required before persistence. Created at and Updated at are generated by the planner; do not add them to Markdown.", { requiredInputs: ["title", "reason"], generatedMetadata: ["Created at", "Updated at"] });
1744
2958
  const phases = await st.loadAllPhases();
1745
2959
  const features = (await st.loadFeatures()).features;
1746
2960
  const resolvedTaskUpdates = [];
@@ -1759,9 +2973,13 @@ server.registerTool("planner-handoff-write", {
1759
2973
  }
1760
2974
  try {
1761
2975
  const result = await st.refreshPhaseHandoff(r.phase.id, {
2976
+ reason: reason.trim(),
1762
2977
  content: body,
1763
2978
  expectedHandoffUpdatedAt,
1764
2979
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2980
+ ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2981
+ ...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
2982
+ ...(supportingDocuments ? { supportingDocuments } : {}),
1765
2983
  contextSync: {
1766
2984
  taskUpdates: resolvedTaskUpdates,
1767
2985
  ...(phaseUpdate ? { phaseUpdate: {
@@ -1775,15 +2993,31 @@ server.registerTool("planner-handoff-write", {
1775
2993
  },
1776
2994
  });
1777
2995
  await st.writeGenerated();
1778
- return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result });
2996
+ return text(`⚠️ Handoff candidate persisted on ${r.compositeRef}, but it is NOT resume-ready yet. Required next action: call planner-handoff-show ${r.compositeRef}, read the entire persisted body, compare it again with every required source, then call planner-handoff-verify with the returned contentHash. If that review finds any gap, run prepare+write again instead of verifying.`, {
2997
+ phaseRef: r.compositeRef,
2998
+ phaseId: r.phase.id,
2999
+ updatedTaskIds: result.updatedTaskIds,
3000
+ handoffUpdatedAt: result.handoffUpdatedAt,
3001
+ handoffAudit: result.handoffAudit,
3002
+ persisted: true,
3003
+ resumeReady: false,
3004
+ verificationRequired: true,
3005
+ nextActions: [
3006
+ `Call planner-handoff-show ${r.compositeRef} and read the entire persisted body.`,
3007
+ "Compare the persisted body against conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer-agent output.",
3008
+ "If any omission exists, run planner-handoff-prepare and planner-handoff-write again; otherwise call planner-handoff-verify with the shown contentHash.",
3009
+ ],
3010
+ });
1779
3011
  }
1780
3012
  catch (error) {
3013
+ if (error instanceof HandoffContractError)
3014
+ return handoffContractFailure(error);
1781
3015
  const message = error instanceof Error ? error.message : String(error);
1782
3016
  return text(`❌ Handoff refresh denied: ${message}`, { error: message });
1783
3017
  }
1784
3018
  });
1785
3019
  server.registerTool("planner-handoff-prepare", {
1786
- description: "Identify or audit the exact handoff target. Without phaseRef, returns the confirmation workflow. With a confirmed phaseRef, returns the current handoff/version and done tasks missing durable completion evidence.",
3020
+ description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the canonical scaffold, a bounded priority-ordered phase work map with sibling goals/dependencies/capability ownership, the current token, missing task evidence, mandatory audits, and the 24,000-character inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
1787
3021
  inputSchema: { phaseRef: z.string().min(1).optional() },
1788
3022
  }, async ({ phaseRef }) => {
1789
3023
  if (!phaseRef)
@@ -1802,9 +3036,24 @@ server.registerTool("planner-handoff-prepare", {
1802
3036
  return text([
1803
3037
  `Handoff preparation audit for ${r.compositeRef}`,
1804
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
+ "",
1805
3045
  "Done tasks missing durable completion/verification evidence:",
1806
3046
  missing,
1807
3047
  "",
3048
+ `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
3049
+ ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3050
+ "",
3051
+ `Required cold-start source review v${audit.coldStartInventoryVersion} — inspect and substantively summarize every source before drafting:`,
3052
+ ...audit.coldStartSourceReviews.map((entry) => `- ${entry.id} — ${entry.label}`),
3053
+ "Required cold-start inventory — every category needs a concrete item (use an explicit verified-negative statement when nothing exists), and every item must appear verbatim in the handoff or a validated supporting document:",
3054
+ ...audit.coldStartInventoryCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3055
+ `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
3056
+ "",
1808
3057
  "Existing active handoff (reconcile all still-relevant content):",
1809
3058
  audit.handoff.trim() || "(none)",
1810
3059
  ].join("\n"), { phaseRef: r.compositeRef, ...audit });
@@ -1833,47 +3082,93 @@ server.registerTool("planner-web", {
1833
3082
  }, async ({ action }) => {
1834
3083
  if (action === "start") {
1835
3084
  if (webHandle) {
1836
- return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
3085
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3086
+ return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
1837
3087
  }
1838
3088
  const root = planRoot();
1839
3089
  const storeCheck = new PlanStore(root);
1840
3090
  if (!(await storeCheck.exists())) {
1841
- return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`);
3091
+ return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "", errorCode: "PLANNER_NOT_INITIALIZED" });
1842
3092
  }
1843
3093
  try {
1844
3094
  // port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
1845
3095
  // host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
1846
3096
  webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
1847
- return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
3097
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3098
+ return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
1848
3099
  }
1849
3100
  catch (err) {
1850
3101
  webHandle = null;
1851
- return text(`planner-web start failed: ${err.message}`);
3102
+ const web = { localUrl: "", error: err.message };
3103
+ return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
1852
3104
  }
1853
3105
  }
1854
3106
  if (action === "stop") {
1855
3107
  if (!webHandle)
1856
- return text("planner-web not running.");
3108
+ return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
1857
3109
  const stoppedUrl = webHandle.localUrl;
1858
3110
  await webHandle.close().catch(() => { });
1859
3111
  webHandle = null;
1860
- return text(`planner-web stopped (was ${stoppedUrl}).`);
3112
+ return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
1861
3113
  }
1862
3114
  // status
1863
3115
  if (webHandle) {
1864
- return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`);
3116
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3117
+ return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`, plannerWebMetadata(web));
1865
3118
  }
1866
- return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
3119
+ 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: "" });
1867
3120
  });
1868
3121
  server.registerTool("planner-load", {
1869
- description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap verbatim in that reply, including its final prominent Web UI line. A pending handoff is read-only context: NEVER call planner-handoff-show or planner-handoff-clear as part of load/recap. Archive it only when every phase task is done/canceled, when replacing it with a new handoff, or after an explicit user handoff-clear request. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
1870
- }, async () => {
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) => {
1871
3124
  const st = store();
1872
3125
  if (!(await st.exists()))
1873
3126
  return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
3127
+ const plannerSessionId = plannerSessionIdFor(extra);
3128
+ let preparation;
3129
+ try {
3130
+ preparation = await st.preparePlannerSession();
3131
+ }
3132
+ catch (error) {
3133
+ return text(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", loaded: false });
3134
+ }
3135
+ const project = preparation.project;
3136
+ if (project.projectGuidelines.content.trim()) {
3137
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
3138
+ }
3139
+ const plannerSkill = await st.syncPlannerSkill();
3140
+ await st.syncGrillMeSkill();
1874
3141
  const web = await ensureWebStarted();
1875
3142
  const recap = await buildRecap(st, web, { harness: "mcp" });
1876
- return text(recap);
3143
+ const plan = await st.loadAll();
3144
+ const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
3145
+ { scope: "project", decisions: plan.project.acceptedDecisions },
3146
+ ...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
3147
+ ...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
3148
+ ...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
3149
+ scope: `task ${taskCompositeRef(task, phase, plan.features.features)}`,
3150
+ decisions: task.acceptedDecisions,
3151
+ }))),
3152
+ ]);
3153
+ const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
3154
+ return {
3155
+ content: [{ type: "text", text: visibleRecap }],
3156
+ structuredContent: {
3157
+ loaded: true,
3158
+ recap: { text: visibleRecap },
3159
+ webUi: plannerWebMetadata(web),
3160
+ preparation: preparation.legacyProjectContext,
3161
+ agentContext: {
3162
+ kind: "planner-usage-skill",
3163
+ content: plannerSkill.content,
3164
+ status: plannerSkill.status,
3165
+ customized: plannerSkill.customized,
3166
+ 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.",
3168
+ acceptedDecisions: acceptedDecisionContext,
3169
+ },
3170
+ },
3171
+ };
1877
3172
  });
1878
3173
  server.registerTool("planner-disable", {
1879
3174
  description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",