@agent-plan/mcp 0.2.22-next.4 → 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":";AAwjDA,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, 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 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
  };
@@ -451,7 +469,7 @@ server.registerTool("planner-feature-show", {
451
469
  return text(resolvedFeature.error);
452
470
  const feature = resolvedFeature.feature;
453
471
  if (full)
454
- markFeatureRead(feature.id);
472
+ markFeatureReadForSessionId(plannerSessionId, feature.id);
455
473
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
456
474
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
457
475
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
@@ -634,6 +652,7 @@ server.registerTool("planner-phase-add", {
634
652
  handoffReadAt: "",
635
653
  handoffHistory: [],
636
654
  statusLog: [],
655
+ sessionInfo: [],
637
656
  };
638
657
  await st.savePhase(phase);
639
658
  if (feature) {
@@ -660,7 +679,7 @@ server.registerTool("planner-phase-show", {
660
679
  if (!phase)
661
680
  return text(`Phase not found: ${ref}`);
662
681
  if (full)
663
- markPhaseRead(phase.id, phase.featureId);
682
+ markPhaseReadForSessionId(plannerSessionId, phase.id);
664
683
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
665
684
  const reqCount = linkedRequirements.length;
666
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"}` : ""})`;
@@ -822,6 +841,7 @@ server.registerTool("planner-task-add", {
822
841
  descriptionUpdatedAt: timestamp,
823
842
  notes: "",
824
843
  statusLog: [],
844
+ sessionInfo: [],
825
845
  decisions: [],
826
846
  acceptedDecisions: [],
827
847
  checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
@@ -852,7 +872,7 @@ server.registerTool("planner-task-show", {
852
872
  if (!found)
853
873
  return text(`Task not found: ${ref}`);
854
874
  if (full)
855
- markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
875
+ markTaskReadForSessionId(plannerSessionId, found.task.id);
856
876
  const features = (await st.loadFeatures()).features;
857
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))})`;
858
878
  if (!full)
@@ -935,6 +955,9 @@ server.registerTool("planner-task-update", {
935
955
  if (status === "in-progress" && status !== found.task.status) {
936
956
  return text("Task start/resume transitions require planner-task-start so lifecycle state, checkpoints, and return stacks remain consistent.");
937
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.");
960
+ }
938
961
  // Validate motivation requirement for status transitions.
939
962
  if (status !== undefined && needsMotivation(found.task.status, status)) {
940
963
  if (!motivation || !motivation.trim()) {
@@ -1176,7 +1199,7 @@ server.registerTool("planner-task-pause", {
1176
1199
  resumeLocation: resume_location, howToResume: how_to_resume, relatedTaskId: "",
1177
1200
  pausedAt: nowISO(), pausedBy: paused_by?.trim() ?? "",
1178
1201
  };
1179
- invalidateReads();
1202
+ invalidateReads(plannerSessionId);
1180
1203
  const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
1181
1204
  await st.syncTaskStatusRollup(found.phase.id);
1182
1205
  const activeDeviation = (await st.loadProject()).workDeviations
@@ -1200,7 +1223,7 @@ server.registerTool("planner-task-pause", {
1200
1223
  ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1201
1224
  });
1202
1225
  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.",
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.",
1204
1227
  inputSchema: {
1205
1228
  from_task: z.string().min(1),
1206
1229
  to_task: z.string().min(1),
@@ -1225,16 +1248,32 @@ server.registerTool("planner-task-switch", {
1225
1248
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1226
1249
  return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1227
1250
  }
1228
- const contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
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);
1229
1269
  if (!contextEligibility.eligible)
1230
1270
  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)) {
1271
+ if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
1236
1272
  return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds });
1237
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 });
1276
+ }
1238
1277
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
1239
1278
  if (!eligibility.eligible)
1240
1279
  return text(`Task switch denied: ${eligibility.reason}`);
@@ -1323,7 +1362,7 @@ server.registerTool("planner-task-switch", {
1323
1362
  await st.syncTaskStatusRollup(source.phase.id);
1324
1363
  if (target.phase.id !== source.phase.id)
1325
1364
  await st.syncTaskStatusRollup(target.phase.id);
1326
- invalidateReads();
1365
+ invalidateReads(plannerSessionId);
1327
1366
  return writeAndSummarize(st, [
1328
1367
  `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
1329
1368
  `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
@@ -1332,40 +1371,72 @@ server.registerTool("planner-task-switch", {
1332
1371
  ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
1333
1372
  });
1334
1373
  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.",
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.",
1336
1375
  inputSchema: { task: z.string().min(1) },
1337
1376
  }, async ({ task: ref }) => {
1338
- 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
+ }
1339
1384
  const features = (await st.loadFeatures()).features;
1340
1385
  const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1341
1386
  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.`);
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
+ }
1345
1396
  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);
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);
1348
1415
  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 });
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 });
1356
1428
  }
