@agent-plan/mcp 0.2.19-next.20 → 0.2.19-next.22

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":";AA2rCA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAgwCA,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, migrateToGlobalSequence, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
7
+ import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, migrateToGlobalSequence, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
8
8
  import { serve } from "@agent-plan/server";
9
9
  import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, 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"];
@@ -137,16 +137,12 @@ async function resolvePhaseForHandoff(st, ref) {
137
137
  if (ref && ref.trim()) {
138
138
  phase = findPhaseByRef(phases, features, ref.trim());
139
139
  }
140
- else {
141
- phase = phases.find((p) => p.status === "in-progress")
142
- ?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
143
- }
144
140
  if (!phase) {
145
141
  return {
146
142
  ok: false,
147
143
  error: ref && ref.trim()
148
144
  ? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
149
- : "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
145
+ : "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
150
146
  };
151
147
  }
152
148
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
@@ -248,7 +244,7 @@ server.registerTool("planner-repair", {
248
244
  }, async () => {
249
245
  const st = await requireStore();
250
246
  const report = await st.repair();
251
- return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Containment: ${report.containment.changed} phase files rewritten (${report.containment.tasks} tasks scanned, ${report.containment.orphan} orphan). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
247
+ return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Containment: ${report.containment.changed} phase files rewritten (${report.containment.tasks} tasks scanned, ${report.containment.orphan} orphan). Handoffs: archived ${report.handoffs.archived} stale completed/canceled handoff(s). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
252
248
  });
253
249
  server.registerTool("planner-cleanup-orphan-phases", {
254
250
  description: "Discover phase files that no longer resolve to a valid owning feature, and optionally delete them. Run once with confirm=false (default) to inspect, then rerun with confirm=true to remove them.",
@@ -653,7 +649,18 @@ server.registerTool("planner-phase-show", {
653
649
  const requirementsBlock = reqCount > 0
654
650
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
655
651
  : "";
656
- return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, { linkedRequirements });
652
+ return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
653
+ phase: {
654
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
655
+ shortId: phase.shortId,
656
+ title: phase.title,
657
+ summary: phase.summary,
658
+ status: phase.status,
659
+ taskCount: phase.tasks.length,
660
+ ...(full ? { description: phase.description } : {}),
661
+ },
662
+ linkedRequirements,
663
+ });
657
664
  });
658
665
  server.registerTool("planner-phase-discuss", {
659
666
  description: "Persist phase discovery fields and mark phase planned.",
@@ -1039,27 +1046,79 @@ server.registerTool("planner-task-delete", {
1039
1046
  const tdFeatures = (await st.loadFeatures()).features;
1040
1047
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1041
1048
  });
1049
+ server.registerTool("planner-task-recommend", {
1050
+ 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.",
1051
+ inputSchema: {},
1052
+ }, async () => {
1053
+ const st = await requireStore();
1054
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1055
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
1056
+ if (!result.candidate)
1057
+ return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1058
+ const { candidate } = result;
1059
+ const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1060
+ return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1061
+ kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1062
+ });
1063
+ });
1064
+ server.registerTool("planner-task-deviation", {
1065
+ description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task and never starts, pauses, or blocks work; use normal lifecycle tools for those transitions.",
1066
+ inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
1067
+ }, async ({ temporary_task, resume_task, reason }) => {
1068
+ const st = await requireStore();
1069
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1070
+ const temporary = findTaskByRef(phases, features.features, temporary_task);
1071
+ if (!temporary)
1072
+ return text(`Task not found: ${temporary_task}`);
1073
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
1074
+ const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
1075
+ if (!resume)
1076
+ return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
1077
+ if (temporary.task.id === resume.task.id)
1078
+ return text("A temporary task must differ from its resume target.");
1079
+ if (temporary.task.status !== "planned")
1080
+ return text(`Temporary task is not startable: ${temporary.task.status}.`);
1081
+ 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: "" };
1083
+ await st.addWorkDeviation(record);
1084
+ 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
+ });
1042
1086
  server.registerTool("planner-task-start", {
1043
- description: "Set a task to in-progress.",
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.",
1044
1088
  inputSchema: { task: z.string().min(1) },
1045
1089
  }, async ({ task: ref }) => {
1046
1090
  const st = await requireStore();
1047
- const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1091
+ const features = (await st.loadFeatures()).features;
1092
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1048
1093
  if (!found)
1049
1094
  return text(`Task not found: ${ref}`);
1050
- // Auto-archive the handoff on THIS task's phase (reason: task-started) — the
1051
- // agent is now actively working, so the captured context is consumed. Other
1052
- // phases' handoffs are left untouched (informational, non-blocking).
1053
- const allHandoffs = await st.listHandoffs();
1054
- let handoffNotice = "";
1055
- if ((found.phase.handoff ?? "") !== "") {
1056
- await st.clearPhaseHandoff(found.phase.id, "task-started").catch(() => { });
1057
- handoffNotice = "📦 Archived handoff for this phase (task started) — recover via planner-handoff-list + .planner/.local/handoff-archive/.\n";
1058
- }
1059
- const _otherHandoffs = allHandoffs.filter((h) => h.phaseId !== found.phase.id);
1060
- if (_otherHandoffs.length > 0) {
1061
- handoffNotice += `ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with planner-handoff-list if relevant.\n`;
1062
- }
1095
+ if (found.task.status === "in-progress")
1096
+ return text(`Task ${found.task.id} is already in-progress.`);
1097
+ if (found.task.status === "done")
1098
+ return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1099
+ const project = await st.loadProject();
1100
+ const phases = await st.loadAllPhases();
1101
+ const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
1102
+ if (!eligibility.eligible)
1103
+ return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
1104
+ const selection = recommendNextTask(features, phases, project.workDeviations);
1105
+ // Assemble the mandatory parent context before changing task lifecycle state.
1106
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1107
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
1108
+ st.loadPhaseWithRequirements(found.phase.id),
1109
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1110
+ ]);
1111
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1112
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
1113
+ // archived only when the phase completes (auto) or the user explicitly
1114
+ // 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
+ : "";
1063
1122
  const timestamp = nowISO();
