@agent-plan/mcp 0.2.28 → 0.3.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 +354 -381
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -5,9 +5,9 @@ import * as z from "zod/v4";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
-
import { PlanStore, PlanStoreError, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_INVENTORY_CATEGORIES,
|
|
8
|
+
import { PlanStore, PlanStoreError, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, replaceChecklist, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, taskStartGateState, taskStartGateStateLine, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_INVENTORY_CATEGORIES, HandoffContractError, buildHandoffShowReply, buildHandoffPrepareReply, buildProjectContextLoadReply, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS, projectContextStaleAdvisory, LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE, LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, buildMutationReply, longTextFieldLimitNotice, buildRecommendationReply, buildTaskOrderContext, taskOrderContextLine, taskCreatedPriorityFragment, findHigherPriorityOpenPhase, higherPriorityOpenPhaseAdvisory, listOpenPhaseWork, openPhaseWorkLines, boundedOpenPhaseWork, deleteFeatureCascade, evaluateTaskCompletionGate, taskCompletionChecklistRefusal, taskCompletionForceMotivationRequired, taskCompletionOverrideNote, taskCompletionMismatchLine, boundedPage, nowISO, resolveFeatureRefStrict, resolveAcceptedDecisionTarget, applyTaskLifecycleDates, handoffContractFailureReply, derivedStatusReadOnlyReply, acceptedDecisionMutationFailureReply, legacyContextMigrationSummary } from "@agent-plan/core";
|
|
9
9
|
import { serve } from "@agent-plan/server";
|
|
10
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
10
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
11
11
|
const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
12
12
|
const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
|
|
13
13
|
const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
|
|
@@ -18,73 +18,28 @@ function plannerSessionIdFor(extra) {
|
|
|
18
18
|
startReadSession(sessionId);
|
|
19
19
|
return sessionId;
|
|
20
20
|
}
|
|
21
|
+
/** Stated on every long-prose input so a caller learns the limit before the
|
|
22
|
+
* call rather than from the rejection. The number itself lives in the core
|
|
23
|
+
* (PLANNER_LONG_TEXT_FIELD_MAX_CHARS) so both adapters state the same one. */
|
|
24
|
+
const LONG_TEXT_LIMIT_NOTICE = longTextFieldLimitNotice();
|
|
21
25
|
function text(textValue, structuredContent) {
|
|
22
26
|
return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
|
|
23
27
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
|
|
28
|
-
structuredContent: { errorCode: error.code, ...error.details },
|
|
29
|
-
};
|
|
28
|
+
function mcpHandoffContractFailure(error, action = "refresh") {
|
|
29
|
+
const reply = handoffContractFailureReply(error, action);
|
|
30
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
|
|
30
31
|
}
|
|
31
32
|
function handoffPreflightFailure(errorCode, message, details = {}) {
|
|
32
33
|
return { isError: true, content: [{ type: "text", text: `❌ Handoff refresh denied [${errorCode}]: ${message}` }], structuredContent: { errorCode, persisted: false, ...details } };
|
|
33
34
|
}
|
|
34
|
-
function
|
|
35
|
-
if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
|
|
36
|
-
return { content, fullLength: content.length, truncated: false };
|
|
37
|
-
const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
|
|
38
|
-
return {
|
|
39
|
-
content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
|
|
40
|
-
fullLength: content.length,
|
|
41
|
-
truncated: true,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function boundedPage(items, page, pageSize) {
|
|
45
|
-
const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
|
|
46
|
-
const totalPages = Math.max(1, Math.ceil(items.length / size));
|
|
47
|
-
const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
|
|
48
|
-
return {
|
|
49
|
-
items: items.slice((currentPage - 1) * size, currentPage * size),
|
|
50
|
-
page: currentPage,
|
|
51
|
-
pageSize: size,
|
|
52
|
-
total: items.length,
|
|
53
|
-
totalPages,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
57
|
-
const childKind = entity === "feature" ? "phases" : "tasks";
|
|
35
|
+
function mcpDerivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
58
36
|
const retryAction = entity === "feature"
|
|
59
37
|
? `Retry planner-feature-update ${ref} without the status field if you need to update metadata.`
|
|
60
38
|
: `Retry planner-phase-update ${ref} without the status field if you need to update metadata.`;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
retryAction,
|
|
64
|
-
];
|
|
65
|
-
return {
|
|
66
|
-
isError: true,
|
|
67
|
-
content: [{ type: "text", text: [
|
|
68
|
-
`❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
|
|
69
|
-
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
|
|
70
|
-
`Attempted status: ${attemptedStatus}`,
|
|
71
|
-
`Effective derived status: ${effectiveStatus}`,
|
|
72
|
-
"No planner data was changed.",
|
|
73
|
-
"Next required actions:",
|
|
74
|
-
...nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
75
|
-
].join("\n") }],
|
|
76
|
-
structuredContent: {
|
|
77
|
-
updated: false,
|
|
78
|
-
errorCode: "DERIVED_STATUS_READ_ONLY",
|
|
79
|
-
entity,
|
|
80
|
-
ref,
|
|
81
|
-
attemptedStatus,
|
|
82
|
-
effectiveStatus,
|
|
83
|
-
nextActions,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
39
|
+
const reply = derivedStatusReadOnlyReply({ entity, ref, attemptedStatus, effectiveStatus, retryAction });
|
|
40
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
|
|
86
41
|
}
|
|
87
|
-
function
|
|
42
|
+
function mcpMutationNoFieldsFailure(input) {
|
|
88
43
|
const outcome = noMutableFieldsReceived(input);
|
|
89
44
|
return {
|
|
90
45
|
isError: true,
|
|
@@ -92,23 +47,9 @@ function mutationNoFieldsFailure(input) {
|
|
|
92
47
|
structuredContent: { ...outcome },
|
|
93
48
|
};
|
|
94
49
|
}
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
isError: true,
|
|
100
|
-
content: [{ type: "text", text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}` }],
|
|
101
|
-
structuredContent: { [outcome]: false, ...error.details },
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
function legacyContextMigrationSummary(preview) {
|
|
105
|
-
return [
|
|
106
|
-
`Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
|
|
107
|
-
`Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
|
|
108
|
-
`Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
|
|
109
|
-
`Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
|
|
110
|
-
`Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
|
|
111
|
-
].join("\n");
|
|
50
|
+
function mcpAcceptedDecisionMutationFailure(error, outcome) {
|
|
51
|
+
const reply = acceptedDecisionMutationFailureReply(error, outcome);
|
|
52
|
+
return { isError: true, content: [{ type: "text", text: reply.text }], structuredContent: reply.structured };
|
|
112
53
|
}
|
|
113
54
|
function taskStartError(outcome, structuredContent = {}) {
|
|
114
55
|
return {
|
|
@@ -142,9 +83,6 @@ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
|
|
|
142
83
|
}
|
|
143
84
|
const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
|
|
144
85
|
const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
|
|
145
|
-
function nowISO() {
|
|
146
|
-
return new Date().toISOString();
|
|
147
|
-
}
|
|
148
86
|
function planRoot() {
|
|
149
87
|
return process.env.AGENT_PLAN_ROOT || join(process.cwd(), ".planner");
|
|
150
88
|
}
|
|
@@ -220,83 +158,9 @@ function findFeatureByRef(features, ref) {
|
|
|
220
158
|
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
221
159
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
|
|
222
160
|
}
|
|
223
|
-
function resolveFeatureRefStrict(features, ref) {
|
|
224
|
-
const raw = ref.trim();
|
|
225
|
-
if (!raw)
|
|
226
|
-
return { ok: false, error: "Feature ref is required." };
|
|
227
|
-
const normalized = raw.toLowerCase();
|
|
228
|
-
const byNumber = normalized.match(/^f(\d+)$/)
|
|
229
|
-
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
230
|
-
: undefined;
|
|
231
|
-
if (byNumber)
|
|
232
|
-
return { ok: true, feature: byNumber };
|
|
233
|
-
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
234
|
-
if (byShortId)
|
|
235
|
-
return { ok: true, feature: byShortId };
|
|
236
|
-
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
237
|
-
if (byId)
|
|
238
|
-
return { ok: true, feature: byId };
|
|
239
|
-
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
240
|
-
if (exactName.length === 1)
|
|
241
|
-
return { ok: true, feature: exactName[0] };
|
|
242
|
-
if (exactName.length > 1)
|
|
243
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
244
|
-
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
245
|
-
if (partialName.length === 1)
|
|
246
|
-
return { ok: true, feature: partialName[0] };
|
|
247
|
-
if (partialName.length > 1)
|
|
248
|
-
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
249
|
-
return { ok: false, error: `Feature not found: ${raw}` };
|
|
250
|
-
}
|
|
251
|
-
/** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
|
|
252
|
-
* must still exist in the store before we allocate numbers or write. */
|
|
253
|
-
function featureNumberOfPhase(phase, features) {
|
|
254
|
-
return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
|
|
255
|
-
}
|
|
256
161
|
function taskCompositeRef(task, phase, features) {
|
|
257
162
|
return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
|
|
258
163
|
}
|
|
259
|
-
async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
|
|
260
|
-
if (targetType === "project") {
|
|
261
|
-
const project = await st.loadProject();
|
|
262
|
-
return { ok: true, owner: { kind: "project" }, ref: project.name };
|
|
263
|
-
}
|
|
264
|
-
const ref = targetRef?.trim();
|
|
265
|
-
if (!ref)
|
|
266
|
-
return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
|
|
267
|
-
const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
|
|
268
|
-
const features = featuresDoc.features;
|
|
269
|
-
if (targetType === "feature") {
|
|
270
|
-
const resolved = resolveFeatureRefStrict(features, ref);
|
|
271
|
-
if (!resolved.ok)
|
|
272
|
-
return { ok: false, error: resolved.error };
|
|
273
|
-
return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
|
|
274
|
-
}
|
|
275
|
-
if (targetType === "phase") {
|
|
276
|
-
const phase = findPhaseByRef(phases, features, ref);
|
|
277
|
-
if (!phase)
|
|
278
|
-
return { ok: false, error: `Phase not found: ${ref}` };
|
|
279
|
-
return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
|
|
280
|
-
}
|
|
281
|
-
const found = findTaskByRef(phases, features, ref);
|
|
282
|
-
if (!found)
|
|
283
|
-
return { ok: false, error: `Task not found: ${ref}` };
|
|
284
|
-
return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, ref: taskCompositeRef(found.task, found.phase, features) };
|
|
285
|
-
}
|
|
286
|
-
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
287
|
-
const previousStatus = task.status;
|
|
288
|
-
if (nextStatus === "in-progress" && !task.startedAt)
|
|
289
|
-
task.startedAt = now;
|
|
290
|
-
if (nextStatus === "done") {
|
|
291
|
-
if (!task.startedAt)
|
|
292
|
-
task.startedAt = now;
|
|
293
|
-
task.completedAt = now;
|
|
294
|
-
}
|
|
295
|
-
else if (previousStatus === "done") {
|
|
296
|
-
task.completedAt = "";
|
|
297
|
-
}
|
|
298
|
-
task.status = nextStatus;
|
|
299
|
-
}
|
|
300
164
|
async function writeAndSummarize(st, message, structuredContent) {
|
|
301
165
|
await st.writeGenerated();
|
|
302
166
|
return text(message, structuredContent);
|
|
@@ -435,6 +299,12 @@ server.registerTool("planner-show", {
|
|
|
435
299
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
436
300
|
return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
|
|
437
301
|
});
|
|
302
|
+
// P104(F005)/T422: a phase can be left in-progress with no active task and
|
|
303
|
+
// nothing else says so on this cheap read — this renders that fact,
|
|
304
|
+
// priority-ordered, instead of leaving it discoverable only by paying for
|
|
305
|
+
// planner-task-recommend.
|
|
306
|
+
const openPhaseWork = listOpenPhaseWork(phases, features);
|
|
307
|
+
const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
|
|
438
308
|
const summary = [
|
|
439
309
|
`📋 ${project.name}`,
|
|
440
310
|
`Description: ${project.description || "(not set)"}`,
|
|
@@ -445,6 +315,7 @@ server.registerTool("planner-show", {
|
|
|
445
315
|
activeTasks.length === 0
|
|
446
316
|
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
447
317
|
: `Active tasks (${activeTasks.length})${activeTaskState === "conflict" ? " — CONFLICT: multiple tasks are in progress" : ""}:\n${activeTasks.map((task) => `- ${task.ref} — ${task.title} (in-progress)`).join("\n")}`,
|
|
318
|
+
...(openPhaseWorkText ? [`In-progress phases with work left (priority order):`, openPhaseWorkText] : []),
|
|
448
319
|
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
449
320
|
"",
|
|
450
321
|
"Features:",
|
|
@@ -457,12 +328,20 @@ server.registerTool("planner-show", {
|
|
|
457
328
|
description: project.description || null,
|
|
458
329
|
descriptionRef: project.descriptionRef || null,
|
|
459
330
|
goal: project.goal || null,
|
|
460
|
-
|
|
331
|
+
// Identity only: `summary` above already carries every canonical field
|
|
332
|
+
// (decision, rationale, implementationNotes) through projectDecisionContext,
|
|
333
|
+
// bounded at MAX_ACCEPTED_DECISION_CONTEXT_CHARS. Echoing the full
|
|
334
|
+
// AcceptedDecision objects here too would duplicate that content in both
|
|
335
|
+
// channels — the discipline handoff-reply.ts and mutation-reply.ts hold
|
|
336
|
+
// to (P104(F005)/T419). Full canonical detail for every decision: planner-load.
|
|
337
|
+
acceptedDecisions: project.acceptedDecisions.map((decision) => ({ id: decision.id, title: decision.title, acceptedAt: decision.acceptedAt })),
|
|
461
338
|
updatedAt: manifest.updatedAt || null,
|
|
462
339
|
},
|
|
463
340
|
counts: { features: features.length, phases: phases.length, tasks: totalTasks, activeTasks: activeTasks.length },
|
|
464
341
|
activeTaskState,
|
|
465
342
|
activeTasks,
|
|
343
|
+
openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
|
|
344
|
+
openPhaseWorkTotal: openPhaseWork.length,
|
|
466
345
|
features: features.map((feature) => ({
|
|
467
346
|
ref: formatFeatureRef(feature.number),
|
|
468
347
|
shortId: feature.shortId || null,
|
|
@@ -536,7 +415,7 @@ server.registerTool("planner-project-language", {
|
|
|
536
415
|
server.registerTool("planner-project-discuss", {
|
|
537
416
|
description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
|
|
538
417
|
inputSchema: {
|
|
539
|
-
description: z.string().optional(),
|
|
418
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
540
419
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
|
|
541
420
|
goal: z.string().optional(),
|
|
542
421
|
scope: z.array(z.string()).optional(),
|
|
@@ -544,15 +423,18 @@ server.registerTool("planner-project-discuss", {
|
|
|
544
423
|
technologies: z.array(z.string()).optional(),
|
|
545
424
|
tools: z.array(z.string()).optional(),
|
|
546
425
|
globalRules: z.array(z.string()).optional(),
|
|
547
|
-
decisions: z.array(z.string()).optional(),
|
|
426
|
+
decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
|
|
548
427
|
},
|
|
549
428
|
}, async (params) => {
|
|
550
429
|
const st = await requireStore();
|
|
430
|
+
if (params.decisions !== undefined) {
|
|
431
|
+
return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" } };
|
|
432
|
+
}
|
|
551
433
|
const project = await st.loadProject();
|
|
552
|
-
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"
|
|
434
|
+
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
|
|
553
435
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
554
436
|
if (receivedFields.length === 0) {
|
|
555
|
-
return
|
|
437
|
+
return mcpMutationNoFieldsFailure({
|
|
556
438
|
entity: "project",
|
|
557
439
|
ref: project.name,
|
|
558
440
|
operation: "discuss",
|
|
@@ -587,8 +469,6 @@ server.registerTool("planner-project-discuss", {
|
|
|
587
469
|
project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
|
|
588
470
|
if (params.globalRules !== undefined)
|
|
589
471
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
590
|
-
if (params.decisions !== undefined)
|
|
591
|
-
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
592
472
|
const persisted = await st.updateProject((current) => {
|
|
593
473
|
const next = {
|
|
594
474
|
...current,
|
|
@@ -599,7 +479,6 @@ server.registerTool("planner-project-discuss", {
|
|
|
599
479
|
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
600
480
|
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
601
481
|
...(params.globalRules !== undefined ? { globalRules: params.globalRules.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
602
|
-
...(params.decisions !== undefined ? { decisions: params.decisions.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
603
482
|
};
|
|
604
483
|
if (params.descriptionRef !== undefined) {
|
|
605
484
|
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
@@ -787,7 +666,7 @@ server.registerTool("planner-accepted-decision-create", {
|
|
|
787
666
|
});
|
|
788
667
|
}
|
|
789
668
|
catch (error) {
|
|
790
|
-
return
|
|
669
|
+
return mcpAcceptedDecisionMutationFailure(error, "created");
|
|
791
670
|
}
|
|
792
671
|
});
|
|
793
672
|
server.registerTool("planner-accepted-decision-update", {
|
|
@@ -805,7 +684,7 @@ server.registerTool("planner-accepted-decision-update", {
|
|
|
805
684
|
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
806
685
|
const receivedFields = mutableFields.filter((field) => ({ title, decision, rationale, implementationNotes })[field] !== undefined);
|
|
807
686
|
if (receivedFields.length === 0) {
|
|
808
|
-
return
|
|
687
|
+
return mcpMutationNoFieldsFailure({
|
|
809
688
|
entity: "acceptedDecision",
|
|
810
689
|
ref: decisionId,
|
|
811
690
|
operation: "update",
|
|
@@ -834,7 +713,7 @@ server.registerTool("planner-accepted-decision-update", {
|
|
|
834
713
|
});
|
|
835
714
|
}
|
|
836
715
|
catch (error) {
|
|
837
|
-
return
|
|
716
|
+
return mcpAcceptedDecisionMutationFailure(error, "updated");
|
|
838
717
|
}
|
|
839
718
|
});
|
|
840
719
|
server.registerTool("planner-accepted-decision-delete", {
|
|
@@ -870,14 +749,14 @@ server.registerTool("planner-accepted-decision-delete", {
|
|
|
870
749
|
});
|
|
871
750
|
}
|
|
872
751
|
catch (error) {
|
|
873
|
-
return
|
|
752
|
+
return mcpAcceptedDecisionMutationFailure(error, "deleted");
|
|
874
753
|
}
|
|
875
754
|
});
|
|
876
755
|
server.registerTool("planner-feature-add", {
|
|
877
756
|
description: "Create a feature with a rich description. REQUIRED: description must include code references (file:line), current implementation state (what exists, what is unimplemented), systems/structs/traits involved, concrete goals, and behaviors to preserve. The description is the primary context for future agents resuming this feature; one-liners cause misalignment.",
|
|
878
757
|
inputSchema: {
|
|
879
758
|
name: z.string().min(1),
|
|
880
|
-
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, goals, and behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks without code refs.").describe(
|
|
759
|
+
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, goals, and behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks without code refs.").describe(`Required code references (file:line), current state of the art, structs/traits/systems involved, goals, and behaviors to preserve. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
|
|
881
760
|
status: z.enum(STATUS_VALUES).optional(),
|
|
882
761
|
},
|
|
883
762
|
}, async ({ name, description, status }) => {
|
|
@@ -885,7 +764,6 @@ server.registerTool("planner-feature-add", {
|
|
|
885
764
|
return st.runBatch(async () => {
|
|
886
765
|
const timestamp = nowISO();
|
|
887
766
|
const effectiveStatus = status ?? "planned";
|
|
888
|
-
const existingFeatures = (await st.loadFeatures()).features;
|
|
889
767
|
const id = createFeatureId();
|
|
890
768
|
const identity = await st.allocateEntityIdentity("feature", id);
|
|
891
769
|
const priority = await st.nextPriority("feature");
|
|
@@ -952,7 +830,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
952
830
|
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
953
831
|
inputSchema: {
|
|
954
832
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
955
|
-
description: z.string().optional().describe(
|
|
833
|
+
description: z.string().optional().describe(`Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}`),
|
|
956
834
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
957
835
|
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
958
836
|
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
@@ -969,7 +847,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
969
847
|
const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
|
|
970
848
|
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
971
849
|
if (receivedFields.length === 0) {
|
|
972
|
-
return
|
|
850
|
+
return mcpMutationNoFieldsFailure({
|
|
973
851
|
entity: "feature",
|
|
974
852
|
ref: formatFeatureRef(feature.number),
|
|
975
853
|
operation: "discuss",
|
|
@@ -1010,14 +888,21 @@ server.registerTool("planner-feature-discuss", {
|
|
|
1010
888
|
return doc;
|
|
1011
889
|
});
|
|
1012
890
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
1013
|
-
|
|
891
|
+
const featureDiscussReply = buildMutationReply({
|
|
892
|
+
identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
|
|
893
|
+
operation: "discuss",
|
|
894
|
+
updatedFields: receivedFields,
|
|
895
|
+
changedValues: result,
|
|
896
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
|
|
897
|
+
});
|
|
898
|
+
return writeAndSummarize(st, featureDiscussReply.text, featureDiscussReply.structured);
|
|
1014
899
|
});
|
|
1015
900
|
server.registerTool("planner-feature-update", {
|
|
1016
901
|
description: "Update a feature.",
|
|
1017
902
|
inputSchema: {
|
|
1018
903
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
1019
904
|
name: z.string().optional(),
|
|
1020
|
-
description: z.string().optional(),
|
|
905
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1021
906
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
1022
907
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1023
908
|
workDone: z.string().optional(),
|
|
@@ -1034,12 +919,12 @@ server.registerTool("planner-feature-update", {
|
|
|
1034
919
|
return text(resolvedFeature.error);
|
|
1035
920
|
const feature = resolvedFeature.feature;
|
|
1036
921
|
if (updates.status !== undefined) {
|
|
1037
|
-
return
|
|
922
|
+
return mcpDerivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
|
|
1038
923
|
}
|
|
1039
924
|
const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
|
|
1040
925
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1041
926
|
if (receivedFields.length === 0) {
|
|
1042
|
-
return
|
|
927
|
+
return mcpMutationNoFieldsFailure({
|
|
1043
928
|
entity: "feature",
|
|
1044
929
|
ref: formatFeatureRef(feature.number),
|
|
1045
930
|
operation: "update",
|
|
@@ -1083,7 +968,14 @@ server.registerTool("planner-feature-update", {
|
|
|
1083
968
|
return doc;
|
|
1084
969
|
});
|
|
1085
970
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
1086
|
-
|
|
971
|
+
const featureUpdateReply = buildMutationReply({
|
|
972
|
+
identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
|
|
973
|
+
operation: "update",
|
|
974
|
+
updatedFields: receivedFields,
|
|
975
|
+
changedValues: result,
|
|
976
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
|
|
977
|
+
});
|
|
978
|
+
return writeAndSummarize(st, featureUpdateReply.text, featureUpdateReply.structured);
|
|
1087
979
|
});
|
|
1088
980
|
server.registerTool("planner-feature-delete", {
|
|
1089
981
|
description: "Delete a feature. By default, phases are unlinked rather than deleted.",
|
|
@@ -1097,23 +989,11 @@ server.registerTool("planner-feature-delete", {
|
|
|
1097
989
|
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
1098
990
|
if (!resolvedFeature.ok)
|
|
1099
991
|
return text(resolvedFeature.error);
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
return doc;
|
|
1106
|
-
});
|
|
1107
|
-
for (const phase of phases) {
|
|
1108
|
-
if (cascade) {
|
|
1109
|
-
await st.deletePhase(phase.id);
|
|
1110
|
-
}
|
|
1111
|
-
else {
|
|
1112
|
-
await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
});
|
|
1116
|
-
return writeAndSummarize(st, `Feature deleted: ${feature.id}${cascade ? `; deleted ${phases.length} phases` : `; unlinked ${phases.length} phases`}`, { deleted: feature.id, affectedPhases: phases.length, cascade: Boolean(cascade) });
|
|
992
|
+
const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(cascade) });
|
|
993
|
+
if (!outcome.ok)
|
|
994
|
+
return text(`❌ ${outcome.error}`, { deleted: false, errorCode: outcome.errorCode });
|
|
995
|
+
const { result } = outcome;
|
|
996
|
+
return text(`✅ Feature deleted: ${result.ref}${result.cascade ? `; deleted ${result.phaseCount} phases` : `; unlinked ${result.phaseCount} phases`}`, { deleted: true, ref: result.ref, id: result.id, affectedPhases: result.phaseCount, cascade: result.cascade });
|
|
1117
997
|
});
|
|
1118
998
|
server.registerTool("planner-phase-add", {
|
|
1119
999
|
description: "Create a phase linked to a feature with a rich description. REQUIRED: feature ref and description. The description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
|
|
@@ -1121,7 +1001,7 @@ server.registerTool("planner-phase-add", {
|
|
|
1121
1001
|
title: z.string().min(1),
|
|
1122
1002
|
feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
|
|
1123
1003
|
summary: z.string().optional().describe("One-line summary of the phase"),
|
|
1124
|
-
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe(
|
|
1004
|
+
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe(`Required code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
|
|
1125
1005
|
},
|
|
1126
1006
|
}, async ({ title, feature: featureRef, summary, description }) => {
|
|
1127
1007
|
const st = await requireStore();
|
|
@@ -1140,8 +1020,6 @@ server.registerTool("planner-phase-add", {
|
|
|
1140
1020
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
1141
1021
|
let phase;
|
|
1142
1022
|
await st.runBatch(() => withFeatureLock(lockKey, async () => {
|
|
1143
|
-
const phases = await st.loadAllPhases();
|
|
1144
|
-
const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
|
|
1145
1023
|
const timestamp = nowISO();
|
|
1146
1024
|
const id = createPhaseId();
|
|
1147
1025
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
@@ -1269,7 +1147,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1269
1147
|
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1270
1148
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1271
1149
|
if (receivedFields.length === 0) {
|
|
1272
|
-
return
|
|
1150
|
+
return mcpMutationNoFieldsFailure({
|
|
1273
1151
|
entity: "phase",
|
|
1274
1152
|
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1275
1153
|
operation: "discuss",
|
|
@@ -1316,7 +1194,16 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1316
1194
|
entry.updatedAt = nowISO();
|
|
1317
1195
|
return entry;
|
|
1318
1196
|
});
|
|
1319
|
-
|
|
1197
|
+
const phaseDiscussRef = formatPhaseRef(found.number, featureNumberOfPhase(found, features));
|
|
1198
|
+
const phaseDiscussReply = buildMutationReply({
|
|
1199
|
+
identity: { entity: "phase", ref: phaseDiscussRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
1200
|
+
operation: "discuss",
|
|
1201
|
+
updatedFields: receivedFields,
|
|
1202
|
+
changedValues: phase,
|
|
1203
|
+
extras: { contextReady: true },
|
|
1204
|
+
readBackCommand: `planner-phase-show ${phaseDiscussRef} full=true`,
|
|
1205
|
+
});
|
|
1206
|
+
return writeAndSummarize(st, phaseDiscussReply.text, phaseDiscussReply.structured);
|
|
1320
1207
|
});
|
|
1321
1208
|
server.registerTool("planner-phase-update", {
|
|
1322
1209
|
description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
|
|
@@ -1325,7 +1212,7 @@ server.registerTool("planner-phase-update", {
|
|
|
1325
1212
|
title: z.string().optional(),
|
|
1326
1213
|
status: z.enum(PHASE_STATUS_VALUES).optional(),
|
|
1327
1214
|
summary: z.string().optional(),
|
|
1328
|
-
description: z.string().optional(),
|
|
1215
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1329
1216
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
1330
1217
|
featureId: z.string().optional().describe("Feature ref to relink this phase to, or an empty string to unlink it."),
|
|
1331
1218
|
priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
|
|
@@ -1334,7 +1221,7 @@ server.registerTool("planner-phase-update", {
|
|
|
1334
1221
|
dependencies: z.array(z.string()).optional(),
|
|
1335
1222
|
risks: z.array(z.string()).optional(),
|
|
1336
1223
|
openQuestions: z.array(z.string()).optional(),
|
|
1337
|
-
decisions: z.array(z.string()).optional(),
|
|
1224
|
+
decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
|
|
1338
1225
|
completionCriteria: z.array(z.string()).optional(),
|
|
1339
1226
|
},
|
|
1340
1227
|
}, async ({ phase: ref, ...updates }) => {
|
|
@@ -1344,12 +1231,15 @@ server.registerTool("planner-phase-update", {
|
|
|
1344
1231
|
if (!found)
|
|
1345
1232
|
return text(`Phase not found: ${ref}`);
|
|
1346
1233
|
if (updates.status !== undefined) {
|
|
1347
|
-
return
|
|
1234
|
+
return mcpDerivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
|
|
1235
|
+
}
|
|
1236
|
+
if (updates.decisions !== undefined) {
|
|
1237
|
+
return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(found.number, featureNumberOfPhase(found, features)) } };
|
|
1348
1238
|
}
|
|
1349
|
-
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "
|
|
1239
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1350
1240
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1351
1241
|
if (receivedFields.length === 0) {
|
|
1352
|
-
return
|
|
1242
|
+
return mcpMutationNoFieldsFailure({
|
|
1353
1243
|
entity: "phase",
|
|
1354
1244
|
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1355
1245
|
operation: "update",
|
|
@@ -1409,8 +1299,6 @@ server.registerTool("planner-phase-update", {
|
|
|
1409
1299
|
entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
|
|
1410
1300
|
if (updates.openQuestions !== undefined)
|
|
1411
1301
|
entry.openQuestions = updates.openQuestions.map((item) => item.trim()).filter(Boolean);
|
|
1412
|
-
if (updates.decisions !== undefined)
|
|
1413
|
-
entry.decisions = updates.decisions.map((item) => item.trim()).filter(Boolean);
|
|
1414
1302
|
if (updates.completionCriteria !== undefined)
|
|
1415
1303
|
entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
1416
1304
|
entry.updatedAt = nowISO();
|
|
@@ -1437,10 +1325,16 @@ server.registerTool("planner-phase-update", {
|
|
|
1437
1325
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1438
1326
|
.filter((entry) => entry.state === "stale" && entry.ownerId === persisted.featureId)
|
|
1439
1327
|
.map((entry) => entry.ownerRef) ?? [];
|
|
1440
|
-
const
|
|
1441
|
-
|
|
1442
|
-
: ""
|
|
1443
|
-
|
|
1328
|
+
const phaseUpdateRef = formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures));
|
|
1329
|
+
const phaseUpdateReply = buildMutationReply({
|
|
1330
|
+
identity: { entity: "phase", ref: phaseUpdateRef, id: persisted.id, shortId: persisted.shortId, title: persisted.title, status: persisted.status },
|
|
1331
|
+
operation: "update",
|
|
1332
|
+
updatedFields: receivedFields,
|
|
1333
|
+
changedValues: persisted,
|
|
1334
|
+
extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
|
|
1335
|
+
readBackCommand: `planner-phase-show ${phaseUpdateRef} full=true`,
|
|
1336
|
+
});
|
|
1337
|
+
return text(phaseUpdateReply.text, phaseUpdateReply.structured);
|
|
1444
1338
|
});
|
|
1445
1339
|
server.registerTool("planner-phase-delete", {
|
|
1446
1340
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -1467,7 +1361,7 @@ server.registerTool("planner-task-add", {
|
|
|
1467
1361
|
feature: z.string().min(1).optional().describe("Feature ref the task's phase belongs to. Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title. REQUIRED."),
|
|
1468
1362
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
1469
1363
|
title: z.string().min(1),
|
|
1470
|
-
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe(
|
|
1364
|
+
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe(`Required code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner. ${LONG_TEXT_LIMIT_NOTICE}`),
|
|
1471
1365
|
checklist: z.array(z.string()).optional(),
|
|
1472
1366
|
},
|
|
1473
1367
|
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
@@ -1534,7 +1428,7 @@ server.registerTool("planner-task-add", {
|
|
|
1534
1428
|
return entry;
|
|
1535
1429
|
});
|
|
1536
1430
|
const finalFeatures = (await st.loadFeatures()).features;
|
|
1537
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
1431
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned; ${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
1538
1432
|
});
|
|
1539
1433
|
});
|
|
1540
1434
|
server.registerTool("planner-task-show", {
|
|
@@ -1543,10 +1437,11 @@ server.registerTool("planner-task-show", {
|
|
|
1543
1437
|
}, async ({ task: ref, full }, extra) => {
|
|
1544
1438
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1545
1439
|
const st = await requireStore();
|
|
1546
|
-
const
|
|
1440
|
+
const features = (await st.loadFeatures()).features;
|
|
1441
|
+
const allPhases = await st.loadAllPhases();
|
|
1442
|
+
const found = findTaskByRef(allPhases, features, ref);
|
|
1547
1443
|
if (!found)
|
|
1548
1444
|
return text(`Task not found: ${ref}`);
|
|
1549
|
-
const features = (await st.loadFeatures()).features;
|
|
1550
1445
|
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))})`;
|
|
1551
1446
|
if (!full)
|
|
1552
1447
|
return text(summary);
|
|
@@ -1559,7 +1454,15 @@ server.registerTool("planner-task-show", {
|
|
|
1559
1454
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1560
1455
|
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
1561
1456
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
1562
|
-
|
|
1457
|
+
// P104(F005)/T421: priority and dependsOn at the point an agent reads
|
|
1458
|
+
// the task and decides what to do next — the fact planner-task-list and
|
|
1459
|
+
// planner-task-recommend already have, made visible here instead of
|
|
1460
|
+
// requiring a second call.
|
|
1461
|
+
const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
|
|
1462
|
+
const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
|
|
1463
|
+
const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
|
|
1464
|
+
if (completionMismatch)
|
|
1465
|
+
sections.push(completionMismatch);
|
|
1563
1466
|
if (found.task.description?.trim())
|
|
1564
1467
|
sections.push(found.task.description.trim());
|
|
1565
1468
|
if (found.task.descriptionRef)
|
|
@@ -1584,18 +1487,61 @@ server.registerTool("planner-task-show", {
|
|
|
1584
1487
|
description: found.task.description,
|
|
1585
1488
|
descriptionRef: found.task.descriptionRef || "",
|
|
1586
1489
|
acceptedDecisions: found.task.acceptedDecisions,
|
|
1490
|
+
priority: orderContext.priority,
|
|
1491
|
+
dependsOn: orderContext.dependsOn,
|
|
1492
|
+
...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
|
|
1493
|
+
},
|
|
1494
|
+
taskOrder: {
|
|
1495
|
+
readyCount: orderContext.readyCount,
|
|
1496
|
+
readyAheadCount: orderContext.readyAheadCount,
|
|
1497
|
+
nextByPriorityRef: orderContext.nextByPriorityRef,
|
|
1587
1498
|
},
|
|
1588
1499
|
});
|
|
1589
1500
|
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
1590
|
-
|
|
1501
|
+
// Advisory only (P104(F005)/T420): report whether planner-task-start
|
|
1502
|
+
// would succeed right now, using the exact checks it runs itself, so a
|
|
1503
|
+
// caller reading the task before starting it learns what else is needed
|
|
1504
|
+
// in this same call instead of from a denial. task-start recomputes this
|
|
1505
|
+
// at call time and remains the sole authority on whether work may begin.
|
|
1506
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
1507
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
1508
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
1509
|
+
const linkedRequirements = [
|
|
1510
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
1511
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
1512
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
1513
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
1514
|
+
const contextEligibility = contextReadEligibilityForSession({
|
|
1515
|
+
sessionId: plannerSessionId,
|
|
1516
|
+
taskId: found.task.id,
|
|
1517
|
+
phaseId: found.phase.id,
|
|
1518
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
1519
|
+
task: found.task,
|
|
1520
|
+
phase: found.phase,
|
|
1521
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
1522
|
+
requirements: linkedRequirements,
|
|
1523
|
+
requirementIds: linkedRequirementIds,
|
|
1524
|
+
});
|
|
1525
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
1526
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1527
|
+
const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
|
|
1528
|
+
const gateNextActions = gateState.ready ? [] : mcpContextReadActions(contextEligibility, { task: taskCompositeRef(found.task, found.phase, features), phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `planner-task-start ${taskCompositeRef(found.task, found.phase, features)}`);
|
|
1529
|
+
return {
|
|
1530
|
+
content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
|
|
1531
|
+
structuredContent: {
|
|
1532
|
+
...result.structuredContent,
|
|
1533
|
+
taskStartGateState: gateState,
|
|
1534
|
+
...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
|
|
1535
|
+
},
|
|
1536
|
+
};
|
|
1591
1537
|
});
|
|
1592
1538
|
server.registerTool("planner-task-discuss", {
|
|
1593
1539
|
description: "Persist task execution notes/checklist.",
|
|
1594
1540
|
inputSchema: {
|
|
1595
1541
|
task: z.string().min(1),
|
|
1596
|
-
description: z.string().optional(),
|
|
1542
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1597
1543
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
1598
|
-
checklist: z.array(z.string()).optional(),
|
|
1544
|
+
checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick). Use planner-task-checklist-toggle/add/remove for granular edits."),
|
|
1599
1545
|
},
|
|
1600
1546
|
}, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
|
|
1601
1547
|
const st = await requireStore();
|
|
@@ -1606,7 +1552,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
1606
1552
|
const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
|
|
1607
1553
|
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
1608
1554
|
if (receivedFields.length === 0) {
|
|
1609
|
-
return
|
|
1555
|
+
return mcpMutationNoFieldsFailure({
|
|
1610
1556
|
entity: "task",
|
|
1611
1557
|
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1612
1558
|
operation: "discuss",
|
|
@@ -1622,6 +1568,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
1622
1568
|
});
|
|
1623
1569
|
}
|
|
1624
1570
|
let updatedTask;
|
|
1571
|
+
let checklistLostTicks = [];
|
|
1625
1572
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
1626
1573
|
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
1627
1574
|
if (!task)
|
|
@@ -1635,8 +1582,11 @@ server.registerTool("planner-task-discuss", {
|
|
|
1635
1582
|
else
|
|
1636
1583
|
delete task.descriptionRef;
|
|
1637
1584
|
}
|
|
1638
|
-
if (checklist !== undefined)
|
|
1639
|
-
|
|
1585
|
+
if (checklist !== undefined) {
|
|
1586
|
+
const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
|
|
1587
|
+
task.checklist = replacement.items;
|
|
1588
|
+
checklistLostTicks = replacement.lostTicks;
|
|
1589
|
+
}
|
|
1640
1590
|
task.updatedAt = nowISO();
|
|
1641
1591
|
phase.updatedAt = task.updatedAt;
|
|
1642
1592
|
updatedTask = task;
|
|
@@ -1644,12 +1594,25 @@ server.registerTool("planner-task-discuss", {
|
|
|
1644
1594
|
});
|
|
1645
1595
|
const features = (await st.loadFeatures()).features;
|
|
1646
1596
|
const t = updatedTask ?? found.task;
|
|
1647
|
-
|
|
1597
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
1598
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
1599
|
+
: "";
|
|
1600
|
+
const taskDiscussRef = taskCompositeRef(t, found.phase, features);
|
|
1601
|
+
const taskDiscussReply = buildMutationReply({
|
|
1602
|
+
identity: { entity: "task", ref: taskDiscussRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
|
|
1603
|
+
operation: "discuss",
|
|
1604
|
+
updatedFields: receivedFields,
|
|
1605
|
+
changedValues: t,
|
|
1606
|
+
extras: checklistLostTicks.length > 0 ? { checklistLostTicks } : {},
|
|
1607
|
+
notices: [lostTicksNotice],
|
|
1608
|
+
readBackCommand: `planner-task-show ${taskDiscussRef} full=true`,
|
|
1609
|
+
});
|
|
1610
|
+
return writeAndSummarize(st, taskDiscussReply.text, taskDiscussReply.structured);
|
|
1648
1611
|
});
|
|
1649
1612
|
server.registerTool("planner-task-dependency-add", {
|
|
1650
1613
|
description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
|
|
1651
1614
|
inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1) },
|
|
1652
|
-
}, async ({ task: ref, dependsOn }
|
|
1615
|
+
}, async ({ task: ref, dependsOn }) => {
|
|
1653
1616
|
const st = await requireStore();
|
|
1654
1617
|
const features = (await st.loadFeatures()).features;
|
|
1655
1618
|
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
@@ -1659,7 +1622,8 @@ server.registerTool("planner-task-dependency-add", {
|
|
|
1659
1622
|
try {
|
|
1660
1623
|
const result = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1661
1624
|
await st.writeGenerated();
|
|
1662
|
-
|
|
1625
|
+
const reply = buildMutationReply({ identity: { entity: "task", ref: taskCompositeRef(found.task, found.phase, features), id: result.id, shortId: result.shortId, title: result.title, status: result.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: result.dependsOn }, readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, features)} full=true` });
|
|
1626
|
+
return text(`${reply.text} Depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, reply.structured);
|
|
1663
1627
|
}
|
|
1664
1628
|
catch (error) {
|
|
1665
1629
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -1681,7 +1645,8 @@ server.registerTool("planner-task-dependency-delete", {
|
|
|
1681
1645
|
try {
|
|
1682
1646
|
const result = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1683
1647
|
await st.writeGenerated();
|
|
1684
|
-
|
|
1648
|
+
const reply = buildMutationReply({ identity: { entity: "task", ref: taskCompositeRef(found.task, found.phase, features), id: result.id, shortId: result.shortId, title: result.title, status: result.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: result.dependsOn }, readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, features)} full=true` });
|
|
1649
|
+
return text(reply.text, reply.structured);
|
|
1685
1650
|
}
|
|
1686
1651
|
catch (error) {
|
|
1687
1652
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -1694,13 +1659,13 @@ server.registerTool("planner-task-update", {
|
|
|
1694
1659
|
task: z.string().min(1),
|
|
1695
1660
|
title: z.string().optional(),
|
|
1696
1661
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1697
|
-
description: z.string().optional(),
|
|
1662
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1698
1663
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
1699
1664
|
notes: z.string().optional().describe("Implementation notes."),
|
|
1700
|
-
decisions: z.array(z.string()).optional().describe("
|
|
1665
|
+
decisions: z.array(z.string()).optional().describe("Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use planner-accepted-decision-create for new durable decisions."),
|
|
1701
1666
|
motivation: z.string().optional(),
|
|
1702
1667
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
1703
|
-
checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Agents should tick steps via planner-task-checklist-toggle, not write DONE in titles."),
|
|
1668
|
+
checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick); an item that cannot be matched loses its tick and that loss is reported. Agents should tick steps via planner-task-checklist-toggle, not write DONE in titles, and use planner-task-checklist-add/remove for granular edits."),
|
|
1704
1669
|
subtasks: z.array(z.object({ id: z.string().optional(), title: z.string().min(1), description: z.string().optional(), status: z.enum(STATUS_VALUES).optional() })).optional().describe("Replace subtasks; existing IDs must belong to this task and omitted IDs are planner-generated."),
|
|
1705
1670
|
},
|
|
1706
1671
|
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
|
|
@@ -1714,6 +1679,9 @@ server.registerTool("planner-task-update", {
|
|
|
1714
1679
|
if (status === "done" && status !== found.task.status) {
|
|
1715
1680
|
return text("Task completion transitions require planner-task-complete with durable completion and verification evidence.");
|
|
1716
1681
|
}
|
|
1682
|
+
if (decisions !== undefined) {
|
|
1683
|
+
return { isError: true, content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }], structuredContent: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "task", targetRef: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features) } };
|
|
1684
|
+
}
|
|
1717
1685
|
// Validate motivation requirement for status transitions.
|
|
1718
1686
|
if (status !== undefined && needsMotivation(found.task.status, status)) {
|
|
1719
1687
|
if (!motivation || !motivation.trim()) {
|
|
@@ -1721,11 +1689,11 @@ server.registerTool("planner-task-update", {
|
|
|
1721
1689
|
`Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
|
|
1722
1690
|
}
|
|
1723
1691
|
}
|
|
1724
|
-
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "
|
|
1692
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
|
|
1725
1693
|
const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks };
|
|
1726
1694
|
const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
|
|
1727
1695
|
if (receivedFields.length === 0) {
|
|
1728
|
-
return
|
|
1696
|
+
return mcpMutationNoFieldsFailure({
|
|
1729
1697
|
entity: "task",
|
|
1730
1698
|
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1731
1699
|
operation: "update",
|
|
@@ -1747,6 +1715,7 @@ server.registerTool("planner-task-update", {
|
|
|
1747
1715
|
}
|
|
1748
1716
|
}
|
|
1749
1717
|
let updatedTask;
|
|
1718
|
+
let checklistLostTicks = [];
|
|
1750
1719
|
const timestamp = nowISO();
|
|
1751
1720
|
try {
|
|
1752
1721
|
await st.updateTask(found.phase.id, found.task.id, (task) => {
|
|
@@ -1765,15 +1734,10 @@ server.registerTool("planner-task-update", {
|
|
|
1765
1734
|
}
|
|
1766
1735
|
if (notes !== undefined)
|
|
1767
1736
|
task.notes = notes.trim();
|
|
1768
|
-
if (decisions !== undefined)
|
|
1769
|
-
task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
|
|
1770
1737
|
if (checklist !== undefined) {
|
|
1771
|
-
task.checklist
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
title: item.trim(),
|
|
1775
|
-
checked: false,
|
|
1776
|
-
}));
|
|
1738
|
+
const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
|
|
1739
|
+
task.checklist = replacement.items;
|
|
1740
|
+
checklistLostTicks = replacement.lostTicks;
|
|
1777
1741
|
}
|
|
1778
1742
|
if (subtasks !== undefined) {
|
|
1779
1743
|
const existingIds = new Set(task.subtasks.map((item) => item.id));
|
|
@@ -1829,13 +1793,24 @@ server.registerTool("planner-task-update", {
|
|
|
1829
1793
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1830
1794
|
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
1831
1795
|
.map((entry) => entry.ownerRef) ?? [];
|
|
1832
|
-
const
|
|
1833
|
-
?
|
|
1796
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
1797
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
1834
1798
|
: "";
|
|
1835
|
-
const
|
|
1836
|
-
|
|
1837
|
-
: {
|
|
1838
|
-
|
|
1799
|
+
const taskUpdateRef = taskCompositeRef(t, found.phase, features);
|
|
1800
|
+
const taskUpdateReply = buildMutationReply({
|
|
1801
|
+
identity: { entity: "task", ref: taskUpdateRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
|
|
1802
|
+
operation: "update",
|
|
1803
|
+
updatedFields: receivedFields,
|
|
1804
|
+
changedValues: t,
|
|
1805
|
+
extras: {
|
|
1806
|
+
...(resumeRequired ? { resumeRequired } : {}),
|
|
1807
|
+
...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
|
|
1808
|
+
...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
|
|
1809
|
+
},
|
|
1810
|
+
notices: [resumeNotice, lostTicksNotice],
|
|
1811
|
+
readBackCommand: `planner-task-show ${taskUpdateRef} full=true`,
|
|
1812
|
+
});
|
|
1813
|
+
return writeAndSummarize(st, taskUpdateReply.text, taskUpdateReply.structured);
|
|
1839
1814
|
});
|
|
1840
1815
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1841
1816
|
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.",
|
|
@@ -1977,36 +1952,8 @@ server.registerTool("planner-task-recommend", {
|
|
|
1977
1952
|
})),
|
|
1978
1953
|
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
1979
1954
|
});
|
|
1980
|
-
const
|
|
1981
|
-
|
|
1982
|
-
return text(`No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}`, {
|
|
1983
|
-
kind: selection.kind,
|
|
1984
|
-
reason: selection.reason,
|
|
1985
|
-
activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
|
|
1986
|
-
activeTask: result.activeTask,
|
|
1987
|
-
nextFeature: result.nextFeature,
|
|
1988
|
-
nextPhase: result.nextPhase,
|
|
1989
|
-
nextTask: result.nextTask,
|
|
1990
|
-
claims: result.claims,
|
|
1991
|
-
selection,
|
|
1992
|
-
});
|
|
1993
|
-
const { candidate } = selection;
|
|
1994
|
-
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1995
|
-
const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
1996
|
-
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
1997
|
-
return text(`${activeRef}Next work (${selection.kind}): ${ref} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}`, {
|
|
1998
|
-
kind: selection.kind,
|
|
1999
|
-
taskId: candidate.task.id,
|
|
2000
|
-
phaseId: candidate.phase.id,
|
|
2001
|
-
featureId: candidate.feature?.id,
|
|
2002
|
-
deviation: selection.deviation,
|
|
2003
|
-
activeTask: result.activeTask,
|
|
2004
|
-
nextFeature: result.nextFeature,
|
|
2005
|
-
nextPhase: result.nextPhase,
|
|
2006
|
-
nextTask: result.nextTask,
|
|
2007
|
-
claims: result.claims,
|
|
2008
|
-
selection,
|
|
2009
|
-
});
|
|
1955
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
1956
|
+
return text(reply.text, reply.structured);
|
|
2010
1957
|
});
|
|
2011
1958
|
server.registerTool("planner-task-deviation", {
|
|
2012
1959
|
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.",
|
|
@@ -2284,7 +2231,8 @@ server.registerTool("planner-task-start", {
|
|
|
2284
2231
|
}
|
|
2285
2232
|
return st.runBatch(async () => {
|
|
2286
2233
|
const features = (await st.loadFeatures()).features;
|
|
2287
|
-
const
|
|
2234
|
+
const initialPhases = await st.loadAllPhases();
|
|
2235
|
+
const found = findTaskByRef(initialPhases, features, ref);
|
|
2288
2236
|
if (!found)
|
|
2289
2237
|
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."]));
|
|
2290
2238
|
const taskRef = taskCompositeRef(found.task, found.phase, features);
|
|
@@ -2294,6 +2242,12 @@ server.registerTool("planner-task-start", {
|
|
|
2294
2242
|
if (found.task.status === "done")
|
|
2295
2243
|
return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-reopen with task=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry planner-task-start ${taskRef} only if reopening is not the intended operation.`], { taskId: found.task.id }));
|
|
2296
2244
|
const project = await st.loadProject();
|
|
2245
|
+
// Targeted stale-context refresh (P102(F005)/T404): task-start delivery
|
|
2246
|
+
// stays scoped to feature/phase/task context, but a session that already
|
|
2247
|
+
// did a full planner-load gets a non-blocking advisory when project-level
|
|
2248
|
+
// context has drifted since, instead of silently going stale.
|
|
2249
|
+
const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
|
|
2250
|
+
const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
|
|
2297
2251
|
const linkedRequirements = [
|
|
2298
2252
|
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
2299
2253
|
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
@@ -2327,7 +2281,9 @@ server.registerTool("planner-task-start", {
|
|
|
2327
2281
|
}
|
|
2328
2282
|
if (found.task.status === "in-progress") {
|
|
2329
2283
|
const outcome = taskStartSucceeded(found.task.id, true);
|
|
2330
|
-
|
|
2284
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
2285
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
2286
|
+
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true${projectContextAdvisory}${phaseOrderAdvisory}`, { ...outcome, task: found.task, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase });
|
|
2331
2287
|
}
|
|
2332
2288
|
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
2333
2289
|
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
@@ -2424,21 +2380,26 @@ server.registerTool("planner-task-start", {
|
|
|
2424
2380
|
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 }));
|
|
2425
2381
|
}
|
|
2426
2382
|
const outcome = taskStartSucceeded(persistedTask.id);
|
|
2427
|
-
|
|
2383
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
2384
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
2385
|
+
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}`, {
|
|
2428
2386
|
...outcome,
|
|
2429
2387
|
task: persistedTask,
|
|
2430
2388
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
2389
|
+
projectContextStale: projectContextReadState === "stale",
|
|
2390
|
+
higherPriorityOpenPhase: higherPriorityPhase,
|
|
2431
2391
|
});
|
|
2432
2392
|
});
|
|
2433
2393
|
});
|
|
2434
2394
|
server.registerTool("planner-task-complete", {
|
|
2435
|
-
description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete
|
|
2395
|
+
description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete — tick the completed items with planner-task-checklist-toggle first. force=true is the exception for a checklist that no longer matches the work, and requires a motivation; the overridden items are recorded, never auto-ticked.",
|
|
2436
2396
|
inputSchema: {
|
|
2437
2397
|
task: z.string().min(1),
|
|
2438
2398
|
force: z.boolean().optional(),
|
|
2399
|
+
motivation: z.string().optional().describe("Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work."),
|
|
2439
2400
|
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."),
|
|
2440
2401
|
},
|
|
2441
|
-
}, async ({ task: ref, force, description_update }) => {
|
|
2402
|
+
}, async ({ task: ref, force, motivation, description_update }) => {
|
|
2442
2403
|
const st = await requireStore();
|
|
2443
2404
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
2444
2405
|
if (!found)
|
|
@@ -2448,9 +2409,16 @@ server.registerTool("planner-task-complete", {
|
|
|
2448
2409
|
return text("Task completion denied: provide at least 10 characters of durable completion and verification evidence.");
|
|
2449
2410
|
if (found.task.pauseSnapshot)
|
|
2450
2411
|
return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
|
|
2451
|
-
const
|
|
2452
|
-
if (
|
|
2453
|
-
|
|
2412
|
+
const gate = evaluateTaskCompletionGate(found.task.checklist, force === true, motivation);
|
|
2413
|
+
if (!gate.allowed) {
|
|
2414
|
+
const taskRefForToggle = taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features);
|
|
2415
|
+
const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
|
|
2416
|
+
? taskCompletionForceMotivationRequired(gate.uncheckedItems)
|
|
2417
|
+
: taskCompletionChecklistRefusal(gate.uncheckedItems, `planner-task-checklist-toggle ${taskRefForToggle} <item>`);
|
|
2418
|
+
return text(message, { completed: false, errorCode: gate.errorCode, uncheckedItems: gate.uncheckedItems.map((item) => item.title) });
|
|
2419
|
+
}
|
|
2420
|
+
const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, motivation);
|
|
2421
|
+
const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
|
|
2454
2422
|
const timestamp = nowISO();
|
|
2455
2423
|
let updatedTask;
|
|
2456
2424
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -2465,11 +2433,11 @@ server.registerTool("planner-task-complete", {
|
|
|
2465
2433
|
fromStatus: previousStatus,
|
|
2466
2434
|
toStatus: "done",
|
|
2467
2435
|
title: `${previousStatus} → done`,
|
|
2468
|
-
description:
|
|
2436
|
+
description: completionEvidence,
|
|
2469
2437
|
};
|
|
2470
2438
|
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2471
2439
|
const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2472
|
-
task.description = task.description + sep +
|
|
2440
|
+
task.description = task.description + sep + completionEvidence;
|
|
2473
2441
|
task.descriptionUpdatedAt = timestamp;
|
|
2474
2442
|
task.updatedAt = timestamp;
|
|
2475
2443
|
phase.updatedAt = timestamp;
|
|
@@ -2528,57 +2496,27 @@ server.registerTool("planner-handoff-show", {
|
|
|
2528
2496
|
if (!r.ok)
|
|
2529
2497
|
return text(`❌ ${r.error}`);
|
|
2530
2498
|
const phase = await st.loadPhase(r.phase.id);
|
|
2531
|
-
|
|
2532
|
-
if (!bounded.content.trim()) {
|
|
2499
|
+
if (!phase.handoff.trim()) {
|
|
2533
2500
|
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
2534
2501
|
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
|
|
2539
|
-
return text([
|
|
2540
|
-
`Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
|
|
2541
|
-
...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
|
|
2542
|
-
"",
|
|
2543
|
-
archivedContent.content,
|
|
2544
|
-
].join("\n"), {
|
|
2502
|
+
const reply = archived
|
|
2503
|
+
? buildHandoffShowReply({
|
|
2504
|
+
kind: "archived",
|
|
2545
2505
|
phaseRef: r.compositeRef,
|
|
2546
2506
|
phaseId: phase.id,
|
|
2547
|
-
|
|
2548
|
-
archived: true,
|
|
2507
|
+
content: archived.content,
|
|
2549
2508
|
archiveReason: archived.reason,
|
|
2550
2509
|
archivedAt: archived.archivedAt,
|
|
2551
2510
|
archiveFile: archived.file,
|
|
2552
|
-
...archivedContent,
|
|
2553
|
-
supportingDocuments,
|
|
2554
|
-
empty: false,
|
|
2555
|
-
resumeReady: false,
|
|
2556
2511
|
handoffAudit: phase.handoffAudit,
|
|
2557
|
-
})
|
|
2558
|
-
|
|
2559
|
-
return text(
|
|
2512
|
+
})
|
|
2513
|
+
: buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
|
|
2514
|
+
return text(reply.text, reply.structured);
|
|
2560
2515
|
}
|
|
2561
2516
|
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
2562
2517
|
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
2563
|
-
const
|
|
2564
|
-
|
|
2565
|
-
&& phase.handoffAudit.contentHash === contentHash
|
|
2566
|
-
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
2567
|
-
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
2568
|
-
const status = resumeReady
|
|
2569
|
-
? "Resume-ready: persisted compact capsule read-back completed."
|
|
2570
|
-
: "NOT resume-ready: after reading this persisted capsule, call planner-handoff-verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
|
|
2571
|
-
return text(`Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}`, {
|
|
2572
|
-
phaseRef: r.compositeRef,
|
|
2573
|
-
phaseId: r.phase.id,
|
|
2574
|
-
phaseWorkMap,
|
|
2575
|
-
...bounded,
|
|
2576
|
-
contentHash,
|
|
2577
|
-
persistenceVerified,
|
|
2578
|
-
resumeReady,
|
|
2579
|
-
verificationRequired: !resumeReady,
|
|
2580
|
-
handoffAudit: phase.handoffAudit,
|
|
2581
|
-
});
|
|
2518
|
+
const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
|
|
2519
|
+
return text(reply.text, reply.structured);
|
|
2582
2520
|
});
|
|
2583
2521
|
server.registerTool("planner-handoff-verify", {
|
|
2584
2522
|
description: "Finalize one persisted handoff as resume-ready after planner-handoff-show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
|
|
@@ -2612,7 +2550,7 @@ server.registerTool("planner-handoff-verify", {
|
|
|
2612
2550
|
}
|
|
2613
2551
|
catch (error) {
|
|
2614
2552
|
if (error instanceof HandoffContractError)
|
|
2615
|
-
return
|
|
2553
|
+
return mcpHandoffContractFailure(error);
|
|
2616
2554
|
const message = error instanceof Error ? error.message : String(error);
|
|
2617
2555
|
return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
|
|
2618
2556
|
}
|
|
@@ -2660,7 +2598,7 @@ server.registerTool("planner-requirement-create", {
|
|
|
2660
2598
|
description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use planner-project-guidelines-update instead. Requirements have no lifecycle status. Requirement and macro-task IDs/timestamps are assigned by the planner.",
|
|
2661
2599
|
inputSchema: {
|
|
2662
2600
|
title: z.string().min(1),
|
|
2663
|
-
description: z.string().optional(),
|
|
2601
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
2664
2602
|
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2665
2603
|
macroTasks: z.array(z.object({
|
|
2666
2604
|
title: z.string().min(1),
|
|
@@ -2705,7 +2643,7 @@ server.registerTool("planner-requirement-update", {
|
|
|
2705
2643
|
inputSchema: {
|
|
2706
2644
|
requirementId: z.string().min(1),
|
|
2707
2645
|
title: z.string().min(1).optional(),
|
|
2708
|
-
description: z.string().optional(),
|
|
2646
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
2709
2647
|
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2710
2648
|
macroTasks: z.array(z.object({
|
|
2711
2649
|
id: z.string().min(1).optional(),
|
|
@@ -2721,7 +2659,7 @@ server.registerTool("planner-requirement-update", {
|
|
|
2721
2659
|
if (!current)
|
|
2722
2660
|
return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
|
|
2723
2661
|
if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
|
|
2724
|
-
return
|
|
2662
|
+
return mcpMutationNoFieldsFailure({
|
|
2725
2663
|
entity: "requirement",
|
|
2726
2664
|
ref: requirementId,
|
|
2727
2665
|
operation: "update",
|
|
@@ -2729,6 +2667,12 @@ server.registerTool("planner-requirement-update", {
|
|
|
2729
2667
|
retryCommand: `planner-requirement-update ${requirementId}`,
|
|
2730
2668
|
});
|
|
2731
2669
|
}
|
|
2670
|
+
const requirementUpdatedFields = [
|
|
2671
|
+
...(title !== undefined ? ["title"] : []),
|
|
2672
|
+
...(description !== undefined ? ["description"] : []),
|
|
2673
|
+
...(linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
|
|
2674
|
+
...(macroTasks !== undefined ? ["macroTasks"] : []),
|
|
2675
|
+
];
|
|
2732
2676
|
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2733
2677
|
if (!links.ok)
|
|
2734
2678
|
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
@@ -2754,7 +2698,14 @@ server.registerTool("planner-requirement-update", {
|
|
|
2754
2698
|
return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2755
2699
|
}
|
|
2756
2700
|
await st.writeGenerated();
|
|
2757
|
-
|
|
2701
|
+
const requirementUpdateReply = buildMutationReply({
|
|
2702
|
+
identity: { entity: "requirement", ref: next.id, id: next.id, title: next.title },
|
|
2703
|
+
operation: "update",
|
|
2704
|
+
updatedFields: requirementUpdatedFields,
|
|
2705
|
+
changedValues: next,
|
|
2706
|
+
readBackCommand: "planner-requirement-list",
|
|
2707
|
+
});
|
|
2708
|
+
return writeAndSummarize(st, requirementUpdateReply.text, requirementUpdateReply.structured);
|
|
2758
2709
|
});
|
|
2759
2710
|
server.registerTool("planner-requirement-delete", {
|
|
2760
2711
|
description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
|
|
@@ -2820,7 +2771,7 @@ server.registerTool("planner-idea-update", {
|
|
|
2820
2771
|
if (!current)
|
|
2821
2772
|
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2822
2773
|
if (title === undefined && description === undefined) {
|
|
2823
|
-
return
|
|
2774
|
+
return mcpMutationNoFieldsFailure({
|
|
2824
2775
|
entity: "idea",
|
|
2825
2776
|
ref,
|
|
2826
2777
|
operation: "update",
|
|
@@ -2828,9 +2779,17 @@ server.registerTool("planner-idea-update", {
|
|
|
2828
2779
|
retryCommand: `planner-idea-update ${ref}`,
|
|
2829
2780
|
});
|
|
2830
2781
|
}
|
|
2782
|
+
const ideaUpdatedFields = [...(title !== undefined ? ["title"] : []), ...(description !== undefined ? ["description"] : [])];
|
|
2831
2783
|
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2832
2784
|
await st.writeGenerated();
|
|
2833
|
-
|
|
2785
|
+
const ideaUpdateReply = buildMutationReply({
|
|
2786
|
+
identity: { entity: "idea", ref: formatIdeaRef(idea.number), id: idea.id, title: idea.title },
|
|
2787
|
+
operation: "update",
|
|
2788
|
+
updatedFields: ideaUpdatedFields,
|
|
2789
|
+
changedValues: idea,
|
|
2790
|
+
readBackCommand: `planner-idea-show ${formatIdeaRef(idea.number)}`,
|
|
2791
|
+
});
|
|
2792
|
+
return text(ideaUpdateReply.text, ideaUpdateReply.structured);
|
|
2834
2793
|
});
|
|
2835
2794
|
server.registerTool("planner-idea-delete", {
|
|
2836
2795
|
description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
|
|
@@ -2887,15 +2846,19 @@ server.registerTool("planner-idea-promotion-finalize", {
|
|
|
2887
2846
|
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
2888
2847
|
});
|
|
2889
2848
|
server.registerTool("planner-handoff-write", {
|
|
2890
|
-
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown.",
|
|
2849
|
+
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown. On failure the submitted content is retained against phaseRef + expectedHandoffUpdatedAt: a retry against the exact same token may omit content to reuse it instead of resending the whole body.",
|
|
2891
2850
|
inputSchema: {
|
|
2892
2851
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
2893
|
-
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
|
|
2852
|
+
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work. Ignored when content is omitted (the retained title is reused)."),
|
|
2894
2853
|
reason: z.string().min(1).optional().describe("Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown."),
|
|
2895
2854
|
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
2896
|
-
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
2855
|
+
content: z.string().min(1).optional().describe("Full reconciled handoff text (markdown). Omit only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the body retained from that failed attempt."),
|
|
2897
2856
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
2898
2857
|
reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
|
|
2858
|
+
sectionDispositions: z.array(z.object({
|
|
2859
|
+
section: z.string().min(1).describe("Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal."),
|
|
2860
|
+
disposition: z.string().min(1).describe("What happened to that section: dropped, superseded, or folded into another section."),
|
|
2861
|
+
})).optional().describe("Required only when a prior write was refused with HANDOFF_SECTION_RECONCILIATION_REQUIRED; one entry per section it named. Omit on a first write."),
|
|
2899
2862
|
completenessAudit: z.object({
|
|
2900
2863
|
version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
2901
2864
|
entries: z.array(z.object({
|
|
@@ -2934,23 +2897,29 @@ server.registerTool("planner-handoff-write", {
|
|
|
2934
2897
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
2935
2898
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
2936
2899
|
},
|
|
2937
|
-
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2900
|
+
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, sectionDispositions, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2938
2901
|
const st = await requireStore();
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
const
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
lines
|
|
2953
|
-
|
|
2902
|
+
// content is optional so a retry after a failed write can omit it and reuse
|
|
2903
|
+
// the body PlanStore retained against this phase + expectedHandoffUpdatedAt
|
|
2904
|
+
// (see PlanStore.refreshPhaseHandoff). body stays undefined only in that
|
|
2905
|
+
// case; the title/heading checks below apply only to freshly-submitted text.
|
|
2906
|
+
let body = content?.trim() || undefined;
|
|
2907
|
+
if (body !== undefined) {
|
|
2908
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
2909
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
2910
|
+
const effectiveHeadingText = title?.trim() || firstHeadingText;
|
|
2911
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
2912
|
+
return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
|
|
2913
|
+
}
|
|
2914
|
+
if (title?.trim()) {
|
|
2915
|
+
const lines = body.split(/\r?\n/);
|
|
2916
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
2917
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
2918
|
+
lines[firstIdx] = `# ${title.trim()}`;
|
|
2919
|
+
else
|
|
2920
|
+
lines.unshift(`# ${title.trim()}`, "");
|
|
2921
|
+
body = lines.join("\n");
|
|
2922
|
+
}
|
|
2954
2923
|
}
|
|
2955
2924
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2956
2925
|
if (!r.ok)
|
|
@@ -2980,9 +2949,10 @@ server.registerTool("planner-handoff-write", {
|
|
|
2980
2949
|
try {
|
|
2981
2950
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
2982
2951
|
reason: reason.trim(),
|
|
2983
|
-
content: body,
|
|
2952
|
+
...(body !== undefined ? { content: body } : {}),
|
|
2984
2953
|
expectedHandoffUpdatedAt,
|
|
2985
2954
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2955
|
+
...(sectionDispositions ? { sectionDispositions } : {}),
|
|
2986
2956
|
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2987
2957
|
...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
|
|
2988
2958
|
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
@@ -3016,15 +2986,21 @@ server.registerTool("planner-handoff-write", {
|
|
|
3016
2986
|
}
|
|
3017
2987
|
catch (error) {
|
|
3018
2988
|
if (error instanceof HandoffContractError)
|
|
3019
|
-
return
|
|
2989
|
+
return mcpHandoffContractFailure(error);
|
|
3020
2990
|
const message = error instanceof Error ? error.message : String(error);
|
|
3021
|
-
return text(`❌ Handoff refresh denied: ${message}
|
|
2991
|
+
return text(`❌ Handoff refresh denied: ${message} If you retry against the same phaseRef and expectedHandoffUpdatedAt, you may omit content to reuse the body just submitted.`, { error: message });
|
|
3022
2992
|
}
|
|
3023
2993
|
});
|
|
3024
2994
|
server.registerTool("planner-handoff-prepare", {
|
|
3025
2995
|
description: "Identify or prepare the exact handoff target. With a confirmed phaseRef, returns a compact resume scaffold, bounded priority-ordered phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
|
|
3026
|
-
inputSchema: {
|
|
3027
|
-
|
|
2996
|
+
inputSchema: {
|
|
2997
|
+
phaseRef: z.string().min(1).optional(),
|
|
2998
|
+
supportingDocuments: z.array(z.object({
|
|
2999
|
+
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
3000
|
+
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
3001
|
+
})).optional().describe("Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically."),
|
|
3002
|
+
},
|
|
3003
|
+
}, async ({ phaseRef, supportingDocuments }) => {
|
|
3028
3004
|
if (!phaseRef)
|
|
3029
3005
|
return text([
|
|
3030
3006
|
"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.",
|
|
@@ -3036,35 +3012,13 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
3036
3012
|
if (!r.ok)
|
|
3037
3013
|
return text(`❌ ${r.error}`);
|
|
3038
3014
|
try {
|
|
3039
|
-
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3040
|
-
const
|
|
3041
|
-
|
|
3042
|
-
return text([
|
|
3043
|
-
`Handoff preparation for ${r.compositeRef}`,
|
|
3044
|
-
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3045
|
-
"Required human inputs before drafting:",
|
|
3046
|
-
...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
|
|
3047
|
-
"Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
|
|
3048
|
-
"Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
|
|
3049
|
-
audit.draftTemplate,
|
|
3050
|
-
"",
|
|
3051
|
-
"Done tasks missing durable completion/verification evidence:",
|
|
3052
|
-
missing,
|
|
3053
|
-
"",
|
|
3054
|
-
"Compact handoff contract: include only the exact focus/resume point, current or partial state, constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
|
|
3055
|
-
"No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
|
|
3056
|
-
"Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
|
|
3057
|
-
`Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
|
|
3058
|
-
"",
|
|
3059
|
-
"Existing active handoff (reconcile all still-relevant content):",
|
|
3060
|
-
audit.handoff.trim() || "(none)",
|
|
3061
|
-
].join("\n"), {
|
|
3062
|
-
phaseRef: r.compositeRef,
|
|
3063
|
-
...compactAudit,
|
|
3064
|
-
evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
|
|
3065
|
-
});
|
|
3015
|
+
const audit = await st.preparePhaseHandoff(r.phase.id, supportingDocuments);
|
|
3016
|
+
const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
|
|
3017
|
+
return text(reply.text, reply.structured);
|
|
3066
3018
|
}
|
|
3067
3019
|
catch (error) {
|
|
3020
|
+
if (error instanceof HandoffContractError)
|
|
3021
|
+
return mcpHandoffContractFailure(error, "preparation");
|
|
3068
3022
|
const message = error instanceof Error ? error.message : String(error);
|
|
3069
3023
|
return text(`❌ Handoff preparation failed: ${message}`, { error: message });
|
|
3070
3024
|
}
|
|
@@ -3125,8 +3079,11 @@ server.registerTool("planner-web", {
|
|
|
3125
3079
|
return text("planner-web not running. Use planner-web with action=start to start the dashboard.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
3126
3080
|
});
|
|
3127
3081
|
server.registerTool("planner-load", {
|
|
3128
|
-
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap plus
|
|
3129
|
-
|
|
3082
|
+
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated human-facing recap, plus agent-only structuredContent carrying the complete project-level context (description, goal, scope, out-of-scope, technologies, tools, language preferences, Project Guidelines, every Requirement, and every project Accepted Decision, in structuredContent.projectContext and structuredContent.agentContext.projectContext) and bounded canonical Accepted Decision agentContext for feature/phase/task scopes. This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present ONLY structuredContent.recap.text (equivalently, the text content) verbatim to the user, including its final prominent Web UI line — never quote projectContext or agentContext in the human-facing recap. If structuredContent.projectContext.contextComplete is false, the project-level context was NOT delivered this turn — follow structuredContent.projectContext.nextActions and retry before relying on it. A pending handoff is read-only context: NEVER call planner-handoff-show or planner-handoff-clear as part of load/recap. Archive it only when every phase task is done/canceled, when replacing it with a new handoff, or after an explicit user handoff-clear request. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
|
|
3083
|
+
inputSchema: {
|
|
3084
|
+
maxChars: z.number().int().positive().optional().describe(`Single-response bound (chars) for the project-context delivery. Default ${DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS}. Raise this and retry if a prior call returned projectContext.contextComplete=false.`),
|
|
3085
|
+
},
|
|
3086
|
+
}, async ({ maxChars }, extra) => {
|
|
3130
3087
|
const st = store();
|
|
3131
3088
|
if (!(await st.exists()))
|
|
3132
3089
|
return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
|
|
@@ -3156,12 +3113,27 @@ server.registerTool("planner-load", {
|
|
|
3156
3113
|
decisions: task.acceptedDecisions,
|
|
3157
3114
|
}))),
|
|
3158
3115
|
]);
|
|
3116
|
+
// Explicit planner load must deliver the complete project-level context
|
|
3117
|
+
// (P102(F005)/T404), attested through the same lossless contract used
|
|
3118
|
+
// everywhere else: loadProjectContextDelivery + recordProjectContextRead.
|
|
3119
|
+
// buildProjectContextLoadReply is the single shared shaping for this reply
|
|
3120
|
+
// (Pi's planner-load and /planner load use the identical builder).
|
|
3121
|
+
const boundedMaxChars = maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
|
|
3122
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
|
|
3123
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
|
|
3124
|
+
if (projectContextReply.structured.contextComplete) {
|
|
3125
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
3126
|
+
}
|
|
3159
3127
|
const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
|
|
3160
3128
|
return {
|
|
3129
|
+
// The project-context text (like the planner skill and Accepted Decision
|
|
3130
|
+
// context below) is agent-only: read and retain it, but never quote it
|
|
3131
|
+
// or the Accepted Decisions it contains in the human-facing recap.
|
|
3161
3132
|
content: [{ type: "text", text: visibleRecap }],
|
|
3162
3133
|
structuredContent: {
|
|
3163
3134
|
loaded: true,
|
|
3164
3135
|
recap: { text: visibleRecap },
|
|
3136
|
+
projectContext: projectContextReply.structured,
|
|
3165
3137
|
webUi: plannerWebMetadata(web),
|
|
3166
3138
|
preparation: preparation.legacyProjectContext,
|
|
3167
3139
|
agentContext: {
|
|
@@ -3170,7 +3142,8 @@ server.registerTool("planner-load", {
|
|
|
3170
3142
|
status: plannerSkill.status,
|
|
3171
3143
|
customized: plannerSkill.customized,
|
|
3172
3144
|
message: plannerSkill.message,
|
|
3173
|
-
instruction: "Read and retain this project-local planner usage skill and Accepted Decision context. Do not quote it or the Accepted Decision context in the human-facing recap.",
|
|
3145
|
+
instruction: "Read and retain this project-local planner usage skill, project context, and Accepted Decision context. Do not quote it, the project context, or the Accepted Decision context in the human-facing recap.",
|
|
3146
|
+
projectContext: { text: projectContextReply.text },
|
|
3174
3147
|
acceptedDecisions: acceptedDecisionContext,
|
|
3175
3148
|
},
|
|
3176
3149
|
},
|