@agent-plan/mcp 0.2.26 → 0.2.28

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,7 +5,7 @@ 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, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, HandoffContractError } from "@agent-plan/core";
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
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"];
@@ -28,6 +28,9 @@ function handoffContractFailure(error) {
28
28
  structuredContent: { errorCode: error.code, ...error.details },
29
29
  };
30
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
+ }
31
34
  function boundedHandoffForTransport(content) {
32
35
  if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
33
36
  return { content, fullLength: content.length, truncated: false };
@@ -89,6 +92,15 @@ function mutationNoFieldsFailure(input) {
89
92
  structuredContent: { ...outcome },
90
93
  };
91
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
+ }
92
104
  function legacyContextMigrationSummary(preview) {
93
105
  return [
94
106
  `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
@@ -119,10 +131,12 @@ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
119
131
  return [`planner-task-show ${refs.task} with full=true`];
120
132
  if (read.kind === "phase")
121
133
  return [`planner-phase-show ${refs.phase} with full=true`];
122
- return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
134
+ if (read.kind === "feature")
135
+ return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
136
+ return [];
123
137
  }));
124
138
  if (requirementReadRequired)
125
- actions.push("planner-requirement-list");
139
+ actions.push(`planner-requirement-list with phaseRef=${refs.phase}`);
126
140
  actions.push(retryAction);
127
141
  return actions;
128
142
  }
@@ -150,21 +164,44 @@ async function requireStore() {
150
164
  // In-process web server handle, managed by the planner-web tool and planner-load.
151
165
  // Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
152
166
  let webHandle = null;
153
- /** Start the web dashboard on LAN (0.0.0.0:0, OS-assigned port) if not already
154
- * 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
+ }
155
192
  async function ensureWebStarted() {
156
193
  if (webHandle)
157
- return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
194
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
158
195
  const root = planRoot();
159
196
  const st = new PlanStore(root);
160
197
  if (!(await st.exists()))
161
- return { localUrl: "" };
198
+ return { localUrl: "", error: "Planner workspace does not exist." };
162
199
  try {
163
200
  webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
164
- return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
201
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
165
202
  }
166
- catch {
167
- return { localUrl: "" };
203
+ catch (error) {
204
+ return { localUrl: "", error: error instanceof Error ? error.message : String(error) };
168
205
  }
169
206
  }
170
207
  function findFeatureByRef(features, ref) {
@@ -219,6 +256,33 @@ function featureNumberOfPhase(phase, features) {
219
256
  function taskCompositeRef(task, phase, features) {
220
257
  return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
221
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
+ }
222
286
  function applyTaskLifecycleDates(task, nextStatus, now) {
223
287
  const previousStatus = task.status;
224
288
  if (nextStatus === "in-progress" && !task.startedAt)
@@ -275,17 +339,22 @@ async function resolvePhaseForHandoff(st, ref) {
275
339
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
276
340
  }
277
341
  server.registerTool("planner-version", {
278
- 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.",
279
343
  }, async () => {
344
+ const packages = runtimePackagesDiagnostic([MCP_PACKAGE, CORE_PACKAGE]);
345
+ const capabilities = runtimeCapabilities();
280
346
  const versions = {
281
347
  [MCP_PACKAGE.name]: MCP_PACKAGE.version,
282
348
  [CORE_PACKAGE.name]: CORE_PACKAGE.version,
283
349
  };
284
350
  return text([
285
- "Agent Plan runtime versions",
286
- `${MCP_PACKAGE.name}: ${MCP_PACKAGE.version}`,
287
- `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
288
- ].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 });
289
358
  });
