@agent-plan/mcp 0.2.20 → 0.2.22-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.
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  MCP server for Agent Plan.
4
4
 
5
- Exposes `planner-*` tools over stdio for hosts such as Claude Code.
5
+ Exposes `planner-*` tools over stdio for hosts such as Claude Code and Codex.
6
+
7
+ Use `planner-version` (or `/planner version` in the Claude plugin) to report the `@agent-plan/mcp` and `@agent-plan/core` versions actually loaded by the server process. The tool works without an initialized `.planner/` workspace.
6
8
 
7
9
  See the repository root README for full documentation:
8
10
  https://github.com/ovidius72/agent-planner#readme
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA+vCA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAmjDA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import * as z from "zod/v4";
5
5
  import { join } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
7
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureRead, markPhaseRead, markTaskRead, parentReadAdvisory, requirementReadAdvisory, markRequirementRead, invalidateReads } from "@agent-plan/core";
8
8
  import { serve } from "@agent-plan/server";
9
9
  import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
10
10
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
@@ -12,6 +12,8 @@ const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
12
12
  function text(textValue, structuredContent) {
13
13
  return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
14
14
  }
15
+ const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
16
+ const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
15
17
  function nowISO() {
16
18
  return new Date().toISOString();
17
19
  }
@@ -123,7 +125,7 @@ async function writeAndSummarize(st, message, structuredContent) {
123
125
  }
124
126
  const server = new McpServer({
125
127
  name: "agent-plan-planner",
126
- version: "0.1.0",
128
+ version: MCP_PACKAGE.version,
127
129
  });
