@agent-plan/mcp 0.2.22 → 0.2.23-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAo+CA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAmvDA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -3,15 +3,32 @@ 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, packageVersionFromModule, resolvedPackageVersion } 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
- const STATUS_VALUES = ["planned", "in-progress", "paused", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
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 plannerSessionId = randomUUID();
14
+ startReadSession(plannerSessionId);
12
15
  function text(textValue, structuredContent) {
13
16
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
14
17
  }
18
+ function taskStartError(outcome, structuredContent = {}) {
19
+ return {
20
+ isError: true,
21
+ content: [{ type: "text", text: [
22
+ `❌ TASK START FAILED [${outcome.errorCode}]`,
23
+ "started: false",
24
+ outcome.message,
25
+ "No task lifecycle transition was applied.",
26
+ "Next required actions:",
27
+ ...outcome.nextActions.map((action, index) => `${index + 1}. ${action}`),
28
+ ].join("\n") }],
29
+ structuredContent: { ...outcome, ...structuredContent },
30
+ };
31
+ }
15
32
  const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
16
33
  const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
17
34
  function nowISO() {
@@ -431,6 +448,7 @@ server.registerTool("planner-feature-add", {
431
448
  phaseIds: [],
432
449
  dependsOn: [],
433
450
  statusLog: [],
451
+ sessionInfo: [],
434
452
  createdAt: timestamp,
435
453
  updatedAt: timestamp,
436
454
  };
@@ -450,6 +468,8 @@ server.registerTool("planner-feature-show", {
450
468
  if (!resolvedFeature.ok)
451
469
  return text(resolvedFeature.error);
452
470
  const feature = resolvedFeature.feature;
471
+ if (full)
472
+ markFeatureReadForSessionId(plannerSessionId, feature.id);
453
473
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
454
474
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
455
475
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
@@ -632,6 +652,7 @@ server.registerTool("planner-phase-add", {
632
652
  handoffReadAt: "",
633
653
  handoffHistory: [],
634
654
  statusLog: [],
655
+ sessionInfo: [],
635
656
  };
636
657
  await st.savePhase(phase);
637
658
  if (feature) {
@@ -657,6 +678,8 @@ server.registerTool("planner-phase-show", {
657
678
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
658
679
  if (!phase)
659
680
  return text(`Phase not found: ${ref}`);
681
+ if (full)
682
+ markPhaseReadForSessionId(plannerSessionId, phase.id);
660
683
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
661
684
  const reqCount = linkedRequirements.length;
662
685
  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"}` : ""})`;
@@ -818,6 +841,7 @@ server.registerTool("planner-task-add", {
818
841
  descriptionUpdatedAt: timestamp,
819
842
  notes: "",
820
843
  statusLog: [],
844
+ sessionInfo: [],
821
845
  decisions: [],
822
846
  acceptedDecisions: [],
823
847
  checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
@@ -841,18 +865,44 @@ server.registerTool("planner-task-add", {
841
865
  });
842
866
  server.registerTool("planner-task-show", {
843
867
  description: "Show a task by id or name.",
844
- 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 + statusLog. Default: compact identity only (saves tokens).") },
868
+ 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).") },
845
869
  }, async ({ task: ref, full }) => {
846
870
  const st = await requireStore();
847
871
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
848
872
  if (!found)
849
873
  return text(`Task not found: ${ref}`);
874
+ if (full)
875
+ markTaskReadForSessionId(plannerSessionId, found.task.id);
850
876
  const features = (await st.loadFeatures()).features;
851
877
  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))})`;
852
878
  if (!full)
853
879
  return text(summary);
880
+ const project = await st.loadProject();
881
+ const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
882
+ ? undefined
883
+ : project.workDeviations
884
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
885
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
886
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
887
+ const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
854
888
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
855
- return text(`${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}`);
889
+ const sections = [summary];
890
+ if (found.task.description?.trim())
891
+ sections.push(found.task.description.trim());
892
+ if (snapshot || pendingDeviation) {
893
+ sections.push([
894
+ pendingDeviation
895
+ ? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
896
+ : "Resume checkpoint:",
897
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
898
+ snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
899
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
900
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
901
+ ].filter(Boolean).join("\n"));
902
+ }
903
+ if (log)
904
+ sections.push(`Status log:\n${log}`);
905
+ return text(sections.join("\n\n"));
856
906
  });
857
907
  server.registerTool("planner-task-discuss", {
858
908
  description: "Persist task execution notes/checklist.",
@@ -902,8 +952,11 @@ server.registerTool("planner-task-update", {
902
952
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
903
953
  if (!found)
904
954
  return text(`Task not found: ${ref}`);
905
- if (status === "paused") {
906
- return text("Use planner-task-pause or planner-task-switch to enter paused state; a structured resume checkpoint is mandatory.");
955
+ if (status === "in-progress" && status !== found.task.status) {
956
+ return text("Task start/resume transitions require planner-task-start so lifecycle state, checkpoints, and return stacks remain consistent.");
957
+ }
958
+ if (status === "done" && status !== found.task.status) {
959
+ return text("Task completion transitions require planner-task-complete with durable completion and verification evidence.");
907
960
  }
908
961
  // Validate motivation requirement for status transitions.
909
962
  if (status !== undefined && needsMotivation(found.task.status, status)) {
@@ -1082,12 +1135,12 @@ server.registerTool("planner-task-delete", {
1082
1135
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1083
1136
  });
1084
1137
  server.registerTool("planner-task-recommend", {
1085
- description: "Return the harness-agnostic recommended task: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports dependency, availability, and approved deviation/resume context without starting or blocking work.",
1138
+ 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.",
1086
1139
  inputSchema: {},
1087
1140
  }, async () => {
1088
1141
  const st = await requireStore();
1089
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1090
- const result = recommendNextTask(features.features, phases, project.workDeviations);
1142
+ const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1143
+ const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
1091
1144
  if (!result.candidate)
1092
1145
  return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1093
1146
  const { candidate } = result;
@@ -1101,11 +1154,11 @@ server.registerTool("planner-task-deviation", {
1101
1154
  inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
1102
1155
  }, async ({ temporary_task, resume_task, reason }) => {
1103
1156
  const st = await requireStore();
1104
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1157
+ const [features, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1105
1158
  const temporary = findTaskByRef(phases, features.features, temporary_task);
1106
1159
  if (!temporary)
1107
1160
  return text(`Task not found: ${temporary_task}`);
1108
- const selected = recommendNextTask(features.features, phases, project.workDeviations);
1161
+ const selected = recommendNextTask(features.features, phases, project.workDeviations, focus?.currentPhaseId);
1109
1162
  const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
1110
1163
  if (!resume)
1111
1164
  return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
@@ -1146,7 +1199,8 @@ server.registerTool("planner-task-pause", {
1146
1199
  resumeLocation: resume_location, howToResume: how_to_resume, relatedTaskId: "",
1147
1200
  pausedAt: nowISO(), pausedBy: paused_by?.trim() ?? "",
1148
1201
  };
1149
- const paused = await st.pauseTask(found.phase.id, found.task.id, snapshot);
1202
+ invalidateReads(plannerSessionId);
1203
+ const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
1150
1204
  await st.syncTaskStatusRollup(found.phase.id);
1151
1205
  const activeDeviation = (await st.loadProject()).workDeviations
1152
1206
  .filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
@@ -1161,15 +1215,15 @@ server.registerTool("planner-task-pause", {
1161
1215
  ? `\n↩️ RESUME REQUIRED: ${taskCompositeRef(resume.task, resume.phase, features)} — ${resume.task.title}`
1162
1216
  : "";
1163
1217
  return writeAndSummarize(st, [
1164
- `⏸️ Task paused: ${taskCompositeRef(paused, found.phase, features)} — ${paused.title}`,
1218
+ `💾 Resume checkpoint saved: ${taskCompositeRef(checkpointed, found.phase, features)} — ${checkpointed.title}`,
1165
1219
  `Why: ${snapshot.reason}`,
1166
1220
  `Checkpoint: ${snapshot.whatWasBeingDone}`,
1167
1221
  `Resume from: ${snapshot.resumeLocation}`,
1168
1222
  `How to resume: ${snapshot.howToResume}${resumeNotice}`,
1169
- ].join("\n"), { task: paused, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1223
+ ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1170
1224
  });
1171
1225
  server.registerTool("planner-task-switch", {
1172
- 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.",
1226
+ description: "Atomically switch from active work to a temporary task, even outside normal priority order. Reuse the target's valid sessionInfo context or require the exact full tree when missing or stale. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
1173
1227
  inputSchema: {
1174
1228
  from_task: z.string().min(1),
1175
1229
  to_task: z.string().min(1),
@@ -1181,7 +1235,7 @@ server.registerTool("planner-task-switch", {
1181
1235
  },
1182
1236
  }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }) => {
1183
1237
  const st = await requireStore();
1184
- const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1238
+ const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
1185
1239
  const features = featuresDoc.features;
1186
1240
  const source = findTaskByRef(phases, features, from_task);
1187
1241
  const target = findTaskByRef(phases, features, to_task);
@@ -1191,8 +1245,34 @@ server.registerTool("planner-task-switch", {
1191
1245
  return text(`Task not found: ${to_task}`);
1192
1246
  if (source.task.id === target.task.id)
1193
1247
  return text("Source and temporary task must differ.");
1194
- if (source.task.status !== "in-progress" && source.task.status !== "paused") {
1195
- return text(`Task switch denied: source is ${source.task.status}; only in-progress or pending-resume paused work can be switched.`);
1248
+ if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1249
+ return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1250
+ }
1251
+ const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
1252
+ const linkedRequirements = [
1253
+ ...(await st.linkedRequirementsForPhase(target.phase.id)),
1254
+ ...(target.phase.featureId ? await st.linkedRequirementsForFeature(target.phase.featureId) : []),
1255
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1256
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1257
+ const contextInput = {
1258
+ sessionId: plannerSessionId,
1259
+ taskId: target.task.id,
1260
+ phaseId: target.phase.id,
1261
+ ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}),
1262
+ task: target.task,
1263
+ phase: target.phase,
1264
+ ...(targetFeature ? { feature: targetFeature } : {}),
1265
+ requirements: linkedRequirements,
1266
+ requirementIds: linkedRequirementIds,
1267
+ };
1268
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
1269
+ if (!contextEligibility.eligible)
1270
+ return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, contextEligibility);
1271
+ if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
1272
+ return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds });
1273
+ }
1274
+ if (!hasValidSessionAttestation(contextInput)) {
1275
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1196
1276
  }
1197
1277
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
1198
1278
  if (!eligibility.eligible)
@@ -1203,7 +1283,7 @@ server.registerTool("planner-task-switch", {
1203
1283
  resumeLocation: resume_location, howToResume: how_to_resume,
1204
1284
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
1205
1285
  };
1206
- const selection = recommendNextTask(features, phases, project.workDeviations);
1286
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1207
1287
  const record = {
1208
1288
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
1209
1289
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
@@ -1222,7 +1302,7 @@ server.registerTool("planner-task-switch", {
1222
1302
  await st.updatePhase(source.phase.id, (phase) => {
1223
1303
  const task = phase.tasks.find((entry) => entry.id === source.task.id);
1224
1304
  if (!task)
1225
- throw new Error(`Paused source task disappeared: ${from_task}`);
1305
+ throw new Error(`Checkpointed source task disappeared: ${from_task}`);
1226
1306
  task.pauseSnapshot = snapshot;
1227
1307
  task.pauseHistory = [...task.pauseHistory, snapshot];
1228
1308
  task.updatedAt = timestamp;
@@ -1230,7 +1310,7 @@ server.registerTool("planner-task-switch", {
1230
1310
  });
1231
1311
  }
1232
1312
  sourceCheckpointed = true;
1233
- if (target.task.status === "paused") {
1313
+ if (target.task.pauseSnapshot) {
1234
1314
  await st.resumeTask(target.phase.id, target.task.id, timestamp);
1235
1315
  }
1236
1316
  else {
@@ -1252,7 +1332,7 @@ server.registerTool("planner-task-switch", {
1252
1332
  }
1253
1333
  targetStarted = true;
1254
1334
  await st.addWorkDeviation(record);
1255
- if (target.task.status === "paused") {
1335
+ if (target.task.pauseSnapshot) {
1256
1336
  const resumedDeviation = project.workDeviations
1257
1337
  .filter((deviation) => deviation.resumeTaskId === target.task.id
1258
1338
  && (deviation.state === "resume-required" || deviation.state === "resolved"))
@@ -1282,45 +1362,104 @@ server.registerTool("planner-task-switch", {
1282
1362
  await st.syncTaskStatusRollup(source.phase.id);
1283
1363
  if (target.phase.id !== source.phase.id)
1284
1364
  await st.syncTaskStatusRollup(target.phase.id);
1365
+ invalidateReads(plannerSessionId);
1285
1366
  return writeAndSummarize(st, [
1286
1367
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
1287
- `Paused snapshot: ${snapshot.whatWasBeingDone}`,
1368
+ `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
1288
1369
  `Return target: ${taskCompositeRef(source.task, source.phase, features)} from ${snapshot.resumeLocation}`,
1289
1370
  `Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
1290
1371
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1291
1372
  });
1292
1373
  server.registerTool("planner-task-start", {
1293
- description: "Set a task to in-progress or resume a paused task. BEFORE calling, use planner-task-show with full=true and planner-phase-show with full=true for the parent phase, including linked requirements. A different active task must be suspended through planner-task-switch first. Otherwise follow active/pending-resume/feature → phase → task priority guidance.",
1374
+ description: "Set a task to in-progress or resume checkpointed work. Read the exact full task → phase → feature tree and linked requirements once per harness session; subsequent starts reuse persisted sessionInfo while entity updatedAt values remain unchanged. Every stale or denied start is an MCP isError result with started=false and exact retry actions; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
1294
1375
  inputSchema: { task: z.string().min(1) },
1295
1376
  }, async ({ task: ref }) => {
1296
- const st = await requireStore();
1377
+ let st;
1378
+ try {
1379
+ st = await requireStore();
1380
+ }
1381
+ catch {
1382
+ return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
1383
+ }
1297
1384
  const features = (await st.loadFeatures()).features;
1298
1385
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1299
1386
  if (!found)
1300
- return text(`Task not found: ${ref}`);
1301
- if (found.task.status === "in-progress")
1302
- return text(`Task ${found.task.id} is already in-progress.`);
1387
+ 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."]));
1388
+ const taskRef = taskCompositeRef(found.task, found.phase, features);
1389
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
1390
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
1391
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
1392
+ if (found.task.status === "in-progress") {
1393
+ const outcome = taskStartSucceeded(found.task.id, true);
1394
+ return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
1395
+ }
1303
1396
  if (found.task.status === "done")
1304
- return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1305
- const project = await st.loadProject();
1306
- const phases = await st.loadAllPhases();
1397
+ 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 }));
1398
+ const linkedRequirements = [
1399
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
1400
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
1401
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
1402
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
1403
+ const contextInput = {
1404
+ sessionId: plannerSessionId,
1405
+ taskId: found.task.id,
1406
+ phaseId: found.phase.id,
1407
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
1408
+ task: found.task,
1409
+ phase: found.phase,
1410
+ ...(parentFeature ? { feature: parentFeature } : {}),
1411
+ requirements: linkedRequirements,
1412
+ requirementIds: linkedRequirementIds,
1413
+ };
1414
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
1415
+ if (!contextEligibility.eligible)
1416
+ return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, [
1417
+ `planner-task-show ${taskRef} with full=true`,
1418
+ `planner-phase-show ${phaseRef} with full=true`,
1419
+ ...(featureRef ? [`planner-feature-show ${featureRef} with full=true`] : []),
1420
+ ...(linkedRequirementIds.length > 0 ? ["planner-requirement-list"] : []),
1421
+ `Retry planner-task-start ${taskRef}`,
1422
+ ], { taskId: found.task.id }), { contextEligibility });
1423
+ if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
1424
+ 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 }));
1425
+ }
1426
+ if (!hasValidSessionAttestation(contextInput)) {
1427
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
1428
+ }
1429
+ const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1307
1430
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1308
1431
  if (!eligibility.eligible)
1309
- return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1310
- const selection = recommendNextTask(features, phases, project.workDeviations);
1432
+ return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
1433
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1311
1434
  if (selection.kind === "conflict") {
1312
- return text(`${selection.reason} Pause/reconcile active tasks before starting another task.`, {
1313
- activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1314
- });
1435
+ return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
1315
1436
  }
1316
1437
  if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
1317
- 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)}.`);
1438
+ const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
1439
+ 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 });
1318
1440
  }
1319
- if (selection.kind === "resume" && selection.candidate?.task.id !== found.task.id) {
1320
- return text(`Task start denied: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is pending resume. Resume it now, or use planner-task-switch from that paused task to ${taskCompositeRef(found.task, found.phase, features)} with a new checkpoint and reason.`);
1441
+ let resumeProposal;
1442
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
1443
+ // Advisory only: a pending resume must be surfaced loudly, but it must
1444
+ // not hard-block an explicit start of a different task. Capture a
1445
+ // structured proposal so the resume-first guidance is explicit and
1446
+ // machine-readable for the host/agent.
1447
+ const resumeTask = selection.candidate;
1448
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
1449
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
1450
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
1451
+ ?? null;
1452
+ resumeProposal = buildResumeRequiredProposal({
1453
+ ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
1454
+ title: resumeTask.task.title,
1455
+ taskId: resumeTask.task.id,
1456
+ phaseId: resumeTask.phase.id,
1457
+ snapshot: resumeSnapshot
1458
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
1459
+ : null,
1460
+ });
1321
1461
  }
1322
1462
  // Assemble the mandatory parent context before changing task lifecycle state.
1323
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1324
1463
  const [phaseWithRequirements, featureRequirements] = await Promise.all([
1325
1464
  st.loadPhaseWithRequirements(found.phase.id),
1326
1465
  parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
@@ -1330,11 +1469,17 @@ server.registerTool("planner-task-start", {
1330
1469
  // archived only when the phase completes (auto) or the user explicitly
1331
1470
  // clears it.
1332
1471
  const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
1333
- ? `\n\n⚠️ ${selection.kind === "resume" ? "Resume" : "Priority"} advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1472
+ ? selection.kind === "resume"
1473
+ ? `\n\n${(() => {
1474
+ if (resumeProposal)
1475
+ return resumeProposal.text;
1476
+ 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.`;
1477
+ })()}`
1478
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1334
1479
  : "";
1335
1480
  const timestamp = nowISO();
1336
1481
  let updatedTask;
1337
- if (found.task.status === "paused") {
1482
+ if (found.task.pauseSnapshot) {
1338
1483
  updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp);
1339
1484
  }
1340
1485
  else {
@@ -1369,23 +1514,35 @@ server.registerTool("planner-task-start", {
1369
1514
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1370
1515
  if (resumedDeviation)
1371
1516
  await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1372
- const t = updatedTask ?? found.task;
1373
- return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
1517
+ await st.writeGenerated();
1518
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
1519
+ if (!updatedTask || persistedTask?.status !== "in-progress") {
1520
+ 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 }));
1521
+ }
1522
+ const outcome = taskStartSucceeded(persistedTask.id);
1523
+ return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
1524
+ ...outcome,
1525
+ task: persistedTask,
1526
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1527
+ });
1374
1528
  });
1375
1529
  server.registerTool("planner-task-complete", {
1376
- description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
1530
+ description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete unless force=true.",
1377
1531
  inputSchema: {
1378
1532
  task: z.string().min(1),
1379
1533
  force: z.boolean().optional(),
1380
- 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."),
1534
+ 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."),
1381
1535
  },
1382
1536
  }, async ({ task: ref, force, description_update }) => {
1383
1537
  const st = await requireStore();
1384
1538
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1385
1539
  if (!found)
1386
1540
  return text(`Task not found: ${ref}`);
1387
- if (found.task.status === "paused")
1388
- return text("Task completion denied: resume paused work with planner-task-start before completing it, or cancel it explicitly with motivation.");
1541
+ const completionSummary = description_update.trim();
1542
+ if (completionSummary.length < 10)
1543
+ return text("Task completion denied: provide at least 10 characters of durable completion and verification evidence.");
1544
+ if (found.task.pauseSnapshot)
1545
+ return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
1389
1546
  const unchecked = found.task.checklist.filter((item) => !item.checked);
1390
1547
  if (unchecked.length > 0 && !force)
1391
1548
  return text(`${unchecked.length} checklist item(s) not done. Re-run with force=true to complete anyway.`);
@@ -1403,13 +1560,12 @@ server.registerTool("planner-task-complete", {
1403
1560
  fromStatus: previousStatus,
1404
1561
  toStatus: "done",
1405
1562
  title: `${previousStatus} → done`,
1406
- description: "",
1563
+ description: completionSummary,
1407
1564
  };
1408
1565
  task.statusLog = [...(task.statusLog ?? []), entry];
1409
- if (description_update) {
1410
- const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
1411
- task.description = task.description + sep + description_update;
1412
- }
1566
+ const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
1567
+ task.description = task.description + sep + completionSummary;
1568
+ task.descriptionUpdatedAt = timestamp;
1413
1569
  task.updatedAt = timestamp;
1414
1570
  phase.updatedAt = timestamp;
1415
1571
  updatedTask = task;
@@ -1432,7 +1588,7 @@ server.registerTool("planner-task-complete", {
1432
1588
  resumeNotice = [
1433
1589
  "",
1434
1590
  `↩️ RESUME REQUIRED: ${taskCompositeRef(resume.task, resume.phase, features)} — ${resume.task.title}`,
1435
- snapshot ? `Paused because: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
1591
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
1436
1592
  snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
1437
1593
  snapshot ? `How to resume: ${snapshot.howToResume}` : "",
1438
1594
  `Next action: planner-task-start ${taskCompositeRef(resume.task, resume.phase, features)}`,
@@ -1465,52 +1621,139 @@ server.registerTool("planner-handoff-show", {
1465
1621
  return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, empty: true });
1466
1622
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1467
1623
  });
1624
+ server.registerTool("planner-requirement-list", {
1625
+ 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).",
1626
+ inputSchema: {},
1627
+ }, async () => {
1628
+ const st = await requireStore();
1629
+ const requirements = await st.loadRequirements();
1630
+ requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1631
+ return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1632
+ });
1468
1633
  server.registerTool("planner-handoff-write", {
1469
- 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.",
1634
+ 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.",
1470
1635
  inputSchema: {
1471
1636
  phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1472
- 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'."),
1637
+ title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
1473
1638
  confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
1474
- content: z.string().min(1).describe("Handoff text (markdown)."),
1639
+ content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
1640
+ expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
1641
+ reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
1642
+ taskUpdates: z.array(z.object({
1643
+ taskRef: z.string().min(1),
1644
+ completionSummary: z.string().min(1),
1645
+ verification: z.string().min(1),
1646
+ remainingWork: z.string().min(1),
1647
+ filesTouched: z.array(z.string().min(1)).optional(),
1648
+ decisions: z.array(z.string().min(1)).optional(),
1649
+ })).optional(),
1650
+ phaseUpdate: z.object({
1651
+ progressSummary: z.string().min(1),
1652
+ remainingWork: z.string().min(1),
1653
+ decisions: z.array(z.string().min(1)).optional(),
1654
+ }).optional(),
1655
+ phaseNoUpdateReason: z.string().min(1).optional(),
1656
+ featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
1657
+ featureNoUpdateReason: z.string().min(1).optional(),
1475
1658
  },
1476
- }, async ({ phaseRef, title, confirmed, content }) => {
1659
+ }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
1477
1660
  const st = await requireStore();
1478
1661
  let body = content.trim();
1479
- const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1662
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
1480
1663
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1481
- const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
1482
- if (!title && isGeneric) {
1483
- 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.");
1664
+ const effectiveHeadingText = title?.trim() || firstHeadingText;
1665
+ if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
1666
+ return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
1484
1667
  }
1485
- if (title && title.trim()) {
1486
- const t = title.trim();
1668
+ if (title?.trim()) {
1487
1669
  const lines = body.split(/\r?\n/);
1488
- const firstIdx = lines.findIndex((l) => l.trim().length > 0);
1489
- if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
1490
- lines[firstIdx] = `# ${t}`;
1491
- body = lines.join("\n");
1492
- }
1493
- else {
1494
- body = `# ${t}\n\n${body}`;
1495
- }
1670
+ const firstIdx = lines.findIndex((line) => line.trim().length > 0);
1671
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
1672
+ lines[firstIdx] = `# ${title.trim()}`;
1673
+ else
1674
+ lines.unshift(`# ${title.trim()}`, "");
1675
+ body = lines.join("\n");
1496
1676
  }
1497
1677
  const r = await resolvePhaseForHandoff(st, phaseRef);
1498
1678
  if (!r.ok)
1499
1679
  return text(`❌ ${r.error}`);
1500
1680
  if (!confirmed)
1501
- 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 });
1502
- await st.setPhaseHandoff(r.phase.id, body);
1503
- return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1681
+ 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 });
1682
+ if (expectedHandoffUpdatedAt === undefined)
1683
+ return text("❌ Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
1684
+ const phases = await st.loadAllPhases();
1685
+ const features = (await st.loadFeatures()).features;
1686
+ const resolvedTaskUpdates = [];
1687
+ for (const update of taskUpdates ?? []) {
1688
+ const found = findTaskByRef(phases, features, update.taskRef.trim());
1689
+ if (!found || found.phase.id !== r.phase.id)
1690
+ return text(`❌ Task ${update.taskRef} does not belong to ${r.compositeRef}.`);
1691
+ resolvedTaskUpdates.push({
1692
+ taskId: found.task.id,
1693
+ completionSummary: update.completionSummary,
1694
+ verification: update.verification,
1695
+ remainingWork: update.remainingWork,
1696
+ ...(update.filesTouched ? { filesTouched: update.filesTouched } : {}),
1697
+ ...(update.decisions ? { decisions: update.decisions } : {}),
1698
+ });
1699
+ }
1700
+ try {
1701
+ const result = await st.refreshPhaseHandoff(r.phase.id, {
1702
+ content: body,
1703
+ expectedHandoffUpdatedAt,
1704
+ reconciledExistingHandoff: reconciledExistingHandoff === true,
1705
+ contextSync: {
1706
+ taskUpdates: resolvedTaskUpdates,
1707
+ ...(phaseUpdate ? { phaseUpdate: {
1708
+ progressSummary: phaseUpdate.progressSummary,
1709
+ remainingWork: phaseUpdate.remainingWork,
1710
+ ...(phaseUpdate.decisions ? { decisions: phaseUpdate.decisions } : {}),
1711
+ } } : {}),
1712
+ ...(phaseNoUpdateReason ? { phaseNoUpdateReason } : {}),
1713
+ ...(featureUpdate ? { featureUpdate } : {}),
1714
+ ...(featureNoUpdateReason ? { featureNoUpdateReason } : {}),
1715
+ },
1716
+ });
1717
+ await st.writeGenerated();
1718
+ return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result });
1719
+ }
1720
+ catch (error) {
1721
+ const message = error instanceof Error ? error.message : String(error);
1722
+ return text(`❌ Handoff refresh denied: ${message}`, { error: message });
1723
+ }
1504
1724
  });
1505
1725
  server.registerTool("planner-handoff-prepare", {
1506
- 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.",
1507
- }, async () => text([
1508
- "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.",
1509
- "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1510
- "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1511
- "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.",
1512
- "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
1513
- ].join("\n")));
1726
+ 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.",
1727
+ inputSchema: { phaseRef: z.string().min(1).optional() },
1728
+ }, async ({ phaseRef }) => {
1729
+ if (!phaseRef)
1730
+ return text([
1731
+ "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.",
1732
+ "Tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1733
+ "After confirmation, call planner-handoff-prepare again with that exact phaseRef, then reconcile its current handoff and missing task evidence through planner-handoff-write.",
1734
+ ].join("\n"));
1735
+ const st = await requireStore();
1736
+ const r = await resolvePhaseForHandoff(st, phaseRef);
1737
+ if (!r.ok)
1738
+ return text(`❌ ${r.error}`);
1739
+ try {
1740
+ const audit = await st.preparePhaseHandoff(r.phase.id);
1741
+ const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
1742
+ return text([
1743
+ `Handoff preparation audit for ${r.compositeRef}`,
1744
+ `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
1745
+ "Done tasks missing durable completion/verification evidence:",
1746
+ missing,
1747
+ "",
1748
+ "Existing active handoff (reconcile all still-relevant content):",
1749
+ audit.handoff.trim() || "(none)",
1750
+ ].join("\n"), { phaseRef: r.compositeRef, ...audit });
1751
+ }
1752
+ catch (error) {
1753
+ const message = error instanceof Error ? error.message : String(error);
1754
+ return text(`❌ Handoff preparation failed: ${message}`, { error: message });
1755
+ }
1756
+ });
1514
1757
  server.registerTool("planner-handoff-clear", {
1515
1758
  description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1516
1759
  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.22",
3
+ "version": "0.2.23-next.0",
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.22",
47
- "@agent-plan/server": "0.2.22"
46
+ "@agent-plan/core": "0.2.23-next.0",
47
+ "@agent-plan/server": "0.2.23-next.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",