290
359
  server.registerTool("planner-export", {
291
360
  description: "Export the project plan as a Markdown report. Supports a concise summary or full hierarchical detail.",
@@ -340,13 +409,26 @@ server.registerTool("planner-init", {
340
409
  return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
341
410
  });
342
411
  server.registerTool("planner-show", {
343
- 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.",
344
413
  }, async () => {
345
414
  const st = await requireStore();
346
415
  const features = (await st.loadFeatures()).features;
347
416
  const phases = await st.loadAllPhases();
348
- 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";
349
430
  const project = await st.loadProject();
431
+ const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: project.acceptedDecisions }]);
350
432
  const manifest = await st.loadManifest();
351
433
  const featureLines = features.map((feature) => {
352
434
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
@@ -358,7 +440,11 @@ server.registerTool("planner-show", {
358
440
  `Description: ${project.description || "(not set)"}`,
359
441
  ...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
360
442
  `Goal: ${project.goal || "(not set)"}`,
443
+ projectDecisionContext.content,
361
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")}`,
362
448
  `Updated: ${manifest.updatedAt || "(unknown)"}`,
363
449
  "",
364
450
  "Features:",
@@ -366,8 +452,17 @@ server.registerTool("planner-show", {
366
452
  ].join("\n");
367
453
  // Compact structured overview (no full plan: avoids 1M+ char token overflow).
368
454
  const overview = {
369
- project: { name: project.name, description: project.description || null, descriptionRef: project.descriptionRef || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
370
- 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,
371
466
  features: features.map((feature) => ({
372
467
  ref: formatFeatureRef(feature.number),
373
468
  shortId: feature.shortId || null,
@@ -379,6 +474,16 @@ server.registerTool("planner-show", {
379
474
  };
380
475
  return text(summary, { overview });
381
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
+ });
382
487
  server.registerTool("planner-repair", {
383
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.",
384
489
  }, async () => {
@@ -421,12 +526,11 @@ server.registerTool("planner-project-language", {
421
526
  },
422
527
  }, async ({ contentLanguage, chatLanguage }) => {
423
528
  const st = await requireStore();
424
- const project = await st.loadProject();
425
- if (contentLanguage !== undefined)
426
- project.contentLanguage = contentLanguage.trim();
427
- if (chatLanguage !== undefined)
428
- project.chatLanguage = chatLanguage.trim();
429
- 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
+ }));
430
534
  return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
431
535
  });
432
536
  server.registerTool("planner-project-discuss", {
@@ -485,8 +589,28 @@ server.registerTool("planner-project-discuss", {
485
589
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
486
590
  if (params.decisions !== undefined)
487
591
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
488
- await st.saveProject(project);
489
- return writeAndSummarize(st, `Project discussed/updated: ${project.name}. Fields saved: ${receivedFields.join(", ")}.`, { project, discussed: true, updatedFields: receivedFields });
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 });
490
614
  });
491
615
  server.registerTool("planner-project-guidelines-show", {
492
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.",
@@ -511,12 +635,10 @@ server.registerTool("planner-project-guidelines-update", {
511
635
  }, async ({ content }) => {
512
636
  const st = await requireStore();
513
637
  const project = await st.loadProject();
514
- project.projectGuidelines = {
515
- content: content.trim(),
516
- updatedAt: project.projectGuidelines.updatedAt,
517
- sessionInfo: project.projectGuidelines.sessionInfo,
518
- };
519
- await st.saveProject(project);
638
+ await st.updateProject((current) => ({
639
+ ...current,
640
+ projectGuidelines: { ...current.projectGuidelines, content: content.trim() },
641
+ }), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
520
642
  const refreshed = await st.loadProject();
521
643
  return writeAndSummarize(st, refreshed.projectGuidelines.content
522
644
  ? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
@@ -634,6 +756,123 @@ server.registerTool("planner-task-list", {
634
756
  const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
635
757
  return text(out.join("\n") || "No tasks", { tasks: items });
636
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
+ }
875
+ });
637
876
  server.registerTool("planner-feature-add", {
638
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.",
639
878
  inputSchema: {
@@ -643,44 +882,46 @@ server.registerTool("planner-feature-add", {
643
882
  },
644
883
  }, async ({ name, description, status }) => {
645
884
  const st = await requireStore();
646
- const timestamp = nowISO();
647
- const effectiveStatus = status ?? "planned";
648
- const existingFeatures = (await st.loadFeatures()).features;
649
- const id = createFeatureId();
650
- const identity = await st.allocateEntityIdentity("feature", id);
651
- const priority = await st.nextPriority("feature");
652
- const feature = {
653
- id,
654
- number: identity.number,
655
- shortId: identity.shortId,
656
- priority,
657
- name: name.trim(),
658
- description: description?.trim() ?? "",
659
- descriptionUpdatedAt: timestamp,
660
- status: effectiveStatus,
661
- discussedAt: "",
662
- contextReady: false,
663
- contextReadyReason: "",
664
- startDate: effectiveStatus === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
665
- endDate: effectiveStatus === "done" ? new Date().toISOString().slice(0, 10) : "",
666
- workDone: "",
667
- workRemaining: "",
668
- acceptedDecisions: [],
669
- phaseIds: [],
670
- dependsOn: [],
671
- statusLog: [],
672
- sessionInfo: [],
673
- createdAt: timestamp,
674
- updatedAt: timestamp,
675
- };
676
- await st.updateFeatures((doc) => {
677
- doc.features.push(feature);
678
- 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}` : ""}`);
679
921
  });
680
- return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
681
922
  });
682
923
  server.registerTool("planner-feature-show", {
683
- 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.",
684
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).") },
685
926
  }, async ({ feature: ref, full }, extra) => {
686
927
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -690,12 +931,22 @@ server.registerTool("planner-feature-show", {
690
931
  if (!resolvedFeature.ok)
691
932
  return text(resolvedFeature.error);
692
933
  const feature = resolvedFeature.feature;
693
- if (full)
694
- markFeatureReadForSessionId(plannerSessionId, feature.id);
695
934
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
696
935
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
697
936
  const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
698
- return text(full ? `${summary}\n\n${feature.description || ""}${descriptionRefBlock}` : summary, full ? { feature: { ref: formatFeatureRef(feature.number), description: feature.description, descriptionRef: feature.descriptionRef || "" } } : undefined);
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;
699
950
  });
700
951
  server.registerTool("planner-feature-discuss", {
701
952
  description: "Persist feature discovery/governance fields and mark the feature context as ready.",
@@ -848,20 +1099,20 @@ server.registerTool("planner-feature-delete", {
848
1099
  return text(resolvedFeature.error);
849
1100
  const feature = resolvedFeature.feature;
850
1101
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
851
- await st.updateFeatures((doc) => {
852
- doc.features = doc.features.filter((entry) => entry.id !== feature.id);
853
- return doc;
854
- });
855
- for (const phase of phases) {
856
- if (cascade) {
857
- await st.deletePhase(phase.id);
858
- }
859
- else {
860
- phase.featureId = undefined;
861
- phase.updatedAt = nowISO();
862
- 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
+ }
863
1114
  }
864
- }
1115
+ });
865
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) });
866
1117
  });
867
1118
  server.registerTool("planner-phase-add", {
@@ -888,7 +1139,7 @@ server.registerTool("planner-phase-add", {
888
1139
  return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
889
1140
  const lockKey = feature?.id ?? "__unscoped__";
890
1141
  let phase;
891
- await withFeatureLock(lockKey, async () => {
1142
+ await st.runBatch(() => withFeatureLock(lockKey, async () => {
892
1143
  const phases = await st.loadAllPhases();
893
1144
  const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
894
1145
  const timestamp = nowISO();
@@ -942,13 +1193,13 @@ server.registerTool("planner-phase-add", {
942
1193
  });
943
1194
  }
944
1195
  await st.writeGenerated();
945
- });
1196
+ }));
946
1197
  if (!phase)
947
1198
  return text("Phase creation failed.");
948
1199
  return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
949
1200
  });
950
1201
  server.registerTool("planner-phase-show", {
951
- 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.",
952
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).") },
953
1204
  }, async ({ phase: ref, full }, extra) => {
954
1205
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -957,16 +1208,27 @@ server.registerTool("planner-phase-show", {
957
1208
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
958
1209
  if (!phase)
959
1210
  return text(`Phase not found: ${ref}`);
960
- if (full)
961
- markPhaseReadForSessionId(plannerSessionId, phase.id);
962
1211
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
963
1212
  const reqCount = linkedRequirements.length;
964
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"}` : ""})`;
965
1214
  const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
966
1215
  const requirementsBlock = reqCount > 0
967
- ? `\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")}`
968
1217
  : "";
969
- return text(full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${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}`, {
970
1232
  phase: {
971
1233
  ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
972
1234
  shortId: phase.shortId,
@@ -974,22 +1236,28 @@ server.registerTool("planner-phase-show", {
974
1236
  summary: phase.summary,
975
1237
  status: phase.status,
976
1238
  taskCount: phase.tasks.length,
977
- ...(full ? { description: phase.description, descriptionRef: phase.descriptionRef || "" } : {}),
1239
+ description: phase.description,
1240
+ descriptionRef: phase.descriptionRef || "",
1241
+ acceptedDecisions: phase.acceptedDecisions,
978
1242
  },
979
1243
  linkedRequirements,
980
1244
  });
1245
+ markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
1246
+ return result;
981
1247
  });
982
1248
  server.registerTool("planner-phase-discuss", {
983
- description: "Persist phase discovery fields and mark phase planned.",
1249
+ description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
984
1250
  inputSchema: {
985
1251
  phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
986
- 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."),
987
1254
  summary: z.string().optional(),
988
1255
  scope: z.string().optional(),
989
1256
  descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
990
1257
  nonGoals: z.array(z.string()).optional(),
991
1258
  dependencies: z.array(z.string()).optional(),
992
1259
  risks: z.array(z.string()).optional(),
1260
+ openQuestions: z.array(z.string()).optional(),
993
1261
  completionCriteria: z.array(z.string()).optional(),
994
1262
  },
995
1263
  }, async ({ phase: ref, ...updates }) => {
@@ -998,7 +1266,7 @@ server.registerTool("planner-phase-discuss", {
998
1266
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
999
1267
  if (!found)
1000
1268
  return text(`Phase not found: ${ref}`);
1001
- const mutableFields = ["goal", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "completionCriteria"];
1269
+ const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
1002
1270
  const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1003
1271
  if (receivedFields.length === 0) {
1004
1272
  return mutationNoFieldsFailure({
@@ -1017,7 +1285,9 @@ server.registerTool("planner-phase-discuss", {
1017
1285
  });
1018
1286
  }
1019
1287
  const phase = await st.updatePhase(found.id, (entry) => {
1020
- 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)
1021
1291
  entry.goals = [updates.goal.trim()].filter(Boolean);
1022
1292
  if (updates.summary !== undefined)
1023
1293
  entry.summary = updates.summary.trim();
@@ -1036,19 +1306,20 @@ server.registerTool("planner-phase-discuss", {
1036
1306
  entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
1037
1307
  if (updates.risks !== undefined)
1038
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);
1039
1311
  if (updates.completionCriteria !== undefined)
1040
1312
  entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
1041
- entry.status = "planned";
1042
1313
  entry.discussedAt = nowISO();
1043
1314
  entry.contextReady = true;
1044
1315
  entry.contextReadyReason = "Updated through planner-phase-discuss MCP tool.";
1045
1316
  entry.updatedAt = nowISO();
1046
1317
  return entry;
1047
1318
  });
1048
- return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, discussed: true, updatedFields: receivedFields });
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 });
1049
1320
  });
1050
1321
  server.registerTool("planner-phase-update", {
1051
- description: "Update phase fields.",
1322
+ description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
1052
1323
  inputSchema: {
1053
1324
  phase: z.string().min(1),
1054
1325
  title: z.string().optional(),
@@ -1056,7 +1327,15 @@ server.registerTool("planner-phase-update", {
1056
1327
  summary: z.string().optional(),
1057
1328
  description: z.string().optional(),
1058
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."),
1059
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(),
1060
1339
  },
1061
1340
  }, async ({ phase: ref, ...updates }) => {
1062
1341
  const st = await requireStore();
@@ -1067,7 +1346,7 @@ server.registerTool("planner-phase-update", {
1067
1346
  if (updates.status !== undefined) {
1068
1347
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
1069
1348
  }
1070
- const mutableFields = ["title", "summary", "description", "descriptionRef", "priority"];
1349
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
1071
1350
  const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
1072
1351
  if (receivedFields.length === 0) {
1073
1352
  return mutationNoFieldsFailure({
@@ -1085,26 +1364,83 @@ server.registerTool("planner-phase-update", {
1085
1364
  },
1086
1365
  });
1087
1366
  }
1088
- const phase = await st.updatePhase(found.id, (entry) => {
1089
- if (updates.title !== undefined)
1090
- entry.title = updates.title.trim();
1091
- if (updates.summary !== undefined)
1092
- entry.summary = updates.summary.trim();
1093
- if (updates.description !== undefined)
1094
- entry.description = updates.description.trim();
1095
- if (updates.descriptionRef !== undefined) {
1096
- const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
1097
- if (descriptionRef)
1098
- entry.descriptionRef = descriptionRef;
1099
- else
1100
- delete entry.descriptionRef;
1367
+ const previousFeatureId = found.featureId;
1368
+ let nextFeatureId = previousFeatureId;
1369
+ if (updates.featureId !== undefined) {
1370
+ if (updates.featureId.trim() === "") {
1371
+ nextFeatureId = undefined;
1101
1372
  }
1102
- if (updates.priority !== undefined)
1103
- entry.priority = updates.priority;
1104
- entry.updatedAt = nowISO();
1105
- return entry;
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();
1106
1431
  });
1107
- return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, updated: true, updatedFields: receivedFields });
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 } : {}) });
1108
1444
  });
1109
1445
  server.registerTool("planner-phase-delete", {
1110
1446
  description: "Delete a phase and unlink it from features.",
@@ -1114,11 +1450,13 @@ server.registerTool("planner-phase-delete", {
1114
1450
  const phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1115
1451
  if (!phase)
1116
1452
  return text(`Phase not found: ${ref}`);
1117
- await st.deletePhase(phase.id);
1118
- await st.updateFeatures((doc) => {
1119
- for (const feature of doc.features)
1120
- feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
1121
- 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
+ });
1122
1460
  });
1123
1461
  const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
1124
1462
  return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
@@ -1134,70 +1472,73 @@ server.registerTool("planner-task-add", {
1134
1472
  },
1135
1473
  }, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
1136
1474
  const st = await requireStore();
1137
- const features = (await st.loadFeatures()).features;
1138
- if (!featureRef?.trim())
1139
- return text("feature is required: a task must belong to a feature.");
1140
- const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
1141
- if (!resolvedFeature.ok)
1142
- return text(resolvedFeature.error);
1143
- const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
1144
- if (!featureValidation.ok)
1145
- return text(featureValidation.error);
1146
- const feature = resolvedFeature.feature;
1147
- if (!feature)
1148
- return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
1149
- const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
1150
- if (!found)
1151
- return text(`Phase not found: ${ref}`);
1152
- if (found.featureId !== feature.id)
1153
- return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
1154
- const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
1155
- if (!phaseValidation.ok)
1156
- return text(phaseValidation.error);
1157
- const existingPhase = await st.loadPhase(found.id);
1158
- if (!existingPhase)
1159
- return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
1160
- const timestamp = nowISO();
1161
- const taskId = createTaskId();
1162
- const identity = await st.allocateEntityIdentity("task", taskId);
1163
- const priority = await st.nextPriority("task", found.id);
1164
- const task = {
1165
- id: taskId,
1166
- phaseId: found.id,
1167
- number: identity.number,
1168
- shortId: identity.shortId,
1169
- priority,
1170
- shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
1171
- title: title.trim(),
1172
- status: "planned",
1173
- description: description?.trim() ?? "",
1174
- descriptionUpdatedAt: timestamp,
1175
- notes: "",
1176
- statusLog: [],
1177
- sessionInfo: [],
1178
- decisions: [],
1179
- acceptedDecisions: [],
1180
- checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
1181
- subtasks: [],
1182
- dependsOn: [],
1183
- pauseSnapshot: null,
1184
- pauseHistory: [],
1185
- startedAt: "",
1186
- completedAt: "",
1187
- createdAt: timestamp,
1188
- updatedAt: timestamp,
1189
- };
1190
- await st.updatePhase(found.id, (entry) => {
1191
- entry.tasks.push(task);
1192
- entry.taskIds.push(task.id);
1193
- entry.updatedAt = timestamp;
1194
- 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}` : ""}`);
1195
1538
  });
1196
- const finalFeatures = (await st.loadFeatures()).features;
1197
- return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
1198
1539
  });
1199
1540
  server.registerTool("planner-task-show", {
1200
- 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.",
1201
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).") },
1202
1543
  }, async ({ task: ref, full }, extra) => {
1203
1544
  const plannerSessionId = plannerSessionIdFor(extra);
@@ -1205,8 +1546,6 @@ server.registerTool("planner-task-show", {
1205
1546
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1206
1547
  if (!found)
1207
1548
  return text(`Task not found: ${ref}`);
1208
- if (full)
1209
- markTaskReadForSessionId(plannerSessionId, found.task.id);
1210
1549
  const features = (await st.loadFeatures()).features;
1211
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))})`;
1212
1551
  if (!full)
@@ -1238,7 +1577,17 @@ server.registerTool("planner-task-show", {
1238
1577
  }
1239
1578
  if (log)
1240
1579
  sections.push(`Status log:\n${log}`);
1241
- return text(sections.join("\n\n"), { task: { ref: taskCompositeRef(found.task, found.phase, features), description: found.task.description, descriptionRef: found.task.descriptionRef || "" } });
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;
1242
1591
  });
1243
1592
  server.registerTool("planner-task-discuss", {
1244
1593
  description: "Persist task execution notes/checklist.",
@@ -1297,6 +1646,48 @@ server.registerTool("planner-task-discuss", {
1297
1646
  const t = updatedTask ?? found.task;
1298
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 });
1299
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
+ }
1690
+ });
1300
1691
  server.registerTool("planner-task-update", {
1301
1692
  description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
1302
1693
  inputSchema: {
@@ -1305,11 +1696,14 @@ server.registerTool("planner-task-update", {
1305
1696
  status: z.enum(STATUS_VALUES).optional(),
1306
1697
  description: z.string().optional(),
1307
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."),
1308
1701
  motivation: z.string().optional(),
1309
1702
  priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
1310
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."),
1311
1705
  },
1312
- }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist }) => {
1706
+ }, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
1313
1707
  const st = await requireStore();
1314
1708
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1315
1709
  if (!found)
@@ -1327,8 +1721,8 @@ server.registerTool("planner-task-update", {
1327
1721
  `Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
1328
1722
  }
1329
1723
  }
1330
- const mutableFields = ["title", "status", "description", "descriptionRef", "motivation", "priority", "checklist"];
1331
- const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist };
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 };
1332
1726
  const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
1333
1727
  if (receivedFields.length === 0) {
1334
1728
  return mutationNoFieldsFailure({
@@ -1346,43 +1740,70 @@ server.registerTool("planner-task-update", {
1346
1740
  },
1347
1741
  });
1348
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
+ }
1349
1749
  let updatedTask;
1350
1750
  const timestamp = nowISO();
1351
- await st.updatePhase(found.phase.id, (phase) => {
1352
- const task = phase.tasks.find((entry) => entry.id === found.task.id);
1353
- if (!task)
1354
- return phase;
1355
- if (title !== undefined)
1356
- task.title = title.trim();
1357
- if (priority !== undefined)
1358
- task.priority = priority;
1359
- if (description !== undefined)
1360
- task.description = description.trim();
1361
- if (rawDescriptionRef !== undefined) {
1362
- const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1363
- if (descriptionRef)
1364
- task.descriptionRef = descriptionRef;
1365
- else
1366
- delete task.descriptionRef;
1367
- }
1368
- if (status !== undefined && status !== task.status) {
1369
- // Record status change in the incremental statusLog.
1370
- const entry = {
1371
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-${status}`),
1372
- date: timestamp,
1373
- fromStatus: task.status,
1374
- toStatus: status,
1375
- title: motivation?.split("\n")[0]?.trim() || `${task.status} → ${status}`,
1376
- description: motivation?.trim() || "",
1377
- };
1378
- task.statusLog = [...(task.statusLog ?? []), entry];
1379
- applyTaskLifecycleDates(task, status, timestamp);
1380
- }
1381
- task.updatedAt = timestamp;
1382
- phase.updatedAt = timestamp;
1383
- updatedTask = task;
1384
- return phase;
1385
- });
1751
+ try {
1752
+ await st.updateTask(found.phase.id, found.task.id, (task) => {
1753
+ if (title !== undefined)
1754
+ task.title = title.trim();
1755
+ if (priority !== undefined)
1756
+ task.priority = priority;
1757
+ if (description !== undefined)
1758
+ task.description = description.trim();
1759
+ if (rawDescriptionRef !== undefined) {
1760
+ const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
1761
+ if (descriptionRef)
1762
+ task.descriptionRef = descriptionRef;
1763
+ else
1764
+ delete task.descriptionRef;
1765
+ }
1766
+ if (notes !== undefined)
1767
+ task.notes = notes.trim();
1768
+ if (decisions !== undefined)
1769
+ task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
1770
+ if (checklist !== undefined) {
1771
+ task.checklist = checklist.map((item, index) => ({
1772
+ id: createChecklistItemId(task.id, index + 1, item),
1773
+ number: index + 1,
1774
+ title: item.trim(),
1775
+ checked: false,
1776
+ }));
1777
+ }
1778
+ if (subtasks !== undefined) {
1779
+ const existingIds = new Set(task.subtasks.map((item) => item.id));
1780
+ if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
1781
+ throw new PlanStoreError("Subtask IDs must belong to the target task.", undefined, { errorCode: "SUBTASK_ID_INVALID" });
1782
+ }
1783
+ 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 }));
1784
+ }
1785
+ if (status !== undefined && status !== task.status) {
1786
+ // Record status change in the incremental statusLog.
1787
+ const entry = {
1788
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-${status}`),
1789
+ date: timestamp,
1790
+ fromStatus: task.status,
1791
+ toStatus: status,
1792
+ title: motivation?.split("\n")[0]?.trim() || `${task.status} → ${status}`,
1793
+ description: motivation?.trim() || "",
1794
+ };
1795
+ task.statusLog = [...(task.statusLog ?? []), entry];
1796
+ applyTaskLifecycleDates(task, status, timestamp);
1797
+ }
1798
+ task.updatedAt = timestamp;
1799
+ updatedTask = task;
1800
+ return task;
1801
+ });
1802
+ }
1803
+ catch (error) {
1804
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1805
+ return { ...text(`Task update failed: ${error instanceof Error ? error.message : "unknown error"}`, { updated: false, errorCode: details?.errorCode ?? "TASK_UPDATE_FAILED" }), isError: true };
1806
+ }
1386
1807
  await st.syncTaskStatusRollup(found.phase.id);
1387
1808
  const features = (await st.loadFeatures()).features;
1388
1809
  const t = updatedTask ?? found.task;
@@ -1401,7 +1822,20 @@ server.registerTool("planner-task-update", {
1401
1822
  }
1402
1823
  }
1403
1824
  }
1404
- return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${resumeNotice}`, resumeRequired ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired } : { task: t, updated: true, updatedFields: receivedFields });
1825
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
1826
+ ? await st.previewDescriptionReconciliation()
1827
+ : null;
1828
+ const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
1829
+ const staleParentRefs = descriptionFreshness?.diagnostics
1830
+ .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
1831
+ .map((entry) => entry.ownerRef) ?? [];
1832
+ const freshnessNotice = staleParentRefs.length > 0
1833
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
1834
+ : "";
1835
+ const details = resumeRequired
1836
+ ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
1837
+ : { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
1838
+ 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);
1405
1839
  });
1406
1840
  server.registerTool("planner-task-checklist-toggle", {
1407
1841
  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.",
@@ -1523,18 +1957,55 @@ server.registerTool("planner-task-delete", {
1523
1957
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1524
1958
  });
1525
1959
  server.registerTool("planner-task-recommend", {
1526
- 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.",
1960
+ 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.",
1527
1961
  inputSchema: {},
1528
1962
  }, async () => {
1529
1963
  const st = await requireStore();
1530
- const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1531
- const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
1532
- if (!result.candidate)
1533
- return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1534
- const { candidate } = result;
1964
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
1965
+ st.loadFeatures(),
1966
+ st.loadAllPhases(),
1967
+ st.loadProject(),
1968
+ st.loadResume(),
1969
+ st.listHandoffs(),
1970
+ st.listArchivedHandoffs(),
1971
+ ]);
1972
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
1973
+ handoffs: handoffs.map((handoff) => ({
1974
+ compositeRef: handoff.compositeRef,
1975
+ firstLine: handoff.firstLine,
1976
+ resumeReady: handoff.resumeReady,
1977
+ })),
1978
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
1979
+ });
1980
+ const { selection } = result;
1981
+ if (!selection.candidate)
1982
+ 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")}` : ""}`, {
1983
+ kind: selection.kind,
1984
+ reason: selection.reason,
1985
+ activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1986
+ activeTask: result.activeTask,
1987
+ nextFeature: result.nextFeature,
1988
+ nextPhase: result.nextPhase,
1989
+ nextTask: result.nextTask,
1990
+ claims: result.claims,
1991
+ selection,
1992
+ });
1993
+ const { candidate } = selection;
1535
1994
  const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1536
- return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1537
- kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1995
+ const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
1996
+ const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
1997
+ 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}`, {
1998
+ kind: selection.kind,
1999
+ taskId: candidate.task.id,
2000
+ phaseId: candidate.phase.id,
2001
+ featureId: candidate.feature?.id,
2002
+ deviation: selection.deviation,
2003
+ activeTask: result.activeTask,
2004
+ nextFeature: result.nextFeature,
2005
+ nextPhase: result.nextPhase,
2006
+ nextTask: result.nextTask,
2007
+ claims: result.claims,
2008
+ selection,
1538
2009
  });
1539
2010
  });
1540
2011
  server.registerTool("planner-task-deviation", {
@@ -1638,6 +2109,9 @@ server.registerTool("planner-task-switch", {
1638
2109
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1639
2110
  return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1640
2111
  }
2112
+ if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
2113
+ return text(`Task switch denied: source task is owned by another session.`);
2114
+ }
1641
2115
  const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
1642
2116
  const linkedRequirements = [
1643
2117
  ...(await st.linkedRequirementsForPhase(target.phase.id)),
@@ -1656,7 +2130,8 @@ server.registerTool("planner-task-switch", {
1656
2130
  requirementIds: linkedRequirementIds,
1657
2131
  };
1658
2132
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1659
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2133
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2134
+ const requirementsReady = requirementEligibility.eligible;
1660
2135
  const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1661
2136
  const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1662
2137
  const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
@@ -1671,7 +2146,7 @@ server.registerTool("planner-task-switch", {
1671
2146
  return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1672
2147
  }
1673
2148
  if (!requirementsReady) {
1674
- return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds, nextActions: ["planner-requirement-list", `Retry planner-task-switch to ${targetTaskRef}`] });
2149
+ return text(`Task switch denied: ${requirementEligibility.reason}`, { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`planner-requirement-list with phaseRef=${targetPhaseRef}`, `Retry planner-task-switch to ${targetTaskRef}`] });
1675
2150
  }
1676
2151
  if (!hasValidSessionAttestation(contextInput)) {
1677
2152
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -1685,7 +2160,7 @@ server.registerTool("planner-task-switch", {
1685
2160
  resumeLocation: resume_location, howToResume: how_to_resume,
1686
2161
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
1687
2162
  };
1688
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
2163
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
1689
2164
  const record = {
1690
2165
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
1691
2166
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
@@ -1696,74 +2171,77 @@ server.registerTool("planner-task-switch", {
1696
2171
  const sourceWasActive = source.task.status === "in-progress";
1697
2172
  let sourceCheckpointed = false;
1698
2173
  let targetStarted = false;
1699
- try {
1700
- if (sourceWasActive) {
1701
- await st.pauseTask(source.phase.id, source.task.id, snapshot);
1702
- }
1703
- else {
1704
- await st.updatePhase(source.phase.id, (phase) => {
1705
- const task = phase.tasks.find((entry) => entry.id === source.task.id);
1706
- if (!task)
1707
- throw new Error(`Checkpointed source task disappeared: ${from_task}`);
1708
- task.pauseSnapshot = snapshot;
1709
- task.pauseHistory = [...task.pauseHistory, snapshot];
1710
- task.updatedAt = timestamp;
1711
- return phase;
1712
- });
1713
- }
1714
- sourceCheckpointed = true;
1715
- if (target.task.pauseSnapshot) {
1716
- await st.resumeTask(target.phase.id, target.task.id, timestamp);
1717
- }
1718
- else {
1719
- await st.updatePhase(target.phase.id, (phase) => {
1720
- const task = phase.tasks.find((entry) => entry.id === target.task.id);
1721
- if (!task)
1722
- throw new Error(`Temporary task disappeared: ${to_task}`);
1723
- const previousStatus = task.status;
1724
- applyTaskLifecycleDates(task, "in-progress", timestamp);
1725
- task.statusLog = [...task.statusLog, {
1726
- id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
1727
- date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
1728
- title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
1729
- }];
1730
- task.updatedAt = timestamp;
1731
- phase.updatedAt = timestamp;
1732
- return phase;
1733
- });
1734
- }
1735
- targetStarted = true;
1736
- await st.addWorkDeviation(record);
1737
- if (target.task.pauseSnapshot) {
1738
- const resumedDeviation = project.workDeviations
1739
- .filter((deviation) => deviation.resumeTaskId === target.task.id
1740
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
1741
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1742
- if (resumedDeviation)
1743
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1744
- }
1745
- }
1746
- catch (error) {
1747
- if (targetStarted) {
1748
- await st.updatePhase(target.phase.id, (phase) => {
1749
- phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1750
- return phase;
1751
- }).catch(() => { });
1752
- }
1753
- if (sourceCheckpointed) {
1754
- if (sourceWasActive)
1755
- await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
1756
- else
2174
+ await st.runBatch(async () => {
2175
+ try {
2176
+ if (sourceWasActive) {
2177
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
2178
+ }
2179
+ else {
1757
2180
  await st.updatePhase(source.phase.id, (phase) => {
1758
- phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2181
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
2182
+ if (!task)
2183
+ throw new Error(`Checkpointed source task disappeared: ${from_task}`);
2184
+ task.pauseSnapshot = snapshot;
2185
+ task.pauseHistory = [...task.pauseHistory, snapshot];
2186
+ task.updatedAt = timestamp;
2187
+ return phase;
2188
+ });
2189
+ }
2190
+ sourceCheckpointed = true;
2191
+ if (target.task.pauseSnapshot) {
2192
+ await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
2193
+ }
2194
+ else {
2195
+ await st.updatePhase(target.phase.id, (phase) => {
2196
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
2197
+ if (!task)
2198
+ throw new Error(`Temporary task disappeared: ${to_task}`);
2199
+ const previousStatus = task.status;
2200
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2201
+ task.activeOwnerSession = plannerSessionId;
2202
+ task.statusLog = [...task.statusLog, {
2203
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
2204
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
2205
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
2206
+ }];
2207
+ task.updatedAt = timestamp;
2208
+ phase.updatedAt = timestamp;
2209
+ return phase;
2210
+ });
2211
+ }
2212
+ targetStarted = true;
2213
+ await st.addWorkDeviation(record);
2214
+ if (target.task.pauseSnapshot) {
2215
+ const resumedDeviation = project.workDeviations
2216
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
2217
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2218
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2219
+ if (resumedDeviation)
2220
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2221
+ }
2222
+ }
2223
+ catch (error) {
2224
+ if (targetStarted) {
2225
+ await st.updatePhase(target.phase.id, (phase) => {
2226
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1759
2227
  return phase;
1760
2228
  }).catch(() => { });
2229
+ }
2230
+ if (sourceCheckpointed) {
2231
+ if (sourceWasActive)
2232
+ await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
2233
+ else
2234
+ await st.updatePhase(source.phase.id, (phase) => {
2235
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2236
+ return phase;
2237
+ }).catch(() => { });
2238
+ }
2239
+ throw error;
1761
2240
  }
1762
- throw error;
1763
- }
1764
- await st.syncTaskStatusRollup(source.phase.id);
1765
- if (target.phase.id !== source.phase.id)
1766
- await st.syncTaskStatusRollup(target.phase.id);
2241
+ await st.syncTaskStatusRollup(source.phase.id);
2242
+ if (target.phase.id !== source.phase.id)
2243
+ await st.syncTaskStatusRollup(target.phase.id);
2244
+ });
1767
2245
  invalidateReads(plannerSessionId);
1768
2246
  return writeAndSummarize(st, [
1769
2247
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
@@ -1772,6 +2250,26 @@ server.registerTool("planner-task-switch", {
1772
2250
  `Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
1773
2251
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1774
2252
  });
2253
+ server.registerTool("planner-task-reopen", {
2254
+ description: "Atomically reopen a completed task. Requires confirmed=true; retains completion evidence and never pauses another task.",
2255
+ inputSchema: { task: z.string().min(1), confirmed: z.boolean().optional().default(false) },
2256
+ }, async ({ task: ref, confirmed }, extra) => {
2257
+ const st = await requireStore();
2258
+ const features = (await st.loadFeatures()).features;
2259
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2260
+ if (!found)
2261
+ return { ...text(`Task not found: ${ref}`, { reopened: false, errorCode: "TASK_NOT_FOUND" }), isError: true };
2262
+ try {
2263
+ const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed, ownerSessionId: plannerSessionIdFor(extra) });
2264
+ await st.syncTaskStatusRollup(found.phase.id);
2265
+ await st.writeGenerated();
2266
+ return text(`✅ Task reopened: ${taskCompositeRef(reopened, found.phase, features)} — ${reopened.title} (in-progress)`, { reopened: true, task: reopened });
2267
+ }
2268
+ catch (error) {
2269
+ const details = error instanceof PlanStoreError ? error.details : undefined;
2270
+ return { ...text(`Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}`, { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }), isError: true };
2271
+ }
2272
+ });
1775
2273
  server.registerTool("planner-task-start", {
1776
2274
  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.",
1777
2275
  inputSchema: { task: z.string().min(1) },
@@ -1784,149 +2282,153 @@ server.registerTool("planner-task-start", {
1784
2282
  catch {
1785
2283
  return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
1786
2284
  }
1787
- const features = (await st.loadFeatures()).features;
1788
- const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1789
- if (!found)
1790
- 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."]));
1791
- const taskRef = taskCompositeRef(found.task, found.phase, features);
1792
- const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1793
- const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1794
- const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1795
- if (found.task.status === "done")
1796
- return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Retry planner-task-start ${taskRef}. If it then reports read prerequisites, perform only the missing or stale nextActions before retrying again.`], { taskId: found.task.id }));
1797
- const project = await st.loadProject();
1798
- const linkedRequirements = [
1799
- ...(await st.linkedRequirementsForPhase(found.phase.id)),
1800
- ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
1801
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1802
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1803
- const contextInput = {
1804
- sessionId: plannerSessionId,
1805
- taskId: found.task.id,
1806
- phaseId: found.phase.id,
1807
- ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
1808
- task: found.task,
1809
- phase: found.phase,
1810
- ...(parentFeature ? { feature: parentFeature } : {}),
1811
- requirements: linkedRequirements,
1812
- requirementIds: linkedRequirementIds,
1813
- };
1814
- const contextEligibility = contextReadEligibilityForSession(contextInput);
1815
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1816
- const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1817
- const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1818
- if (!projectGuidelinesReady)
1819
- return taskStartError(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { projectGuidelinesReadState, contextEligibility });
1820
- if (!contextEligibility.eligible)
1821
- return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
1822
- if (!requirementsReady) {
1823
- return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["planner-requirement-list", `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }));
1824
- }
1825
- if (!hasValidSessionAttestation(contextInput)) {
1826
- await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1827
- }
1828
- if (found.task.status === "in-progress") {
1829
- const outcome = taskStartSucceeded(found.task.id, true);
1830
- return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1831
- }
1832
- const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
1833
- const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1834
- if (!eligibility.eligible)
1835
- return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
1836
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1837
- if (selection.kind === "conflict") {
1838
- return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1839
- }
1840
- if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
1841
- const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
1842
- 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 });
1843
- }
1844
- let resumeProposal;
1845
- if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
1846
- // Advisory only: a pending resume must be surfaced loudly, but it must
1847
- // not hard-block an explicit start of a different task. Capture a
1848
- // structured proposal so the resume-first guidance is explicit and
1849
- // machine-readable for the host/agent.
1850
- const resumeTask = selection.candidate;
1851
- const resumeSnapshot = resumeTask.task.pauseSnapshot
1852
- ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
1853
- && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
1854
- ?? null;
1855
- resumeProposal = buildResumeRequiredProposal({
1856
- ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
1857
- title: resumeTask.task.title,
1858
- taskId: resumeTask.task.id,
1859
- phaseId: resumeTask.phase.id,
1860
- snapshot: resumeSnapshot
1861
- ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
1862
- : null,
1863
- });
1864
- }
1865
- // Assemble the mandatory parent context before changing task lifecycle state.
1866
- const [phaseWithRequirements, featureRequirements] = await Promise.all([
1867
- st.loadPhaseWithRequirements(found.phase.id),
1868
- parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1869
- ]);
1870
- const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1871
- // A pending handoff is context, not a lock: task start RETAINS it. It is
1872
- // archived only when the phase completes (auto) or the user explicitly
1873
- // clears it.
1874
- const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
1875
- ? selection.kind === "resume"
1876
- ? `\n\n${(() => {
1877
- if (resumeProposal)
1878
- return resumeProposal.text;
1879
- 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.`;
1880
- })()}`
1881
- : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1882
- : "";
1883
- const timestamp = nowISO();
1884
- let updatedTask;
1885
- if (found.task.pauseSnapshot) {
1886
- updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp);
1887
- }
1888
- else {
1889
- await st.updatePhase(found.phase.id, (phase) => {
1890
- const task = phase.tasks.find((entry) => entry.id === found.task.id);
1891
- if (!task)
2285
+ return st.runBatch(async () => {
2286
+ const features = (await st.loadFeatures()).features;
2287
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2288
+ if (!found)
2289
+ 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."]));
2290
+ const taskRef = taskCompositeRef(found.task, found.phase, features);
2291
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
2292
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
2293
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
2294
+ if (found.task.status === "done")
2295
+ 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 }));
2296
+ const project = await st.loadProject();
2297
+ const linkedRequirements = [
2298
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
2299
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
2300
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2301
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2302
+ const contextInput = {
2303
+ sessionId: plannerSessionId,
2304
+ taskId: found.task.id,
2305
+ phaseId: found.phase.id,
2306
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
2307
+ task: found.task,
2308
+ phase: found.phase,
2309
+ ...(parentFeature ? { feature: parentFeature } : {}),
2310
+ requirements: linkedRequirements,
2311
+ requirementIds: linkedRequirementIds,
2312
+ };
2313
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
2314
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2315
+ const requirementsReady = requirementEligibility.eligible;
2316
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2317
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2318
+ if (!projectGuidelinesReady)
2319
+ 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 });
2320
+ if (!contextEligibility.eligible)
2321
+ 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 });
2322
+ if (!requirementsReady) {
2323
+ 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 });
2324
+ }
2325
+ if (!hasValidSessionAttestation(contextInput)) {
2326
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
2327
+ }
2328
+ if (found.task.status === "in-progress") {
2329
+ const outcome = taskStartSucceeded(found.task.id, true);
2330
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
2331
+ }
2332
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
2333
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
2334
+ if (!eligibility.eligible)
2335
+ return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
2336
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
2337
+ if (selection.kind === "conflict") {
2338
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
2339
+ }
2340
+ if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
2341
+ const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
2342
+ 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 });
2343
+ }
2344
+ let resumeProposal;
2345
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
2346
+ // Advisory only: a pending resume must be surfaced loudly, but it must
2347
+ // not hard-block an explicit start of a different task. Capture a
2348
+ // structured proposal so the resume-first guidance is explicit and
2349
+ // machine-readable for the host/agent.
2350
+ const resumeTask = selection.candidate;
2351
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
2352
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
2353
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
2354
+ ?? null;
2355
+ resumeProposal = buildResumeRequiredProposal({
2356
+ ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
2357
+ title: resumeTask.task.title,
2358
+ taskId: resumeTask.task.id,
2359
+ phaseId: resumeTask.phase.id,
2360
+ snapshot: resumeSnapshot
2361
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
2362
+ : null,
2363
+ });
2364
+ }
2365
+ // Assemble the mandatory parent context before changing task lifecycle state.
2366
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
2367
+ st.loadPhaseWithRequirements(found.phase.id),
2368
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
2369
+ ]);
2370
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
2371
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
2372
+ // archived only when the phase completes (auto) or the user explicitly
2373
+ // clears it.
2374
+ const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
2375
+ ? selection.kind === "resume"
2376
+ ? `\n\n${(() => {
2377
+ if (resumeProposal)
2378
+ return resumeProposal.text;
2379
+ 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.`;
2380
+ })()}`
2381
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
2382
+ : "";
2383
+ const timestamp = nowISO();
2384
+ let updatedTask;
2385
+ if (found.task.pauseSnapshot) {
2386
+ updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp, plannerSessionId);
2387
+ }
2388
+ else {
2389
+ await st.updatePhase(found.phase.id, (phase) => {
2390
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
2391
+ if (!task)
2392
+ return phase;
2393
+ const previousStatus = task.status;
2394
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2395
+ const entry = {
2396
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
2397
+ date: timestamp,
2398
+ fromStatus: previousStatus,
2399
+ toStatus: "in-progress",
2400
+ title: `${previousStatus} → in-progress`,
2401
+ description: "",
2402
+ };
2403
+ task.activeOwnerSession = plannerSessionId;
2404
+ task.statusLog = [...(task.statusLog ?? []), entry];
2405
+ task.updatedAt = timestamp;
2406
+ phase.updatedAt = timestamp;
2407
+ updatedTask = task;
1892
2408
  return phase;
1893
- const previousStatus = task.status;
1894
- applyTaskLifecycleDates(task, "in-progress", timestamp);
1895
- const entry = {
1896
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
1897
- date: timestamp,
1898
- fromStatus: previousStatus,
1899
- toStatus: "in-progress",
1900
- title: `${previousStatus} → in-progress`,
1901
- description: "",
1902
- };
1903
- task.statusLog = [...(task.statusLog ?? []), entry];
1904
- task.updatedAt = timestamp;
1905
- phase.updatedAt = timestamp;
1906
- updatedTask = task;
1907
- return phase;
2409
+ });
2410
+ }
2411
+ await st.syncTaskStatusRollup(found.phase.id);
2412
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
2413
+ if (approvedDeviation)
2414
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
2415
+ const resumedDeviation = project.workDeviations
2416
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
2417
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2418
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2419
+ if (resumedDeviation)
2420
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2421
+ await st.writeGenerated();
2422
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
2423
+ if (!updatedTask || persistedTask?.status !== "in-progress") {
2424
+ 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 }));
2425
+ }
2426
+ const outcome = taskStartSucceeded(persistedTask.id);
2427
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
2428
+ ...outcome,
2429
+ task: persistedTask,
2430
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1908
2431
  });
