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

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":";AAkvCA,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, 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.",
@@ -1039,27 +1035,76 @@ server.registerTool("planner-task-delete", {
1039
1035
  const tdFeatures = (await st.loadFeatures()).features;
1040
1036
  return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
1041
1037
  });
1038
+ server.registerTool("planner-task-recommend", {
1039
+ 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.",
1040
+ inputSchema: {},
1041
+ }, async () => {
1042
+ const st = await requireStore();
1043
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1044
+ const result = recommendNextTask(features.features, phases, project.workDeviations);
1045
+ if (!result.candidate)
1046
+ return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
1047
+ const { candidate } = result;
1048
+ const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
1049
+ return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
1050
+ kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
1051
+ });
1052
+ });
1053
+ server.registerTool("planner-task-deviation", {
1054
+ 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.",
1055
+ inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
1056
+ }, async ({ temporary_task, resume_task, reason }) => {
1057
+ const st = await requireStore();
1058
+ const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
1059
+ const temporary = findTaskByRef(phases, features.features, temporary_task);
1060
+ if (!temporary)
1061
+ return text(`Task not found: ${temporary_task}`);
1062
+ const selected = recommendNextTask(features.features, phases, project.workDeviations);
1063
+ const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
1064
+ if (!resume)
1065
+ return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
1066
+ if (temporary.task.id === resume.task.id)
1067
+ return text("A temporary task must differ from its resume target.");
1068
+ if (temporary.task.status !== "planned")
1069
+ return text(`Temporary task is not startable: ${temporary.task.status}.`);
1070
+ const timestamp = nowISO();
1071
+ 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: "" };
1072
+ await st.addWorkDeviation(record);
1073
+ 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 });
1074
+ });
1042
1075
  server.registerTool("planner-task-start", {
1043
- description: "Set a task to in-progress.",
1076
+ 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
1077
  inputSchema: { task: z.string().min(1) },
1045
1078
  }, async ({ task: ref }) => {
1046
1079
  const st = await requireStore();
1047
- const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
1080
+ const features = (await st.loadFeatures()).features;
1081
+ const found = findTaskByRef(await st.loadAllPhases(), features, ref);
1048
1082
  if (!found)
1049
1083
  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`;
1084
+ if (found.task.status === "in-progress")
1085
+ return text(`Task ${found.task.id} is already in-progress.`);
1086
+ if (found.task.status === "done")
1087
+ return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
1088
+ const project = await st.loadProject();
1089
+ const selection = recommendNextTask(features, await st.loadAllPhases(), project.workDeviations);
1090
+ if (!selection.candidate || selection.candidate.task.id !== found.task.id) {
1091
+ const recommended = selection.candidate ? ` Start ${selection.candidate.task.id} instead, or record a user-approved planner-task-deviation first.` : "";
1092
+ return text(`Task start denied: ${selection.reason}.${recommended}`, {
1093
+ kind: selection.kind,
1094
+ reason: selection.reason,
1095
+ recommendedTaskId: selection.candidate?.task.id,
1096
+ });
1062
1097
  }
1098
+ // Assemble the mandatory parent context before changing task lifecycle state.
1099
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
1100
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
1101
+ st.loadPhaseWithRequirements(found.phase.id),
1102
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
1103
+ ]);
1104
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
1105
+ // A pending handoff is context, not a lock: task start RETAINS it. It is
1106
+ // archived only when the phase completes (auto) or the user explicitly
1107
+ // clears it.
1063
1108
  const timestamp = nowISO();
1064
1109
  let updatedTask;
1065
1110
  await st.updatePhase(found.phase.id, (phase) => {
@@ -1084,11 +1129,11 @@ server.registerTool("planner-task-start", {
1084
1129
  return phase;
1085
1130
  });
1086
1131
  await st.syncTaskStatusRollup(found.phase.id);
1087
- const features = (await st.loadFeatures()).features;
1132
+ if (selection.deviation?.temporaryTaskId === found.task.id && selection.deviation.state === "approved") {
1133
+ await st.setWorkDeviationState(selection.deviation.id, "active", timestamp);
1134
+ }
1088
1135
  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}`);
1136
+ return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}`);
1092
1137
  });
1093
1138
  server.registerTool("planner-task-complete", {
1094
1139
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -1133,6 +1178,11 @@ server.registerTool("planner-task-complete", {
1133
1178
  return phase;
1134
1179
  });
1135
1180
  const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
1181
+ const completedDeviation = (await st.loadProject()).workDeviations
1182
+ .filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
1183
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
1184
+ if (completedDeviation)
1185
+ await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
1136
1186
  const features = (await st.loadFeatures()).features;
1137
1187
  const t = updatedTask ?? found.task;
1138
1188
  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 +1198,8 @@ server.registerTool("planner-handoff-list", {
1148
1198
  return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
1149
1199
  });
1150
1200
  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.") },
1201
+ description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
1202
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
1153
1203
  }, async ({ phaseRef }) => {
1154
1204
  const st = await requireStore();
1155
1205
  const r = await resolvePhaseForHandoff(st, phaseRef);
@@ -1161,13 +1211,14 @@ server.registerTool("planner-handoff-show", {
1161
1211
  return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1162
1212
  });
1163
1213
  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.",
1214
+ 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
1215
  inputSchema: {
1166
- phaseRef: z.string().optional().describe("Phase ref. Default: current in-progress phase."),
1216
+ phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
1167
1217
  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'."),
1218
+ confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
1168
1219
  content: z.string().min(1).describe("Handoff text (markdown)."),
1169
1220
  },
1170
- }, async ({ phaseRef, title, content }) => {
1221
+ }, async ({ phaseRef, title, confirmed, content }) => {
1171
1222
  const st = await requireStore();
1172
1223
  let body = content.trim();
1173
1224
  const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
@@ -1191,20 +1242,23 @@ server.registerTool("planner-handoff-write", {
1191
1242
  const r = await resolvePhaseForHandoff(st, phaseRef);
1192
1243
  if (!r.ok)
1193
1244
  return text(`❌ ${r.error}`);
1245
+ if (!confirmed)
1246
+ 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
1247
  await st.setPhaseHandoff(r.phase.id, body);
1195
1248
  return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
1196
1249
  });
1197
1250
  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).",
1251
+ 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
1252
  }, 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.",
1253
+ "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.",
1254
+ "Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
1255
+ "After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
1256
+ "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.",
1257
+ "Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
1204
1258
  ].join("\n")));
1205
1259
  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.") },
1260
+ description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
1261
+ inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
1208
1262
  }, async ({ phaseRef }) => {
1209
1263
  const st = await requireStore();
1210
1264
  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.21",
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/server": "0.2.19-next.21",
47
+ "@agent-plan/core": "0.2.19-next.21"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",