@agent-plan/mcp 0.2.26 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,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,6 +1740,12 @@ 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
1751
  await st.updatePhase(found.phase.id, (phase) => {
@@ -1365,6 +1765,25 @@ server.registerTool("planner-task-update", {
1365
1765
  else
1366
1766
  delete task.descriptionRef;
1367
1767
  }
1768
+ if (notes !== undefined)
1769
+ task.notes = notes.trim();
1770
+ if (decisions !== undefined)
1771
+ task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
1772
+ if (checklist !== undefined) {
1773
+ task.checklist = checklist.map((item, index) => ({
1774
+ id: createChecklistItemId(task.id, index + 1, item),
1775
+ number: index + 1,
1776
+ title: item.trim(),
1777
+ checked: false,
1778
+ }));
1779
+ }
1780
+ if (subtasks !== undefined) {
1781
+ const existingIds = new Set(task.subtasks.map((item) => item.id));
1782
+ if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
1783
+ throw new PlanStoreError("Subtask IDs must belong to the target task.", undefined, { errorCode: "SUBTASK_ID_INVALID" });
1784
+ }
1785
+ task.subtasks = subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: item.status ?? "planned", createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? timestamp, updatedAt: timestamp }));
1786
+ }
1368
1787
  if (status !== undefined && status !== task.status) {
1369
1788
  // Record status change in the incremental statusLog.
1370
1789
  const entry = {
@@ -1401,7 +1820,20 @@ server.registerTool("planner-task-update", {
1401
1820
  }
1402
1821
  }
1403
1822
  }
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 });
1823
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
1824
+ ? await st.previewDescriptionReconciliation()
1825
+ : null;
1826
+ const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
1827
+ const staleParentRefs = descriptionFreshness?.diagnostics
1828
+ .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
1829
+ .map((entry) => entry.ownerRef) ?? [];
1830
+ const freshnessNotice = staleParentRefs.length > 0
1831
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
1832
+ : "";
1833
+ const details = resumeRequired
1834
+ ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
1835
+ : { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
1836
+ return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}${resumeNotice}`, details);
1405
1837
  });
1406
1838
  server.registerTool("planner-task-checklist-toggle", {
1407
1839
  description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), the item id, or the title (case-insensitive, first match). Pass checked=true/false to set explicitly, or omit to toggle. Use this instead of writing DONE in step titles. Items are numbered C1/C2… per task.",
@@ -1523,18 +1955,55 @@ server.registerTool("planner-task-delete", {
1523
1955
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1524
1956
  });
1525
1957
  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.",
1958
+ description: "Return the harness-agnostic next-work chain: active task if present, otherwise the lowest-priority ready feature → phase → task, plus bounded typed claims. Only persisted resume-ready handoffs are actionable; Markdown prose and terminal archives are not authority. Does not start or block work.",
1527
1959
  inputSchema: {},
1528
1960
  }, async () => {
1529
1961
  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;
1962
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
1963
+ st.loadFeatures(),
1964
+ st.loadAllPhases(),
1965
+ st.loadProject(),
1966
+ st.loadResume(),
1967
+ st.listHandoffs(),
1968
+ st.listArchivedHandoffs(),
1969
+ ]);
1970
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
1971
+ handoffs: handoffs.map((handoff) => ({
1972
+ compositeRef: handoff.compositeRef,
1973
+ firstLine: handoff.firstLine,
1974
+ resumeReady: handoff.resumeReady,
1975
+ })),
1976
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
1977
+ });
1978
+ const { selection } = result;
1979
+ if (!selection.candidate)
1980
+ return text(`No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}`, {
1981
+ kind: selection.kind,
1982
+ reason: selection.reason,
1983
+ activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1984
+ activeTask: result.activeTask,
1985
+ nextFeature: result.nextFeature,
1986
+ nextPhase: result.nextPhase,
1987
+ nextTask: result.nextTask,
1988
+ claims: result.claims,
1989
+ selection,
1990
+ });
1991
+ const { candidate } = selection;
1535
1992
  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,
1993
+ const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
1994
+ const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
1995
+ return text(`${activeRef}Next work (${selection.kind}): ${ref} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}`, {
1996
+ kind: selection.kind,
1997
+ taskId: candidate.task.id,
1998
+ phaseId: candidate.phase.id,
1999
+ featureId: candidate.feature?.id,
2000
+ deviation: selection.deviation,
2001
+ activeTask: result.activeTask,
2002
+ nextFeature: result.nextFeature,
2003
+ nextPhase: result.nextPhase,
2004
+ nextTask: result.nextTask,
2005
+ claims: result.claims,
2006
+ selection,
1538
2007
  });
1539
2008
  });
1540
2009
  server.registerTool("planner-task-deviation", {
@@ -1638,6 +2107,9 @@ server.registerTool("planner-task-switch", {
1638
2107
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1639
2108
  return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1640
2109
  }
2110
+ if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
2111
+ return text(`Task switch denied: source task is owned by another session.`);
2112
+ }
1641
2113
  const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
1642
2114
  const linkedRequirements = [
1643
2115
  ...(await st.linkedRequirementsForPhase(target.phase.id)),
@@ -1656,7 +2128,8 @@ server.registerTool("planner-task-switch", {
1656
2128
  requirementIds: linkedRequirementIds,
1657
2129
  };
1658
2130
  const contextEligibility = contextReadEligibilityForSession(contextInput);
1659
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2131
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2132
+ const requirementsReady = requirementEligibility.eligible;
1660
2133
  const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
1661
2134
  const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
1662
2135
  const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
@@ -1671,7 +2144,7 @@ server.registerTool("planner-task-switch", {
1671
2144
  return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1672
2145
  }
1673
2146
  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}`] });