128
130
  async function resolvePhaseForHandoff(st, ref) {
129
131
  const phases = await st.loadAllPhases();
@@ -143,6 +145,19 @@ async function resolvePhaseForHandoff(st, ref) {
143
145
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
144
146
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
145
147
  }
148
+ server.registerTool("planner-version", {
149
+ description: "Report the versions of the Agent Plan MCP server and core package actually loaded by this process. Available without an initialized .planner/ workspace.",
150
+ }, async () => {
151
+ const versions = {
152
+ [MCP_PACKAGE.name]: MCP_PACKAGE.version,
153
+ [CORE_PACKAGE.name]: CORE_PACKAGE.version,
154
+ };
155
+ return text([
156
+ "Agent Plan runtime versions",
157
+ `${MCP_PACKAGE.name}: ${MCP_PACKAGE.version}`,
158
+ `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
159
+ ].join("\n"), { versions });
160
+ });
146
161
  server.registerTool("planner-export", {
147
162
  description: "Export the project plan as a Markdown report. Supports a concise summary or full hierarchical detail.",
148
163
  inputSchema: {
@@ -435,6 +450,7 @@ server.registerTool("planner-feature-show", {
435
450
  if (!resolvedFeature.ok)
436
451
  return text(resolvedFeature.error);
437
452
  const feature = resolvedFeature.feature;
453
+ markFeatureRead(feature.id);
438
454
  const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
439
455
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
440
456
  return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
@@ -642,6 +658,7 @@ server.registerTool("planner-phase-show", {
642
658
  const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
643
659
  if (!phase)
644
660
  return text(`Phase not found: ${ref}`);
661
+ markPhaseRead(phase.id, phase.featureId);
645
662
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
646
663
  const reqCount = linkedRequirements.length;
647
664
  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"}` : ""})`;
@@ -808,6 +825,8 @@ server.registerTool("planner-task-add", {
808
825
  checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
809
826
  subtasks: [],
810
827
  dependsOn: [],
828
+ pauseSnapshot: null,
829
+ pauseHistory: [],
811
830
  startedAt: "",
812
831
  completedAt: "",
813
832
  createdAt: timestamp,
@@ -824,18 +843,43 @@ server.registerTool("planner-task-add", {
824
843
  });
825
844
  server.registerTool("planner-task-show", {
826
845
  description: "Show a task by id or name.",
827
- 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).") },
846
+ 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).") },
828
847
  }, async ({ task: ref, full }) => {
829
848
  const st = await requireStore();
830
849
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
831
850
  if (!found)
832
851
  return text(`Task not found: ${ref}`);
852
+ markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
833
853
  const features = (await st.loadFeatures()).features;
834
854
  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))})`;
835
855
  if (!full)
836
856
  return text(summary);
857
+ const project = await st.loadProject();
858
+ const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
859
+ ? undefined
860
+ : project.workDeviations
861
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
862
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
863
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
864
+ const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
837
865
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
838
- return text(`${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}`);
866
+ const sections = [summary];
867
+ if (found.task.description?.trim())
868
+ sections.push(found.task.description.trim());
869
+ if (snapshot || pendingDeviation) {
870
+ sections.push([
871
+ pendingDeviation
872
+ ? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
873
+ : "Resume checkpoint:",
874
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
875
+ snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
876
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
877
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
878
+ ].filter(Boolean).join("\n"));
879
+ }
880
+ if (log)
881
+ sections.push(`Status log:\n${log}`);
882
+ return text(sections.join("\n\n"));
839
883
  });
840
884
  server.registerTool("planner-task-discuss", {
841
885
  description: "Persist task execution notes/checklist.",
@@ -885,6 +929,9 @@ server.registerTool("planner-task-update", {
885
929
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
886
930
  if (!found)
887
931
  return text(`Task not found: ${ref}`);
932
+ if (found.task.pauseSnapshot && status === "in-progress" && status !== found.task.status) {
933
+ return text("Resume checkpoint lifecycle transitions require planner-task-start so the checkpoint and return stack remain consistent.");
934
+ }
888
935
  // Validate motivation requirement for status transitions.
889
936
  if (status !== undefined && needsMotivation(found.task.status, status)) {
890
937
  if (!motivation || !motivation.trim()) {
@@ -925,7 +972,22 @@ server.registerTool("planner-task-update", {
925
972
  await st.syncTaskStatusRollup(found.phase.id);
926
973
  const features = (await st.loadFeatures()).features;
927
974
  const t = updatedTask ?? found.task;
928
- return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
975
+ let resumeNotice = "";
976
+ let resumeRequired;
977
+ if (status && status !== "in-progress" && status !== found.task.status) {
978
+ const deviation = (await st.loadProject()).workDeviations
979
+ .filter((entry) => (entry.state === "approved" || entry.state === "active") && entry.temporaryTaskId === found.task.id)
980
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
981
+ if (deviation) {
982
+ await st.setWorkDeviationState(deviation.id, "resume-required", timestamp);
983
+ const resume = findTaskByRef(await st.loadAllPhases(), features, deviation.resumeTaskId);
984
+ if (resume) {
985
+ resumeNotice = `\n↩️ RESUME REQUIRED: ${taskCompositeRef(resume.task, resume.phase, features)} — ${resume.task.title}`;
986
+ resumeRequired = { taskId: resume.task.id, phaseId: resume.phase.id, snapshot: resume.task.pauseSnapshot ?? deviation.snapshot };
987
+ }
988
+ }
989
+ }
990
+ return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
929
991
  });
930
992
  server.registerTool("planner-task-checklist-toggle", {
931
993
  description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), the item id, or the title (case-insensitive, first match). Pass checked=true/false to set explicitly, or omit to toggle. Use this instead of writing DONE in step titles. Items are numbered C1/C2… per task.",
@@ -1079,12 +1141,191 @@ server.registerTool("planner-task-deviation", {
1079
1141
  if (temporary.task.status !== "planned")
1080
1142
  return text(`Temporary task is not startable: ${temporary.task.status}.`);
1081
1143
  const timestamp = nowISO();
1082
- const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
1144
+ const record = {
1145
+ id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id,
1146
+ temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason, snapshot: null,
1147
+ requestedBy: "user", approvedBy: "user", state: "approved",
1148
+ createdAt: timestamp, activatedAt: "", resumeRequiredAt: "", resolvedAt: "", resumedAt: "",
1149
+ };
1083
1150
  await st.addWorkDeviation(record);
1084
1151
  return writeAndSummarize(st, `✅ Approved deviation: ${taskCompositeRef(temporary.task, temporary.phase, features.features)} temporarily overrides ${taskCompositeRef(resume.task, resume.phase, features.features)}. Resume target retained.`, { deviation: record });
1085
1152
  });
1153
+ server.registerTool("planner-task-pause", {
1154
+ description: "Pause an in-progress task with a mandatory durable checkpoint describing why work stopped, what was underway, the exact resume location, and how to continue.",
1155
+ inputSchema: {
1156
+ task: z.string().min(1),
1157
+ reason: z.string().min(1),
1158
+ what_was_being_done: z.string().min(1),
1159
+ resume_location: z.string().min(1),
1160
+ how_to_resume: z.string().min(1),
1161
+ paused_by: z.string().optional(),
1162
+ },
1163
+ }, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }) => {
1164
+ const st = await requireStore();
1165
+ const features = (await st.loadFeatures()).features;
1166
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1167
+ if (!found)
1168
+ return text(`Task not found: ${ref}`);
1169
+ if (found.task.status !== "in-progress")
1170
+ return text(`Task pause denied: ${taskCompositeRef(found.task, found.phase, features)} is ${found.task.status}, not in-progress.`);
1171
+ const snapshot = {
1172
+ id: crypto.randomUUID(), reason, whatWasBeingDone: what_was_being_done,
1173
+ resumeLocation: resume_location, howToResume: how_to_resume, relatedTaskId: "",
1174
+ pausedAt: nowISO(), pausedBy: paused_by?.trim() ?? "",
1175
+ };
1176
+ invalidateReads();
1177
+ const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
1178
+ await st.syncTaskStatusRollup(found.phase.id);
1179
+ const activeDeviation = (await st.loadProject()).workDeviations
1180
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
1181
+ && deviation.temporaryTaskId === found.task.id)
1182
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1183
+ if (activeDeviation)
1184
+ await st.setWorkDeviationState(activeDeviation.id, "resume-required", snapshot.pausedAt);
1185
+ const resume = activeDeviation
1186
+ ? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
1187
+ : undefined;
1188
+ const resumeNotice = resume
1189
+ ? `\n↩️ RESUME REQUIRED: ${taskCompositeRef(resume.task, resume.phase, features)} — ${resume.task.title}`
1190
+ : "";
1191
+ return writeAndSummarize(st, [
1192
+ `💾 Resume checkpoint saved: ${taskCompositeRef(checkpointed, found.phase, features)} — ${checkpointed.title}`,
1193
+ `Why: ${snapshot.reason}`,
1194
+ `Checkpoint: ${snapshot.whatWasBeingDone}`,
1195
+ `Resume from: ${snapshot.resumeLocation}`,
1196
+ `How to resume: ${snapshot.howToResume}${resumeNotice}`,
1197
+ ].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
1198
+ });
1199
+ server.registerTool("planner-task-switch", {
1200
+ 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.",
1201
+ inputSchema: {
1202
+ from_task: z.string().min(1),
1203
+ to_task: z.string().min(1),
1204
+ reason: z.string().min(1),
1205
+ what_was_being_done: z.string().min(1),
1206
+ resume_location: z.string().min(1),
1207
+ how_to_resume: z.string().min(1),
1208
+ switched_by: z.string().optional(),
1209
+ },
1210
+ }, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }) => {
1211
+ const st = await requireStore();
1212
+ const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1213
+ const features = featuresDoc.features;
1214
+ const source = findTaskByRef(phases, features, from_task);
1215
+ const target = findTaskByRef(phases, features, to_task);
1216
+ if (!source)
1217
+ return text(`Task not found: ${from_task}`);
1218
+ if (!target)
1219
+ return text(`Task not found: ${to_task}`);
1220
+ if (source.task.id === target.task.id)
1221
+ return text("Source and temporary task must differ.");
1222
+ if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
1223
+ return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
1224
+ }
1225
+ const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
1226
+ if (!eligibility.eligible)
1227
+ return text(`Task switch denied: ${eligibility.reason}`);
1228
+ const timestamp = nowISO();
1229
+ const snapshot = {
1230
+ id: crypto.randomUUID(), reason, whatWasBeingDone: what_was_being_done,
1231
+ resumeLocation: resume_location, howToResume: how_to_resume,
1232
+ relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
1233
+ };
1234
+ const selection = recommendNextTask(features, phases, project.workDeviations);
1235
+ const record = {
1236
+ id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
1237
+ temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
1238
+ requestedBy: "agent", approvedBy: switched_by?.trim() || "explicit task_switch",
1239
+ state: "active", createdAt: timestamp, activatedAt: timestamp,
1240
+ resumeRequiredAt: "", resolvedAt: "", resumedAt: "",
1241
+ };
1242
+ const sourceWasActive = source.task.status === "in-progress";
1243
+ let sourceCheckpointed = false;
1244
+ let targetStarted = false;
1245
+ try {
1246
+ if (sourceWasActive) {
1247
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
1248
+ }
1249
+ else {
1250
+ await st.updatePhase(source.phase.id, (phase) => {
1251
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
1252
+ if (!task)
1253
+ throw new Error(`Checkpointed source task disappeared: ${from_task}`);
1254
+ task.pauseSnapshot = snapshot;
1255
+ task.pauseHistory = [...task.pauseHistory, snapshot];
1256
+ task.updatedAt = timestamp;
1257
+ return phase;
1258
+ });
1259
+ }
1260
+ sourceCheckpointed = true;
1261
+ if (target.task.pauseSnapshot) {
1262
+ await st.resumeTask(target.phase.id, target.task.id, timestamp);
1263
+ }
1264
+ else {
1265
+ await st.updatePhase(target.phase.id, (phase) => {
1266
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
1267
+ if (!task)
1268
+ throw new Error(`Temporary task disappeared: ${to_task}`);
1269
+ const previousStatus = task.status;
1270
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
1271
+ task.statusLog = [...task.statusLog, {
1272
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
1273
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
1274
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
1275
+ }];
1276
+ task.updatedAt = timestamp;
1277
+ phase.updatedAt = timestamp;
1278
+ return phase;
1279
+ });
1280
+ }
1281
+ targetStarted = true;
1282
+ await st.addWorkDeviation(record);
1283
+ if (target.task.pauseSnapshot) {
1284
+ const resumedDeviation = project.workDeviations
1285
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
1286
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
1287
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1288
+ if (resumedDeviation)
1289
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1290
+ }
1291
+ }
1292
+ catch (error) {
1293
+ if (targetStarted) {
1294
+ await st.updatePhase(target.phase.id, (phase) => {
1295
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
1296
+ return phase;
1297
+ }).catch(() => { });
1298
+ }
1299
+ if (sourceCheckpointed) {
1300
+ if (sourceWasActive)
1301
+ await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
1302
+ else
1303
+ await st.updatePhase(source.phase.id, (phase) => {
1304
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
1305
+ return phase;
1306
+ }).catch(() => { });
1307
+ }
1308
+ throw error;
1309
+ }
1310
+ await st.syncTaskStatusRollup(source.phase.id);
1311
+ if (target.phase.id !== source.phase.id)
1312
+ await st.syncTaskStatusRollup(target.phase.id);
1313
+ const targetReadAdvisory = parentReadAdvisory(target.phase.featureId, target.phase.id);
1314
+ const targetRequirementAdvisory = requirementReadAdvisory([
1315
+ ...(await st.linkedRequirementsForPhase(target.phase.id)).map((r) => r.id),
1316
+ ...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((r) => r.id) : []),
1317
+ ]);
1318
+ return writeAndSummarize(st, [
1319
+ `🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
1320
+ `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
1321
+ `Return target: ${taskCompositeRef(source.task, source.phase, features)} from ${snapshot.resumeLocation}`,
1322
+ `Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
1323
+ ...(targetReadAdvisory ? [targetReadAdvisory.trimStart()] : []),
1324
+ ...(targetRequirementAdvisory ? [targetRequirementAdvisory.trimStart()] : []),
1325
+ ].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id, ...(targetReadAdvisory || targetRequirementAdvisory ? { readRequired: true } : {}) });
1326
+ });
1086
1327
  server.registerTool("planner-task-start", {
1087
- description: "Set a task to in-progress. BEFORE calling, use planner-task-show with full=true and planner-phase-show with full=true for the parent phase, including linked requirements. Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Returns a phase/requirements briefing.",
1328
+ description: "Set a task to in-progress or resume checkpointed work. 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.",
1088
1329
  inputSchema: { task: z.string().min(1) },
1089
1330
  }, async ({ task: ref }) => {
1090
1331
  const st = await requireStore();
@@ -1102,6 +1343,35 @@ server.registerTool("planner-task-start", {
1102
1343
  if (!eligibility.eligible)
1103
1344
  return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1104
1345
  const selection = recommendNextTask(features, phases, project.workDeviations);
1346
+ if (selection.kind === "conflict") {
1347
+ return text(`${selection.reason} Pause/reconcile active tasks before starting another task.`, {
1348
+ activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
1349
+ });
1350
+ }
1351
+ if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
1352
+ 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)}.`);
1353
+ }
1354
+ let resumeProposal;
1355
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
1356
+ // Advisory only: a pending resume must be surfaced loudly, but it must
1357
+ // not hard-block an explicit start of a different task. Capture a
1358
+ // structured proposal so the resume-first guidance is explicit and
1359
+ // machine-readable for the host/agent.
1360
+ const resumeTask = selection.candidate;
1361
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
1362
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
1363
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
1364
+ ?? null;
1365
+ resumeProposal = buildResumeRequiredProposal({
1366
+ ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
1367
+ title: resumeTask.task.title,
1368
+ taskId: resumeTask.task.id,
1369
+ phaseId: resumeTask.phase.id,
1370
+ snapshot: resumeSnapshot
1371
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
1372
+ : null,
1373
+ });
1374
+ }
1105
1375
  // Assemble the mandatory parent context before changing task lifecycle state.
1106
1376
  const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1107
1377
  const [phaseWithRequirements, featureRequirements] = await Promise.all([
@@ -1112,42 +1382,62 @@ server.registerTool("planner-task-start", {
1112
1382
  // A pending handoff is context, not a lock: task start RETAINS it. It is
1113
1383
  // archived only when the phase completes (auto) or the user explicitly
1114
1384
  // clears it.
1115
- const advisory = selection.kind === "conflict"
1116
- ? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
1117
- : selection.candidate && selection.candidate.task.id !== found.task.id
1118
- ? selection.kind === "active"
1119
- ? `\n\n⚠️ Active-task advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is already in progress. Explicit task request honored.`
1120
- : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1121
- : "";
1385
+ const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
1386
+ ? selection.kind === "resume"
1387
+ ? `\n\n${(() => {
1388
+ if (resumeProposal)
1389
+ return resumeProposal.text;
1390
+ 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.`;
1391
+ })()}`
1392
+ : `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
1393
+ : "";
1122
1394
  const timestamp = nowISO();
1123
1395
  let updatedTask;
1124
- await st.updatePhase(found.phase.id, (phase) => {
1125
- const task = phase.tasks.find((entry) => entry.id === found.task.id);
1126
- if (!task)
1127
- return phase;
1128
- applyTaskLifecycleDates(task, "in-progress", timestamp);
1129
- if (task.status !== "in-progress") {
1396
+ if (found.task.pauseSnapshot) {
1397
+ updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp);
1398
+ }
1399
+ else {
1400
+ await st.updatePhase(found.phase.id, (phase) => {
1401
+ const task = phase.tasks.find((entry) => entry.id === found.task.id);
1402
+ if (!task)
1403
+ return phase;
1404
+ const previousStatus = task.status;
1405
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
1130
1406
  const entry = {
1131
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
1407
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
1132
1408
  date: timestamp,
1133
- fromStatus: task.status,
1409
+ fromStatus: previousStatus,
1134
1410
  toStatus: "in-progress",
1135
- title: task.status === "done" ? "Reopened" : `→ in-progress`,
1136
- description: task.status === "done" ? "Task reopened from done status." : "",
1411
+ title: `${previousStatus} → in-progress`,
1412
+ description: "",
1137
1413
  };
1138
1414
  task.statusLog = [...(task.statusLog ?? []), entry];
1139
- }
1140
- task.updatedAt = timestamp;
1141
- phase.updatedAt = timestamp;
1142
- updatedTask = task;
1143
- return phase;
1144
- });
1415
+ task.updatedAt = timestamp;
1416
+ phase.updatedAt = timestamp;
1417
+ updatedTask = task;
1418
+ return phase;
1419
+ });
1420
+ }
1145
1421
  await st.syncTaskStatusRollup(found.phase.id);
1146
1422
  const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1147
1423
  if (approvedDeviation)
1148
1424
  await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
1425
+ const resumedDeviation = project.workDeviations
1426
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
1427
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
1428
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1429
+ if (resumedDeviation)
1430
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
1149
1431
  const t = updatedTask ?? found.task;
1150
- return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
1432
+ const readAdvisory = parentReadAdvisory(parentFeature?.id, found.phase.id);
1433
+ const requirementAdvisory = requirementReadAdvisory([
1434
+ ...(phaseWithRequirements.linkedRequirements ?? []).map((r) => r.id),
1435
+ ...featureRequirements.map((r) => r.id),
1436
+ ]);
1437
+ return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}${readAdvisory}${requirementAdvisory}`, {
1438
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
1439
+ ...(readAdvisory || requirementAdvisory ? { readRequired: true } : {}),
1440
+ });
1151
1441
  });
1152
1442
  server.registerTool("planner-task-complete", {
1153
1443
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -1161,6 +1451,8 @@ server.registerTool("planner-task-complete", {
1161
1451
  const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1162
1452
  if (!found)
1163
1453
  return text(`Task not found: ${ref}`);
1454
+ if (found.task.pauseSnapshot)
1455
+ return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
1164
1456
  const unchecked = found.task.checklist.filter((item) => !item.checked);
1165
1457
  if (unchecked.length > 0 && !force)
1166
1458
  return text(`${unchecked.length} checklist item(s) not done. Re-run with force=true to complete anyway.`);
@@ -1170,18 +1462,17 @@ server.registerTool("planner-task-complete", {
1170
1462
  const task = phase.tasks.find((entry) => entry.id === found.task.id);
1171
1463
  if (!task)
1172
1464
  return phase;
1465
+ const previousStatus = task.status;
1173
1466
  applyTaskLifecycleDates(task, "done", timestamp);
1174
- if (task.status !== "done") {
1175
- const entry = {
1176
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-done`),
1177
- date: timestamp,
1178
- fromStatus: task.status,
1179
- toStatus: "done",
1180
- title: `→ done`,
1181
- description: "",
1182
- };
1183
- task.statusLog = [...(task.statusLog ?? []), entry];
1184
- }
1467
+ const entry = {
1468
+ id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-done`),
1469
+ date: timestamp,
1470
+ fromStatus: previousStatus,
1471
+ toStatus: "done",
1472
+ title: `${previousStatus} → done`,
1473
+ description: "",
1474
+ };
1475
+ task.statusLog = [...(task.statusLog ?? []), entry];
1185
1476
  if (description_update) {
1186
1477
  const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
1187
1478
  task.description = task.description + sep + description_update;
@@ -1196,10 +1487,27 @@ server.registerTool("planner-task-complete", {
1196
1487
  .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
1197
1488
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1198
1489
  if (completedDeviation)
1199
- await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
1490
+ await st.setWorkDeviationState(completedDeviation.id, "resume-required", timestamp);
1200
1491
  const features = (await st.loadFeatures()).features;
1201
1492
  const t = updatedTask ?? found.task;
1202
- return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}`);
1493
+ let resumeNotice = "";
1494
+ let resumeRequired;
1495
+ if (completedDeviation) {
1496
+ const resume = findTaskByRef(await st.loadAllPhases(), features, completedDeviation.resumeTaskId);
1497
+ if (resume) {
1498
+ const snapshot = resume.task.pauseSnapshot ?? completedDeviation.snapshot;
1499
+ resumeNotice = [
1500
+ "",
1501
+ `↩️ RESUME REQUIRED: ${taskCompositeRef(resume.task, resume.phase, features)} — ${resume.task.title}`,
1502
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
1503
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
1504
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
1505
+ `Next action: planner-task-start ${taskCompositeRef(resume.task, resume.phase, features)}`,
1506
+ ].filter(Boolean).join("\n");
1507
+ resumeRequired = { taskId: resume.task.id, phaseId: resume.phase.id, snapshot };
1508
+ }
1509
+ }
1510
+ return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
1203
1511
  });
