@agent-plan/mcp 0.2.23 → 0.2.25

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAwjDA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAw0DA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -3,15 +3,82 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import * as z from "zod/v4";
5
5
  import { join } from "node:path";
6
+ import { randomUUID } from "node:crypto";
6
7
  import { pathToFileURL } from "node:url";
7
- import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureRead, markPhaseRead, markTaskRead, contextReadEligibility, hasReadRequirements, markRequirementRead, invalidateReads } from "@agent-plan/core";
8
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
8
9
  import { serve } from "@agent-plan/server";
9
10
  import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
11
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
11
12
  const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
13
+ const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
14
+ startReadSession(fallbackPlannerSessionId);
15
+ function plannerSessionIdFor(extra) {
16
+ const transportSessionId = extra?.sessionId?.trim();
17
+ const sessionId = transportSessionId ? `mcp:${transportSessionId}` : fallbackPlannerSessionId;
18
+ startReadSession(sessionId);
19
+ return sessionId;
20
+ }
12
21
  function text(textValue, structuredContent) {
13
22
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
14
23
  }
24
+ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
25
+ const childKind = entity === "feature" ? "phases" : "tasks";
26
+ const retryAction = entity === "feature"
27
+ ? `Retry planner-feature-update ${ref} without the status field if you need to update metadata.`
28
+ : `Retry planner-phase-update ${ref} without the status field if you need to update metadata.`;
29
+ const nextActions = [
30
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
31
+ retryAction,
32
+ ];
33
+ return {
34
+ isError: true,
35
+ content: [{ type: "text", text: [
36
+ `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
37
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
38
+ `Attempted status: ${attemptedStatus}`,
39
+ `Effective derived status: ${effectiveStatus}`,
40
+ "No planner data was changed.",
41
+ "Next required actions:",
42
+ ...nextActions.map((action, index) => `${index + 1}. ${action}`),
43
+ ].join("\n") }],
44
+ structuredContent: {
45
+ updated: false,
46
+ errorCode: "DERIVED_STATUS_READ_ONLY",
47
+ entity,
48
+ ref,
49
+ attemptedStatus,
50
+ effectiveStatus,
51
+ nextActions,
52
+ },
53
+ };
54
+ }
55
+ function taskStartError(outcome, structuredContent = {}) {
56
+ return {
57
+ isError: true,
58
+ content: [{ type: "text", text: [
59
+ `❌ TASK START FAILED [${outcome.errorCode}]`,
60
+ "started: false",
61
+ outcome.message,
62
+ "No task lifecycle transition was applied.",
63
+ "Next required actions:",
64
+ ...outcome.nextActions.map((action, index) => `${index + 1}. ${action}`),
65
+ ].join("\n") }],
66
+ structuredContent: { ...outcome, ...structuredContent },
67
+ };
68
+ }
69
+ function mcpContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
70
+ const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
71
+ if (read.kind === "task")
72
+ return [`planner-task-show ${refs.task} with full=true`];
73
+ if (read.kind === "phase")
74
+ return [`planner-phase-show ${refs.phase} with full=true`];
75
+ return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
76
+ });
77
+ if (requirementReadRequired)
78
+ actions.push("planner-requirement-list");
79
+ actions.push(retryAction);
80
+ return actions;
81
+ }
15
82
  const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
16
83
  const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
17
84
  function nowISO() {
@@ -431,6 +498,7 @@ server.registerTool("planner-feature-add", {
431
498
  phaseIds: [],
432
499
  dependsOn: [],
433
500
  statusLog: [],
501
+ sessionInfo: [],
434
502
  createdAt: timestamp,
435
503
  updatedAt: timestamp,
436
504
  };
@@ -443,7 +511,8 @@ server.registerTool("planner-feature-add", {
443
511
  server.registerTool("planner-feature-show", {
444
512
  description: "Show a feature by id or name.",
445
513
  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).") },
446
- }, async ({ feature: ref, full }) => {
514
+ }, async ({ feature: ref, full }, extra) => {
515
+ const plannerSessionId = plannerSessionIdFor(extra);
447
516
  const st = await requireStore();
448
517
  const features = (await st.loadFeatures()).features;
449
518
  const resolvedFeature = resolveFeatureRefStrict(features, ref);
@@ -451,7 +520,7 @@ server.registerTool("planner-feature-show", {
451
520
  return text(resolvedFeature.error);
452
521
  const feature = resolvedFeature.feature;
453
522
  if (full)
454
- markFeatureRead(feature.id);
523
+ markFeatureReadForSessionId(plannerSessionId, feature.id);
455
524
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
456
525
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
457
526
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
@@ -513,6 +582,9 @@ server.registerTool("planner-feature-update", {
513
582
  if (!resolvedFeature.ok)
514
583
  return text(resolvedFeature.error);
515
584
  const feature = resolvedFeature.feature;
585
+ if (updates.status !== undefined) {
586
+ return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
587
+ }
516
588
  const updated = await st.updateFeatures((doc) => {
517
589
  const target = doc.features.find((entry) => entry.id === feature.id);
518
590
  if (!target)
@@ -521,8 +593,6 @@ server.registerTool("planner-feature-update", {
521
593
  target.name = updates.name.trim();
522
594
  if (updates.description !== undefined)
523
595
  target.description = updates.description.trim();
524
- if (updates.status !== undefined)
525
- target.status = updates.status;
526
596
  if (updates.workDone !== undefined)
527
597
  target.workDone = updates.workDone.trim();
528
598
  if (updates.workRemaining !== undefined)
@@ -634,6 +704,7 @@ server.registerTool("planner-phase-add", {
634
704
  handoffReadAt: "",
635
705
  handoffHistory: [],
636
706
  statusLog: [],
707
+ sessionInfo: [],
637
708
  };
638
709
  await st.savePhase(phase);
639
710
  if (feature) {
@@ -653,14 +724,15 @@ server.registerTool("planner-phase-add", {
653
724
  server.registerTool("planner-phase-show", {
654
725
  description: "Show a phase by id or name, including derived linked requirements in the full view.",
655
726
  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).") },
656
- }, async ({ phase: ref, full }) => {
727
+ }, async ({ phase: ref, full }, extra) => {
728
+ const plannerSessionId = plannerSessionIdFor(extra);
657
729
  const st = await requireStore();
658
730
  const features = (await st.loadFeatures()).features;
659
731
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
660
732
  if (!phase)
661
733
  return text(`Phase not found: ${ref}`);
662
734
  if (full)
663
- markPhaseRead(phase.id, phase.featureId);
735
+ markPhaseReadForSessionId(plannerSessionId, phase.id);
664
736
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
665
737
  const reqCount = linkedRequirements.length;
666
738
  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"}` : ""})`;
@@ -738,11 +810,12 @@ server.registerTool("planner-phase-update", {
738
810
  const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
739
811
  if (!found)
740
812
  return text(`Phase not found: ${ref}`);
813
+ if (updates.status !== undefined) {
814
+ return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
815
+ }
741
816
  const phase = await st.updatePhase(found.id, (entry) => {
742
817
  if (updates.title !== undefined)
743
818
  entry.title = updates.title.trim();
744
- if (updates.status !== undefined)
745
- entry.status = updates.status;
746
819
  if (updates.summary !== undefined)
747
820
  entry.summary = updates.summary.trim();
748
821
  if (updates.description !== undefined)
@@ -822,6 +895,7 @@ server.registerTool("planner-task-add", {
822
895
  descriptionUpdatedAt: timestamp,
823
896
  notes: "",
824
897
  statusLog: [],
898
+ sessionInfo: [],
825
899
  decisions: [],
826
900
  acceptedDecisions: [],
827
901
  checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
@@ -846,13 +920,14 @@ server.registerTool("planner-task-add", {
846
920
  server.registerTool("planner-task-show", {
847
921
  description: "Show a task by id or name.",
848
922
  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).") },
849
- }, async ({ task: ref, full }) => {
923
+ }, async ({ task: ref, full }, extra) => {
924
+ const plannerSessionId = plannerSessionIdFor(extra);
850
925
  const st = await requireStore();
851
926
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
852
927
  if (!found)
853
928
  return text(`Task not found: ${ref}`);
854
929
  if (full)
855
- markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
930
+ markTaskReadForSessionId(plannerSessionId, found.task.id);
856
931
  const features = (await st.loadFeatures()).features;
857
932
  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))})`;
858
933
  if (!full)
@@ -935,6 +1010,9 @@ server.registerTool("planner-task-update", {
935
1010
  if (status === "in-progress" && status !== found.task.status) {
936
1011
  return text("Task start/resume transitions require planner-task-start so lifecycle state, checkpoints, and return stacks remain consistent.");
937
1012
  }
1013
+ if (status === "done" && status !== found.task.status) {
1014
+ return text("Task completion transitions require planner-task-complete with durable completion and verification evidence.");
1015
+ }
938
1016
  // Validate motivation requirement for status transitions.
939
1017
  if (status !== undefined && needsMotivation(found.task.status, status)) {
940
1018
  if (!motivation || !motivation.trim()) {
@@ -1163,7 +1241,8 @@ server.registerTool("planner-task-pause", {
1163
1241
  how_to_resume: z.string().min(1),
1164
1242
  paused_by: z.string().optional(),
1165
1243
  },
1166
- }, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }) => {
1244
+ }, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }, extra) => {
1245
+ const plannerSessionId = plannerSessionIdFor(extra);
1167
1246
  const st = await requireStore();
1168
1247
  const features = (await st.loadFeatures()).features;
1169
1248
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
@@ -1176,7 +1255,7 @@ server.registerTool("planner-task-pause", {
1176
1255
  resumeLocation: resume_location, howToResume: how_to_resume, relatedTaskId: "",
1177
1256
  pausedAt: nowISO(), pausedBy: paused_by?.trim() ?? "",
1178
1257
  };
1179
- invalidateReads();
1258
+ invalidateReads(plannerSessionId);
1180
1259
  const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
1181
1260
  await st.syncTaskStatusRollup(found.phase.id);
1182
1261
  const activeDeviation = (await st.loadProject()).workDeviations
@@ -1200,7 +1279,7 @@ server.registerTool("planner-task-pause", {
1200
1279
  ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1201
1280
  });
1202
1281
  server.registerTool("planner-task-switch", {
1203
- description: "Atomically switch from active work to a temporary task, even outside normal priority order. Pauses the source with a mandatory snapshot, starts the target, and pushes a durable LIFO return target.",
1282
+ description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
1204
1283
  inputSchema: {
1205
1284
  from_task: z.string().min(1),
1206
1285
  to_task: z.string().min(1),
@@ -1210,7 +1289,8 @@ server.registerTool("planner-task-switch", {
1210
1289
  how_to_resume: z.string().min(1),
1211
1290
  switched_by: z.string().optional(),
1212
1291
  },
1213
- }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }) => {
1292
+ }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }, extra) => {
1293
+ const plannerSessionId = plannerSessionIdFor(extra);
1214
1294
  const st = await requireStore();
1215
1295
  const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1216
1296
  const features = featuresDoc.features;
@@ -1225,15 +1305,37 @@ server.registerTool("planner-task-switch", {
1225
1305
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1226
1306
  return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1227
1307
  }
1228
- const contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
1229
- if (!contextEligibility.eligible)
1230
- return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, contextEligibility);
1231
- const linkedRequirementIds = [
1232
- ...(await st.linkedRequirementsForPhase(target.phase.id)).map((requirement) => requirement.id),
1233
- ...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((requirement) => requirement.id) : []),
1234
- ];
1235
- if (!hasReadRequirements(linkedRequirementIds)) {
1236
- return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds });
1308
+ const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
1309
+ const linkedRequirements = [
1310
+ ...(await st.linkedRequirementsForPhase(target.phase.id)),
1311
+ ...(target.phase.featureId ? await st.linkedRequirementsForFeature(target.phase.featureId) : []),
1312
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1313
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1314
+ const contextInput = {
1315
+ sessionId: plannerSessionId,
1316
+ taskId: target.task.id,
1317
+ phaseId: target.phase.id,
1318
+ ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}),
1319
+ task: target.task,
1320
+ phase: target.phase,
1321
+ ...(targetFeature ? { feature: targetFeature } : {}),
1322
+ requirements: linkedRequirements,
1323
+ requirementIds: linkedRequirementIds,
1324
+ };
1325
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
1326
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1327
+ const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
1328
+ const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
1329
+ const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
1330
+ if (!contextEligibility.eligible) {
1331
+ const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
1332
+ return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
1333
+ }
1334
+ if (!requirementsReady) {
1335
+ return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds, nextActions: ["planner-requirement-list", `Retry planner-task-switch to ${targetTaskRef}`] });
1336
+ }
1337
+ if (!hasValidSessionAttestation(contextInput)) {
1338
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1237
1339
  }