1064
1123
  let updatedTask;
1065
1124
  await st.updatePhase(found.phase.id, (phase) => {
@@ -1084,11 +1143,11 @@ server.registerTool("planner-task-start", {
1084
1143
  return phase;
1085
1144
  });
1086
1145
  await st.syncTaskStatusRollup(found.phase.id);
1087
- const features = (await st.loadFeatures()).features;
1146
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
1147
+ if (approvedDeviation)
1148
+ await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
1088
1149
  const t = updatedTask ?? found.task;
1089
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1090
- const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
1091
- return writeAndSummarize(st, `${handoffNotice}✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}`);
1150
+ return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
1092
1151
  });
1093
1152
  server.registerTool("planner-task-complete", {
1094
1153
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -1133,6 +1192,11 @@ server.registerTool("planner-task-complete", {
1133
1192
  return phase;
1134
1193
  });
1135
1194
  const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
1195
+ const completedDeviation = (await st.loadProject()).workDeviations
1196
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
1197
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1198
+ if (completedDeviation)
1199
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
1136
1200
  const features = (await st.loadFeatures()).features;
1137
1201
  const t = updatedTask ?? found.task;
1138
1202
  return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}`);
@@ -1148,8 +1212,8 @@ server.registerTool("planner-handoff-list", {
1148
1212
  return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
1149
1213
  });
1150
1214
  server.registerTool("planner-handoff-show", {
1151
- description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title. Omit to target the current in-progress phase.",
1152
- inputSchema: { phaseRef: z.string().optional().describe("Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase.") },
1215
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
1216
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
1153
1217
  }, async ({ phaseRef }) => {
1154
1218
  const st = await requireStore();
1155
1219
  const r = await resolvePhaseForHandoff(st, phaseRef);
@@ -1161,13 +1225,14 @@ server.registerTool("planner-handoff-show", {
1161
1225
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1162
1226
  });
1163
1227
  server.registerTool("planner-handoff-write", {
1164
- description: "Write/refresh the entity-scoped handoff on a phase. Pass content (markdown) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). The title becomes the H1 / first line shown in handoff lists; generic titles like 'Handoff' or 'Canonical handoff' are REJECTED. phaseRef optional (default: current in-progress phase). Captures design context for a resuming agent on that phase.",
1228
+ 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.",
1165
1229
  inputSchema: {
1166
- phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase."),
1230
+ phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1167
1231
  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'."),
1232
+ confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
1168
1233
  content: z.string().min(1).describe("Handoff text (markdown)."),
1169
1234
  },
1170
- }, async ({ phaseRef, title, content }) => {
1235
+ }, async ({ phaseRef, title, confirmed, content }) => {
1171
1236
  const st = await requireStore();
1172
1237
  let body = content.trim();
1173
1238
  const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
@@ -1191,20 +1256,23 @@ server.registerTool("planner-handoff-write", {
1191
1256
  const r = await resolvePhaseForHandoff(st, phaseRef);
1192
1257
  if (!r.ok)
1193
1258
  return text(`❌ ${r.error}`);
1259
+ if (!confirmed)
1260
+ 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 });
1194
1261
  await st.setPhaseHandoff(r.phase.id, body);
1195
1262
  return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1196
1263
  });
1197
1264
  server.registerTool("planner-handoff-prepare", {
1198
- description: "Return instructions for the agent to prepare a canonical handoff, then call planner-handoff-write (entity-scoped, phase.handoff).",
1265
+ 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.",
1199
1266
  }, async () => text([
1200
- "Prepare the canonical session handoff and write it on the current in-progress phase with planner-handoff-write (omit phaseRef to target the in-progress phase).",
1201
- "Pass a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring') — it becomes the H1 / first line in handoff lists. Generic titles like 'Handoff' or 'Canonical handoff' are REJECTED.",
1202
- "Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
1203
- "Stored on the phase.handoff field (entity-scoped). .planner/HANDOFF.md is deprecated.",
1267
+ "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.",
1268
+ "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1269
+ "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1270
+ "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.",
1271
+ "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
1204
1272
  ].join("\n")));
1205
1273
  server.registerTool("planner-handoff-clear", {
1206
- description: "Clear the entity-scoped handoff of a phase (sets handoff to empty, keeps handoffUpdatedAt as an audit trail). phaseRef optional (default: current in-progress phase).",
1207
- inputSchema: { phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase.") },
1274
+ description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1275
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
1208
1276
  }, async ({ phaseRef }) => {
1209
1277
  const st = await requireStore();
1210
1278
  const r = await resolvePhaseForHandoff(st, phaseRef);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.19-next.20",
3
+ "version": "0.2.19-next.22",
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.19-next.20",
47
- "@agent-plan/server": "0.2.19-next.20"
46
+ "@agent-plan/core": "0.2.19-next.22",
47
+ "@agent-plan/server": "0.2.19-next.22"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",