1204
1512
  server.registerTool("planner-handoff-list", {
1205
1513
  description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
@@ -1224,6 +1532,15 @@ server.registerTool("planner-handoff-show", {
1224
1532
  return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, empty: true });
1225
1533
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1226
1534
  });
1535
+ server.registerTool("planner-requirement-list", {
1536
+ 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).",
1537
+ inputSchema: {},
1538
+ }, async () => {
1539
+ const st = await requireStore();
1540
+ const requirements = await st.loadRequirements();
1541
+ requirements.requirements.forEach((req) => markRequirementRead(req.id));
1542
+ return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
1543
+ });
1227
1544
  server.registerTool("planner-handoff-write", {
1228
1545
  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.",
1229
1546
  inputSchema: {
@@ -1237,8 +1554,10 @@ server.registerTool("planner-handoff-write", {
1237
1554
  let body = content.trim();
1238
1555
  const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
1239
1556
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
1240
- const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
1241
- if (!title && isGeneric) {
1557
+ const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
1558
+ const effectiveHeadingText = title?.trim() ? title.trim() : firstHeadingText;
1559
+ const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
1560
+ if (isGeneric) {
1242
1561
  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.");
1243
1562
  }
1244
1563
  if (title && title.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.20",
3
+ "version": "0.2.22-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.20",
47
- "@agent-plan/server": "0.2.20"
46
+ "@agent-plan/core": "0.2.22-next.0",
47
+ "@agent-plan/server": "0.2.22-next.0"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",