1238
1340
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
1239
1341
  if (!eligibility.eligible)
@@ -1323,7 +1425,7 @@ server.registerTool("planner-task-switch", {
1323
1425
  await st.syncTaskStatusRollup(source.phase.id);
1324
1426
  if (target.phase.id !== source.phase.id)
1325
1427
  await st.syncTaskStatusRollup(target.phase.id);
1326
- invalidateReads();
1428
+ invalidateReads(plannerSessionId);
1327
1429
  return writeAndSummarize(st, [
1328
1430
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
1329
1431
  `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
@@ -1332,40 +1434,68 @@ server.registerTool("planner-task-switch", {
1332
1434
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1333
1435
  });
1334
1436
  server.registerTool("planner-task-start", {
1335
- description: "Set a task to in-progress or resume checkpointed work. BEFORE calling, use planner-task-show(full=true), then planner-phase-show(full=true), then planner-feature-show(full=true) for the same lineage, in that exact order. Read linked requirements with planner-requirement-list when present. A different active task must first be suspended through planner-task-switch.",
1437
+ description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1336
1438
  inputSchema: { task: z.string().min(1) },
1337
- }, async ({ task: ref }) => {
1338
- const st = await requireStore();
1439
+ }, async ({ task: ref }, extra) => {
1440
+ const plannerSessionId = plannerSessionIdFor(extra);
1441
+ let st;
1442
+ try {
1443
+ st = await requireStore();
1444
+ }
1445
+ catch {
1446
+ return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
1447
+ }
1339
1448
  const features = (await st.loadFeatures()).features;
1340
1449
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1341
1450
  if (!found)
1342
- return text(`Task not found: ${ref}`);
1343
- if (found.task.status === "in-progress")
1344
- return text(`Task ${found.task.id} is already in-progress.`);
1451
+ return taskStartError(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${ref}`, ["Resolve the task with planner-task-list or planner-task-show, then retry planner-task-start."]));
1452
+ const taskRef = taskCompositeRef(found.task, found.phase, features);
1453
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1454
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1455
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1345
1456
  if (found.task.status === "done")
1346
- return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1347
- const contextEligibility = contextReadEligibility(found.task.id, found.phase.id, found.phase.featureId);
1457
+ return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry planner-task-start ${taskRef}.`], { taskId: found.task.id }));
1458
+ const linkedRequirements = [
1459
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
1460
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
1461
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1462
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1463
+ const contextInput = {
1464
+ sessionId: plannerSessionId,
1465
+ taskId: found.task.id,
1466
+ phaseId: found.phase.id,
1467
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
1468
+ task: found.task,
1469
+ phase: found.phase,
1470
+ ...(parentFeature ? { feature: parentFeature } : {}),
1471
+ requirements: linkedRequirements,
1472
+ requirementIds: linkedRequirementIds,
1473
+ };
1474
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
1475
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
1348
1476
  if (!contextEligibility.eligible)
1349
- return text(`Task start denied: context reads required. ${contextEligibility.reason}`, contextEligibility);
1350
- const linkedRequirementIds = [
1351
- ...(await st.linkedRequirementsForPhase(found.phase.id)).map((requirement) => requirement.id),
1352
- ...(found.phase.featureId ? (await st.linkedRequirementsForFeature(found.phase.featureId)).map((requirement) => requirement.id) : []),
1353
- ];
1354
- if (!hasReadRequirements(linkedRequirementIds)) {
1355
- return text("Task start denied: read the requirements linked to the task's phase and feature first.", { requirementIds: linkedRequirementIds });
1477
+ return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
1478
+ if (!requirementsReady) {
1479
+ return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["planner-requirement-list", `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }));
1480
+ }
1481
+ if (!hasValidSessionAttestation(contextInput)) {
1482
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1483
+ }
1484
+ if (found.task.status === "in-progress") {
1485
+ const outcome = taskStartSucceeded(found.task.id, true);
1486
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1356
1487
  }
