@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +229 -74
- package/package.json +3 -3
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
|
1347
|
-
const
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
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).
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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) =>
|
|
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: "
|
|
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
|
|
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("
|
|
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((
|
|
1662
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
1566
1663
|
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
|
1574
|
-
const t = title.trim();
|
|
1668
|
+
if (title?.trim()) {
|
|
1575
1669
|
const lines = body.split(/\r?\n/);
|
|
1576
|
-
const firstIdx = lines.findIndex((
|
|
1577
|
-
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
1578
|
-
lines[firstIdx] = `# ${
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
|
1590
|
-
|
|
1591
|
-
|
|
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: "
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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.
|
|
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.
|
|
47
|
-
"@agent-plan/server": "0.2.
|
|
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",
|