1357
1429
  const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
1358
1430
  const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1359
1431
  if (!eligibility.eligible)
1360
- return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
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 });
1361
1433
  const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
1362
1434
  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
- });
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 });
1366
1436
  }
1367
1437
  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)}.`);
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 });
1369
1440
  }
1370
1441
  let resumeProposal;
1371
1442
  if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
@@ -1389,7 +1460,6 @@ server.registerTool("planner-task-start", {
1389
1460
  });
1390
1461
  }
1391
1462
  // 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
1463
  const [phaseWithRequirements, featureRequirements] = await Promise.all([
1394
1464
  st.loadPhaseWithRequirements(found.phase.id),
1395
1465
  parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
@@ -1444,23 +1514,33 @@ server.registerTool("planner-task-start", {
1444
1514
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1445
1515
  if (resumedDeviation)
1446
1516
  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}`, {
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,
1449
1526
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1450
1527
  });
1451
1528
  });
1452
1529
  server.registerTool("planner-task-complete", {
1453
- 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.",
1454
1531
  inputSchema: {
1455
1532
  task: z.string().min(1),
1456
1533
  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."),
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."),
1458
1535
  },
1459
1536
  }, async ({ task: ref, force, description_update }) => {
1460
1537
  const st = await requireStore();
1461
1538
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1462
1539
  if (!found)
1463
1540
  return text(`Task not found: ${ref}`);
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.");
1464
1544
  if (found.task.pauseSnapshot)
1465
1545
  return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
1466
1546
  const unchecked = found.task.checklist.filter((item) => !item.checked);
@@ -1480,13 +1560,12 @@ server.registerTool("planner-task-complete", {
1480
1560
  fromStatus: previousStatus,
1481
1561
  toStatus: "done",
1482
1562
  title: `${previousStatus} → done`,
1483
- description: "",
1563
+ description: completionSummary,
1484
1564
  };
1485
1565
  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
- }
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;
1490
1569
  task.updatedAt = timestamp;
1491
1570
  phase.updatedAt = timestamp;
1492
1571
  updatedTask = task;
@@ -1548,57 +1627,133 @@ server.registerTool("planner-requirement-list", {
1548
1627
  }, async () => {
1549
1628
  const st = await requireStore();
1550
1629
  const requirements = await st.loadRequirements();
1551
- requirements.requirements.forEach((req) => markRequirementRead(req.id));
1630
+ requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
1552
1631
  return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1553
1632
  });
1554
1633
  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.",
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.",
1556
1635
  inputSchema: {
1557
1636
  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'."),
1637
+ title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
1559
1638
  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)."),
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(),
1561
1658
  },
1562
- }, async ({ phaseRef, title, confirmed, content }) => {
1659
+ }, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
1563
1660
  const st = await requireStore();
1564
1661
  let body = content.trim();
1565
- 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) ?? "";
1566
1663
  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.");
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.");
1572
1667
  }
1573
- if (title && title.trim()) {
1574
- const t = title.trim();
1668
+ if (title?.trim()) {
1575
1669
  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
- }
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");
1584
1676
  }
1585
1677
  const r = await resolvePhaseForHandoff(st, phaseRef);
1586
1678
  if (!r.ok)
1587
1679
  return text(`❌ ${r.error}`);
1588
1680
  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 });
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
+ }
1592
1724
  });
1593
1725
  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")));
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
+ });
1602
1757
  server.registerTool("planner-handoff-clear", {
1603
1758
  description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1604
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-next.4",
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-next.4",
47
- "@agent-plan/server": "0.2.22-next.4"
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",