1909
- }
1910
- await st.syncTaskStatusRollup(found.phase.id);
1911
- const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1912
- if (approvedDeviation)
1913
- await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
1914
- const resumedDeviation = project.workDeviations
1915
- .filter((deviation) => deviation.resumeTaskId === found.task.id
1916
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
1917
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1918
- if (resumedDeviation)
1919
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1920
- await st.writeGenerated();
1921
- const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
1922
- if (!updatedTask || persistedTask?.status !== "in-progress") {
1923
- 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 }));
1924
- }
1925
- const outcome = taskStartSucceeded(persistedTask.id);
1926
- return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
1927
- ...outcome,
1928
- task: persistedTask,
1929
- ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1930
2432
  });
1931
2433
  });
1932
2434
  server.registerTool("planner-task-complete", {
@@ -2014,11 +2516,11 @@ server.registerTool("planner-handoff-list", {
2014
2516
  return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
2015
2517
  const result = boundedPage(list, page, pageSize);
2016
2518
  const { items, ...pageInfo } = result;
2017
- const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
2519
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
2018
2520
  return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
2019
2521
  });
2020
2522
  server.registerTool("planner-handoff-show", {
2021
- description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
2523
+ 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.",
2022
2524
  inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
2023
2525
  }, async ({ phaseRef }) => {
2024
2526
  const st = await requireStore();
@@ -2027,26 +2529,138 @@ server.registerTool("planner-handoff-show", {
2027
2529
  return text(`❌ ${r.error}`);
2028
2530
  const phase = await st.loadPhase(r.phase.id);
2029
2531
  const bounded = boundedHandoffForTransport(phase.handoff);
2030
- if (!bounded.content.trim())
2031
- return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, handoffAudit: phase.handoffAudit });
2032
- return text(`Handoff for ${r.compositeRef}:\n\n${bounded.content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit });
2532
+ if (!bounded.content.trim()) {
2533
+ const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
2534
+ && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
2535
+ if (archived) {
2536
+ const archivedContent = boundedHandoffForTransport(archived.content);
2537
+ const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
2538
+ const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
2539
+ return text([
2540
+ `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
2541
+ ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
2542
+ "",
2543
+ archivedContent.content,
2544
+ ].join("\n"), {
2545
+ phaseRef: r.compositeRef,
2546
+ phaseId: phase.id,
2547
+ active: false,
2548
+ archived: true,
2549
+ archiveReason: archived.reason,
2550
+ archivedAt: archived.archivedAt,
2551
+ archiveFile: archived.file,
2552
+ ...archivedContent,
2553
+ supportingDocuments,
2554
+ empty: false,
2555
+ resumeReady: false,
2556
+ handoffAudit: phase.handoffAudit,
2557
+ });
2558
+ }
2559
+ return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
2560
+ }
2561
+ const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
2562
+ const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
2563
+ const contentHash = handoffContentHash(phase.handoff);
2564
+ const persistenceVerified = Boolean(phase.handoffAudit
2565
+ && phase.handoffAudit.contentHash === contentHash
2566
+ && phase.handoffAudit.contentLength === phase.handoff.length);
2567
+ const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
2568
+ const status = resumeReady
2569
+ ? "Resume-ready: persisted compact capsule read-back completed."
2570
+ : "NOT resume-ready: after reading this persisted capsule, call planner-handoff-verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
2571
+ 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}`, {
2572
+ phaseRef: r.compositeRef,
2573
+ phaseId: r.phase.id,
2574
+ phaseWorkMap,
2575
+ ...bounded,
2576
+ contentHash,
2577
+ persistenceVerified,
2578
+ resumeReady,
2579
+ verificationRequired: !resumeReady,
2580
+ handoffAudit: phase.handoffAudit,
2581
+ });
2582
+ });
2583
+ server.registerTool("planner-handoff-verify", {
2584
+ description: "Finalize one persisted handoff as resume-ready after planner-handoff-show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
2585
+ inputSchema: {
2586
+ phaseRef: z.string().min(1),
2587
+ expectedContentHash: z.string().regex(/^[a-f0-9]{64}$/),
2588
+ sourceReviews: z.array(z.object({
2589
+ source: z.enum(["conversation", "planner-entities", "working-tree", "verification-runtime", "peer-agent-output"]),
2590
+ detail: z.string().min(12),
2591
+ })).optional().describe("Optional legacy evidence; omitted for compact handoffs."),
2592
+ omissionsFound: z.array(z.string().min(1)).optional().describe("Optional read-back gaps; omit when the persisted capsule is complete."),
2593
+ },
2594
+ }, async ({ phaseRef, expectedContentHash, sourceReviews, omissionsFound }) => {
2595
+ const st = await requireStore();
2596
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2597
+ if (!r.ok)
2598
+ return text(`❌ ${r.error}`, { resumeReady: false });
2599
+ try {
2600
+ const result = await st.verifyPhaseHandoffReadBack(r.phase.id, {
2601
+ expectedContentHash,
2602
+ ...(sourceReviews ? { sourceReviews } : {}),
2603
+ ...(omissionsFound ? { omissionsFound } : {}),
2604
+ });
2605
+ await st.writeGenerated();
2606
+ return text(`✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.`, {
2607
+ phaseRef: r.compositeRef,
2608
+ ...result,
2609
+ resumeReady: true,
2610
+ verificationRequired: false,
2611
+ });
2612
+ }
2613
+ catch (error) {
2614
+ if (error instanceof HandoffContractError)
2615
+ return handoffContractFailure(error);
2616
+ const message = error instanceof Error ? error.message : String(error);
2617
+ return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
2618
+ }
2033
2619
  });
2034
2620
  server.registerTool("planner-requirement-list", {
2035
- description: "List all top-level requirements in requirements.json. Reading this list records the requirements as read for the start/resume read-enforcement (explicit requirement read, required alongside feature/phase reads).",
2036
- inputSchema: {},
2037
- }, async (_args, extra) => {
2621
+ 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.",
2622
+ inputSchema: {
2623
+ 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."),
2624
+ },
2625
+ }, async ({ phaseRef }, extra) => {
2038
2626
  const plannerSessionId = plannerSessionIdFor(extra);
2039
2627
  const st = await requireStore();
2040
2628
  const requirements = await st.loadRequirements();
2041
- requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
2042
- return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
2629
+ if (!phaseRef?.trim()) {
2630
+ return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements", {
2631
+ requirements,
2632
+ attestedRequirementIds: [],
2633
+ readScope: "inventory",
2634
+ });
2635
+ }
2636
+ const features = (await st.loadFeatures()).features;
2637
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, phaseRef.trim());
2638
+ if (!phase)
2639
+ return { ...text(`Phase not found: ${phaseRef}`, { attestedRequirementIds: [] }), isError: true };
2640
+ const delivered = [
2641
+ ...(await st.linkedRequirementsForPhase(phase.id)),
2642
+ ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2643
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2644
+ delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
2645
+ const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2646
+ const body = delivered.map((requirement) => [
2647
+ `- ${requirement.id} — ${requirement.title}`,
2648
+ ` Description: ${requirement.description || "None"}`,
2649
+ ` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
2650
+ ` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
2651
+ ].join("\n")).join("\n");
2652
+ return text(body || `No requirements linked to ${targetRef}`, {
2653
+ requirements: { ...requirements, requirements: delivered },
2654
+ attestedRequirementIds: delivered.map((requirement) => requirement.id),
2655
+ readScope: "target",
2656
+ target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
2657
+ });
2043
2658
  });
2044
2659
  server.registerTool("planner-requirement-create", {
2045
- description: "Create a top-level requirement with optional nested macro tasks. Requirement and macro-task IDs/timestamps are assigned by the planner.",
2660
+ 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.",
2046
2661
  inputSchema: {
2047
2662
  title: z.string().min(1),
2048
2663
  description: z.string().optional(),
2049
- status: z.enum(STATUS_VALUES).optional(),
2050
2664
  linkedPhaseIds: z.array(z.string().min(1)),
2051
2665
  macroTasks: z.array(z.object({
2052
2666
  title: z.string().min(1),
@@ -2054,7 +2668,7 @@ server.registerTool("planner-requirement-create", {
2054
2668
  status: z.enum(STATUS_VALUES).optional(),
2055
2669
  })).optional(),
2056
2670
  },
2057
- }, async ({ title, description, status, linkedPhaseIds, macroTasks }) => {
2671
+ }, async ({ title, description, linkedPhaseIds, macroTasks }) => {
2058
2672
  const st = await requireStore();
2059
2673
  const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2060
2674
  if (!links.ok)
@@ -2076,25 +2690,22 @@ server.registerTool("planner-requirement-create", {
2076
2690
  id: createRequirementId(),
2077
2691
  title: title.trim(),
2078
2692
  description: description?.trim() ?? "",
2079
- status: status ?? "planned",
2080
2693
  macroTasks: normalizedMacroTasks,
2081
2694
  linkedPhaseIds: links.linkedPhaseIds,
2082
2695
  createdAt: now,
2083
2696
  updatedAt: now,
2084
2697
  sessionInfo: [],
2085
2698
  };
2086
- const requirements = await st.loadRequirements();
2087
- await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
2699
+ await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
2088
2700
  await st.writeGenerated();
2089
2701
  return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
2090
2702
  });
2091
2703
  server.registerTool("planner-requirement-update", {
2092
- description: "Update a requirement’s human-owned title, description, status, linked phases, or complete ordered macro-task list. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
2704
+ 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.",
2093
2705
  inputSchema: {
2094
2706
  requirementId: z.string().min(1),
2095
2707
  title: z.string().min(1).optional(),
2096
2708
  description: z.string().optional(),
2097
- status: z.enum(STATUS_VALUES).optional(),
2098
2709
  linkedPhaseIds: z.array(z.string().min(1)).optional(),
2099
2710
  macroTasks: z.array(z.object({
2100
2711
  id: z.string().min(1).optional(),
@@ -2103,41 +2714,45 @@ server.registerTool("planner-requirement-update", {
2103
2714
  status: z.enum(STATUS_VALUES),
2104
2715
  })).optional(),
2105
2716
  },
2106
- }, async ({ requirementId, title, description, status, linkedPhaseIds, macroTasks }) => {
2717
+ }, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
2107
2718
  const st = await requireStore();
2108
2719
  const requirements = await st.loadRequirements();
2109
2720
  const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
2110
2721
  if (!current)
2111
2722
  return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
2723
+ if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
2724
+ return mutationNoFieldsFailure({
2725
+ entity: "requirement",
2726
+ ref: requirementId,
2727
+ operation: "update",
2728
+ mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
2729
+ retryCommand: `planner-requirement-update ${requirementId}`,
2730
+ });
2731
+ }
2112
2732
  const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2113
2733
  if (!links.ok)
2114
2734
  return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2115
2735
  const now = nowISO();
2116
- let normalizedMacroTasks = current.macroTasks;
2117
- if (macroTasks !== undefined) {
2118
- try {
2119
- normalizedMacroTasks = reconcileRequirementMacroTasks(current.macroTasks, macroTasks.map((task) => ({
2120
- ...(task.id !== undefined ? { id: task.id } : {}),
2121
- title: task.title,
2122
- ...(task.description !== undefined ? { description: task.description } : {}),
2123
- status: task.status,
2124
- })), now);
2125
- }
2126
- catch (error) {
2127
- const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2128
- return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2129
- }
2736
+ let next;
2737
+ try {
2738
+ next = await st.updateRequirement(current.id, (canonical) => ({
2739
+ ...canonical,
2740
+ ...(title !== undefined ? { title: title.trim() } : {}),
2741
+ ...(description !== undefined ? { description: description.trim() } : {}),
2742
+ ...(linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
2743
+ ...(macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, macroTasks.map((task) => ({
2744
+ ...(task.id !== undefined ? { id: task.id } : {}),
2745
+ title: task.title,
2746
+ ...(task.description !== undefined ? { description: task.description } : {}),
2747
+ status: task.status,
2748
+ })), now) } : {}),
2749
+ updatedAt: now,
2750
+ }));
2751
+ }
2752
+ catch (error) {
2753
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2754
+ return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2130
2755
  }
2131
- const next = {
2132
- ...current,
2133
- ...(title !== undefined ? { title: title.trim() } : {}),
2134
- ...(description !== undefined ? { description: description.trim() } : {}),
2135
- ...(status !== undefined ? { status } : {}),
2136
- linkedPhaseIds: links.linkedPhaseIds,
2137
- macroTasks: normalizedMacroTasks,
2138
- updatedAt: now,
2139
- };
2140
- await st.saveRequirements({ requirements: requirements.requirements.map((requirement) => requirement.id === current.id ? next : requirement) });
2141
2756
  await st.writeGenerated();
2142
2757
  return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
2143
2758
  });
@@ -2146,13 +2761,16 @@ server.registerTool("planner-requirement-delete", {
2146
2761
  inputSchema: { requirementId: z.string().min(1) },
2147
2762
  }, async ({ requirementId }) => {
2148
2763
  const st = await requireStore();
2149
- const requirements = await st.loadRequirements();
2150
- const next = requirements.requirements.filter((requirement) => requirement.id !== requirementId);
2151
- if (next.length === requirements.requirements.length)
2764
+ let deleted = false;
2765
+ await st.updateRequirements((document) => {
2766
+ const requirements = document.requirements.filter((requirement) => requirement.id !== requirementId);
2767
+ deleted = requirements.length !== document.requirements.length;
2768
+ return deleted ? { requirements } : document;
2769
+ });
2770
+ if (!deleted)
2152
2771
  return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
2153
- await st.saveRequirements({ requirements: next });
2154
2772
  await st.writeGenerated();
2155
- return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
2773
+ return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
2156
2774
  });
2157
2775
  function ideaPromotionTarget(st, type, ref) {
2158
2776
  return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
@@ -2201,8 +2819,15 @@ server.registerTool("planner-idea-update", {
2201
2819
  const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2202
2820
  if (!current)
2203
2821
  return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
2204
- if (title === undefined && description === undefined)
2205
- return text("No mutable idea fields were received.", { errorCode: "NO_MUTABLE_FIELDS_RECEIVED", updated: false });
2822
+ if (title === undefined && description === undefined) {
2823
+ return mutationNoFieldsFailure({
2824
+ entity: "idea",
2825
+ ref,
2826
+ operation: "update",
2827
+ mutableFields: ["title", "description"],
2828
+ retryCommand: `planner-idea-update ${ref}`,
2829
+ });
2830
+ }
2206
2831
  const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
2207
2832
  await st.writeGenerated();
2208
2833
  return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
@@ -2262,10 +2887,11 @@ server.registerTool("planner-idea-promotion-finalize", {
2262
2887
  return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
2263
2888
  });
2264
2889
  server.registerTool("planner-handoff-write", {
2265
- description: "Reconcile one active handoff with durable context synchronization and a mandatory versioned completeness audit. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
2890
+ description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown.",
2266
2891
  inputSchema: {
2267
2892
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
2268
2893
  title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
2894
+ 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."),
2269
2895
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
2270
2896
  content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
2271
2897
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
@@ -2277,7 +2903,16 @@ server.registerTool("planner-handoff-write", {
2277
2903
  status: z.enum(["captured", "not-applicable"]),
2278
2904
  detail: z.string().min(1),
2279
2905
  })),
2280
- }).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
2906
+ }).optional().describe("Optional legacy completeness evidence. Compact handoffs omit this duplicate prose."),
2907
+ coldStartInventory: z.object({
2908
+ version: z.literal(HANDOFF_COLD_START_INVENTORY_VERSION),
2909
+ sourceReviews: z.array(z.object({ source: z.string().min(1), detail: z.string().min(1) })),
2910
+ entries: z.array(z.object({
2911
+ category: z.string().min(1).describe(`One required cold-start category id: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}`),
2912
+ items: z.array(z.string().min(1)),
2913
+ 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."),
2914
+ })),
2915
+ }).optional().describe("Optional legacy cold-start evidence. Compact handoffs omit this duplicate prose."),
2281
2916
  supportingDocuments: z.array(z.object({
2282
2917
  path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
2283
2918
  description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
@@ -2299,7 +2934,7 @@ server.registerTool("planner-handoff-write", {
2299
2934
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
2300
2935
  featureNoUpdateReason: z.string().min(1).optional(),
2301
2936
  },
2302
- }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2937
+ }, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2303
2938
  const st = await requireStore();
2304
2939
  let body = content.trim();
2305
2940
  const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
@@ -2323,7 +2958,9 @@ server.registerTool("planner-handoff-write", {
2323
2958
  if (!confirmed)
2324
2959
  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 });
2325
2960
  if (expectedHandoffUpdatedAt === undefined)
2326
- return text("❌ Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2961
+ return handoffPreflightFailure("HANDOFF_PREFLIGHT_REQUIRED", "Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2962
+ if (!reason?.trim())
2963
+ 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"] });
2327
2964
  const phases = await st.loadAllPhases();
2328
2965
  const features = (await st.loadFeatures()).features;
2329
2966
  const resolvedTaskUpdates = [];
@@ -2342,10 +2979,12 @@ server.registerTool("planner-handoff-write", {
2342
2979
  }
2343
2980
  try {
2344
2981
  const result = await st.refreshPhaseHandoff(r.phase.id, {
2982
+ reason: reason.trim(),
2345
2983
  content: body,
2346
2984
  expectedHandoffUpdatedAt,
2347
2985
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2348
2986
  ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2987
+ ...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
2349
2988
  ...(supportingDocuments ? { supportingDocuments } : {}),
2350
2989
  contextSync: {
2351
2990
  taskUpdates: resolvedTaskUpdates,
@@ -2360,12 +2999,19 @@ server.registerTool("planner-handoff-write", {
2360
2999
  },
2361
3000
  });
2362
3001
  await st.writeGenerated();
2363
- return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, {
3002
+ 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 persisted compact capsule, then call planner-handoff-verify with the returned contentHash. Rewrite only if resume-critical context is missing.`, {
2364
3003
  phaseRef: r.compositeRef,
2365
3004
  phaseId: r.phase.id,
2366
3005
  updatedTaskIds: result.updatedTaskIds,
2367
3006
  handoffUpdatedAt: result.handoffUpdatedAt,
2368
3007
  handoffAudit: result.handoffAudit,
3008
+ persisted: true,
3009
+ resumeReady: false,
3010
+ verificationRequired: true,
3011
+ nextActions: [
3012
+ `Call planner-handoff-show ${r.compositeRef} and read the entire persisted body.`,
3013
+ "Call planner-handoff-verify with the shown contentHash; legacy sourceReviews and omissionsFound are optional because the planner derives omitted evidence from persisted state.",
3014
+ ],
2369
3015
  });