2147
+ return text(`Task switch denied: ${requirementEligibility.reason}`, { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`planner-requirement-list with phaseRef=${targetPhaseRef}`, `Retry planner-task-switch to ${targetTaskRef}`] });
1675
2148
  }
1676
2149
  if (!hasValidSessionAttestation(contextInput)) {
1677
2150
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -1685,7 +2158,7 @@ server.registerTool("planner-task-switch", {
1685
2158
  resumeLocation: resume_location, howToResume: how_to_resume,
1686
2159
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
1687
2160
  };
1688
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
2161
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
1689
2162
  const record = {
1690
2163
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
1691
2164
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
@@ -1696,74 +2169,77 @@ server.registerTool("planner-task-switch", {
1696
2169
  const sourceWasActive = source.task.status === "in-progress";
1697
2170
  let sourceCheckpointed = false;
1698
2171
  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
2172
+ await st.runBatch(async () => {
2173
+ try {
2174
+ if (sourceWasActive) {
2175
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
2176
+ }
2177
+ else {
1757
2178
  await st.updatePhase(source.phase.id, (phase) => {
1758
- phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2179
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
2180
+ if (!task)
2181
+ throw new Error(`Checkpointed source task disappeared: ${from_task}`);
2182
+ task.pauseSnapshot = snapshot;
2183
+ task.pauseHistory = [...task.pauseHistory, snapshot];
2184
+ task.updatedAt = timestamp;
2185
+ return phase;
2186
+ });
2187
+ }
2188
+ sourceCheckpointed = true;
2189
+ if (target.task.pauseSnapshot) {
2190
+ await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
2191
+ }
2192
+ else {
2193
+ await st.updatePhase(target.phase.id, (phase) => {
2194
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
2195
+ if (!task)
2196
+ throw new Error(`Temporary task disappeared: ${to_task}`);
2197
+ const previousStatus = task.status;
2198
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2199
+ task.activeOwnerSession = plannerSessionId;
2200
+ task.statusLog = [...task.statusLog, {
2201
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
2202
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
2203
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
2204
+ }];
2205
+ task.updatedAt = timestamp;
2206
+ phase.updatedAt = timestamp;
2207
+ return phase;
2208
+ });
2209
+ }
2210
+ targetStarted = true;
2211
+ await st.addWorkDeviation(record);
2212
+ if (target.task.pauseSnapshot) {
2213
+ const resumedDeviation = project.workDeviations
2214
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
2215
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2216
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2217
+ if (resumedDeviation)
2218
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2219
+ }
2220
+ }
2221
+ catch (error) {
2222
+ if (targetStarted) {
2223
+ await st.updatePhase(target.phase.id, (phase) => {
2224
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1759
2225
  return phase;
1760
2226
  }).catch(() => { });
2227
+ }
2228
+ if (sourceCheckpointed) {
2229
+ if (sourceWasActive)
2230
+ await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
2231
+ else
2232
+ await st.updatePhase(source.phase.id, (phase) => {
2233
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
2234
+ return phase;
2235
+ }).catch(() => { });
2236
+ }
2237
+ throw error;
1761
2238
  }
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);
2239
+ await st.syncTaskStatusRollup(source.phase.id);
2240
+ if (target.phase.id !== source.phase.id)
2241
+ await st.syncTaskStatusRollup(target.phase.id);
2242
+ });
1767
2243
  invalidateReads(plannerSessionId);
1768
2244
  return writeAndSummarize(st, [
1769
2245
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
@@ -1772,6 +2248,26 @@ server.registerTool("planner-task-switch", {
1772
2248
  `Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
1773
2249
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1774
2250
  });
2251
+ server.registerTool("planner-task-reopen", {
2252
+ description: "Atomically reopen a completed task. Requires confirmed=true; retains completion evidence and never pauses another task.",
2253
+ inputSchema: { task: z.string().min(1), confirmed: z.boolean().optional().default(false) },
2254
+ }, async ({ task: ref, confirmed }, extra) => {
2255
+ const st = await requireStore();
2256
+ const features = (await st.loadFeatures()).features;
2257
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2258
+ if (!found)
2259
+ return { ...text(`Task not found: ${ref}`, { reopened: false, errorCode: "TASK_NOT_FOUND" }), isError: true };
2260
+ try {
2261
+ const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed, ownerSessionId: plannerSessionIdFor(extra) });
2262
+ await st.syncTaskStatusRollup(found.phase.id);
2263
+ await st.writeGenerated();
2264
+ return text(`✅ Task reopened: ${taskCompositeRef(reopened, found.phase, features)} — ${reopened.title} (in-progress)`, { reopened: true, task: reopened });
2265
+ }
2266
+ catch (error) {
2267
+ const details = error instanceof PlanStoreError ? error.details : undefined;
2268
+ return { ...text(`Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}`, { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }), isError: true };
2269
+ }
2270
+ });
1775
2271
  server.registerTool("planner-task-start", {
1776
2272
  description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1777
2273
  inputSchema: { task: z.string().min(1) },
@@ -1784,149 +2280,153 @@ server.registerTool("planner-task-start", {
1784
2280
  catch {
1785
2281
  return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
1786
2282
  }
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)
2283
+ return st.runBatch(async () => {
2284
+ const features = (await st.loadFeatures()).features;
2285
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
2286
+ if (!found)
2287
+ return taskStartError(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${ref}`, ["Resolve the task with planner-task-list or planner-task-show, then retry planner-task-start."]));
2288
+ const taskRef = taskCompositeRef(found.task, found.phase, features);
2289
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
2290
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
2291
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
2292
+ if (found.task.status === "done")
2293
+ return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-reopen with task=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry planner-task-start ${taskRef} only if reopening is not the intended operation.`], { taskId: found.task.id }));
2294
+ const project = await st.loadProject();
2295
+ const linkedRequirements = [
2296
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
2297
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
2298
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2299
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2300
+ const contextInput = {
2301
+ sessionId: plannerSessionId,
2302
+ taskId: found.task.id,
2303
+ phaseId: found.phase.id,
2304
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
2305
+ task: found.task,
2306
+ phase: found.phase,
2307
+ ...(parentFeature ? { feature: parentFeature } : {}),
2308
+ requirements: linkedRequirements,
2309
+ requirementIds: linkedRequirementIds,
2310
+ };
2311
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
2312
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2313
+ const requirementsReady = requirementEligibility.eligible;
2314
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2315
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2316
+ if (!projectGuidelinesReady)
2317
+ return taskStartError(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { projectGuidelinesReadState, contextEligibility });
2318
+ if (!contextEligibility.eligible)
2319
+ return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
2320
+ if (!requirementsReady) {
2321
+ return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", `Linked requirements were not read; the task remains unchanged. ${requirementEligibility.reason}`, [`planner-requirement-list with phaseRef=${phaseRef}`, `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }), { requirementEligibility });
2322
+ }
2323
+ if (!hasValidSessionAttestation(contextInput)) {
2324
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
2325
+ }
2326
+ if (found.task.status === "in-progress") {
2327
+ const outcome = taskStartSucceeded(found.task.id, true);
2328
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
2329
+ }
2330
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
2331
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
2332
+ if (!eligibility.eligible)
2333
+ return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
2334
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
2335
+ if (selection.kind === "conflict") {
2336
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
2337
+ }
2338
+ if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
2339
+ const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
2340
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use planner-task-switch to snapshot and pause ${activeRef}.`, `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
2341
+ }
2342
+ let resumeProposal;
2343
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
2344
+ // Advisory only: a pending resume must be surfaced loudly, but it must
2345
+ // not hard-block an explicit start of a different task. Capture a
2346
+ // structured proposal so the resume-first guidance is explicit and
2347
+ // machine-readable for the host/agent.
2348
+ const resumeTask = selection.candidate;
2349
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
2350
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
2351
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
2352
+ ?? null;
2353
+ resumeProposal = buildResumeRequiredProposal({
2354
+ ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
2355
+ title: resumeTask.task.title,
2356
+ taskId: resumeTask.task.id,
2357
+ phaseId: resumeTask.phase.id,
2358
+ snapshot: resumeSnapshot
2359
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
2360
+ : null,
2361
+ });
2362
+ }
2363
+ // Assemble the mandatory parent context before changing task lifecycle state.
2364
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
2365
+ st.loadPhaseWithRequirements(found.phase.id),
2366
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
2367
+ ]);
2368
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
2369
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
2370
+ // archived only when the phase completes (auto) or the user explicitly
2371
+ // clears it.
2372
+ const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
2373
+ ? selection.kind === "resume"
2374
+ ? `\n\n${(() => {
2375
+ if (resumeProposal)
2376
+ return resumeProposal.text;
2377
+ return `⚠️ Resume advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`;
2378
+ })()}`
2379
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
2380
+ : "";
2381
+ const timestamp = nowISO();
2382
+ let updatedTask;
2383
+ if (found.task.pauseSnapshot) {
2384
+ updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp, plannerSessionId);
2385
+ }
2386
+ else {
2387
+ await st.updatePhase(found.phase.id, (phase) => {
2388
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
2389
+ if (!task)
2390
+ return phase;
2391
+ const previousStatus = task.status;
2392
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
2393
+ const entry = {
2394
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
2395
+ date: timestamp,
2396
+ fromStatus: previousStatus,
2397
+ toStatus: "in-progress",
2398
+ title: `${previousStatus} → in-progress`,
2399
+ description: "",
2400
+ };
2401
+ task.activeOwnerSession = plannerSessionId;
2402
+ task.statusLog = [...(task.statusLog ?? []), entry];
2403
+ task.updatedAt = timestamp;
2404
+ phase.updatedAt = timestamp;
2405
+ updatedTask = task;
1892
2406
  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;
2407
+ });
2408
+ }
2409
+ await st.syncTaskStatusRollup(found.phase.id);
2410
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
2411
+ if (approvedDeviation)
2412
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
2413
+ const resumedDeviation = project.workDeviations
2414
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
2415
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
2416
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
2417
+ if (resumedDeviation)
2418
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
2419
+ await st.writeGenerated();
2420
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
2421
+ if (!updatedTask || persistedTask?.status !== "in-progress") {
2422
+ return taskStartError(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect planner-task-show for the persisted state and retry only after resolving the persistence failure."], { taskId: found.task.id }));
2423
+ }
2424
+ const outcome = taskStartSucceeded(persistedTask.id);
2425
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
2426
+ ...outcome,
2427
+ task: persistedTask,
2428
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1908
2429
  });
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
2430
  });