1357
1488
  const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1358
1489
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1359
1490
  if (!eligibility.eligible)
1360
- return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1491
+ return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
1361
1492
  const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1362
1493
  if (selection.kind === "conflict") {
1363
- return text(`${selection.reason} Pause/reconcile active tasks before starting another task.`, {
1364
- activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1365
- });
1494
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1366
1495
  }
1367
1496
  if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
1368
- return text(`Task start denied: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is active. Use planner-task-switch to snapshot and pause it before starting ${taskCompositeRef(found.task, found.phase, features)}.`);
1497
+ const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
1498
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use planner-task-switch to snapshot and pause ${activeRef}.`, `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1369
1499
  }
1370
1500
  let resumeProposal;
1371
1501
  if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
@@ -1389,7 +1519,6 @@ server.registerTool("planner-task-start", {
1389
1519
  });
1390
1520
  }
1391
1521
  // Assemble the mandatory parent context before changing task lifecycle state.
1392
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1393
1522
  const [phaseWithRequirements, featureRequirements] = await Promise.all([
1394
1523
  st.loadPhaseWithRequirements(found.phase.id),
1395
1524
  parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
@@ -1444,23 +1573,33 @@ server.registerTool("planner-task-start", {
1444
1573
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1445
1574
  if (resumedDeviation)
1446
1575
  await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1447
- const t = updatedTask ?? found.task;
1448
- return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`, {
1576
+ await st.writeGenerated();
1577
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
1578
+ if (!updatedTask || persistedTask?.status !== "in-progress") {
1579
+ return taskStartError(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect planner-task-show for the persisted state and retry only after resolving the persistence failure."], { taskId: found.task.id }));
1580
+ }
1581
+ const outcome = taskStartSucceeded(persistedTask.id);
1582
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
1583
+ ...outcome,
1584
+ task: persistedTask,
1449
1585
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1450
1586
  });
1451
1587
  });
1452
1588
  server.registerTool("planner-task-complete", {
1453
- description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
1589
+ description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete unless force=true.",
1454
1590
  inputSchema: {
1455
1591
  task: z.string().min(1),
1456
1592
  force: z.boolean().optional(),
1457
- description_update: z.string().min(10).optional().describe("Post-hoc summary of what was done: commit hash(s), files touched, decisions made, updated code references with new line numbers. Keeps the planner alive and traceable."),
1593
+ description_update: z.string().min(10).describe("Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references."),
1458
1594
  },
1459
1595
  }, async ({ task: ref, force, description_update }) => {
1460
1596
  const st = await requireStore();
1461
1597
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1462
1598
  if (!found)
1463
1599
  return text(`Task not found: ${ref}`);
1600
+ const completionSummary = description_update.trim();
1601
+ if (completionSummary.length < 10)
1602
+ return text("Task completion denied: provide at least 10 characters of durable completion and verification evidence.");
1464
1603
  if (found.task.pauseSnapshot)
1465
1604
  return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
1466
1605
  const unchecked = found.task.checklist.filter((item) => !item.checked);
@@ -1480,13 +1619,12 @@ server.registerTool("planner-task-complete", {
1480
1619
  fromStatus: previousStatus,
1481
1620
  toStatus: "done",
1482
1621
  title: `${previousStatus} → done`,
1483
- description: "",
1622
+ description: completionSummary,
1484
1623
  };
1485
1624
  task.statusLog = [...(task.statusLog ?? []), entry];
1486
- if (description_update) {
1487
- const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
1488
- task.description = task.description + sep + description_update;
1489
- }
1625
+ const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
1626
+ task.description = task.description + sep + completionSummary;
1627
+ task.descriptionUpdatedAt = timestamp;
1490
1628
  task.updatedAt = timestamp;
1491
1629
  phase.updatedAt = timestamp;
1492
1630
  updatedTask = task;
@@ -1545,60 +1683,137 @@ server.registerTool("planner-handoff-show", {
1545
1683
  server.registerTool("planner-requirement-list", {
1546
1684
  description: "List all top-level requirements in requirements.json. Reading this list records the requirements as read for the start/resume read-enforcement (explicit requirement read, required alongside feature/phase reads).",
1547
1685
  inputSchema: {},
1548
- }, async () => {
1686
+ }, async (_args, extra) => {
1687
+ const plannerSessionId = plannerSessionIdFor(extra);
1549
1688
  const st = await requireStore();
1550
1689
  const requirements = await st.loadRequirements();
1551
- requirements.requirements.forEach((req) => markRequirementRead(req.id));
1690
+ requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1552
1691
  return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1553
1692
  });
1554
1693
  server.registerTool("planner-handoff-write", {
1555
- description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Never guess from in-progress status or stale context; completed/canceled phases reject new handoffs. Pass content (markdown) and a meaningful title.",
1694
+ description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run planner-handoff-prepare with the confirmed phaseRef first and pass its exact handoffUpdatedAt token.",
1556
1695
  inputSchema: {
1557
1696
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1558
- title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'."),
1697
+ title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
1559
1698
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
1560
- content: z.string().min(1).describe("Handoff text (markdown)."),
1699
+ content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
1700
+ expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
1701
+ reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
1702
+ taskUpdates: z.array(z.object({
1703
+ taskRef: z.string().min(1),
1704
+ completionSummary: z.string().min(1),
1705
+ verification: z.string().min(1),
1706
+ remainingWork: z.string().min(1),
1707
+ filesTouched: z.array(z.string().min(1)).optional(),
1708
+ decisions: z.array(z.string().min(1)).optional(),
1709
+ })).optional(),
1710
+ phaseUpdate: z.object({
1711
+ progressSummary: z.string().min(1),
1712
+ remainingWork: z.string().min(1),
1713
+ decisions: z.array(z.string().min(1)).optional(),
1714
+ }).optional(),
1715
+ phaseNoUpdateReason: z.string().min(1).optional(),
1716
+ featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
1717
+ featureNoUpdateReason: z.string().min(1).optional(),
1561
1718
  },
1562
- }, async ({ phaseRef, title, confirmed, content }) => {
1719
+ }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
1563
1720
  const st = await requireStore();
1564
1721
  let body = content.trim();
1565
- const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1722
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
1566
1723
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1567
- const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
1568
- const effectiveHeadingText = title?.trim() ? title.trim() : firstHeadingText;
1569
- const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
1570
- if (isGeneric) {
1571
- return text("❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted.");
1724
+ const effectiveHeadingText = title?.trim() || firstHeadingText;
1725
+ if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
1726
+ return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
1572
1727
  }
1573
- if (title && title.trim()) {
1574
- const t = title.trim();
1728
+ if (title?.trim()) {
1575
1729
  const lines = body.split(/\r?\n/);
1576
- const firstIdx = lines.findIndex((l) => l.trim().length > 0);
1577
- if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
1578
- lines[firstIdx] = `# ${t}`;
1579
- body = lines.join("\n");
1580
- }
1581
- else {
1582
- body = `# ${t}\n\n${body}`;
1583
- }
1730
+ const firstIdx = lines.findIndex((line) => line.trim().length > 0);
1731
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
1732
+ lines[firstIdx] = `# ${title.trim()}`;
1733
+ else
1734
+ lines.unshift(`# ${title.trim()}`, "");
1735
+ body = lines.join("\n");
1584
1736
  }
1585
1737
  const r = await resolvePhaseForHandoff(st, phaseRef);
1586
1738
  if (!r.ok)
1587
1739
  return text(`❌ ${r.error}`);
1588
1740
  if (!confirmed)
1589
- return text(`Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.`, { phaseRef: r.compositeRef, confirmationRequired: true });
1590
- await st.setPhaseHandoff(r.phase.id, body);
1591
- return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1741
+ return text(`Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run planner-handoff-prepare with the exact phaseRef.`, { phaseRef: r.compositeRef, confirmationRequired: true });
1742
+ if (expectedHandoffUpdatedAt === undefined)
1743
+ return text("❌ Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
1744
+ const phases = await st.loadAllPhases();
1745
+ const features = (await st.loadFeatures()).features;
1746
+ const resolvedTaskUpdates = [];
1747
+ for (const update of taskUpdates ?? []) {
1748
+ const found = findTaskByRef(phases, features, update.taskRef.trim());
1749
+ if (!found || found.phase.id !== r.phase.id)
1750
+ return text(`❌ Task ${update.taskRef} does not belong to ${r.compositeRef}.`);
1751
+ resolvedTaskUpdates.push({
1752
+ taskId: found.task.id,
1753
+ completionSummary: update.completionSummary,
1754
+ verification: update.verification,
1755
+ remainingWork: update.remainingWork,
1756
+ ...(update.filesTouched ? { filesTouched: update.filesTouched } : {}),
1757
+ ...(update.decisions ? { decisions: update.decisions } : {}),
1758
+ });
1759
+ }
1760
+ try {
1761
+ const result = await st.refreshPhaseHandoff(r.phase.id, {
1762
+ content: body,
1763
+ expectedHandoffUpdatedAt,
1764
+ reconciledExistingHandoff: reconciledExistingHandoff === true,
1765
+ contextSync: {
1766
+ taskUpdates: resolvedTaskUpdates,
1767
+ ...(phaseUpdate ? { phaseUpdate: {
1768
+ progressSummary: phaseUpdate.progressSummary,
1769
+ remainingWork: phaseUpdate.remainingWork,
1770
+ ...(phaseUpdate.decisions ? { decisions: phaseUpdate.decisions } : {}),
1771
+ } } : {}),
1772
+ ...(phaseNoUpdateReason ? { phaseNoUpdateReason } : {}),
1773
+ ...(featureUpdate ? { featureUpdate } : {}),
1774
+ ...(featureNoUpdateReason ? { featureNoUpdateReason } : {}),
1775
+ },
1776
+ });
1777
+ await st.writeGenerated();
1778
+ return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result });
1779
+ }
1780
+ catch (error) {
1781
+ const message = error instanceof Error ? error.message : String(error);
1782
+ return text(`❌ Handoff refresh denied: ${message}`, { error: message });
1783
+ }
1592
1784
  });