2370
3016
  }
2371
3017
  catch (error) {
@@ -2376,7 +3022,7 @@ server.registerTool("planner-handoff-write", {
2376
3022
  }
2377
3023
  });
2378
3024
  server.registerTool("planner-handoff-prepare", {
2379
- description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the current token, missing task evidence, mandatory completeness categories, and the 24,000-character inline budget.",
3025
+ description: "Identify or prepare the exact handoff target. With a confirmed phaseRef, returns a compact resume scaffold, bounded priority-ordered phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
2380
3026
  inputSchema: { phaseRef: z.string().min(1).optional() },
2381
3027
  }, async ({ phaseRef }) => {
2382
3028
  if (!phaseRef)
@@ -2391,20 +3037,32 @@ server.registerTool("planner-handoff-prepare", {
2391
3037
  return text(`❌ ${r.error}`);
2392
3038
  try {
2393
3039
  const audit = await st.preparePhaseHandoff(r.phase.id);
3040
+ const { completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...compactAudit } = audit;
2394
3041
  const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
2395
3042
  return text([
2396
- `Handoff preparation audit for ${r.compositeRef}`,
3043
+ `Handoff preparation for ${r.compositeRef}`,
2397
3044
  `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
3045
+ "Required human inputs before drafting:",
3046
+ ...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
3047
+ "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
3048
+ "Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
3049
+ audit.draftTemplate,
3050
+ "",
2398
3051
  "Done tasks missing durable completion/verification evidence:",
2399
3052
  missing,
2400
3053
  "",
2401
- `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
2402
- ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
2403
- `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
3054
+ "Compact handoff contract: include only the exact focus/resume point, current or partial state, constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
3055
+ "No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
3056
+ "Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
3057
+ `Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
2404
3058
  "",
2405
3059
  "Existing active handoff (reconcile all still-relevant content):",
2406
3060
  audit.handoff.trim() || "(none)",
2407
- ].join("\n"), { phaseRef: r.compositeRef, ...audit });
3061
+ ].join("\n"), {
3062
+ phaseRef: r.compositeRef,
3063
+ ...compactAudit,
3064
+ evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
3065
+ });
2408
3066
  }
2409
3067
  catch (error) {
2410
3068
  const message = error instanceof Error ? error.message : String(error);
@@ -2430,40 +3088,44 @@ server.registerTool("planner-web", {
2430
3088
  }, async ({ action }) => {
2431
3089
  if (action === "start") {
2432
3090
  if (webHandle) {
2433
- return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
3091
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3092
+ return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
2434
3093
  }
2435
3094
  const root = planRoot();
2436
3095
  const storeCheck = new PlanStore(root);
2437
3096
  if (!(await storeCheck.exists())) {
2438
- return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`);
3097
+ 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" });
2439
3098
  }
2440
3099
  try {
2441
3100
  // port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
2442
3101
  // host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
2443
3102
  webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
2444
- return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
3103
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3104
+ return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
2445
3105
  }
2446
3106
  catch (err) {
2447
3107
  webHandle = null;
2448
- return text(`planner-web start failed: ${err.message}`);
3108
+ const web = { localUrl: "", error: err.message };
3109
+ return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
2449
3110
  }
2450
3111
  }
2451
3112
  if (action === "stop") {
2452
3113
  if (!webHandle)
2453
- return text("planner-web not running.");
3114
+ return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
2454
3115
  const stoppedUrl = webHandle.localUrl;
2455
3116
  await webHandle.close().catch(() => { });
2456
3117
  webHandle = null;
2457
- return text(`planner-web stopped (was ${stoppedUrl}).`);
3118
+ return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
2458
3119
  }
2459
3120
  // status
2460
3121
  if (webHandle) {
2461
- return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`);
3122
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3123
+ return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`, plannerWebMetadata(web));
2462
3124
  }
2463
- return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
3125
+ 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: "" });
2464
3126
  });
2465
3127
  server.registerTool("planner-load", {
2466
- description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap verbatim in that reply, including its final prominent Web UI line. A pending handoff is read-only context: NEVER call planner-handoff-show or planner-handoff-clear as part of load/recap. Archive it only when every phase task is done/canceled, when replacing it with a new handoff, or after an explicit user handoff-clear request. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
3128
+ 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).",
2467
3129
  }, async (extra) => {
2468
3130
  const st = store();
2469
3131
  if (!(await st.exists()))
@@ -2484,10 +3146,23 @@ server.registerTool("planner-load", {
2484
3146
  await st.syncGrillMeSkill();
2485
3147
  const web = await ensureWebStarted();
2486
3148
  const recap = await buildRecap(st, web, { harness: "mcp" });
3149
+ const plan = await st.loadAll();
3150
+ const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
3151
+ { scope: "project", decisions: plan.project.acceptedDecisions },
3152
+ ...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
3153
+ ...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
3154
+ ...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
3155
+ scope: `task ${taskCompositeRef(task, phase, plan.features.features)}`,
3156
+ decisions: task.acceptedDecisions,
3157
+ }))),
3158
+ ]);
2487
3159
  const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
2488
3160
  return {
2489
3161
  content: [{ type: "text", text: visibleRecap }],
2490
3162
  structuredContent: {
3163
+ loaded: true,
3164
+ recap: { text: visibleRecap },
3165
+ webUi: plannerWebMetadata(web),
2491
3166
  preparation: preparation.legacyProjectContext,
2492
3167
  agentContext: {
2493
3168
  kind: "planner-usage-skill",
@@ -2495,7 +3170,8 @@ server.registerTool("planner-load", {
2495
3170
  status: plannerSkill.status,
2496
3171
  customized: plannerSkill.customized,
2497
3172
  message: plannerSkill.message,
2498
- instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
3173
+ 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.",
3174
+ acceptedDecisions: acceptedDecisionContext,
2499
3175
  },
2500
3176
  },
2501
3177
  };