1931
2431
  });
1932
2432
  server.registerTool("planner-task-complete", {
@@ -2014,11 +2514,11 @@ server.registerTool("planner-handoff-list", {
2014
2514
  return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
2015
2515
  const result = boundedPage(list, page, pageSize);
2016
2516
  const { items, ...pageInfo } = result;
2017
- const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
2517
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
2018
2518
  return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
2019
2519
  });
2020
2520
  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.",
2521
+ description: "Read the active entity-scoped handoff of a phase, or its latest terminal archive, together with a bounded priority-ordered phase work map. Reread the canonical phase and relevant sibling tasks before proposing work so capability ownership is not duplicated. phaseRef is required; never infer a phase from in-progress status or stale context.",
2022
2522
  inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
2023
2523
  }, async ({ phaseRef }) => {
2024
2524
  const st = await requireStore();
@@ -2027,26 +2527,134 @@ server.registerTool("planner-handoff-show", {
2027
2527
  return text(`❌ ${r.error}`);
2028
2528
  const phase = await st.loadPhase(r.phase.id);
2029
2529
  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 });
2530
+ if (!bounded.content.trim()) {
2531
+ const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
2532
+ && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
2533
+ if (archived) {
2534
+ const archivedContent = boundedHandoffForTransport(archived.content);
2535
+ const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
2536
+ const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
2537
+ return text([
2538
+ `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
2539
+ ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
2540
+ "",
2541
+ archivedContent.content,
2542
+ ].join("\n"), {
2543
+ phaseRef: r.compositeRef,
2544
+ phaseId: phase.id,
2545
+ active: false,
2546
+ archived: true,
2547
+ archiveReason: archived.reason,
2548
+ archivedAt: archived.archivedAt,
2549
+ archiveFile: archived.file,
2550
+ ...archivedContent,
2551
+ supportingDocuments,
2552
+ empty: false,
2553
+ resumeReady: false,
2554
+ handoffAudit: phase.handoffAudit,
2555
+ });
2556
+ }
2557
+ return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
2558
+ }
2559
+ const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
2560
+ const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
2561
+ const contentHash = handoffContentHash(phase.handoff);
2562
+ const persistenceVerified = Boolean(phase.handoffAudit
2563
+ && phase.handoffAudit.contentHash === contentHash
2564
+ && phase.handoffAudit.contentLength === phase.handoff.length);
2565
+ const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
2566
+ const status = resumeReady
2567
+ ? "Resume-ready: persisted read-back and source reconciliation completed."
2568
+ : "NOT resume-ready: after reading this entire persisted body, call planner-handoff-verify with its contentHash and a fresh comparison against every required source. If any gap exists, rewrite instead of verifying.";
2569
+ return text(`Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}`, {
2570
+ phaseRef: r.compositeRef,
2571
+ phaseId: r.phase.id,
2572
+ phaseWorkMap,
2573
+ ...bounded,
2574
+ contentHash,
2575
+ persistenceVerified,
2576
+ resumeReady,
2577
+ verificationRequired: !resumeReady,
2578
+ handoffAudit: phase.handoffAudit,
2579
+ });
2580
+ });
2581
+ server.registerTool("planner-handoff-verify", {
2582
+ description: "Finalize one persisted handoff as resume-ready only after planner-handoff-show has displayed the entire body and the agent has compared it again with conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer output. Any discovered omission blocks verification and requires prepare+rewrite.",
2583
+ inputSchema: {
2584
+ phaseRef: z.string().min(1),
2585
+ expectedContentHash: z.string().regex(/^[a-f0-9]{64}$/),
2586
+ sourceReviews: z.array(z.object({
2587
+ source: z.enum(["conversation", "planner-entities", "working-tree", "verification-runtime", "peer-agent-output"]),
2588
+ detail: z.string().min(12),
2589
+ })),
2590
+ omissionsFound: z.array(z.string().min(1)),
2591
+ },
2592
+ }, async ({ phaseRef, expectedContentHash, sourceReviews, omissionsFound }) => {
2593
+ const st = await requireStore();
2594
+ const r = await resolvePhaseForHandoff(st, phaseRef);
2595
+ if (!r.ok)
2596
+ return text(`❌ ${r.error}`, { resumeReady: false });
2597
+ try {
2598
+ const result = await st.verifyPhaseHandoffReadBack(r.phase.id, { expectedContentHash, sourceReviews, omissionsFound });
2599
+ await st.writeGenerated();
2600
+ return text(`✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.`, {
2601
+ phaseRef: r.compositeRef,
2602
+ ...result,
2603
+ resumeReady: true,
2604
+ verificationRequired: false,
2605
+ });
2606
+ }
2607
+ catch (error) {
2608
+ if (error instanceof HandoffContractError)
2609
+ return handoffContractFailure(error);
2610
+ const message = error instanceof Error ? error.message : String(error);
2611
+ return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
2612
+ }
2033
2613
  });
2034
2614
  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) => {
2615
+ description: "List declarative product Requirements: user, business, or system outcomes. Requirements never contain coding standards, best practices, formatting, verification process, or other Project Guidelines. Pass phaseRef to deliver and attest only the Requirements linked to that phase or its owning feature; an unscoped inventory does not satisfy task-start read gates.",
2616
+ inputSchema: {
2617
+ phaseRef: z.string().min(1).optional().describe("Optional target phase ref. When supplied, return full target-relevant requirements and record only those delivered requirements as read."),
2618
+ },
2619
+ }, async ({ phaseRef }, extra) => {
2038
2620
  const plannerSessionId = plannerSessionIdFor(extra);
2039
2621
  const st = await requireStore();
2040
2622
  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 });
2623
+ if (!phaseRef?.trim()) {
2624
+ return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements", {
2625
+ requirements,
2626
+ attestedRequirementIds: [],
2627
+ readScope: "inventory",
2628
+ });
2629
+ }
2630
+ const features = (await st.loadFeatures()).features;
2631
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, phaseRef.trim());
2632
+ if (!phase)
2633
+ return { ...text(`Phase not found: ${phaseRef}`, { attestedRequirementIds: [] }), isError: true };
2634
+ const delivered = [
2635
+ ...(await st.linkedRequirementsForPhase(phase.id)),
2636
+ ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2637
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2638
+ delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
2639
+ const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2640
+ const body = delivered.map((requirement) => [
2641
+ `- ${requirement.id} — ${requirement.title}`,
2642
+ ` Description: ${requirement.description || "None"}`,
2643
+ ` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
2644
+ ` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
2645
+ ].join("\n")).join("\n");
2646
+ return text(body || `No requirements linked to ${targetRef}`, {
2647
+ requirements: { ...requirements, requirements: delivered },
2648
+ attestedRequirementIds: delivered.map((requirement) => requirement.id),
2649
+ readScope: "target",
2650
+ target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
2651
+ });
2043
2652
  });
2044
2653
  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.",
2654
+ description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use planner-project-guidelines-update instead. Requirements have no lifecycle status. Requirement and macro-task IDs/timestamps are assigned by the planner.",
2046
2655
  inputSchema: {
2047
2656
  title: z.string().min(1),
2048
2657
  description: z.string().optional(),
2049
- status: z.enum(STATUS_VALUES).optional(),
2050
2658
  linkedPhaseIds: z.array(z.string().min(1)),
2051
2659
  macroTasks: z.array(z.object({
2052
2660
  title: z.string().min(1),
@@ -2054,7 +2662,7 @@ server.registerTool("planner-requirement-create", {
2054
2662
  status: z.enum(STATUS_VALUES).optional(),
2055
2663
  })).optional(),
2056
2664
  },
2057
- }, async ({ title, description, status, linkedPhaseIds, macroTasks }) => {
2665
+ }, async ({ title, description, linkedPhaseIds, macroTasks }) => {
2058
2666
  const st = await requireStore();
2059
2667
  const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2060
2668
  if (!links.ok)
@@ -2076,25 +2684,22 @@ server.registerTool("planner-requirement-create", {
2076
2684
  id: createRequirementId(),
2077
2685
  title: title.trim(),
2078
2686
  description: description?.trim() ?? "",
2079
- status: status ?? "planned",
2080
2687
  macroTasks: normalizedMacroTasks,
2081
2688
  linkedPhaseIds: links.linkedPhaseIds,
2082
2689
  createdAt: now,
2083
2690
  updatedAt: now,
2084
2691
  sessionInfo: [],
2085
2692
  };
2086
- const requirements = await st.loadRequirements();
2087
- await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
2693
+ await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
2088
2694
  await st.writeGenerated();
2089
2695
  return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
2090
2696
  });
2091
2697
  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.",
2698
+ description: "Update a Requirement’s product outcome, linked phases, or complete ordered macro-task list. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use planner-project-guidelines-update instead. Requirements have no lifecycle status. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
2093
2699
  inputSchema: {
2094
2700
  requirementId: z.string().min(1),
2095
2701
  title: z.string().min(1).optional(),
2096
2702
  description: z.string().optional(),
2097
- status: z.enum(STATUS_VALUES).optional(),
2098
2703
  linkedPhaseIds: z.array(z.string().min(1)).optional(),
2099
2704
  macroTasks: z.array(z.object({
2100
2705
  id: z.string().min(1).optional(),
@@ -2103,41 +2708,45 @@ server.registerTool("planner-requirement-update", {
2103
2708
  status: z.enum(STATUS_VALUES),
2104
2709
  })).optional(),
2105
2710
  },
2106
- }, async ({ requirementId, title, description, status, linkedPhaseIds, macroTasks }) => {
2711
+ }, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
2107
2712
  const st = await requireStore();
2108
2713
  const requirements = await st.loadRequirements();
2109
2714
  const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
2110
2715
  if (!current)
2111
2716
  return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
2717
+ if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
2718
+ return mutationNoFieldsFailure({
2719
+ entity: "requirement",
2720
+ ref: requirementId,
2721
+ operation: "update",
2722
+ mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
2723
+ retryCommand: `planner-requirement-update ${requirementId}`,
2724
+ });
2725
+ }
2112
2726
  const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
2113
2727
  if (!links.ok)
2114
2728
  return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
2115
2729
  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
- }
2730
+ let next;
2731
+ try {
2732
+ next = await st.updateRequirement(current.id, (canonical) => ({
2733
+ ...canonical,
2734
+ ...(title !== undefined ? { title: title.trim() } : {}),
2735
+ ...(description !== undefined ? { description: description.trim() } : {}),
2736
+ ...(linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
2737
+ ...(macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, macroTasks.map((task) => ({
2738
+ ...(task.id !== undefined ? { id: task.id } : {}),
2739
+ title: task.title,
2740
+ ...(task.description !== undefined ? { description: task.description } : {}),
2741
+ status: task.status,
2742
+ })), now) } : {}),
2743
+ updatedAt: now,
2744
+ }));
2745
+ }
2746
+ catch (error) {
2747
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
2748
+ return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
2130
2749
  }
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
2750
  await st.writeGenerated();
2142
2751
  return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
2143
2752
  });
@@ -2146,13 +2755,16 @@ server.registerTool("planner-requirement-delete", {
2146
2755
  inputSchema: { requirementId: z.string().min(1) },
2147
2756
  }, async ({ requirementId }) => {
2148
2757
  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)
2758
+ let deleted = false;
2759
+ await st.updateRequirements((document) => {
2760
+ const requirements = document.requirements.filter((requirement) => requirement.id !== requirementId);
2761
+ deleted = requirements.length !== document.requirements.length;
2762
+ return deleted ? { requirements } : document;
2763
+ });
2764
+ if (!deleted)
2152
2765
  return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
2153
- await st.saveRequirements({ requirements: next });
2154
2766
  await st.writeGenerated();
2155
- return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
2767
+ return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
2156
2768
  });
2157
2769
  function ideaPromotionTarget(st, type, ref) {
2158
2770
  return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
@@ -2201,8 +2813,15 @@ server.registerTool("planner-idea-update", {
2201
2813
  const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
2202
2814
  if (!current)
2203
2815
  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 });
2816
+ if (title === undefined && description === undefined) {
2817
+ return mutationNoFieldsFailure({
2818
+ entity: "idea",
2819
+ ref,
2820
+ operation: "update",
2821
+ mutableFields: ["title", "description"],
2822
+ retryCommand: `planner-idea-update ${ref}`,
2823
+ });
2824
+ }
2206
2825
  const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
2207
2826
  await st.writeGenerated();
2208
2827
  return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
@@ -2262,10 +2881,11 @@ server.registerTool("planner-idea-promotion-finalize", {
2262
2881
  return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
2263
2882
  });
2264
2883
  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.",
2884
+ description: "Persist a reconciled handoff candidate with durable context synchronization and mandatory audits. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify source reconciliation succeeds. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
2266
2885
  inputSchema: {
2267
2886
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
2268
2887
  title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
2888
+ reason: z.string().min(1).optional().describe("Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown."),
2269
2889
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
2270
2890
  content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
2271
2891
  expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
@@ -2278,6 +2898,15 @@ server.registerTool("planner-handoff-write", {
2278
2898
  detail: z.string().min(1),
2279
2899
  })),
2280
2900
  }).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
2901
+ coldStartInventory: z.object({
2902
+ version: z.literal(HANDOFF_COLD_START_INVENTORY_VERSION),
2903
+ sourceReviews: z.array(z.object({ source: z.string().min(1), detail: z.string().min(1) })),
2904
+ entries: z.array(z.object({
2905
+ category: z.string().min(1).describe(`One required cold-start category id: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}`),
2906
+ items: z.array(z.string().min(1)),
2907
+ notApplicableReason: z.string().min(1).optional().describe("Optional explanation only; every category still requires a concrete item, using an explicit verified-negative statement when nothing exists."),
2908
+ })),
2909
+ }).optional().describe("Mandatory for confirmed writes. Every concrete item must appear verbatim in the handoff or a validated supporting document."),
2281
2910
  supportingDocuments: z.array(z.object({
2282
2911
  path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
2283
2912
  description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
@@ -2299,7 +2928,7 @@ server.registerTool("planner-handoff-write", {
2299
2928
  featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
2300
2929
  featureNoUpdateReason: z.string().min(1).optional(),
2301
2930
  },
2302
- }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2931
+ }, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
2303
2932
  const st = await requireStore();
2304
2933
  let body = content.trim();
2305
2934
  const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
@@ -2323,7 +2952,9 @@ server.registerTool("planner-handoff-write", {
2323
2952
  if (!confirmed)
2324
2953
  return text(`Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run planner-handoff-prepare with the exact phaseRef.`, { phaseRef: r.compositeRef, confirmationRequired: true });
2325
2954
  if (expectedHandoffUpdatedAt === undefined)
2326
- return text("❌ Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2955
+ return handoffPreflightFailure("HANDOFF_PREFLIGHT_REQUIRED", "Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
2956
+ if (!reason?.trim())
2957
+ return handoffPreflightFailure("HANDOFF_REASON_REQUIRED", "Handoff reason is required before persistence. Created at and Updated at are generated by the planner; do not add them to Markdown.", { requiredInputs: ["title", "reason"], generatedMetadata: ["Created at", "Updated at"] });
2327
2958
  const phases = await st.loadAllPhases();
2328
2959
  const features = (await st.loadFeatures()).features;
2329
2960
  const resolvedTaskUpdates = [];
@@ -2342,10 +2973,12 @@ server.registerTool("planner-handoff-write", {
2342
2973
  }
2343
2974
  try {
2344
2975
  const result = await st.refreshPhaseHandoff(r.phase.id, {
2976
+ reason: reason.trim(),
2345
2977
  content: body,
2346
2978
  expectedHandoffUpdatedAt,
2347
2979
  reconciledExistingHandoff: reconciledExistingHandoff === true,
2348
2980
  ...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
2981
+ ...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
2349
2982
  ...(supportingDocuments ? { supportingDocuments } : {}),
2350
2983
  contextSync: {
2351
2984
  taskUpdates: resolvedTaskUpdates,
@@ -2360,12 +2993,20 @@ server.registerTool("planner-handoff-write", {
2360
2993
  },
2361
2994
  });
2362
2995
  await st.writeGenerated();
2363
- return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, {
2996
+ return text(`⚠️ Handoff candidate persisted on ${r.compositeRef}, but it is NOT resume-ready yet. Required next action: call planner-handoff-show ${r.compositeRef}, read the entire persisted body, compare it again with every required source, then call planner-handoff-verify with the returned contentHash. If that review finds any gap, run prepare+write again instead of verifying.`, {
2364
2997
  phaseRef: r.compositeRef,
2365
2998
  phaseId: r.phase.id,
2366
2999
  updatedTaskIds: result.updatedTaskIds,
2367
3000
  handoffUpdatedAt: result.handoffUpdatedAt,
2368
3001
  handoffAudit: result.handoffAudit,
3002
+ persisted: true,
3003
+ resumeReady: false,
3004
+ verificationRequired: true,
3005
+ nextActions: [
3006
+ `Call planner-handoff-show ${r.compositeRef} and read the entire persisted body.`,
3007
+ "Compare the persisted body against conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer-agent output.",
3008
+ "If any omission exists, run planner-handoff-prepare and planner-handoff-write again; otherwise call planner-handoff-verify with the shown contentHash.",
3009
+ ],
2369
3010
  });
2370
3011
  }
2371
3012
  catch (error) {
@@ -2376,7 +3017,7 @@ server.registerTool("planner-handoff-write", {
2376
3017
  }
2377
3018
  });
2378
3019
  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.",
3020
+ description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the canonical scaffold, a bounded priority-ordered phase work map with sibling goals/dependencies/capability ownership, the current token, missing task evidence, mandatory audits, and the 24,000-character inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
2380
3021
  inputSchema: { phaseRef: z.string().min(1).optional() },
2381
3022
  }, async ({ phaseRef }) => {
2382
3023
  if (!phaseRef)
@@ -2395,11 +3036,22 @@ server.registerTool("planner-handoff-prepare", {
2395
3036
  return text([
2396
3037
  `Handoff preparation audit for ${r.compositeRef}`,
2397
3038
  `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
3039
+ "Required human inputs before drafting:",
3040
+ ...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
3041
+ "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
3042
+ "Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
3043
+ audit.draftTemplate,
3044
+ "",
2398
3045
  "Done tasks missing durable completion/verification evidence:",
2399
3046
  missing,
2400
3047
  "",
2401
3048
  `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
2402
3049
  ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3050
+ "",
3051
+ `Required cold-start source review v${audit.coldStartInventoryVersion} — inspect and substantively summarize every source before drafting:`,
3052
+ ...audit.coldStartSourceReviews.map((entry) => `- ${entry.id} — ${entry.label}`),
3053
+ "Required cold-start inventory — every category needs a concrete item (use an explicit verified-negative statement when nothing exists), and every item must appear verbatim in the handoff or a validated supporting document:",
3054
+ ...audit.coldStartInventoryCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
2403
3055
  `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
2404
3056
  "",
2405
3057
  "Existing active handoff (reconcile all still-relevant content):",
@@ -2430,40 +3082,44 @@ server.registerTool("planner-web", {
2430
3082
  }, async ({ action }) => {
2431
3083
  if (action === "start") {
2432
3084
  if (webHandle) {
2433
- return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
3085
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3086
+ return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
2434
3087
  }
2435
3088
  const root = planRoot();
2436
3089
  const storeCheck = new PlanStore(root);
2437
3090
  if (!(await storeCheck.exists())) {
2438
- return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`);
3091
+ return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "", errorCode: "PLANNER_NOT_INITIALIZED" });
2439
3092
  }
2440
3093
  try {
2441
3094
  // port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
2442
3095
  // host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
2443
3096
  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})`);
3097
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3098
+ return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
2445
3099
  }
2446
3100
  catch (err) {
2447
3101
  webHandle = null;
2448
- return text(`planner-web start failed: ${err.message}`);
3102
+ const web = { localUrl: "", error: err.message };
3103
+ return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
2449
3104
  }
2450
3105
  }
2451
3106
  if (action === "stop") {
2452
3107
  if (!webHandle)
2453
- return text("planner-web not running.");
3108
+ return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
2454
3109
  const stoppedUrl = webHandle.localUrl;
2455
3110
  await webHandle.close().catch(() => { });
2456
3111
  webHandle = null;
2457
- return text(`planner-web stopped (was ${stoppedUrl}).`);
3112
+ return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
2458
3113
  }
2459
3114
  // status
2460
3115
  if (webHandle) {
2461
- return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`);
3116
+ const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
3117
+ return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`, plannerWebMetadata(web));
2462
3118
  }
2463
- return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
3119
+ return text("planner-web not running. Use planner-web with action=start to start the dashboard.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
2464
3120
  });
2465
3121
  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).",
3122
+ description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap plus bounded canonical Accepted Decision agentContext (project, feature, phase, and task scopes). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap verbatim in that reply, including its final prominent Web UI line. A pending handoff is read-only context: NEVER call planner-handoff-show or planner-handoff-clear as part of load/recap. Archive it only when every phase task is done/canceled, when replacing it with a new handoff, or after an explicit user handoff-clear request. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
2467
3123
  }, async (extra) => {
2468
3124
  const st = store();
2469
3125
  if (!(await st.exists()))
@@ -2484,10 +3140,23 @@ server.registerTool("planner-load", {
2484
3140
  await st.syncGrillMeSkill();
2485
3141
  const web = await ensureWebStarted();
2486
3142
  const recap = await buildRecap(st, web, { harness: "mcp" });
3143
+ const plan = await st.loadAll();
3144
+ const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
3145
+ { scope: "project", decisions: plan.project.acceptedDecisions },
3146
+ ...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
3147
+ ...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
3148
+ ...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
3149
+ scope: `task ${taskCompositeRef(task, phase, plan.features.features)}`,
3150
+ decisions: task.acceptedDecisions,
3151
+ }))),
3152
+ ]);
2487
3153
  const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
2488
3154
  return {
2489
3155
  content: [{ type: "text", text: visibleRecap }],
2490
3156
  structuredContent: {
3157
+ loaded: true,
3158
+ recap: { text: visibleRecap },
3159
+ webUi: plannerWebMetadata(web),
2491
3160
  preparation: preparation.legacyProjectContext,
2492
3161
  agentContext: {
2493
3162
  kind: "planner-usage-skill",
@@ -2495,7 +3164,8 @@ server.registerTool("planner-load", {
2495
3164
  status: plannerSkill.status,
2496
3165
  customized: plannerSkill.customized,
2497
3166
  message: plannerSkill.message,
2498
- instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
3167
+ instruction: "Read and retain this project-local planner usage skill and Accepted Decision context. Do not quote it or the Accepted Decision context in the human-facing recap.",
3168
+ acceptedDecisions: acceptedDecisionContext,
2499
3169
  },
2500
3170
  },
2501
3171
  };