@agent-plan/mcp 0.2.29 → 0.3.1
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 +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +401 -404
- 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, createStaleProcessCheck, 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, buildPlannerLoadReply, buildScopedDecisionContext, 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,7 @@ 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
|
-
|
|
146
|
-
return new Date().toISOString();
|
|
147
|
-
}
|
|
86
|
+
const staleServerNotice = createStaleProcessCheck(MCP_PACKAGE);
|
|
148
87
|
function planRoot() {
|
|
149
88
|
return process.env.AGENT_PLAN_ROOT || join(process.cwd(), ".planner");
|
|
150
89
|
}
|
|
@@ -220,83 +159,9 @@ function findFeatureByRef(features, ref) {
|
|
|
220
159
|
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
221
160
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
|
|
222
161
|
}
|
|
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
162
|
function taskCompositeRef(task, phase, features) {
|
|
257
163
|
return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
|
|
258
164
|
}
|
|
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
165
|
async function writeAndSummarize(st, message, structuredContent) {
|
|
301
166
|
await st.writeGenerated();
|
|
302
167
|
return text(message, structuredContent);
|
|
@@ -305,6 +170,44 @@ const server = new McpServer({
|
|
|
305
170
|
name: "agent-plan-planner",
|
|
306
171
|
version: MCP_PACKAGE.version,
|
|
307
172
|
});
|
|
173
|
+
/**
|
|
174
|
+
* Claude Code gives the agent only `structuredContent` when a result has
|
|
175
|
+
* one, and drops `content[].text`. Anything a tool says in text — a handoff
|
|
176
|
+
* body, a phase work map, task-start context, an advisory — would never
|
|
177
|
+
* reach the agent. So every result with a structured part also carries its
|
|
178
|
+
* text there, as `text`. `content` keeps the text for clients that read
|
|
179
|
+
* only that. Applied once, to every tool, by the registerTool wrapper below;
|
|
180
|
+
* no handler needs to know.
|
|
181
|
+
*/
|
|
182
|
+
export function foldTextIntoStructured(result) {
|
|
183
|
+
if (!result?.structuredContent)
|
|
184
|
+
return result;
|
|
185
|
+
const replyText = result.content
|
|
186
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
187
|
+
.map((part) => part.text)
|
|
188
|
+
.join("\n\n");
|
|
189
|
+
// A reply that already uses `text` for something else is left as it is;
|
|
190
|
+
// the MCP tests fail on that, so it never ships.
|
|
191
|
+
if (!replyText || "text" in result.structuredContent)
|
|
192
|
+
return result;
|
|
193
|
+
return { ...result, structuredContent: { text: replyText, ...result.structuredContent } };
|
|
194
|
+
}
|
|
195
|
+
/** Add one line to a reply when this server is older than what is now
|
|
196
|
+
* installed (see createStaleProcessCheck). */
|
|
197
|
+
function withStaleServerNotice(result) {
|
|
198
|
+
const notice = staleServerNotice();
|
|
199
|
+
if (!notice || !result?.content)
|
|
200
|
+
return result;
|
|
201
|
+
return { ...result, content: [...result.content, { type: "text", text: notice }] };
|
|
202
|
+
}
|
|
203
|
+
{
|
|
204
|
+
const registerToolUnwrapped = server.registerTool.bind(server);
|
|
205
|
+
server.registerTool = (...args) => {
|
|
206
|
+
const handler = args[args.length - 1];
|
|
207
|
+
const wrapped = async (...handlerArgs) => foldTextIntoStructured(withStaleServerNotice(await handler(...handlerArgs)));
|
|
208
|
+
return registerToolUnwrapped(...args.slice(0, -1), wrapped);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
308
211
|
async function resolveRequirementPhaseRefs(st, refs) {
|
|
309
212
|
if (!refs || refs.length === 0)
|
|
310
213
|
return { ok: false, error: "linkedPhaseIds must contain at least one phase reference." };
|
|
@@ -371,7 +274,7 @@ server.registerTool("planner-export", {
|
|
|
371
274
|
return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`);
|
|
372
275
|
});
|
|
373
276
|
server.registerTool("planner-authorize-bypass", {
|
|
374
|
-
description: "Authorize a temporary guard bypass (default 15 minutes)
|
|
277
|
+
description: "Authorize a temporary guard bypass (default 15 minutes) that silences the no-task warning. Edits are never blocked; this only stops the agent being warned when it changes project code with no task in-progress. Use ONLY after the user explicitly authorizes working without a task. Harness-agnostic: stored in resume.json so all adapters (Pi, Claude Code, Codex, ...) respect it.",
|
|
375
278
|
inputSchema: {
|
|
376
279
|
durationMinutes: z.number().optional().describe("Bypass window in minutes. Default 15."),
|
|
377
280
|
},
|
|
@@ -435,6 +338,12 @@ server.registerTool("planner-show", {
|
|
|
435
338
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
436
339
|
return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
|
|
437
340
|
});
|
|
341
|
+
// P104(F005)/T422: a phase can be left in-progress with no active task and
|
|
342
|
+
// nothing else says so on this cheap read — this renders that fact,
|
|
343
|
+
// priority-ordered, instead of leaving it discoverable only by paying for
|
|
344
|
+
// planner-task-recommend.
|
|
345
|
+
const openPhaseWork = listOpenPhaseWork(phases, features);
|
|
346
|
+
const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
|
|
438
347
|
const summary = [
|
|
439
348
|
`📋 ${project.name}`,
|
|
440
349
|
`Description: ${project.description || "(not set)"}`,
|
|
@@ -445,6 +354,7 @@ server.registerTool("planner-show", {
|
|
|
445
354
|
activeTasks.length === 0
|
|
446
355
|
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
447
356
|
: `Active tasks (${activeTasks.length})${activeTaskState === "conflict" ? " — CONFLICT: multiple tasks are in progress" : ""}:\n${activeTasks.map((task) => `- ${task.ref} — ${task.title} (in-progress)`).join("\n")}`,
|
|
357
|
+
...(openPhaseWorkText ? [`In-progress phases with work left (priority order):`, openPhaseWorkText] : []),
|
|
448
358
|
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
449
359
|
"",
|
|
450
360
|
"Features:",
|
|
@@ -457,12 +367,20 @@ server.registerTool("planner-show", {
|
|
|
457
367
|
description: project.description || null,
|
|
458
368
|
descriptionRef: project.descriptionRef || null,
|
|
459
369
|
goal: project.goal || null,
|
|
460
|
-
|
|
370
|
+
// Identity only: `summary` above already carries every canonical field
|
|
371
|
+
// (decision, rationale, implementationNotes) through projectDecisionContext,
|
|
372
|
+
// bounded at MAX_ACCEPTED_DECISION_CONTEXT_CHARS. Echoing the full
|
|
373
|
+
// AcceptedDecision objects here too would duplicate that content in both
|
|
374
|
+
// channels — the discipline handoff-reply.ts and mutation-reply.ts hold
|
|
375
|
+
// to (P104(F005)/T419). Full canonical detail for every decision: planner-load.
|
|
376
|
+
acceptedDecisions: project.acceptedDecisions.map((decision) => ({ id: decision.id, title: decision.title, acceptedAt: decision.acceptedAt })),
|
|
461
377
|
updatedAt: manifest.updatedAt || null,
|
|
462
378
|
},
|
|
463
379
|
counts: { features: features.length, phases: phases.length, tasks: totalTasks, activeTasks: activeTasks.length },
|
|
464
380
|
activeTaskState,
|
|
465
381
|
activeTasks,
|
|
382
|
+
openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
|
|
383
|
+
openPhaseWorkTotal: openPhaseWork.length,
|
|
466
384
|
features: features.map((feature) => ({
|
|
467
385
|
ref: formatFeatureRef(feature.number),
|
|
468
386
|
shortId: feature.shortId || null,
|
|
@@ -536,7 +454,7 @@ server.registerTool("planner-project-language", {
|
|
|
536
454
|
server.registerTool("planner-project-discuss", {
|
|
537
455
|
description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
|
|
538
456
|
inputSchema: {
|
|
539
|
-
description: z.string().optional(),
|
|
457
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
540
458
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
|
|
541
459
|
goal: z.string().optional(),
|
|
542
460
|
scope: z.array(z.string()).optional(),
|
|
@@ -544,15 +462,18 @@ server.registerTool("planner-project-discuss", {
|
|
|
544
462
|
technologies: z.array(z.string()).optional(),
|
|
545
463
|
tools: z.array(z.string()).optional(),
|
|
546
464
|
globalRules: z.array(z.string()).optional(),
|
|
547
|
-
decisions: z.array(z.string()).optional(),
|
|
465
|
+
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
466
|
},
|
|
549
467
|
}, async (params) => {
|
|
550
468
|
const st = await requireStore();
|
|
469
|
+
if (params.decisions !== undefined) {
|
|
470
|
+
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" } };
|
|
471
|
+
}
|
|
551
472
|
const project = await st.loadProject();
|
|
552
|
-
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"
|
|
473
|
+
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
|
|
553
474
|
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
554
475
|
if (receivedFields.length === 0) {
|
|
555
|
-
return
|
|
476
|
+
return mcpMutationNoFieldsFailure({
|
|
556
477
|
entity: "project",
|
|
557
478
|
ref: project.name,
|
|
558
479
|
operation: "discuss",
|
|
@@ -587,8 +508,6 @@ server.registerTool("planner-project-discuss", {
|
|
|
587
508
|
project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
|
|
588
509
|
if (params.globalRules !== undefined)
|
|
589
510
|
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
511
|
const persisted = await st.updateProject((current) => {
|
|
593
512
|
const next = {
|
|
594
513
|
...current,
|
|
@@ -599,7 +518,6 @@ server.registerTool("planner-project-discuss", {
|
|
|
599
518
|
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
600
519
|
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
601
520
|
...(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
521
|
};
|
|
604
522
|
if (params.descriptionRef !== undefined) {
|
|
605
523
|
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
@@ -787,7 +705,7 @@ server.registerTool("planner-accepted-decision-create", {
|
|
|
787
705
|
});
|
|
788
706
|
}
|
|
789
707
|
catch (error) {
|
|
790
|
-
return
|
|
708
|
+
return mcpAcceptedDecisionMutationFailure(error, "created");
|
|
791
709
|
}
|
|
792
710
|
});
|
|
793
711
|
server.registerTool("planner-accepted-decision-update", {
|
|
@@ -805,7 +723,7 @@ server.registerTool("planner-accepted-decision-update", {
|
|
|
805
723
|
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
806
724
|
const receivedFields = mutableFields.filter((field) => ({ title, decision, rationale, implementationNotes })[field] !== undefined);
|
|
807
725
|
if (receivedFields.length === 0) {
|
|
808
|
-
return
|
|
726
|
+
return mcpMutationNoFieldsFailure({
|
|
809
727
|
entity: "acceptedDecision",
|
|
810
728
|
ref: decisionId,
|
|
811
729
|
operation: "update",
|
|
@@ -834,7 +752,7 @@ server.registerTool("planner-accepted-decision-update", {
|
|
|
834
752
|
});
|
|
835
753
|
}
|
|
836
754
|
catch (error) {
|
|
837
|
-
return
|
|
755
|
+
return mcpAcceptedDecisionMutationFailure(error, "updated");
|
|
838
756
|
}
|
|
839
757
|
});
|
|
840
758
|
server.registerTool("planner-accepted-decision-delete", {
|
|
@@ -870,14 +788,14 @@ server.registerTool("planner-accepted-decision-delete", {
|
|
|
870
788
|
});
|
|
871
789
|
}
|
|
872
790
|
catch (error) {
|
|
873
|
-
return
|
|
791
|
+
return mcpAcceptedDecisionMutationFailure(error, "deleted");
|
|
874
792
|
}
|
|
875
793
|
});
|
|
876
794
|
server.registerTool("planner-feature-add", {
|
|
877
795
|
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
796
|
inputSchema: {
|
|
879
797
|
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(
|
|
798
|
+
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
799
|
status: z.enum(STATUS_VALUES).optional(),
|
|
882
800
|
},
|
|
883
801
|
}, async ({ name, description, status }) => {
|
|
@@ -885,7 +803,6 @@ server.registerTool("planner-feature-add", {
|
|
|
885
803
|
return st.runBatch(async () => {
|
|
886
804
|
const timestamp = nowISO();
|
|
887
805
|
const effectiveStatus = status ?? "planned";
|
|
888
|
-
const existingFeatures = (await st.loadFeatures()).features;
|
|
889
806
|
const id = createFeatureId();
|
|
890
807
|
const identity = await st.allocateEntityIdentity("feature", id);
|
|
891
808
|
const priority = await st.nextPriority("feature");
|
|
@@ -952,7 +869,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
952
869
|
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
953
870
|
inputSchema: {
|
|
954
871
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
955
|
-
description: z.string().optional().describe(
|
|
872
|
+
description: z.string().optional().describe(`Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}`),
|
|
956
873
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
957
874
|
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
958
875
|
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
@@ -969,7 +886,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
969
886
|
const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
|
|
970
887
|
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
971
888
|
if (receivedFields.length === 0) {
|
|
972
|
-
return
|
|
889
|
+
return mcpMutationNoFieldsFailure({
|
|
973
890
|
entity: "feature",
|
|
974
891
|
ref: formatFeatureRef(feature.number),
|
|
975
892
|
operation: "discuss",
|
|
@@ -1010,14 +927,21 @@ server.registerTool("planner-feature-discuss", {
|
|
|
1010
927
|
return doc;
|
|
1011
928
|
});
|
|
1012
929
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
1013
|
-
|
|
930
|
+
const featureDiscussReply = buildMutationReply({
|
|
931
|
+
identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
|
|
932
|
+
operation: "discuss",
|
|
933
|
+
updatedFields: receivedFields,
|
|
934
|
+
changedValues: result,
|
|
935
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
|
|
936
|
+
});
|
|
937
|
+
return writeAndSummarize(st, featureDiscussReply.text, featureDiscussReply.structured);
|
|
1014
938
|
});
|
|
1015
939
|
server.registerTool("planner-feature-update", {
|
|
1016
940
|
description: "Update a feature.",
|
|
1017
941
|
inputSchema: {
|
|
1018
942
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
1019
943
|
name: z.string().optional(),
|
|
1020
|
-
description: z.string().optional(),
|
|
944
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1021
945
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
1022
946
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1023
947
|
workDone: z.string().optional(),
|
|
@@ -1034,12 +958,12 @@ server.registerTool("planner-feature-update", {
|
|
|
1034
958
|
return text(resolvedFeature.error);
|
|
1035
959
|
const feature = resolvedFeature.feature;
|
|
1036
960
|
if (updates.status !== undefined) {
|
|
1037
|
-
return
|
|
961
|
+
return mcpDerivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
|
|
1038
962
|
}
|
|
1039
963
|
const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
|
|
1040
964
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1041
965
|
if (receivedFields.length === 0) {
|
|
1042
|
-
return
|
|
966
|
+
return mcpMutationNoFieldsFailure({
|
|
1043
967
|
entity: "feature",
|
|
1044
968
|
ref: formatFeatureRef(feature.number),
|
|
1045
969
|
operation: "update",
|
|
@@ -1083,7 +1007,14 @@ server.registerTool("planner-feature-update", {
|
|
|
1083
1007
|
return doc;
|
|
1084
1008
|
});
|
|
1085
1009
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
1086
|
-
|
|
1010
|
+
const featureUpdateReply = buildMutationReply({
|
|
1011
|
+
identity: { entity: "feature", ref: formatFeatureRef(result.number), id: result.id, shortId: result.shortId, title: result.name, status: result.status },
|
|
1012
|
+
operation: "update",
|
|
1013
|
+
updatedFields: receivedFields,
|
|
1014
|
+
changedValues: result,
|
|
1015
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(result.number)} full=true`,
|
|
1016
|
+
});
|
|
1017
|
+
return writeAndSummarize(st, featureUpdateReply.text, featureUpdateReply.structured);
|
|
1087
1018
|
});
|
|
1088
1019
|
server.registerTool("planner-feature-delete", {
|
|
1089
1020
|
description: "Delete a feature. By default, phases are unlinked rather than deleted.",
|
|
@@ -1097,23 +1028,11 @@ server.registerTool("planner-feature-delete", {
|
|
|
1097
1028
|
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
1098
1029
|
if (!resolvedFeature.ok)
|
|
1099
1030
|
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) });
|
|
1031
|
+
const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(cascade) });
|
|
1032
|
+
if (!outcome.ok)
|
|
1033
|
+
return text(`❌ ${outcome.error}`, { deleted: false, errorCode: outcome.errorCode });
|
|
1034
|
+
const { result } = outcome;
|
|
1035
|
+
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
1036
|
});
|
|
1118
1037
|
server.registerTool("planner-phase-add", {
|
|
1119
1038
|
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 +1040,7 @@ server.registerTool("planner-phase-add", {
|
|
|
1121
1040
|
title: z.string().min(1),
|
|
1122
1041
|
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
1042
|
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(
|
|
1043
|
+
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
1044
|
},
|
|
1126
1045
|
}, async ({ title, feature: featureRef, summary, description }) => {
|
|
1127
1046
|
const st = await requireStore();
|
|
@@ -1140,8 +1059,6 @@ server.registerTool("planner-phase-add", {
|
|
|
1140
1059
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
1141
1060
|
let phase;
|
|
1142
1061
|
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
1062
|
const timestamp = nowISO();
|
|
1146
1063
|
const id = createPhaseId();
|
|
1147
1064
|
const identity = await st.allocateEntityIdentity("phase", id);
|
|
@@ -1269,7 +1186,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1269
1186
|
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1270
1187
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1271
1188
|
if (receivedFields.length === 0) {
|
|
1272
|
-
return
|
|
1189
|
+
return mcpMutationNoFieldsFailure({
|
|
1273
1190
|
entity: "phase",
|
|
1274
1191
|
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1275
1192
|
operation: "discuss",
|
|
@@ -1316,7 +1233,16 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1316
1233
|
entry.updatedAt = nowISO();
|
|
1317
1234
|
return entry;
|
|
1318
1235
|
});
|
|
1319
|
-
|
|
1236
|
+
const phaseDiscussRef = formatPhaseRef(found.number, featureNumberOfPhase(found, features));
|
|
1237
|
+
const phaseDiscussReply = buildMutationReply({
|
|
1238
|
+
identity: { entity: "phase", ref: phaseDiscussRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
|
|
1239
|
+
operation: "discuss",
|
|
1240
|
+
updatedFields: receivedFields,
|
|
1241
|
+
changedValues: phase,
|
|
1242
|
+
extras: { contextReady: true },
|
|
1243
|
+
readBackCommand: `planner-phase-show ${phaseDiscussRef} full=true`,
|
|
1244
|
+
});
|
|
1245
|
+
return writeAndSummarize(st, phaseDiscussReply.text, phaseDiscussReply.structured);
|
|
1320
1246
|
});
|
|
1321
1247
|
server.registerTool("planner-phase-update", {
|
|
1322
1248
|
description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
|
|
@@ -1325,7 +1251,7 @@ server.registerTool("planner-phase-update", {
|
|
|
1325
1251
|
title: z.string().optional(),
|
|
1326
1252
|
status: z.enum(PHASE_STATUS_VALUES).optional(),
|
|
1327
1253
|
summary: z.string().optional(),
|
|
1328
|
-
description: z.string().optional(),
|
|
1254
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1329
1255
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
1330
1256
|
featureId: z.string().optional().describe("Feature ref to relink this phase to, or an empty string to unlink it."),
|
|
1331
1257
|
priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
|
|
@@ -1334,7 +1260,7 @@ server.registerTool("planner-phase-update", {
|
|
|
1334
1260
|
dependencies: z.array(z.string()).optional(),
|
|
1335
1261
|
risks: z.array(z.string()).optional(),
|
|
1336
1262
|
openQuestions: z.array(z.string()).optional(),
|
|
1337
|
-
decisions: z.array(z.string()).optional(),
|
|
1263
|
+
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
1264
|
completionCriteria: z.array(z.string()).optional(),
|
|
1339
1265
|
},
|
|
1340
1266
|
}, async ({ phase: ref, ...updates }) => {
|
|
@@ -1344,12 +1270,15 @@ server.registerTool("planner-phase-update", {
|
|
|
1344
1270
|
if (!found)
|
|
1345
1271
|
return text(`Phase not found: ${ref}`);
|
|
1346
1272
|
if (updates.status !== undefined) {
|
|
1347
|
-
return
|
|
1273
|
+
return mcpDerivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
|
|
1274
|
+
}
|
|
1275
|
+
if (updates.decisions !== undefined) {
|
|
1276
|
+
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
1277
|
}
|
|
1349
|
-
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "
|
|
1278
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1350
1279
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1351
1280
|
if (receivedFields.length === 0) {
|
|
1352
|
-
return
|
|
1281
|
+
return mcpMutationNoFieldsFailure({
|
|
1353
1282
|
entity: "phase",
|
|
1354
1283
|
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1355
1284
|
operation: "update",
|
|
@@ -1409,8 +1338,6 @@ server.registerTool("planner-phase-update", {
|
|
|
1409
1338
|
entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
|
|
1410
1339
|
if (updates.openQuestions !== undefined)
|
|
1411
1340
|
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
1341
|
if (updates.completionCriteria !== undefined)
|
|
1415
1342
|
entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
1416
1343
|
entry.updatedAt = nowISO();
|
|
@@ -1437,7 +1364,16 @@ server.registerTool("planner-phase-update", {
|
|
|
1437
1364
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1438
1365
|
.filter((entry) => entry.state === "stale" && entry.ownerId === persisted.featureId)
|
|
1439
1366
|
.map((entry) => entry.ownerRef) ?? [];
|
|
1440
|
-
|
|
1367
|
+
const phaseUpdateRef = formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures));
|
|
1368
|
+
const phaseUpdateReply = buildMutationReply({
|
|
1369
|
+
identity: { entity: "phase", ref: phaseUpdateRef, id: persisted.id, shortId: persisted.shortId, title: persisted.title, status: persisted.status },
|
|
1370
|
+
operation: "update",
|
|
1371
|
+
updatedFields: receivedFields,
|
|
1372
|
+
changedValues: persisted,
|
|
1373
|
+
extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
|
|
1374
|
+
readBackCommand: `planner-phase-show ${phaseUpdateRef} full=true`,
|
|
1375
|
+
});
|
|
1376
|
+
return text(phaseUpdateReply.text, phaseUpdateReply.structured);
|
|
1441
1377
|
});
|
|
1442
1378
|
server.registerTool("planner-phase-delete", {
|
|
1443
1379
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -1464,7 +1400,7 @@ server.registerTool("planner-task-add", {
|
|
|
1464
1400
|
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."),
|
|
1465
1401
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
1466
1402
|
title: z.string().min(1),
|
|
1467
|
-
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(
|
|
1403
|
+
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}`),
|
|
1468
1404
|
checklist: z.array(z.string()).optional(),
|
|
1469
1405
|
},
|
|
1470
1406
|
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
@@ -1531,7 +1467,7 @@ server.registerTool("planner-task-add", {
|
|
|
1531
1467
|
return entry;
|
|
1532
1468
|
});
|
|
1533
1469
|
const finalFeatures = (await st.loadFeatures()).features;
|
|
1534
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
1470
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned; ${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
1535
1471
|
});
|
|
1536
1472
|
});
|
|
1537
1473
|
server.registerTool("planner-task-show", {
|
|
@@ -1540,10 +1476,11 @@ server.registerTool("planner-task-show", {
|
|
|
1540
1476
|
}, async ({ task: ref, full }, extra) => {
|
|
1541
1477
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1542
1478
|
const st = await requireStore();
|
|
1543
|
-
const
|
|
1479
|
+
const features = (await st.loadFeatures()).features;
|
|
1480
|
+
const allPhases = await st.loadAllPhases();
|
|
1481
|
+
const found = findTaskByRef(allPhases, features, ref);
|
|
1544
1482
|
if (!found)
|
|
1545
1483
|
return text(`Task not found: ${ref}`);
|
|
1546
|
-
const features = (await st.loadFeatures()).features;
|
|
1547
1484
|
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))})`;
|
|
1548
1485
|
if (!full)
|
|
1549
1486
|
return text(summary);
|
|
@@ -1556,7 +1493,15 @@ server.registerTool("planner-task-show", {
|
|
|
1556
1493
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1557
1494
|
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
1558
1495
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
1559
|
-
|
|
1496
|
+
// P104(F005)/T421: priority and dependsOn at the point an agent reads
|
|
1497
|
+
// the task and decides what to do next — the fact planner-task-list and
|
|
1498
|
+
// planner-task-recommend already have, made visible here instead of
|
|
1499
|
+
// requiring a second call.
|
|
1500
|
+
const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
|
|
1501
|
+
const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
|
|
1502
|
+
const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
|
|
1503
|
+
if (completionMismatch)
|
|
1504
|
+
sections.push(completionMismatch);
|
|
1560
1505
|
if (found.task.description?.trim())
|
|
1561
1506
|
sections.push(found.task.description.trim());
|
|
1562
1507
|
if (found.task.descriptionRef)
|
|
@@ -1581,18 +1526,61 @@ server.registerTool("planner-task-show", {
|
|
|
1581
1526
|
description: found.task.description,
|
|
1582
1527
|
descriptionRef: found.task.descriptionRef || "",
|
|
1583
1528
|
acceptedDecisions: found.task.acceptedDecisions,
|
|
1529
|
+
priority: orderContext.priority,
|
|
1530
|
+
dependsOn: orderContext.dependsOn,
|
|
1531
|
+
...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
|
|
1532
|
+
},
|
|
1533
|
+
taskOrder: {
|
|
1534
|
+
readyCount: orderContext.readyCount,
|
|
1535
|
+
readyAheadCount: orderContext.readyAheadCount,
|
|
1536
|
+
nextByPriorityRef: orderContext.nextByPriorityRef,
|
|
1584
1537
|
},
|
|
1585
1538
|
});
|
|
1586
1539
|
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
1587
|
-
|
|
1540
|
+
// Advisory only (P104(F005)/T420): report whether planner-task-start
|
|
1541
|
+
// would succeed right now, using the exact checks it runs itself, so a
|
|
1542
|
+
// caller reading the task before starting it learns what else is needed
|
|
1543
|
+
// in this same call instead of from a denial. task-start recomputes this
|
|
1544
|
+
// at call time and remains the sole authority on whether work may begin.
|
|
1545
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
1546
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
1547
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
1548
|
+
const linkedRequirements = [
|
|
1549
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
1550
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
1551
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
1552
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
1553
|
+
const contextEligibility = contextReadEligibilityForSession({
|
|
1554
|
+
sessionId: plannerSessionId,
|
|
1555
|
+
taskId: found.task.id,
|
|
1556
|
+
phaseId: found.phase.id,
|
|
1557
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
1558
|
+
task: found.task,
|
|
1559
|
+
phase: found.phase,
|
|
1560
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
1561
|
+
requirements: linkedRequirements,
|
|
1562
|
+
requirementIds: linkedRequirementIds,
|
|
1563
|
+
});
|
|
1564
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
1565
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1566
|
+
const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
|
|
1567
|
+
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)}`);
|
|
1568
|
+
return {
|
|
1569
|
+
content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
|
|
1570
|
+
structuredContent: {
|
|
1571
|
+
...result.structuredContent,
|
|
1572
|
+
taskStartGateState: gateState,
|
|
1573
|
+
...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
|
|
1574
|
+
},
|
|
1575
|
+
};
|
|
1588
1576
|
});
|
|
1589
1577
|
server.registerTool("planner-task-discuss", {
|
|
1590
1578
|
description: "Persist task execution notes/checklist.",
|
|
1591
1579
|
inputSchema: {
|
|
1592
1580
|
task: z.string().min(1),
|
|
1593
|
-
description: z.string().optional(),
|
|
1581
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1594
1582
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
1595
|
-
checklist: z.array(z.string()).optional(),
|
|
1583
|
+
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."),
|
|
1596
1584
|
},
|
|
1597
1585
|
}, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
|
|
1598
1586
|
const st = await requireStore();
|
|
@@ -1603,7 +1591,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
1603
1591
|
const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
|
|
1604
1592
|
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
1605
1593
|
if (receivedFields.length === 0) {
|
|
1606
|
-
return
|
|
1594
|
+
return mcpMutationNoFieldsFailure({
|
|
1607
1595
|
entity: "task",
|
|
1608
1596
|
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1609
1597
|
operation: "discuss",
|
|
@@ -1619,6 +1607,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
1619
1607
|
});
|
|
1620
1608
|
}
|
|
1621
1609
|
let updatedTask;
|
|
1610
|
+
let checklistLostTicks = [];
|
|
1622
1611
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
1623
1612
|
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
1624
1613
|
if (!task)
|
|
@@ -1632,8 +1621,11 @@ server.registerTool("planner-task-discuss", {
|
|
|
1632
1621
|
else
|
|
1633
1622
|
delete task.descriptionRef;
|
|
1634
1623
|
}
|
|
1635
|
-
if (checklist !== undefined)
|
|
1636
|
-
|
|
1624
|
+
if (checklist !== undefined) {
|
|
1625
|
+
const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
|
|
1626
|
+
task.checklist = replacement.items;
|
|
1627
|
+
checklistLostTicks = replacement.lostTicks;
|
|
1628
|
+
}
|
|
1637
1629
|
task.updatedAt = nowISO();
|
|
1638
1630
|
phase.updatedAt = task.updatedAt;
|
|
1639
1631
|
updatedTask = task;
|
|
@@ -1641,12 +1633,25 @@ server.registerTool("planner-task-discuss", {
|
|
|
1641
1633
|
});
|
|
1642
1634
|
const features = (await st.loadFeatures()).features;
|
|
1643
1635
|
const t = updatedTask ?? found.task;
|
|
1644
|
-
|
|
1636
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
1637
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
1638
|
+
: "";
|
|
1639
|
+
const taskDiscussRef = taskCompositeRef(t, found.phase, features);
|
|
1640
|
+
const taskDiscussReply = buildMutationReply({
|
|
1641
|
+
identity: { entity: "task", ref: taskDiscussRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
|
|
1642
|
+
operation: "discuss",
|
|
1643
|
+
updatedFields: receivedFields,
|
|
1644
|
+
changedValues: t,
|
|
1645
|
+
extras: checklistLostTicks.length > 0 ? { checklistLostTicks } : {},
|
|
1646
|
+
notices: [lostTicksNotice],
|
|
1647
|
+
readBackCommand: `planner-task-show ${taskDiscussRef} full=true`,
|
|
1648
|
+
});
|
|
1649
|
+
return writeAndSummarize(st, taskDiscussReply.text, taskDiscussReply.structured);
|
|
1645
1650
|
});
|
|
1646
1651
|
server.registerTool("planner-task-dependency-add", {
|
|
1647
1652
|
description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
|
|
1648
1653
|
inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1) },
|
|
1649
|
-
}, async ({ task: ref, dependsOn }
|
|
1654
|
+
}, async ({ task: ref, dependsOn }) => {
|
|
1650
1655
|
const st = await requireStore();
|
|
1651
1656
|
const features = (await st.loadFeatures()).features;
|
|
1652
1657
|
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
@@ -1656,7 +1661,8 @@ server.registerTool("planner-task-dependency-add", {
|
|
|
1656
1661
|
try {
|
|
1657
1662
|
const result = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1658
1663
|
await st.writeGenerated();
|
|
1659
|
-
|
|
1664
|
+
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` });
|
|
1665
|
+
return text(`${reply.text} Depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, reply.structured);
|
|
1660
1666
|
}
|
|
1661
1667
|
catch (error) {
|
|
1662
1668
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -1678,7 +1684,8 @@ server.registerTool("planner-task-dependency-delete", {
|
|
|
1678
1684
|
try {
|
|
1679
1685
|
const result = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1680
1686
|
await st.writeGenerated();
|
|
1681
|
-
|
|
1687
|
+
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` });
|
|
1688
|
+
return text(reply.text, reply.structured);
|
|
1682
1689
|
}
|
|
1683
1690
|
catch (error) {
|
|
1684
1691
|
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
@@ -1691,13 +1698,13 @@ server.registerTool("planner-task-update", {
|
|
|
1691
1698
|
task: z.string().min(1),
|
|
1692
1699
|
title: z.string().optional(),
|
|
1693
1700
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1694
|
-
description: z.string().optional(),
|
|
1701
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
1695
1702
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
1696
1703
|
notes: z.string().optional().describe("Implementation notes."),
|
|
1697
|
-
decisions: z.array(z.string()).optional().describe("
|
|
1704
|
+
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."),
|
|
1698
1705
|
motivation: z.string().optional(),
|
|
1699
1706
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
1700
|
-
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."),
|
|
1707
|
+
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."),
|
|
1701
1708
|
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."),
|
|
1702
1709
|
},
|
|
1703
1710
|
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
|
|
@@ -1711,6 +1718,9 @@ server.registerTool("planner-task-update", {
|
|
|
1711
1718
|
if (status === "done" && status !== found.task.status) {
|
|
1712
1719
|
return text("Task completion transitions require planner-task-complete with durable completion and verification evidence.");
|
|
1713
1720
|
}
|
|
1721
|
+
if (decisions !== undefined) {
|
|
1722
|
+
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) } };
|
|
1723
|
+
}
|
|
1714
1724
|
// Validate motivation requirement for status transitions.
|
|
1715
1725
|
if (status !== undefined && needsMotivation(found.task.status, status)) {
|
|
1716
1726
|
if (!motivation || !motivation.trim()) {
|
|
@@ -1718,11 +1728,11 @@ server.registerTool("planner-task-update", {
|
|
|
1718
1728
|
`Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
|
|
1719
1729
|
}
|
|
1720
1730
|
}
|
|
1721
|
-
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "
|
|
1731
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
|
|
1722
1732
|
const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks };
|
|
1723
1733
|
const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
|
|
1724
1734
|
if (receivedFields.length === 0) {
|
|
1725
|
-
return
|
|
1735
|
+
return mcpMutationNoFieldsFailure({
|
|
1726
1736
|
entity: "task",
|
|
1727
1737
|
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1728
1738
|
operation: "update",
|
|
@@ -1744,6 +1754,7 @@ server.registerTool("planner-task-update", {
|
|
|
1744
1754
|
}
|
|
1745
1755
|
}
|
|
1746
1756
|
let updatedTask;
|
|
1757
|
+
let checklistLostTicks = [];
|
|
1747
1758
|
const timestamp = nowISO();
|
|
1748
1759
|
try {
|
|
1749
1760
|
await st.updateTask(found.phase.id, found.task.id, (task) => {
|
|
@@ -1762,15 +1773,10 @@ server.registerTool("planner-task-update", {
|
|
|
1762
1773
|
}
|
|
1763
1774
|
if (notes !== undefined)
|
|
1764
1775
|
task.notes = notes.trim();
|
|
1765
|
-
if (decisions !== undefined)
|
|
1766
|
-
task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
|
|
1767
1776
|
if (checklist !== undefined) {
|
|
1768
|
-
task.checklist
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
title: item.trim(),
|
|
1772
|
-
checked: false,
|
|
1773
|
-
}));
|
|
1777
|
+
const replacement = replaceChecklist(task.checklist ?? [], checklist, task.id);
|
|
1778
|
+
task.checklist = replacement.items;
|
|
1779
|
+
checklistLostTicks = replacement.lostTicks;
|
|
1774
1780
|
}
|
|
1775
1781
|
if (subtasks !== undefined) {
|
|
1776
1782
|
const existingIds = new Set(task.subtasks.map((item) => item.id));
|
|
@@ -1826,10 +1832,24 @@ server.registerTool("planner-task-update", {
|
|
|
1826
1832
|
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1827
1833
|
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
1828
1834
|
.map((entry) => entry.ownerRef) ?? [];
|
|
1829
|
-
const
|
|
1830
|
-
?
|
|
1831
|
-
:
|
|
1832
|
-
|
|
1835
|
+
const lostTicksNotice = checklistLostTicks.length > 0
|
|
1836
|
+
? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
|
|
1837
|
+
: "";
|
|
1838
|
+
const taskUpdateRef = taskCompositeRef(t, found.phase, features);
|
|
1839
|
+
const taskUpdateReply = buildMutationReply({
|
|
1840
|
+
identity: { entity: "task", ref: taskUpdateRef, id: t.id, shortId: t.shortId, title: t.title, status: t.status },
|
|
1841
|
+
operation: "update",
|
|
1842
|
+
updatedFields: receivedFields,
|
|
1843
|
+
changedValues: t,
|
|
1844
|
+
extras: {
|
|
1845
|
+
...(resumeRequired ? { resumeRequired } : {}),
|
|
1846
|
+
...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
|
|
1847
|
+
...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
|
|
1848
|
+
},
|
|
1849
|
+
notices: [resumeNotice, lostTicksNotice],
|
|
1850
|
+
readBackCommand: `planner-task-show ${taskUpdateRef} full=true`,
|
|
1851
|
+
});
|
|
1852
|
+
return writeAndSummarize(st, taskUpdateReply.text, taskUpdateReply.structured);
|
|
1833
1853
|
});
|
|
1834
1854
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1835
1855
|
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.",
|
|
@@ -1971,36 +1991,8 @@ server.registerTool("planner-task-recommend", {
|
|
|
1971
1991
|
})),
|
|
1972
1992
|
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
1973
1993
|
});
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
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")}` : ""}`, {
|
|
1977
|
-
kind: selection.kind,
|
|
1978
|
-
reason: selection.reason,
|
|
1979
|
-
activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
|
|
1980
|
-
activeTask: result.activeTask,
|
|
1981
|
-
nextFeature: result.nextFeature,
|
|
1982
|
-
nextPhase: result.nextPhase,
|
|
1983
|
-
nextTask: result.nextTask,
|
|
1984
|
-
claims: result.claims,
|
|
1985
|
-
selection,
|
|
1986
|
-
});
|
|
1987
|
-
const { candidate } = selection;
|
|
1988
|
-
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1989
|
-
const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
1990
|
-
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
1991
|
-
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}`, {
|
|
1992
|
-
kind: selection.kind,
|
|
1993
|
-
taskId: candidate.task.id,
|
|
1994
|
-
phaseId: candidate.phase.id,
|
|
1995
|
-
featureId: candidate.feature?.id,
|
|
1996
|
-
deviation: selection.deviation,
|
|
1997
|
-
activeTask: result.activeTask,
|
|
1998
|
-
nextFeature: result.nextFeature,
|
|
1999
|
-
nextPhase: result.nextPhase,
|
|
2000
|
-
nextTask: result.nextTask,
|
|
2001
|
-
claims: result.claims,
|
|
2002
|
-
selection,
|
|
2003
|
-
});
|
|
1994
|
+
const reply = buildRecommendationReply(result, features.features);
|
|
1995
|
+
return text(reply.text, reply.structured);
|
|
2004
1996
|
});
|
|
2005
1997
|
server.registerTool("planner-task-deviation", {
|
|
2006
1998
|
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.",
|
|
@@ -2278,7 +2270,8 @@ server.registerTool("planner-task-start", {
|
|
|
2278
2270
|
}
|
|
2279
2271
|
return st.runBatch(async () => {
|
|
2280
2272
|
const features = (await st.loadFeatures()).features;
|
|
2281
|
-
const
|
|
2273
|
+
const initialPhases = await st.loadAllPhases();
|
|
2274
|
+
const found = findTaskByRef(initialPhases, features, ref);
|
|
2282
2275
|
if (!found)
|
|
2283
2276
|
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."]));
|
|
2284
2277
|
const taskRef = taskCompositeRef(found.task, found.phase, features);
|
|
@@ -2288,6 +2281,12 @@ server.registerTool("planner-task-start", {
|
|
|
2288
2281
|
if (found.task.status === "done")
|
|
2289
2282
|
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 }));
|
|
2290
2283
|
const project = await st.loadProject();
|
|
2284
|
+
// Targeted stale-context refresh (P102(F005)/T404): task-start delivery
|
|
2285
|
+
// stays scoped to feature/phase/task context, but a session that already
|
|
2286
|
+
// did a full planner-load gets a non-blocking advisory when project-level
|
|
2287
|
+
// context has drifted since, instead of silently going stale.
|
|
2288
|
+
const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
|
|
2289
|
+
const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
|
|
2291
2290
|
const linkedRequirements = [
|
|
2292
2291
|
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
2293
2292
|
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
@@ -2321,7 +2320,9 @@ server.registerTool("planner-task-start", {
|
|
|
2321
2320
|
}
|
|
2322
2321
|
if (found.task.status === "in-progress") {
|
|
2323
2322
|
const outcome = taskStartSucceeded(found.task.id, true);
|
|
2324
|
-
|
|
2323
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
2324
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
2325
|
+
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true${projectContextAdvisory}${phaseOrderAdvisory}`, { ...outcome, task: found.task, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase });
|
|
2325
2326
|
}
|
|
2326
2327
|
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
2327
2328
|
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
@@ -2418,21 +2419,26 @@ server.registerTool("planner-task-start", {
|
|
|
2418
2419
|
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 }));
|
|
2419
2420
|
}
|
|
2420
2421
|
const outcome = taskStartSucceeded(persistedTask.id);
|
|
2421
|
-
|
|
2422
|
+
const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
|
|
2423
|
+
const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
|
|
2424
|
+
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}`, {
|
|
2422
2425
|
...outcome,
|
|
2423
2426
|
task: persistedTask,
|
|
2424
2427
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
2428
|
+
projectContextStale: projectContextReadState === "stale",
|
|
2429
|
+
higherPriorityOpenPhase: higherPriorityPhase,
|
|
2425
2430
|
});
|
|
2426
2431
|
});
|
|
2427
2432
|
});
|
|
2428
2433
|
server.registerTool("planner-task-complete", {
|
|
2429
|
-
description: "Set a task to done with mandatory durable completion and verification evidence. Fails if checklist is incomplete
|
|
2434
|
+
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.",
|
|
2430
2435
|
inputSchema: {
|
|
2431
2436
|
task: z.string().min(1),
|
|
2432
2437
|
force: z.boolean().optional(),
|
|
2438
|
+
motivation: z.string().optional().describe("Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work."),
|
|
2433
2439
|
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."),
|
|
2434
2440
|
},
|
|
2435
|
-
}, async ({ task: ref, force, description_update }) => {
|
|
2441
|
+
}, async ({ task: ref, force, motivation, description_update }) => {
|
|
2436
2442
|
const st = await requireStore();
|
|
2437
2443
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
2438
2444
|
if (!found)
|
|
@@ -2442,9 +2448,16 @@ server.registerTool("planner-task-complete", {
|
|
|
2442
2448
|
return text("Task completion denied: provide at least 10 characters of durable completion and verification evidence.");
|
|
2443
2449
|
if (found.task.pauseSnapshot)
|
|
2444
2450
|
return text("Task completion denied: resume checkpointed work with planner-task-start before completing it, or cancel it explicitly with motivation.");
|
|
2445
|
-
const
|
|
2446
|
-
if (
|
|
2447
|
-
|
|
2451
|
+
const gate = evaluateTaskCompletionGate(found.task.checklist, force === true, motivation);
|
|
2452
|
+
if (!gate.allowed) {
|
|
2453
|
+
const taskRefForToggle = taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features);
|
|
2454
|
+
const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
|
|
2455
|
+
? taskCompletionForceMotivationRequired(gate.uncheckedItems)
|
|
2456
|
+
: taskCompletionChecklistRefusal(gate.uncheckedItems, `planner-task-checklist-toggle ${taskRefForToggle} <item>`);
|
|
2457
|
+
return text(message, { completed: false, errorCode: gate.errorCode, uncheckedItems: gate.uncheckedItems.map((item) => item.title) });
|
|
2458
|
+
}
|
|
2459
|
+
const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, motivation);
|
|
2460
|
+
const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
|
|
2448
2461
|
const timestamp = nowISO();
|
|
2449
2462
|
let updatedTask;
|
|
2450
2463
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -2459,11 +2472,11 @@ server.registerTool("planner-task-complete", {
|
|
|
2459
2472
|
fromStatus: previousStatus,
|
|
2460
2473
|
toStatus: "done",
|
|
2461
2474
|
title: `${previousStatus} → done`,
|
|
2462
|
-
description:
|
|
2475
|
+
description: completionEvidence,
|
|
2463
2476
|
};
|
|
2464
2477
|
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2465
2478
|
const sep = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2466
|
-
task.description = task.description + sep +
|
|
2479
|
+
task.description = task.description + sep + completionEvidence;
|
|
2467
2480
|
task.descriptionUpdatedAt = timestamp;
|
|
2468
2481
|
task.updatedAt = timestamp;
|
|
2469
2482
|
phase.updatedAt = timestamp;
|
|
@@ -2522,57 +2535,27 @@ server.registerTool("planner-handoff-show", {
|
|
|
2522
2535
|
if (!r.ok)
|
|
2523
2536
|
return text(`❌ ${r.error}`);
|
|
2524
2537
|
const phase = await st.loadPhase(r.phase.id);
|
|
2525
|
-
|
|
2526
|
-
if (!bounded.content.trim()) {
|
|
2538
|
+
if (!phase.handoff.trim()) {
|
|
2527
2539
|
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
2528
2540
|
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
|
|
2533
|
-
return text([
|
|
2534
|
-
`Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
|
|
2535
|
-
...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
|
|
2536
|
-
"",
|
|
2537
|
-
archivedContent.content,
|
|
2538
|
-
].join("\n"), {
|
|
2541
|
+
const reply = archived
|
|
2542
|
+
? buildHandoffShowReply({
|
|
2543
|
+
kind: "archived",
|
|
2539
2544
|
phaseRef: r.compositeRef,
|
|
2540
2545
|
phaseId: phase.id,
|
|
2541
|
-
|
|
2542
|
-
archived: true,
|
|
2546
|
+
content: archived.content,
|
|
2543
2547
|
archiveReason: archived.reason,
|
|
2544
2548
|
archivedAt: archived.archivedAt,
|
|
2545
2549
|
archiveFile: archived.file,
|
|
2546
|
-
...archivedContent,
|
|
2547
|
-
supportingDocuments,
|
|
2548
|
-
empty: false,
|
|
2549
|
-
resumeReady: false,
|
|
2550
2550
|
handoffAudit: phase.handoffAudit,
|
|
2551
|
-
})
|
|
2552
|
-
|
|
2553
|
-
return text(
|
|
2551
|
+
})
|
|
2552
|
+
: buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
|
|
2553
|
+
return text(reply.text, reply.structured);
|
|
2554
2554
|
}
|
|
2555
2555
|
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
2556
2556
|
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
2557
|
-
const
|
|
2558
|
-
|
|
2559
|
-
&& phase.handoffAudit.contentHash === contentHash
|
|
2560
|
-
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
2561
|
-
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
2562
|
-
const status = resumeReady
|
|
2563
|
-
? "Resume-ready: persisted compact capsule read-back completed."
|
|
2564
|
-
: "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.";
|
|
2565
|
-
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}`, {
|
|
2566
|
-
phaseRef: r.compositeRef,
|
|
2567
|
-
phaseId: r.phase.id,
|
|
2568
|
-
phaseWorkMap,
|
|
2569
|
-
...bounded,
|
|
2570
|
-
contentHash,
|
|
2571
|
-
persistenceVerified,
|
|
2572
|
-
resumeReady,
|
|
2573
|
-
verificationRequired: !resumeReady,
|
|
2574
|
-
handoffAudit: phase.handoffAudit,
|
|
2575
|
-
});
|
|
2557
|
+
const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
|
|
2558
|
+
return text(reply.text, reply.structured);
|
|
2576
2559
|
});
|
|
2577
2560
|
server.registerTool("planner-handoff-verify", {
|
|
2578
2561
|
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.",
|
|
@@ -2606,7 +2589,7 @@ server.registerTool("planner-handoff-verify", {
|
|
|
2606
2589
|
}
|
|
2607
2590
|
catch (error) {
|
|
2608
2591
|
if (error instanceof HandoffContractError)
|
|
2609
|
-
return
|
|
2592
|
+
return mcpHandoffContractFailure(error);
|
|
2610
2593
|
const message = error instanceof Error ? error.message : String(error);
|
|
2611
2594
|
return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
|
|
2612
2595
|
}
|
|
@@ -2654,7 +2637,7 @@ server.registerTool("planner-requirement-create", {
|
|
|
2654
2637
|
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.",
|
|
2655
2638
|
inputSchema: {
|
|
2656
2639
|
title: z.string().min(1),
|
|
2657
|
-
description: z.string().optional(),
|
|
2640
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
2658
2641
|
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2659
2642
|
macroTasks: z.array(z.object({
|
|
2660
2643
|
title: z.string().min(1),
|
|
@@ -2699,7 +2682,7 @@ server.registerTool("planner-requirement-update", {
|
|
|
2699
2682
|
inputSchema: {
|
|
2700
2683
|
requirementId: z.string().min(1),
|
|
2701
2684
|
title: z.string().min(1).optional(),
|
|
2702
|
-
description: z.string().optional(),
|
|
2685
|
+
description: z.string().optional().describe(LONG_TEXT_LIMIT_NOTICE),
|
|
2703
2686
|
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2704
2687
|
macroTasks: z.array(z.object({
|
|
2705
2688
|
id: z.string().min(1).optional(),
|
|
@@ -2715,7 +2698,7 @@ server.registerTool("planner-requirement-update", {
|
|
|
2715
2698
|
if (!current)
|
|
2716
2699
|
return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
|
|
2717
2700
|
if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
|
|
2718
|
-
return
|
|
2701
|
+
return mcpMutationNoFieldsFailure({
|
|
2719
2702
|
entity: "requirement",
|
|
2720
2703
|
ref: requirementId,
|
|
2721
2704
|
operation: "update",
|
|
@@ -2723,6 +2706,12 @@ server.registerTool("planner-requirement-update", {
|
|
|
2723
2706
|
retryCommand: `planner-requirement-update ${requirementId}`,
|
|
2724
2707
|
});
|
|
2725
2708
|
}
|
|
2709
|
+
const requirementUpdatedFields = [
|
|
2710
|
+
...(title !== undefined ? ["title"] : []),
|
|
2711
|
+
...(description !== undefined ? ["description"] : []),
|
|
2712
|
+
...(linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
|
|
2713
|
+
...(macroTasks !== undefined ? ["macroTasks"] : []),
|
|
2714
|
+
];
|
|
2726
2715
|
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2727
2716
|
if (!links.ok)
|
|
2728
2717
|
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
@@ -2748,7 +2737,14 @@ server.registerTool("planner-requirement-update", {
|
|
|
2748
2737
|
return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2749
2738
|
}
|
|
2750
2739
|
await st.writeGenerated();
|
|
2751
|
-
|
|
2740
|
+
const requirementUpdateReply = buildMutationReply({
|
|
2741
|
+
identity: { entity: "requirement", ref: next.id, id: next.id, title: next.title },
|
|
2742
|
+
operation: "update",
|
|
2743
|
+
updatedFields: requirementUpdatedFields,
|
|
2744
|
+
changedValues: next,
|
|
2745
|
+
readBackCommand: "planner-requirement-list",
|
|
2746
|
+
});
|
|
2747
|
+
return writeAndSummarize(st, requirementUpdateReply.text, requirementUpdateReply.structured);
|
|
2752
2748
|
});
|
|
2753
2749
|
server.registerTool("planner-requirement-delete", {
|
|
2754
2750
|
description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
|
|
@@ -2814,7 +2810,7 @@ server.registerTool("planner-idea-update", {
|
|
|
2814
2810
|
if (!current)
|
|
2815
2811
|
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2816
2812
|
if (title === undefined && description === undefined) {
|
|
2817
|
-
return
|
|
2813
|
+
return mcpMutationNoFieldsFailure({
|
|
2818
2814
|
entity: "idea",
|
|
2819
2815
|
ref,
|
|
2820
2816
|
operation: "update",
|
|
@@ -2822,9 +2818,17 @@ server.registerTool("planner-idea-update", {
|
|
|
2822
2818
|
retryCommand: `planner-idea-update ${ref}`,
|
|
2823
2819
|
});
|
|
2824
2820
|
}
|
|
2821
|
+
const ideaUpdatedFields = [...(title !== undefined ? ["title"] : []), ...(description !== undefined ? ["description"] : [])];
|
|
2825
2822
|
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2826
2823
|
await st.writeGenerated();
|
|
2827
|
-
|
|
2824
|
+
const ideaUpdateReply = buildMutationReply({
|
|
2825
|
+
identity: { entity: "idea", ref: formatIdeaRef(idea.number), id: idea.id, title: idea.title },
|
|
2826
|
+
operation: "update",
|
|
2827
|
+
updatedFields: ideaUpdatedFields,
|
|
2828
|
+
changedValues: idea,
|
|
2829
|
+
readBackCommand: `planner-idea-show ${formatIdeaRef(idea.number)}`,
|
|
2830
|
+
});
|
|
2831
|
+
return text(ideaUpdateReply.text, ideaUpdateReply.structured);
|
|
2828
2832
|
});
|
|
2829
2833
|
server.registerTool("planner-idea-delete", {
|
|
2830
2834
|
description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
|
|
@@ -2881,15 +2885,19 @@ server.registerTool("planner-idea-promotion-finalize", {
|
|
|
2881
2885
|
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
2882
2886
|
});
|
|
2883
2887
|
server.registerTool("planner-handoff-write", {
|
|
2884
|
-
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.",
|
|
2888
|
+
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.",
|
|
2885
2889
|
inputSchema: {
|
|
2886
2890
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
2887
|
-
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
|
|
2891
|
+
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work. Ignored when content is omitted (the retained title is reused)."),
|
|
2888
2892
|
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."),
|
|
2889
2893
|
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
2890
|
-
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
2894
|
+
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."),
|
|
2891
2895
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
2892
2896
|
reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
|
|
2897
|
+
sectionDispositions: z.array(z.object({
|
|
2898
|
+
section: z.string().min(1).describe("Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal."),
|
|
2899
|
+
disposition: z.string().min(1).describe("What happened to that section: dropped, superseded, or folded into another section."),
|
|
2900
|
+
})).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."),
|
|
2893
2901
|
completenessAudit: z.object({
|
|
2894
2902
|
version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
2895
2903
|
entries: z.array(z.object({
|
|
@@ -2928,23 +2936,29 @@ server.registerTool("planner-handoff-write", {
|
|
|
2928
2936
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
2929
2937
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
2930
2938
|
},
|
|
2931
|
-
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2939
|
+
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, sectionDispositions, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2932
2940
|
const st = await requireStore();
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
const
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
lines
|
|
2947
|
-
|
|
2941
|
+
// content is optional so a retry after a failed write can omit it and reuse
|
|
2942
|
+
// the body PlanStore retained against this phase + expectedHandoffUpdatedAt
|
|
2943
|
+
// (see PlanStore.refreshPhaseHandoff). body stays undefined only in that
|
|
2944
|
+
// case; the title/heading checks below apply only to freshly-submitted text.
|
|
2945
|
+
let body = content?.trim() || undefined;
|
|
2946
|
+
if (body !== undefined) {
|
|
2947
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
2948
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
2949
|
+
const effectiveHeadingText = title?.trim() || firstHeadingText;
|
|
2950
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
2951
|
+
return text("❌ Generic handoff title. Provide a meaningful title summarizing the work.");
|
|
2952
|
+
}
|
|
2953
|
+
if (title?.trim()) {
|
|
2954
|
+
const lines = body.split(/\r?\n/);
|
|
2955
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
2956
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
2957
|
+
lines[firstIdx] = `# ${title.trim()}`;
|
|
2958
|
+
else
|
|
2959
|
+
lines.unshift(`# ${title.trim()}`, "");
|
|
2960
|
+
body = lines.join("\n");
|
|
2961
|
+
}
|
|
2948
2962
|
}
|
|
2949
2963
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2950
2964
|
if (!r.ok)
|
|
@@ -2974,9 +2988,10 @@ server.registerTool("planner-handoff-write", {
|
|
|
2974
2988
|
try {
|
|
2975
2989
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
2976
2990
|
reason: reason.trim(),
|
|
2977
|
-
content: body,
|
|
2991
|
+
...(body !== undefined ? { content: body } : {}),
|
|
2978
2992
|
expectedHandoffUpdatedAt,
|
|
2979
2993
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2994
|
+
...(sectionDispositions ? { sectionDispositions } : {}),
|
|
2980
2995
|
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2981
2996
|
...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
|
|
2982
2997
|
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
@@ -3010,15 +3025,21 @@ server.registerTool("planner-handoff-write", {
|
|
|
3010
3025
|
}
|
|
3011
3026
|
catch (error) {
|
|
3012
3027
|
if (error instanceof HandoffContractError)
|
|
3013
|
-
return
|
|
3028
|
+
return mcpHandoffContractFailure(error);
|
|
3014
3029
|
const message = error instanceof Error ? error.message : String(error);
|
|
3015
|
-
return text(`❌ Handoff refresh denied: ${message}
|
|
3030
|
+
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 });
|
|
3016
3031
|
}
|
|
3017
3032
|
});
|
|
3018
3033
|
server.registerTool("planner-handoff-prepare", {
|
|
3019
3034
|
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.",
|
|
3020
|
-
inputSchema: {
|
|
3021
|
-
|
|
3035
|
+
inputSchema: {
|
|
3036
|
+
phaseRef: z.string().min(1).optional(),
|
|
3037
|
+
supportingDocuments: z.array(z.object({
|
|
3038
|
+
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
3039
|
+
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
3040
|
+
})).optional().describe("Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically."),
|
|
3041
|
+
},
|
|
3042
|
+
}, async ({ phaseRef, supportingDocuments }) => {
|
|
3022
3043
|
if (!phaseRef)
|
|
3023
3044
|
return text([
|
|
3024
3045
|
"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.",
|
|
@@ -3030,35 +3051,13 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
3030
3051
|
if (!r.ok)
|
|
3031
3052
|
return text(`❌ ${r.error}`);
|
|
3032
3053
|
try {
|
|
3033
|
-
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3034
|
-
const
|
|
3035
|
-
|
|
3036
|
-
return text([
|
|
3037
|
-
`Handoff preparation for ${r.compositeRef}`,
|
|
3038
|
-
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3039
|
-
"Required human inputs before drafting:",
|
|
3040
|
-
...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
|
|
3041
|
-
"Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
|
|
3042
|
-
"Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
|
|
3043
|
-
audit.draftTemplate,
|
|
3044
|
-
"",
|
|
3045
|
-
"Done tasks missing durable completion/verification evidence:",
|
|
3046
|
-
missing,
|
|
3047
|
-
"",
|
|
3048
|
-
"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.",
|
|
3049
|
-
"No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
|
|
3050
|
-
"Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
|
|
3051
|
-
`Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
|
|
3052
|
-
"",
|
|
3053
|
-
"Existing active handoff (reconcile all still-relevant content):",
|
|
3054
|
-
audit.handoff.trim() || "(none)",
|
|
3055
|
-
].join("\n"), {
|
|
3056
|
-
phaseRef: r.compositeRef,
|
|
3057
|
-
...compactAudit,
|
|
3058
|
-
evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
|
|
3059
|
-
});
|
|
3054
|
+
const audit = await st.preparePhaseHandoff(r.phase.id, supportingDocuments);
|
|
3055
|
+
const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
|
|
3056
|
+
return text(reply.text, reply.structured);
|
|
3060
3057
|
}
|
|
3061
3058
|
catch (error) {
|
|
3059
|
+
if (error instanceof HandoffContractError)
|
|
3060
|
+
return mcpHandoffContractFailure(error, "preparation");
|
|
3062
3061
|
const message = error instanceof Error ? error.message : String(error);
|
|
3063
3062
|
return text(`❌ Handoff preparation failed: ${message}`, { error: message });
|
|
3064
3063
|
}
|
|
@@ -3119,8 +3118,11 @@ server.registerTool("planner-web", {
|
|
|
3119
3118
|
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: "" });
|
|
3120
3119
|
});
|
|
3121
3120
|
server.registerTool("planner-load", {
|
|
3122
|
-
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a
|
|
3123
|
-
|
|
3121
|
+
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns one text with, in order: a recap under '## Recap — show this to the user verbatim', then agent-only context under '## Agent-only context' (the complete project-level context — description, goal, scope, out-of-scope, technologies, tools, language preferences, Project Guidelines, every Requirement, every project Accepted Decision — then Accepted Decisions of features, phases and tasks, then where the Agent Plan operating guide lives). Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Show the user ONLY the recap section, verbatim, including its final Web UI line; never quote the agent-only context. If structuredContent.projectContext.contextComplete is false, the project-level context was NOT delivered — 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).",
|
|
3122
|
+
inputSchema: {
|
|
3123
|
+
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.`),
|
|
3124
|
+
},
|
|
3125
|
+
}, async ({ maxChars }, extra) => {
|
|
3124
3126
|
const st = store();
|
|
3125
3127
|
if (!(await st.exists()))
|
|
3126
3128
|
return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
|
|
@@ -3141,34 +3143,29 @@ server.registerTool("planner-load", {
|
|
|
3141
3143
|
const web = await ensureWebStarted();
|
|
3142
3144
|
const recap = await buildRecap(st, web, { harness: "mcp" });
|
|
3143
3145
|
const plan = await st.loadAll();
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
})
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
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.",
|
|
3168
|
-
acceptedDecisions: acceptedDecisionContext,
|
|
3169
|
-
},
|
|
3170
|
-
},
|
|
3171
|
-
};
|
|
3146
|
+
// Explicit planner load must deliver the complete project-level context
|
|
3147
|
+
// (P102(F005)/T404), attested through the same lossless contract used
|
|
3148
|
+
// everywhere else: loadProjectContextDelivery + recordProjectContextRead.
|
|
3149
|
+
const boundedMaxChars = maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
|
|
3150
|
+
const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
|
|
3151
|
+
const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
|
|
3152
|
+
if (projectContextReply.structured.contextComplete) {
|
|
3153
|
+
await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
|
|
3154
|
+
}
|
|
3155
|
+
// One text, each piece once (shared with Pi via buildPlannerLoadReply).
|
|
3156
|
+
// The guide body is not resent: the /planner command already carries it,
|
|
3157
|
+
// and a bare planner-load call is pointed at the project-local copy.
|
|
3158
|
+
const reply = buildPlannerLoadReply({
|
|
3159
|
+
recap: preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap,
|
|
3160
|
+
projectContext: projectContextReply,
|
|
3161
|
+
scopedDecisions: buildScopedDecisionContext(plan),
|
|
3162
|
+
plannerGuide: { mode: "pointer", path: ".planner/SKILL.md", customized: plannerSkill.customized, message: plannerSkill.message },
|
|
3163
|
+
});
|
|
3164
|
+
return text(reply.text, {
|
|
3165
|
+
...reply.structured,
|
|
3166
|
+
webUi: plannerWebMetadata(web),
|
|
3167
|
+
preparation: preparation.legacyProjectContext,
|
|
3168
|
+
});
|
|
3172
3169
|
});
|
|
3173
3170
|
server.registerTool("planner-disable", {
|
|
3174
3171
|
description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",
|