1593
1785
  server.registerTool("planner-handoff-prepare", {
1594
- description: "Prepare a canonical handoff proposal. Do not write until the agent has identified the exact feature+phase from the conversation and the user has confirmed that target.",
1595
- }, async () => text([
1596
- "First identify the exact feature and phase actually discussed/worked on in this session. Do not use the first in-progress phase and do not target a phase that just became done.",
1597
- "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1598
- "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1599
- "Pass a meaningful title. Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
1600
- "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
1601
- ].join("\n")));
1786
+ description: "Identify or audit the exact handoff target. Without phaseRef, returns the confirmation workflow. With a confirmed phaseRef, returns the current handoff/version and done tasks missing durable completion evidence.",
1787
+ inputSchema: { phaseRef: z.string().min(1).optional() },
1788
+ }, async ({ phaseRef }) => {
1789
+ if (!phaseRef)
1790
+ return text([
1791
+ "First identify the exact feature and phase actually discussed/worked on in this session. Do not use a stale resume pointer or target a phase that just became done.",
1792
+ "Tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1793
+ "After confirmation, call planner-handoff-prepare again with that exact phaseRef, then reconcile its current handoff and missing task evidence through planner-handoff-write.",
1794
+ ].join("\n"));
1795
+ const st = await requireStore();
1796
+ const r = await resolvePhaseForHandoff(st, phaseRef);
1797
+ if (!r.ok)
1798
+ return text(`❌ ${r.error}`);
1799
+ try {
1800
+ const audit = await st.preparePhaseHandoff(r.phase.id);
1801
+ const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
1802
+ return text([
1803
+ `Handoff preparation audit for ${r.compositeRef}`,
1804
+ `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
1805
+ "Done tasks missing durable completion/verification evidence:",
1806
+ missing,
1807
+ "",
1808
+ "Existing active handoff (reconcile all still-relevant content):",
1809
+ audit.handoff.trim() || "(none)",
1810
+ ].join("\n"), { phaseRef: r.compositeRef, ...audit });
1811
+ }
1812
+ catch (error) {
1813
+ const message = error instanceof Error ? error.message : String(error);
1814
+ return text(`❌ Handoff preparation failed: ${message}`, { error: message });
1815
+ }
1816
+ });
1602
1817
  server.registerTool("planner-handoff-clear", {
1603
1818
  description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1604
1819
  inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "private": false,
5
5
  "description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
6
6
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/sdk": "^1.17.0",
45
45
  "zod": "^3.24.0",
46
- "@agent-plan/core": "0.2.23",
47
- "@agent-plan/server": "0.2.23"
46
+ "@agent-plan/core": "0.2.25",
47
+ "@agent-plan/server": "0.2.25"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",