@agent-plan/mcp 0.2.25 → 0.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1739 -444
- 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, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion,
|
|
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, MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, HandoffContractError } from "@agent-plan/core";
|
|
9
9
|
import { serve } from "@agent-plan/server";
|
|
10
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
10
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, 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()}`;
|
|
@@ -21,6 +21,38 @@ function plannerSessionIdFor(extra) {
|
|
|
21
21
|
function text(textValue, structuredContent) {
|
|
22
22
|
return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
|
|
23
23
|
}
|
|
24
|
+
function handoffContractFailure(error) {
|
|
25
|
+
return {
|
|
26
|
+
isError: true,
|
|
27
|
+
content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
|
|
28
|
+
structuredContent: { errorCode: error.code, ...error.details },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function handoffPreflightFailure(errorCode, message, details = {}) {
|
|
32
|
+
return { isError: true, content: [{ type: "text", text: `❌ Handoff refresh denied [${errorCode}]: ${message}` }], structuredContent: { errorCode, persisted: false, ...details } };
|
|
33
|
+
}
|
|
34
|
+
function boundedHandoffForTransport(content) {
|
|
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
|
+
}
|
|
24
56
|
function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
25
57
|
const childKind = entity === "feature" ? "phases" : "tasks";
|
|
26
58
|
const retryAction = entity === "feature"
|
|
@@ -52,6 +84,32 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
|
|
|
52
84
|
},
|
|
53
85
|
};
|
|
54
86
|
}
|
|
87
|
+
function mutationNoFieldsFailure(input) {
|
|
88
|
+
const outcome = noMutableFieldsReceived(input);
|
|
89
|
+
return {
|
|
90
|
+
isError: true,
|
|
91
|
+
content: [{ type: "text", text: outcome.message }],
|
|
92
|
+
structuredContent: { ...outcome },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function acceptedDecisionMutationFailure(error, outcome) {
|
|
96
|
+
if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
|
|
97
|
+
throw error;
|
|
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");
|
|
112
|
+
}
|
|
55
113
|
function taskStartError(outcome, structuredContent = {}) {
|
|
56
114
|
return {
|
|
57
115
|
isError: true,
|
|
@@ -66,16 +124,19 @@ function taskStartError(outcome, structuredContent = {}) {
|
|
|
66
124
|
structuredContent: { ...outcome, ...structuredContent },
|
|
67
125
|
};
|
|
68
126
|
}
|
|
69
|
-
function mcpContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
|
|
70
|
-
const actions =
|
|
127
|
+
function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
|
|
128
|
+
const actions = projectGuidelinesReadRequired ? ["planner-project-guidelines-show"] : [];
|
|
129
|
+
actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
|
|
71
130
|
if (read.kind === "task")
|
|
72
131
|
return [`planner-task-show ${refs.task} with full=true`];
|
|
73
132
|
if (read.kind === "phase")
|
|
74
133
|
return [`planner-phase-show ${refs.phase} with full=true`];
|
|
75
|
-
|
|
76
|
-
|
|
134
|
+
if (read.kind === "feature")
|
|
135
|
+
return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
|
|
136
|
+
return [];
|
|
137
|
+
}));
|
|
77
138
|
if (requirementReadRequired)
|
|
78
|
-
actions.push(
|
|
139
|
+
actions.push(`planner-requirement-list with phaseRef=${refs.phase}`);
|
|
79
140
|
actions.push(retryAction);
|
|
80
141
|
return actions;
|
|
81
142
|
}
|
|
@@ -103,21 +164,44 @@ async function requireStore() {
|
|
|
103
164
|
// In-process web server handle, managed by the planner-web tool and planner-load.
|
|
104
165
|
// Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
|
|
105
166
|
let webHandle = null;
|
|
106
|
-
|
|
107
|
-
|
|
167
|
+
function plannerWebMetadata(web) {
|
|
168
|
+
const address = web.lanUrl || web.localUrl;
|
|
169
|
+
let host = web.bindHost ?? "";
|
|
170
|
+
let port = 0;
|
|
171
|
+
if (address) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = new URL(address);
|
|
174
|
+
host = parsed.hostname;
|
|
175
|
+
port = Number(parsed.port);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Preserve the original address when a custom server returns a non-standard URL.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
running: Boolean(web.localUrl),
|
|
183
|
+
address,
|
|
184
|
+
localUrl: web.localUrl,
|
|
185
|
+
lanUrl: web.lanUrl ?? "",
|
|
186
|
+
host,
|
|
187
|
+
port,
|
|
188
|
+
mode: web.mode ?? "",
|
|
189
|
+
...(web.error ? { errorCode: "PLANNER_WEB_START_FAILED", error: web.error } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
108
192
|
async function ensureWebStarted() {
|
|
109
193
|
if (webHandle)
|
|
110
|
-
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
|
|
194
|
+
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
111
195
|
const root = planRoot();
|
|
112
196
|
const st = new PlanStore(root);
|
|
113
197
|
if (!(await st.exists()))
|
|
114
|
-
return { localUrl: "" };
|
|
198
|
+
return { localUrl: "", error: "Planner workspace does not exist." };
|
|
115
199
|
try {
|
|
116
200
|
webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
|
|
117
|
-
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
|
|
201
|
+
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
118
202
|
}
|
|
119
|
-
catch {
|
|
120
|
-
return { localUrl: "" };
|
|
203
|
+
catch (error) {
|
|
204
|
+
return { localUrl: "", error: error instanceof Error ? error.message : String(error) };
|
|
121
205
|
}
|
|
122
206
|
}
|
|
123
207
|
function findFeatureByRef(features, ref) {
|
|
@@ -172,6 +256,33 @@ function featureNumberOfPhase(phase, features) {
|
|
|
172
256
|
function taskCompositeRef(task, phase, features) {
|
|
173
257
|
return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
|
|
174
258
|
}
|
|
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
|
+
}
|
|
175
286
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
176
287
|
const previousStatus = task.status;
|
|
177
288
|
if (nextStatus === "in-progress" && !task.startedAt)
|
|
@@ -194,6 +305,21 @@ const server = new McpServer({
|
|
|
194
305
|
name: "agent-plan-planner",
|
|
195
306
|
version: MCP_PACKAGE.version,
|
|
196
307
|
});
|
|
308
|
+
async function resolveRequirementPhaseRefs(st, refs) {
|
|
309
|
+
if (!refs || refs.length === 0)
|
|
310
|
+
return { ok: false, error: "linkedPhaseIds must contain at least one phase reference." };
|
|
311
|
+
const [phases, features] = await Promise.all([st.loadAllPhases(), st.loadFeatures()]);
|
|
312
|
+
const linkedPhaseIds = [];
|
|
313
|
+
for (const rawRef of refs) {
|
|
314
|
+
const ref = rawRef.trim();
|
|
315
|
+
const phase = ref ? findPhaseByRef(phases, features.features, ref) : undefined;
|
|
316
|
+
if (!phase)
|
|
317
|
+
return { ok: false, error: `Linked phase not found: ${rawRef}` };
|
|
318
|
+
if (!linkedPhaseIds.includes(phase.id))
|
|
319
|
+
linkedPhaseIds.push(phase.id);
|
|
320
|
+
}
|
|
321
|
+
return { ok: true, linkedPhaseIds };
|
|
322
|
+
}
|
|
197
323
|
async function resolvePhaseForHandoff(st, ref) {
|
|
198
324
|
const phases = await st.loadAllPhases();
|
|
199
325
|
const features = (await st.loadFeatures()).features;
|
|
@@ -213,17 +339,22 @@ async function resolvePhaseForHandoff(st, ref) {
|
|
|
213
339
|
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
214
340
|
}
|
|
215
341
|
server.registerTool("planner-version", {
|
|
216
|
-
description: "Report
|
|
342
|
+
description: "Report loaded Agent Plan MCP/core runtime provenance plus plan schema and allocation compatibility. Available without an initialized .planner/ workspace.",
|
|
217
343
|
}, async () => {
|
|
344
|
+
const packages = runtimePackagesDiagnostic([MCP_PACKAGE, CORE_PACKAGE]);
|
|
345
|
+
const capabilities = runtimeCapabilities();
|
|
218
346
|
const versions = {
|
|
219
347
|
[MCP_PACKAGE.name]: MCP_PACKAGE.version,
|
|
220
348
|
[CORE_PACKAGE.name]: CORE_PACKAGE.version,
|
|
221
349
|
};
|
|
222
350
|
return text([
|
|
223
|
-
"Agent Plan runtime
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
351
|
+
"Agent Plan runtime diagnostics",
|
|
352
|
+
"Runtime packages (loaded package manifests):",
|
|
353
|
+
`- ${MCP_PACKAGE.name}: loaded ${MCP_PACKAGE.version}`,
|
|
354
|
+
`- ${CORE_PACKAGE.name}: loaded ${CORE_PACKAGE.version}`,
|
|
355
|
+
`Plan schema: manifest schemaVersion ${capabilities.planSchema.manifestSchemaVersion}`,
|
|
356
|
+
`Allocation registry: v${capabilities.allocationRegistry.version}; supported kinds: ${capabilities.allocationRegistry.supportedKinds.join(", ")}`,
|
|
357
|
+
].join("\n"), { versions, packages, capabilities });
|
|
227
358
|
});
|
|
228
359
|
server.registerTool("planner-export", {
|
|
229
360
|
description: "Export the project plan as a Markdown report. Supports a concise summary or full hierarchical detail.",
|
|
@@ -278,13 +409,26 @@ server.registerTool("planner-init", {
|
|
|
278
409
|
return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
|
|
279
410
|
});
|
|
280
411
|
server.registerTool("planner-show", {
|
|
281
|
-
description: "Show a compact planner overview: project metadata, counts, and a one-line summary of each feature
|
|
412
|
+
description: "Show a compact planner overview with explicit active-task evidence: project metadata, counts, authoritative active task refs (or a verified-empty state), and a one-line summary of each feature. Never infer that no task is active from omitted task detail. Use planner-feature-list / planner-phase-list / planner-task-list for discovery and planner-*-show for full entity detail.",
|
|
282
413
|
}, async () => {
|
|
283
414
|
const st = await requireStore();
|
|
284
415
|
const features = (await st.loadFeatures()).features;
|
|
285
416
|
const phases = await st.loadAllPhases();
|
|
286
|
-
const
|
|
417
|
+
const allTasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
418
|
+
const totalTasks = allTasks.length;
|
|
419
|
+
const activeTasks = allTasks
|
|
420
|
+
.filter(({ task }) => task.status === "in-progress")
|
|
421
|
+
.map(({ phase, task }) => ({
|
|
422
|
+
id: task.id,
|
|
423
|
+
ref: taskCompositeRef(task, phase, features),
|
|
424
|
+
title: task.title,
|
|
425
|
+
phaseId: phase.id,
|
|
426
|
+
featureId: phase.featureId ?? null,
|
|
427
|
+
status: task.status,
|
|
428
|
+
}));
|
|
429
|
+
const activeTaskState = activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict";
|
|
287
430
|
const project = await st.loadProject();
|
|
431
|
+
const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: project.acceptedDecisions }]);
|
|
288
432
|
const manifest = await st.loadManifest();
|
|
289
433
|
const featureLines = features.map((feature) => {
|
|
290
434
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
@@ -294,8 +438,13 @@ server.registerTool("planner-show", {
|
|
|
294
438
|
const summary = [
|
|
295
439
|
`📋 ${project.name}`,
|
|
296
440
|
`Description: ${project.description || "(not set)"}`,
|
|
441
|
+
...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
|
|
297
442
|
`Goal: ${project.goal || "(not set)"}`,
|
|
443
|
+
projectDecisionContext.content,
|
|
298
444
|
`Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
|
|
445
|
+
activeTasks.length === 0
|
|
446
|
+
? "Active tasks: none (verified from all persisted phase task statuses)"
|
|
447
|
+
: `Active tasks (${activeTasks.length})${activeTaskState === "conflict" ? " — CONFLICT: multiple tasks are in progress" : ""}:\n${activeTasks.map((task) => `- ${task.ref} — ${task.title} (in-progress)`).join("\n")}`,
|
|
299
448
|
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
300
449
|
"",
|
|
301
450
|
"Features:",
|
|
@@ -303,8 +452,17 @@ server.registerTool("planner-show", {
|
|
|
303
452
|
].join("\n");
|
|
304
453
|
// Compact structured overview (no full plan: avoids 1M+ char token overflow).
|
|
305
454
|
const overview = {
|
|
306
|
-
project: {
|
|
307
|
-
|
|
455
|
+
project: {
|
|
456
|
+
name: project.name,
|
|
457
|
+
description: project.description || null,
|
|
458
|
+
descriptionRef: project.descriptionRef || null,
|
|
459
|
+
goal: project.goal || null,
|
|
460
|
+
acceptedDecisions: project.acceptedDecisions,
|
|
461
|
+
updatedAt: manifest.updatedAt || null,
|
|
462
|
+
},
|
|
463
|
+
counts: { features: features.length, phases: phases.length, tasks: totalTasks, activeTasks: activeTasks.length },
|
|
464
|
+
activeTaskState,
|
|
465
|
+
activeTasks,
|
|
308
466
|
features: features.map((feature) => ({
|
|
309
467
|
ref: formatFeatureRef(feature.number),
|
|
310
468
|
shortId: feature.shortId || null,
|
|
@@ -316,6 +474,16 @@ server.registerTool("planner-show", {
|
|
|
316
474
|
};
|
|
317
475
|
return text(summary, { overview });
|
|
318
476
|
});
|
|
477
|
+
server.registerTool("planner-description-freshness", {
|
|
478
|
+
description: "Preview deterministic child-to-parent description drift. Returns exact stale phase/feature refs and leaf-to-root reconciliation steps without rewriting user-authored descriptions.",
|
|
479
|
+
}, async () => {
|
|
480
|
+
const st = await requireStore();
|
|
481
|
+
const freshness = await st.previewDescriptionReconciliation();
|
|
482
|
+
if (!freshness.reconciliationRequired)
|
|
483
|
+
return text("Description hierarchy is fresh; no parent reconciliation is required.", { ...freshness });
|
|
484
|
+
const lines = freshness.reconciliationPreview.map((step) => `- ${step.ownerRef} after ${step.causedByRef}: ${step.action}`);
|
|
485
|
+
return text(`Parent description reconciliation required (${freshness.staleParentRefs.length}):\n${lines.join("\n")}`, { ...freshness });
|
|
486
|
+
});
|
|
319
487
|
server.registerTool("planner-repair", {
|
|
320
488
|
description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug where tasks land in the wrong phase file), and report integrity.",
|
|
321
489
|
}, async () => {
|
|
@@ -358,17 +526,18 @@ server.registerTool("planner-project-language", {
|
|
|
358
526
|
},
|
|
359
527
|
}, async ({ contentLanguage, chatLanguage }) => {
|
|
360
528
|
const st = await requireStore();
|
|
361
|
-
const project = await st.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
await st.saveProject(project);
|
|
529
|
+
const project = await st.updateProject((current) => ({
|
|
530
|
+
...current,
|
|
531
|
+
...(contentLanguage !== undefined ? { contentLanguage: contentLanguage.trim() } : {}),
|
|
532
|
+
...(chatLanguage !== undefined ? { chatLanguage: chatLanguage.trim() } : {}),
|
|
533
|
+
}));
|
|
367
534
|
return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
|
|
368
535
|
});
|
|
369
536
|
server.registerTool("planner-project-discuss", {
|
|
370
537
|
description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
|
|
371
538
|
inputSchema: {
|
|
539
|
+
description: z.string().optional(),
|
|
540
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
|
|
372
541
|
goal: z.string().optional(),
|
|
373
542
|
scope: z.array(z.string()).optional(),
|
|
374
543
|
outOfScope: z.array(z.string()).optional(),
|
|
@@ -380,6 +549,32 @@ server.registerTool("planner-project-discuss", {
|
|
|
380
549
|
}, async (params) => {
|
|
381
550
|
const st = await requireStore();
|
|
382
551
|
const project = await st.loadProject();
|
|
552
|
+
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
|
|
553
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
554
|
+
if (receivedFields.length === 0) {
|
|
555
|
+
return mutationNoFieldsFailure({
|
|
556
|
+
entity: "project",
|
|
557
|
+
ref: project.name,
|
|
558
|
+
operation: "discuss",
|
|
559
|
+
mutableFields,
|
|
560
|
+
retryCommand: "planner-project-discuss with a concise description summary and the intended metadata",
|
|
561
|
+
readBackCommand: "planner-show",
|
|
562
|
+
descriptionFallback: {
|
|
563
|
+
inputField: "description",
|
|
564
|
+
storedField: "project description",
|
|
565
|
+
descriptionRefField: "descriptionRef",
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (params.description !== undefined)
|
|
570
|
+
project.description = params.description.trim();
|
|
571
|
+
if (params.descriptionRef !== undefined) {
|
|
572
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
573
|
+
if (descriptionRef)
|
|
574
|
+
project.descriptionRef = descriptionRef;
|
|
575
|
+
else
|
|
576
|
+
delete project.descriptionRef;
|
|
577
|
+
}
|
|
383
578
|
if (params.goal !== undefined)
|
|
384
579
|
project.goal = params.goal.trim();
|
|
385
580
|
if (params.scope !== undefined)
|
|
@@ -394,26 +589,102 @@ server.registerTool("planner-project-discuss", {
|
|
|
394
589
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
395
590
|
if (params.decisions !== undefined)
|
|
396
591
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
397
|
-
await st.
|
|
398
|
-
|
|
592
|
+
const persisted = await st.updateProject((current) => {
|
|
593
|
+
const next = {
|
|
594
|
+
...current,
|
|
595
|
+
...(params.description !== undefined ? { description: params.description.trim() } : {}),
|
|
596
|
+
...(params.goal !== undefined ? { goal: params.goal.trim() } : {}),
|
|
597
|
+
...(params.scope !== undefined ? { scope: params.scope.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
598
|
+
...(params.outOfScope !== undefined ? { outOfScope: params.outOfScope.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
599
|
+
...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
600
|
+
...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
|
|
601
|
+
...(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
|
+
};
|
|
604
|
+
if (params.descriptionRef !== undefined) {
|
|
605
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
606
|
+
if (descriptionRef)
|
|
607
|
+
next.descriptionRef = descriptionRef;
|
|
608
|
+
else
|
|
609
|
+
delete next.descriptionRef;
|
|
610
|
+
}
|
|
611
|
+
return next;
|
|
612
|
+
});
|
|
613
|
+
return writeAndSummarize(st, `Project discussed/updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.`, { project: persisted, discussed: true, updatedFields: receivedFields });
|
|
614
|
+
});
|
|
615
|
+
server.registerTool("planner-project-guidelines-show", {
|
|
616
|
+
description: "Read the canonical Project Guidelines / Coding Standard section for this project. Call this when planner-task-start or planner-task-switch nextActions require it, and keep the guidance in working memory while doing task work.",
|
|
617
|
+
}, async (extra) => {
|
|
618
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
619
|
+
const st = await requireStore();
|
|
620
|
+
const project = await st.loadProject();
|
|
621
|
+
if (project.projectGuidelines.content.trim()) {
|
|
622
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
623
|
+
}
|
|
624
|
+
const refreshed = await st.loadProject();
|
|
625
|
+
const content = refreshed.projectGuidelines.content.trim();
|
|
626
|
+
return text(content
|
|
627
|
+
? ["## Project Guidelines", content].join("\n\n")
|
|
628
|
+
: "No project guidelines set.", { projectGuidelines: refreshed.projectGuidelines, readRecorded: Boolean(content) });
|
|
629
|
+
});
|
|
630
|
+
server.registerTool("planner-project-guidelines-update", {
|
|
631
|
+
description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
|
|
632
|
+
inputSchema: {
|
|
633
|
+
content: z.string().describe("Full Project Guidelines markdown/text. Pass an empty string to clear the section."),
|
|
634
|
+
},
|
|
635
|
+
}, async ({ content }) => {
|
|
636
|
+
const st = await requireStore();
|
|
637
|
+
const project = await st.loadProject();
|
|
638
|
+
await st.updateProject((current) => ({
|
|
639
|
+
...current,
|
|
640
|
+
projectGuidelines: { ...current.projectGuidelines, content: content.trim() },
|
|
641
|
+
}), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
|
|
642
|
+
const refreshed = await st.loadProject();
|
|
643
|
+
return writeAndSummarize(st, refreshed.projectGuidelines.content
|
|
644
|
+
? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
|
|
645
|
+
: "Project Guidelines cleared.", { updated: true, projectGuidelines: refreshed.projectGuidelines });
|
|
646
|
+
});
|
|
647
|
+
server.registerTool("planner-project-context-migrate", {
|
|
648
|
+
description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
|
|
649
|
+
inputSchema: {
|
|
650
|
+
apply: z.boolean().default(false).describe("False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields."),
|
|
651
|
+
},
|
|
652
|
+
}, async ({ apply }) => {
|
|
653
|
+
const st = await requireStore();
|
|
654
|
+
const preview = await st.previewLegacyProjectContextMigration();
|
|
655
|
+
if (!apply) {
|
|
656
|
+
return text([`Project context migration preview (no changes applied).`, legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n"), { applied: false, preview });
|
|
657
|
+
}
|
|
658
|
+
const result = await st.migrateLegacyProjectContext();
|
|
659
|
+
return writeAndSummarize(st, [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n"), { ...result });
|
|
399
660
|
});
|
|
400
661
|
server.registerTool("planner-feature-list", {
|
|
401
|
-
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
|
|
662
|
+
description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
|
|
402
663
|
inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
|
|
403
664
|
}, async ({ featureRef }) => {
|
|
404
665
|
const st = await requireStore();
|
|
405
666
|
const allFeatures = (await st.loadFeatures()).features;
|
|
406
667
|
const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
|
|
407
668
|
const phases = await st.loadAllPhases();
|
|
408
|
-
const
|
|
669
|
+
const items = features.map((feature) => {
|
|
409
670
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
410
671
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
411
|
-
return
|
|
672
|
+
return {
|
|
673
|
+
id: feature.id,
|
|
674
|
+
ref: formatFeatureRef(feature.number),
|
|
675
|
+
shortId: feature.shortId,
|
|
676
|
+
name: feature.name,
|
|
677
|
+
status: feature.status,
|
|
678
|
+
priority: feature.priority,
|
|
679
|
+
phaseCount: featurePhases.length,
|
|
680
|
+
taskCount,
|
|
681
|
+
};
|
|
412
682
|
});
|
|
413
|
-
|
|
683
|
+
const lines = items.map((feature) => `- ${feature.ref}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}${feature.priority > 0 ? `; priority ${feature.priority}` : ""}; ${feature.phaseCount} phases, ${feature.taskCount} tasks)`);
|
|
684
|
+
return text(lines.join("\n") || "No features", { features: items });
|
|
414
685
|
});
|
|
415
686
|
server.registerTool("planner-phase-list", {
|
|
416
|
-
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
687
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
417
688
|
inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name)."), status: z.string().optional().describe("Optional: filter by status name.") },
|
|
418
689
|
}, async ({ featureRef, status }) => {
|
|
419
690
|
const st = await requireStore();
|
|
@@ -427,15 +698,27 @@ server.registerTool("planner-phase-list", {
|
|
|
427
698
|
}
|
|
428
699
|
if (status)
|
|
429
700
|
phases = phases.filter((p) => p.status === status);
|
|
430
|
-
const
|
|
701
|
+
const items = phases.map((phase) => {
|
|
431
702
|
const fNum = featureNumberOfPhase(phase, features);
|
|
432
|
-
|
|
433
|
-
|
|
703
|
+
return {
|
|
704
|
+
id: phase.id,
|
|
705
|
+
ref: formatPhaseRef(phase.number, fNum),
|
|
706
|
+
shortId: phase.shortId,
|
|
707
|
+
title: phase.title,
|
|
708
|
+
status: phase.status,
|
|
709
|
+
priority: phase.priority,
|
|
710
|
+
taskCount: phase.tasks.length,
|
|
711
|
+
featureNumber: fNum,
|
|
712
|
+
};
|
|
434
713
|
});
|
|
435
|
-
|
|
714
|
+
const lines = items.map((phase) => {
|
|
715
|
+
const fTag = phase.featureNumber !== undefined ? ` [F${String(phase.featureNumber).padStart(3, "0")}]` : "";
|
|
716
|
+
return `- ${phase.ref}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}${phase.priority > 0 ? `; priority ${phase.priority}` : ""}; ${phase.taskCount} tasks)${fTag}`;
|
|
717
|
+
});
|
|
718
|
+
return text(lines.join("\n") || "No phases", { phases: items });
|
|
436
719
|
});
|
|
437
720
|
server.registerTool("planner-task-list", {
|
|
438
|
-
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
721
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
439
722
|
inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
|
|
440
723
|
}, async ({ featureRef, phaseRef, status }) => {
|
|
441
724
|
const st = await requireStore();
|
|
@@ -453,15 +736,142 @@ server.registerTool("planner-task-list", {
|
|
|
453
736
|
return text(`Phase not found: ${phaseRef}`);
|
|
454
737
|
phases = [p];
|
|
455
738
|
}
|
|
456
|
-
const
|
|
739
|
+
const items = [];
|
|
457
740
|
for (const phase of phases) {
|
|
458
741
|
for (const task of phase.tasks) {
|
|
459
742
|
if (status && task.status !== status)
|
|
460
743
|
continue;
|
|
461
|
-
|
|
744
|
+
items.push({
|
|
745
|
+
id: task.id,
|
|
746
|
+
ref: taskCompositeRef(task, phase, features),
|
|
747
|
+
shortId: task.shortId,
|
|
748
|
+
title: task.title,
|
|
749
|
+
status: task.status,
|
|
750
|
+
priority: task.priority,
|
|
751
|
+
phaseId: phase.id,
|
|
752
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
753
|
+
});
|
|
462
754
|
}
|
|
463
755
|
}
|
|
464
|
-
|
|
756
|
+
const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
|
|
757
|
+
return text(out.join("\n") || "No tasks", { tasks: items });
|
|
758
|
+
});
|
|
759
|
+
server.registerTool("planner-accepted-decision-create", {
|
|
760
|
+
description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
|
|
761
|
+
inputSchema: {
|
|
762
|
+
targetType: z.enum(["project", "feature", "phase", "task"]),
|
|
763
|
+
targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
|
|
764
|
+
title: z.string().min(1),
|
|
765
|
+
decision: z.string().optional(),
|
|
766
|
+
rationale: z.string().optional(),
|
|
767
|
+
implementationNotes: z.string().optional(),
|
|
768
|
+
},
|
|
769
|
+
}, async ({ targetType, targetRef, title, decision, rationale, implementationNotes }) => {
|
|
770
|
+
const st = await requireStore();
|
|
771
|
+
const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
|
|
772
|
+
if (!target.ok)
|
|
773
|
+
return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
|
|
774
|
+
const createInput = {
|
|
775
|
+
title,
|
|
776
|
+
...(decision !== undefined ? { decision } : {}),
|
|
777
|
+
...(rationale !== undefined ? { rationale } : {}),
|
|
778
|
+
...(implementationNotes !== undefined ? { implementationNotes } : {}),
|
|
779
|
+
};
|
|
780
|
+
try {
|
|
781
|
+
const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
|
|
782
|
+
return writeAndSummarize(st, `Accepted decision created on ${target.ref}: ${acceptedDecision.title}`, {
|
|
783
|
+
created: true,
|
|
784
|
+
targetType,
|
|
785
|
+
targetRef: target.ref,
|
|
786
|
+
acceptedDecision,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
return acceptedDecisionMutationFailure(error, "created");
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
server.registerTool("planner-accepted-decision-update", {
|
|
794
|
+
description: "Update one accepted decision in place while preserving its id and acceptedAt timestamp.",
|
|
795
|
+
inputSchema: {
|
|
796
|
+
targetType: z.enum(["project", "feature", "phase", "task"]),
|
|
797
|
+
targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
|
|
798
|
+
decisionId: z.string().min(1),
|
|
799
|
+
title: z.string().optional(),
|
|
800
|
+
decision: z.string().optional(),
|
|
801
|
+
rationale: z.string().optional(),
|
|
802
|
+
implementationNotes: z.string().optional(),
|
|
803
|
+
},
|
|
804
|
+
}, async ({ targetType, targetRef, decisionId, title, decision, rationale, implementationNotes }) => {
|
|
805
|
+
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
806
|
+
const receivedFields = mutableFields.filter((field) => ({ title, decision, rationale, implementationNotes })[field] !== undefined);
|
|
807
|
+
if (receivedFields.length === 0) {
|
|
808
|
+
return mutationNoFieldsFailure({
|
|
809
|
+
entity: "acceptedDecision",
|
|
810
|
+
ref: decisionId,
|
|
811
|
+
operation: "update",
|
|
812
|
+
mutableFields,
|
|
813
|
+
retryCommand: "planner-accepted-decision-update with title, decision, rationale, or implementationNotes",
|
|
814
|
+
readBackCommand: "planner-show or the owning entity show tool",
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const st = await requireStore();
|
|
818
|
+
const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
|
|
819
|
+
if (!target.ok)
|
|
820
|
+
return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
|
|
821
|
+
const updateInput = {
|
|
822
|
+
...(title !== undefined ? { title } : {}),
|
|
823
|
+
...(decision !== undefined ? { decision } : {}),
|
|
824
|
+
...(rationale !== undefined ? { rationale } : {}),
|
|
825
|
+
...(implementationNotes !== undefined ? { implementationNotes } : {}),
|
|
826
|
+
};
|
|
827
|
+
try {
|
|
828
|
+
const acceptedDecision = await st.updateAcceptedDecision(target.owner, decisionId, updateInput);
|
|
829
|
+
return writeAndSummarize(st, `Accepted decision updated on ${target.ref}: ${acceptedDecision.title}`, {
|
|
830
|
+
updated: true,
|
|
831
|
+
targetType,
|
|
832
|
+
targetRef: target.ref,
|
|
833
|
+
acceptedDecision,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
return acceptedDecisionMutationFailure(error, "updated");
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
server.registerTool("planner-accepted-decision-delete", {
|
|
841
|
+
description: "Delete one accepted decision from the project, feature, phase, or task without replacing the full acceptedDecisions array.",
|
|
842
|
+
inputSchema: {
|
|
843
|
+
targetType: z.enum(["project", "feature", "phase", "task"]),
|
|
844
|
+
targetRef: z.string().optional().describe("Feature/phase/task ref. Omit for targetType=project."),
|
|
845
|
+
decisionId: z.string().min(1),
|
|
846
|
+
confirmed: z.boolean().describe("Must be true to confirm deleting this accepted decision."),
|
|
847
|
+
},
|
|
848
|
+
}, async ({ targetType, targetRef, decisionId, confirmed }) => {
|
|
849
|
+
const st = await requireStore();
|
|
850
|
+
const target = await resolveAcceptedDecisionTarget(st, targetType, targetRef);
|
|
851
|
+
if (!target.ok)
|
|
852
|
+
return { isError: true, content: [{ type: "text", text: target.error }], structuredContent: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" } };
|
|
853
|
+
if (!confirmed) {
|
|
854
|
+
return text(`Delete not confirmed for accepted decision ${decisionId} on ${target.ref}. Retry with confirmed=true.`, {
|
|
855
|
+
deleted: false,
|
|
856
|
+
confirmRequired: true,
|
|
857
|
+
targetType,
|
|
858
|
+
targetRef: target.ref,
|
|
859
|
+
decisionId,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const acceptedDecision = await st.deleteAcceptedDecision(target.owner, decisionId);
|
|
864
|
+
return writeAndSummarize(st, `Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}`, {
|
|
865
|
+
deleted: true,
|
|
866
|
+
targetType,
|
|
867
|
+
targetRef: target.ref,
|
|
868
|
+
decisionId,
|
|
869
|
+
acceptedDecision,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
return acceptedDecisionMutationFailure(error, "deleted");
|
|
874
|
+
}
|
|
465
875
|
});
|
|
466
876
|
server.registerTool("planner-feature-add", {
|
|
467
877
|
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.",
|
|
@@ -472,44 +882,46 @@ server.registerTool("planner-feature-add", {
|
|
|
472
882
|
},
|
|
473
883
|
}, async ({ name, description, status }) => {
|
|
474
884
|
const st = await requireStore();
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
885
|
+
return st.runBatch(async () => {
|
|
886
|
+
const timestamp = nowISO();
|
|
887
|
+
const effectiveStatus = status ?? "planned";
|
|
888
|
+
const existingFeatures = (await st.loadFeatures()).features;
|
|
889
|
+
const id = createFeatureId();
|
|
890
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
891
|
+
const priority = await st.nextPriority("feature");
|
|
892
|
+
const feature = {
|
|
893
|
+
id,
|
|
894
|
+
number: identity.number,
|
|
895
|
+
shortId: identity.shortId,
|
|
896
|
+
priority,
|
|
897
|
+
name: name.trim(),
|
|
898
|
+
description: description?.trim() ?? "",
|
|
899
|
+
descriptionUpdatedAt: timestamp,
|
|
900
|
+
status: effectiveStatus,
|
|
901
|
+
discussedAt: "",
|
|
902
|
+
contextReady: false,
|
|
903
|
+
contextReadyReason: "",
|
|
904
|
+
startDate: effectiveStatus === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
|
|
905
|
+
endDate: effectiveStatus === "done" ? new Date().toISOString().slice(0, 10) : "",
|
|
906
|
+
workDone: "",
|
|
907
|
+
workRemaining: "",
|
|
908
|
+
acceptedDecisions: [],
|
|
909
|
+
phaseIds: [],
|
|
910
|
+
dependsOn: [],
|
|
911
|
+
statusLog: [],
|
|
912
|
+
sessionInfo: [],
|
|
913
|
+
createdAt: timestamp,
|
|
914
|
+
updatedAt: timestamp,
|
|
915
|
+
};
|
|
916
|
+
await st.updateFeatures((doc) => {
|
|
917
|
+
doc.features.push(feature);
|
|
918
|
+
return doc;
|
|
919
|
+
});
|
|
920
|
+
return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
|
|
508
921
|
});
|
|
509
|
-
return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
|
|
510
922
|
});
|
|
511
923
|
server.registerTool("planner-feature-show", {
|
|
512
|
-
description: "Show a feature by id or name.",
|
|
924
|
+
description: "Show a feature by id or name. Pass full=true for the description and every canonical Accepted Decision field.",
|
|
513
925
|
inputSchema: { feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the feature description. Default: compact identity only (saves tokens).") },
|
|
514
926
|
}, async ({ feature: ref, full }, extra) => {
|
|
515
927
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -519,34 +931,72 @@ server.registerTool("planner-feature-show", {
|
|
|
519
931
|
if (!resolvedFeature.ok)
|
|
520
932
|
return text(resolvedFeature.error);
|
|
521
933
|
const feature = resolvedFeature.feature;
|
|
522
|
-
if (full)
|
|
523
|
-
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
524
934
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
525
935
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
526
|
-
|
|
936
|
+
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
937
|
+
if (!full)
|
|
938
|
+
return text(summary);
|
|
939
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
|
|
940
|
+
const result = text(`${summary}\n\n${feature.description || ""}${descriptionRefBlock}\n\n${decisionBlock}`, {
|
|
941
|
+
feature: {
|
|
942
|
+
ref: formatFeatureRef(feature.number),
|
|
943
|
+
description: feature.description,
|
|
944
|
+
descriptionRef: feature.descriptionRef || "",
|
|
945
|
+
acceptedDecisions: feature.acceptedDecisions,
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
|
|
949
|
+
return result;
|
|
527
950
|
});
|
|
528
951
|
server.registerTool("planner-feature-discuss", {
|
|
529
952
|
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
530
953
|
inputSchema: {
|
|
531
954
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
532
955
|
description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
|
|
956
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
533
957
|
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
534
958
|
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
535
959
|
dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
|
|
536
960
|
},
|
|
537
|
-
}, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
|
|
961
|
+
}, async ({ feature: ref, description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies }) => {
|
|
538
962
|
const st = await requireStore();
|
|
539
963
|
const features = (await st.loadFeatures()).features;
|
|
540
964
|
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
541
965
|
if (!resolvedFeature.ok)
|
|
542
966
|
return text(resolvedFeature.error);
|
|
543
967
|
const feature = resolvedFeature.feature;
|
|
968
|
+
const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
|
|
969
|
+
const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
|
|
970
|
+
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
971
|
+
if (receivedFields.length === 0) {
|
|
972
|
+
return mutationNoFieldsFailure({
|
|
973
|
+
entity: "feature",
|
|
974
|
+
ref: formatFeatureRef(feature.number),
|
|
975
|
+
operation: "discuss",
|
|
976
|
+
mutableFields,
|
|
977
|
+
retryCommand: `planner-feature-discuss ${formatFeatureRef(feature.number)}`,
|
|
978
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
|
|
979
|
+
descriptionFallback: {
|
|
980
|
+
entityId: feature.id,
|
|
981
|
+
inputField: "description",
|
|
982
|
+
storedField: "feature description",
|
|
983
|
+
descriptionRefField: "descriptionRef",
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
}
|
|
544
987
|
const updated = await st.updateFeatures((doc) => {
|
|
545
988
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
546
989
|
if (!target)
|
|
547
990
|
return doc;
|
|
548
991
|
if (description !== undefined)
|
|
549
992
|
target.description = description.trim();
|
|
993
|
+
if (rawDescriptionRef !== undefined) {
|
|
994
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
995
|
+
if (descriptionRef)
|
|
996
|
+
target.descriptionRef = descriptionRef;
|
|
997
|
+
else
|
|
998
|
+
delete target.descriptionRef;
|
|
999
|
+
}
|
|
550
1000
|
if (workDone !== undefined)
|
|
551
1001
|
target.workDone = workDone.trim();
|
|
552
1002
|
if (workRemaining !== undefined)
|
|
@@ -560,7 +1010,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
560
1010
|
return doc;
|
|
561
1011
|
});
|
|
562
1012
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
563
|
-
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}
|
|
1013
|
+
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, discussed: true, updatedFields: receivedFields });
|
|
564
1014
|
});
|
|
565
1015
|
server.registerTool("planner-feature-update", {
|
|
566
1016
|
description: "Update a feature.",
|
|
@@ -568,6 +1018,7 @@ server.registerTool("planner-feature-update", {
|
|
|
568
1018
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
569
1019
|
name: z.string().optional(),
|
|
570
1020
|
description: z.string().optional(),
|
|
1021
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
571
1022
|
status: z.enum(STATUS_VALUES).optional(),
|
|
572
1023
|
workDone: z.string().optional(),
|
|
573
1024
|
workRemaining: z.string().optional(),
|
|
@@ -585,6 +1036,24 @@ server.registerTool("planner-feature-update", {
|
|
|
585
1036
|
if (updates.status !== undefined) {
|
|
586
1037
|
return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
|
|
587
1038
|
}
|
|
1039
|
+
const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
|
|
1040
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1041
|
+
if (receivedFields.length === 0) {
|
|
1042
|
+
return mutationNoFieldsFailure({
|
|
1043
|
+
entity: "feature",
|
|
1044
|
+
ref: formatFeatureRef(feature.number),
|
|
1045
|
+
operation: "update",
|
|
1046
|
+
mutableFields,
|
|
1047
|
+
retryCommand: `planner-feature-update ${formatFeatureRef(feature.number)}`,
|
|
1048
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
|
|
1049
|
+
descriptionFallback: {
|
|
1050
|
+
entityId: feature.id,
|
|
1051
|
+
inputField: "description",
|
|
1052
|
+
storedField: "feature description",
|
|
1053
|
+
descriptionRefField: "descriptionRef",
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
588
1057
|
const updated = await st.updateFeatures((doc) => {
|
|
589
1058
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
590
1059
|
if (!target)
|
|
@@ -593,6 +1062,13 @@ server.registerTool("planner-feature-update", {
|
|
|
593
1062
|
target.name = updates.name.trim();
|
|
594
1063
|
if (updates.description !== undefined)
|
|
595
1064
|
target.description = updates.description.trim();
|
|
1065
|
+
if (updates.descriptionRef !== undefined) {
|
|
1066
|
+
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
1067
|
+
if (descriptionRef)
|
|
1068
|
+
target.descriptionRef = descriptionRef;
|
|
1069
|
+
else
|
|
1070
|
+
delete target.descriptionRef;
|
|
1071
|
+
}
|
|
596
1072
|
if (updates.workDone !== undefined)
|
|
597
1073
|
target.workDone = updates.workDone.trim();
|
|
598
1074
|
if (updates.workRemaining !== undefined)
|
|
@@ -607,7 +1083,7 @@ server.registerTool("planner-feature-update", {
|
|
|
607
1083
|
return doc;
|
|
608
1084
|
});
|
|
609
1085
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
610
|
-
return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}
|
|
1086
|
+
return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, updated: true, updatedFields: receivedFields });
|
|
611
1087
|
});
|
|
612
1088
|
server.registerTool("planner-feature-delete", {
|
|
613
1089
|
description: "Delete a feature. By default, phases are unlinked rather than deleted.",
|
|
@@ -623,20 +1099,20 @@ server.registerTool("planner-feature-delete", {
|
|
|
623
1099
|
return text(resolvedFeature.error);
|
|
624
1100
|
const feature = resolvedFeature.feature;
|
|
625
1101
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
626
|
-
await st.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1102
|
+
await st.runBatch(async () => {
|
|
1103
|
+
await st.updateFeatures((doc) => {
|
|
1104
|
+
doc.features = doc.features.filter((entry) => entry.id !== feature.id);
|
|
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
|
+
}
|
|
638
1114
|
}
|
|
639
|
-
}
|
|
1115
|
+
});
|
|
640
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) });
|
|
641
1117
|
});
|
|
642
1118
|
server.registerTool("planner-phase-add", {
|
|
@@ -663,7 +1139,7 @@ server.registerTool("planner-phase-add", {
|
|
|
663
1139
|
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
|
|
664
1140
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
665
1141
|
let phase;
|
|
666
|
-
await withFeatureLock(lockKey, async () => {
|
|
1142
|
+
await st.runBatch(() => withFeatureLock(lockKey, async () => {
|
|
667
1143
|
const phases = await st.loadAllPhases();
|
|
668
1144
|
const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
|
|
669
1145
|
const timestamp = nowISO();
|
|
@@ -701,6 +1177,7 @@ server.registerTool("planner-phase-add", {
|
|
|
701
1177
|
updatedAt: timestamp,
|
|
702
1178
|
handoff: "",
|
|
703
1179
|
handoffUpdatedAt: "",
|
|
1180
|
+
handoffAudit: null,
|
|
704
1181
|
handoffReadAt: "",
|
|
705
1182
|
handoffHistory: [],
|
|
706
1183
|
statusLog: [],
|
|
@@ -716,13 +1193,13 @@ server.registerTool("planner-phase-add", {
|
|
|
716
1193
|
});
|
|
717
1194
|
}
|
|
718
1195
|
await st.writeGenerated();
|
|
719
|
-
});
|
|
1196
|
+
}));
|
|
720
1197
|
if (!phase)
|
|
721
1198
|
return text("Phase creation failed.");
|
|
722
1199
|
return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
723
1200
|
});
|
|
724
1201
|
server.registerTool("planner-phase-show", {
|
|
725
|
-
description: "Show a phase by id or name, including derived linked requirements in the full view.",
|
|
1202
|
+
description: "Show a phase by id or name, including every canonical Accepted Decision field and derived linked requirements in the full view.",
|
|
726
1203
|
inputSchema: { phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the phase description. Default: compact identity only (saves tokens).") },
|
|
727
1204
|
}, async ({ phase: ref, full }, extra) => {
|
|
728
1205
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -731,15 +1208,27 @@ server.registerTool("planner-phase-show", {
|
|
|
731
1208
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
732
1209
|
if (!phase)
|
|
733
1210
|
return text(`Phase not found: ${ref}`);
|
|
734
|
-
if (full)
|
|
735
|
-
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
736
1211
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
737
1212
|
const reqCount = linkedRequirements.length;
|
|
738
1213
|
const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
|
|
1214
|
+
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
739
1215
|
const requirementsBlock = reqCount > 0
|
|
740
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
1216
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
741
1217
|
: "";
|
|
742
|
-
|
|
1218
|
+
if (!full)
|
|
1219
|
+
return text(summary, {
|
|
1220
|
+
phase: {
|
|
1221
|
+
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
1222
|
+
shortId: phase.shortId,
|
|
1223
|
+
title: phase.title,
|
|
1224
|
+
summary: phase.summary,
|
|
1225
|
+
status: phase.status,
|
|
1226
|
+
taskCount: phase.tasks.length,
|
|
1227
|
+
},
|
|
1228
|
+
linkedRequirements,
|
|
1229
|
+
});
|
|
1230
|
+
const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
|
|
1231
|
+
const result = text(`${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}`, {
|
|
743
1232
|
phase: {
|
|
744
1233
|
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
745
1234
|
shortId: phase.shortId,
|
|
@@ -747,21 +1236,28 @@ server.registerTool("planner-phase-show", {
|
|
|
747
1236
|
summary: phase.summary,
|
|
748
1237
|
status: phase.status,
|
|
749
1238
|
taskCount: phase.tasks.length,
|
|
750
|
-
|
|
1239
|
+
description: phase.description,
|
|
1240
|
+
descriptionRef: phase.descriptionRef || "",
|
|
1241
|
+
acceptedDecisions: phase.acceptedDecisions,
|
|
751
1242
|
},
|
|
752
1243
|
linkedRequirements,
|
|
753
1244
|
});
|
|
1245
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
|
|
1246
|
+
return result;
|
|
754
1247
|
});
|
|
755
1248
|
server.registerTool("planner-phase-discuss", {
|
|
756
|
-
description: "Persist phase discovery fields and mark
|
|
1249
|
+
description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
|
|
757
1250
|
inputSchema: {
|
|
758
1251
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
759
|
-
goal: z.string().optional(),
|
|
1252
|
+
goal: z.string().optional().describe("Backward-compatible single main goal."),
|
|
1253
|
+
goals: z.array(z.string()).optional().describe("Replace the phase goals list."),
|
|
760
1254
|
summary: z.string().optional(),
|
|
761
1255
|
scope: z.string().optional(),
|
|
1256
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
762
1257
|
nonGoals: z.array(z.string()).optional(),
|
|
763
1258
|
dependencies: z.array(z.string()).optional(),
|
|
764
1259
|
risks: z.array(z.string()).optional(),
|
|
1260
|
+
openQuestions: z.array(z.string()).optional(),
|
|
765
1261
|
completionCriteria: z.array(z.string()).optional(),
|
|
766
1262
|
},
|
|
767
1263
|
}, async ({ phase: ref, ...updates }) => {
|
|
@@ -770,39 +1266,76 @@ server.registerTool("planner-phase-discuss", {
|
|
|
770
1266
|
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
771
1267
|
if (!found)
|
|
772
1268
|
return text(`Phase not found: ${ref}`);
|
|
1269
|
+
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1270
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1271
|
+
if (receivedFields.length === 0) {
|
|
1272
|
+
return mutationNoFieldsFailure({
|
|
1273
|
+
entity: "phase",
|
|
1274
|
+
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1275
|
+
operation: "discuss",
|
|
1276
|
+
mutableFields,
|
|
1277
|
+
retryCommand: `planner-phase-discuss ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
|
|
1278
|
+
readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
|
|
1279
|
+
descriptionFallback: {
|
|
1280
|
+
entityId: found.id,
|
|
1281
|
+
inputField: "scope",
|
|
1282
|
+
storedField: "phase description",
|
|
1283
|
+
descriptionRefField: "descriptionRef",
|
|
1284
|
+
},
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
773
1287
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
774
|
-
if (updates.
|
|
1288
|
+
if (updates.goals !== undefined)
|
|
1289
|
+
entry.goals = updates.goals.map((item) => item.trim()).filter(Boolean);
|
|
1290
|
+
else if (updates.goal !== undefined)
|
|
775
1291
|
entry.goals = [updates.goal.trim()].filter(Boolean);
|
|
776
1292
|
if (updates.summary !== undefined)
|
|
777
1293
|
entry.summary = updates.summary.trim();
|
|
778
1294
|
if (updates.scope !== undefined)
|
|
779
1295
|
entry.description = updates.scope.trim();
|
|
1296
|
+
if (updates.descriptionRef !== undefined) {
|
|
1297
|
+
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
1298
|
+
if (descriptionRef)
|
|
1299
|
+
entry.descriptionRef = descriptionRef;
|
|
1300
|
+
else
|
|
1301
|
+
delete entry.descriptionRef;
|
|
1302
|
+
}
|
|
780
1303
|
if (updates.nonGoals !== undefined)
|
|
781
1304
|
entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
782
1305
|
if (updates.dependencies !== undefined)
|
|
783
1306
|
entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
784
1307
|
if (updates.risks !== undefined)
|
|
785
1308
|
entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
|
|
1309
|
+
if (updates.openQuestions !== undefined)
|
|
1310
|
+
entry.openQuestions = updates.openQuestions.map((item) => item.trim()).filter(Boolean);
|
|
786
1311
|
if (updates.completionCriteria !== undefined)
|
|
787
1312
|
entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
788
|
-
entry.status = "planned";
|
|
789
1313
|
entry.discussedAt = nowISO();
|
|
790
1314
|
entry.contextReady = true;
|
|
791
1315
|
entry.contextReadyReason = "Updated through planner-phase-discuss MCP tool.";
|
|
792
1316
|
entry.updatedAt = nowISO();
|
|
793
1317
|
return entry;
|
|
794
1318
|
});
|
|
795
|
-
return writeAndSummarize(st, `✅ Phase discussed/
|
|
1319
|
+
return writeAndSummarize(st, `✅ Phase discussed/context ready: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, discussed: true, contextReady: true, updatedFields: receivedFields });
|
|
796
1320
|
});
|
|
797
1321
|
server.registerTool("planner-phase-update", {
|
|
798
|
-
description: "Update phase fields.",
|
|
1322
|
+
description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
|
|
799
1323
|
inputSchema: {
|
|
800
1324
|
phase: z.string().min(1),
|
|
801
1325
|
title: z.string().optional(),
|
|
802
1326
|
status: z.enum(PHASE_STATUS_VALUES).optional(),
|
|
803
1327
|
summary: z.string().optional(),
|
|
804
1328
|
description: z.string().optional(),
|
|
1329
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
1330
|
+
featureId: z.string().optional().describe("Feature ref to relink this phase to, or an empty string to unlink it."),
|
|
805
1331
|
priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
|
|
1332
|
+
goals: z.array(z.string()).optional(),
|
|
1333
|
+
nonGoals: z.array(z.string()).optional(),
|
|
1334
|
+
dependencies: z.array(z.string()).optional(),
|
|
1335
|
+
risks: z.array(z.string()).optional(),
|
|
1336
|
+
openQuestions: z.array(z.string()).optional(),
|
|
1337
|
+
decisions: z.array(z.string()).optional(),
|
|
1338
|
+
completionCriteria: z.array(z.string()).optional(),
|
|
806
1339
|
},
|
|
807
1340
|
}, async ({ phase: ref, ...updates }) => {
|
|
808
1341
|
const st = await requireStore();
|
|
@@ -813,19 +1346,101 @@ server.registerTool("planner-phase-update", {
|
|
|
813
1346
|
if (updates.status !== undefined) {
|
|
814
1347
|
return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
|
|
815
1348
|
}
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1349
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
|
|
1350
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1351
|
+
if (receivedFields.length === 0) {
|
|
1352
|
+
return mutationNoFieldsFailure({
|
|
1353
|
+
entity: "phase",
|
|
1354
|
+
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1355
|
+
operation: "update",
|
|
1356
|
+
mutableFields,
|
|
1357
|
+
retryCommand: `planner-phase-update ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
|
|
1358
|
+
readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
|
|
1359
|
+
descriptionFallback: {
|
|
1360
|
+
entityId: found.id,
|
|
1361
|
+
inputField: "description",
|
|
1362
|
+
storedField: "phase description",
|
|
1363
|
+
descriptionRefField: "descriptionRef",
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
const previousFeatureId = found.featureId;
|
|
1368
|
+
let nextFeatureId = previousFeatureId;
|
|
1369
|
+
if (updates.featureId !== undefined) {
|
|
1370
|
+
if (updates.featureId.trim() === "") {
|
|
1371
|
+
nextFeatureId = undefined;
|
|
1372
|
+
}
|
|
1373
|
+
else {
|
|
1374
|
+
const resolvedFeature = resolveFeatureRefStrict(features, updates.featureId);
|
|
1375
|
+
if (!resolvedFeature.ok)
|
|
1376
|
+
return text(resolvedFeature.error);
|
|
1377
|
+
nextFeatureId = resolvedFeature.feature.id;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
let phase;
|
|
1381
|
+
await st.runBatch(async () => {
|
|
1382
|
+
phase = await st.updatePhase(found.id, (entry) => {
|
|
1383
|
+
if (updates.title !== undefined) {
|
|
1384
|
+
entry.title = updates.title.trim();
|
|
1385
|
+
entry.slug = normalizeSlug(updates.title);
|
|
1386
|
+
}
|
|
1387
|
+
if (updates.summary !== undefined)
|
|
1388
|
+
entry.summary = updates.summary.trim();
|
|
1389
|
+
if (updates.description !== undefined)
|
|
1390
|
+
entry.description = updates.description.trim();
|
|
1391
|
+
if (updates.descriptionRef !== undefined) {
|
|
1392
|
+
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
1393
|
+
if (descriptionRef)
|
|
1394
|
+
entry.descriptionRef = descriptionRef;
|
|
1395
|
+
else
|
|
1396
|
+
delete entry.descriptionRef;
|
|
1397
|
+
}
|
|
1398
|
+
if (updates.featureId !== undefined)
|
|
1399
|
+
entry.featureId = nextFeatureId;
|
|
1400
|
+
if (updates.priority !== undefined)
|
|
1401
|
+
entry.priority = updates.priority;
|
|
1402
|
+
if (updates.goals !== undefined)
|
|
1403
|
+
entry.goals = updates.goals.map((item) => item.trim()).filter(Boolean);
|
|
1404
|
+
if (updates.nonGoals !== undefined)
|
|
1405
|
+
entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
1406
|
+
if (updates.dependencies !== undefined)
|
|
1407
|
+
entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
1408
|
+
if (updates.risks !== undefined)
|
|
1409
|
+
entry.risks = updates.risks.map((item) => item.trim()).filter(Boolean);
|
|
1410
|
+
if (updates.openQuestions !== undefined)
|
|
1411
|
+
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
|
+
if (updates.completionCriteria !== undefined)
|
|
1415
|
+
entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
1416
|
+
entry.updatedAt = nowISO();
|
|
1417
|
+
return entry;
|
|
1418
|
+
});
|
|
1419
|
+
if (updates.featureId !== undefined && nextFeatureId !== previousFeatureId) {
|
|
1420
|
+
await st.updateFeatures((document) => {
|
|
1421
|
+
for (const feature of document.features) {
|
|
1422
|
+
if (feature.id === previousFeatureId)
|
|
1423
|
+
feature.phaseIds = feature.phaseIds.filter((phaseId) => phaseId !== found.id);
|
|
1424
|
+
if (feature.id === nextFeatureId && !feature.phaseIds.includes(found.id))
|
|
1425
|
+
feature.phaseIds.push(found.id);
|
|
1426
|
+
}
|
|
1427
|
+
return document;
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
await st.writeGenerated();
|
|
827
1431
|
});
|
|
828
|
-
|
|
1432
|
+
const persisted = phase;
|
|
1433
|
+
const refreshedFeatures = (await st.loadFeatures()).features;
|
|
1434
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
1435
|
+
? await st.previewDescriptionReconciliation()
|
|
1436
|
+
: null;
|
|
1437
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1438
|
+
.filter((entry) => entry.state === "stale" && entry.ownerId === persisted.featureId)
|
|
1439
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
1440
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
1441
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
|
|
1442
|
+
: "";
|
|
1443
|
+
return text(`✅ Phase updated: ${formatPhaseRef(persisted.number, featureNumberOfPhase(persisted, refreshedFeatures))} — ${persisted.title}${persisted.shortId ? ` · ${persisted.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}`, { phase: persisted, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) });
|
|
829
1444
|
});
|
|
830
1445
|
server.registerTool("planner-phase-delete", {
|
|
831
1446
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -835,11 +1450,13 @@ server.registerTool("planner-phase-delete", {
|
|
|
835
1450
|
const phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
836
1451
|
if (!phase)
|
|
837
1452
|
return text(`Phase not found: ${ref}`);
|
|
838
|
-
await st.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1453
|
+
await st.runBatch(async () => {
|
|
1454
|
+
await st.deletePhase(phase.id);
|
|
1455
|
+
await st.updateFeatures((doc) => {
|
|
1456
|
+
for (const feature of doc.features)
|
|
1457
|
+
feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
|
|
1458
|
+
return doc;
|
|
1459
|
+
});
|
|
843
1460
|
});
|
|
844
1461
|
const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
|
|
845
1462
|
return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
|
|
@@ -855,70 +1472,73 @@ server.registerTool("planner-task-add", {
|
|
|
855
1472
|
},
|
|
856
1473
|
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
857
1474
|
const st = await requireStore();
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1475
|
+
return st.runBatch(async () => {
|
|
1476
|
+
const features = (await st.loadFeatures()).features;
|
|
1477
|
+
if (!featureRef?.trim())
|
|
1478
|
+
return text("feature is required: a task must belong to a feature.");
|
|
1479
|
+
const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
|
|
1480
|
+
if (!resolvedFeature.ok)
|
|
1481
|
+
return text(resolvedFeature.error);
|
|
1482
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
1483
|
+
if (!featureValidation.ok)
|
|
1484
|
+
return text(featureValidation.error);
|
|
1485
|
+
const feature = resolvedFeature.feature;
|
|
1486
|
+
if (!feature)
|
|
1487
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
|
|
1488
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
1489
|
+
if (!found)
|
|
1490
|
+
return text(`Phase not found: ${ref}`);
|
|
1491
|
+
if (found.featureId !== feature.id)
|
|
1492
|
+
return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
|
|
1493
|
+
const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
|
|
1494
|
+
if (!phaseValidation.ok)
|
|
1495
|
+
return text(phaseValidation.error);
|
|
1496
|
+
const existingPhase = await st.loadPhase(found.id);
|
|
1497
|
+
if (!existingPhase)
|
|
1498
|
+
return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
|
|
1499
|
+
const timestamp = nowISO();
|
|
1500
|
+
const taskId = createTaskId();
|
|
1501
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
1502
|
+
const priority = await st.nextPriority("task", found.id);
|
|
1503
|
+
const task = {
|
|
1504
|
+
id: taskId,
|
|
1505
|
+
phaseId: found.id,
|
|
1506
|
+
number: identity.number,
|
|
1507
|
+
shortId: identity.shortId,
|
|
1508
|
+
priority,
|
|
1509
|
+
shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
|
|
1510
|
+
title: title.trim(),
|
|
1511
|
+
status: "planned",
|
|
1512
|
+
description: description?.trim() ?? "",
|
|
1513
|
+
descriptionUpdatedAt: timestamp,
|
|
1514
|
+
notes: "",
|
|
1515
|
+
statusLog: [],
|
|
1516
|
+
sessionInfo: [],
|
|
1517
|
+
decisions: [],
|
|
1518
|
+
acceptedDecisions: [],
|
|
1519
|
+
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
|
|
1520
|
+
subtasks: [],
|
|
1521
|
+
dependsOn: [],
|
|
1522
|
+
pauseSnapshot: null,
|
|
1523
|
+
pauseHistory: [],
|
|
1524
|
+
startedAt: "",
|
|
1525
|
+
completedAt: "",
|
|
1526
|
+
activeOwnerSession: "",
|
|
1527
|
+
createdAt: timestamp,
|
|
1528
|
+
updatedAt: timestamp,
|
|
1529
|
+
};
|
|
1530
|
+
await st.updatePhase(found.id, (entry) => {
|
|
1531
|
+
entry.tasks.push(task);
|
|
1532
|
+
entry.taskIds.push(task.id);
|
|
1533
|
+
entry.updatedAt = timestamp;
|
|
1534
|
+
return entry;
|
|
1535
|
+
});
|
|
1536
|
+
const finalFeatures = (await st.loadFeatures()).features;
|
|
1537
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
916
1538
|
});
|
|
917
|
-
const finalFeatures = (await st.loadFeatures()).features;
|
|
918
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
919
1539
|
});
|
|
920
1540
|
server.registerTool("planner-task-show", {
|
|
921
|
-
description: "Show a task by id or name.",
|
|
1541
|
+
description: "Show a task by id or name. Pass full=true for every canonical Accepted Decision field plus execution and resume context.",
|
|
922
1542
|
inputSchema: { task: z.string().min(1).describe("Task ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."), full: z.boolean().optional().describe("If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only (saves tokens).") },
|
|
923
1543
|
}, async ({ task: ref, full }, extra) => {
|
|
924
1544
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -926,8 +1546,6 @@ server.registerTool("planner-task-show", {
|
|
|
926
1546
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
927
1547
|
if (!found)
|
|
928
1548
|
return text(`Task not found: ${ref}`);
|
|
929
|
-
if (full)
|
|
930
|
-
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
931
1549
|
const features = (await st.loadFeatures()).features;
|
|
932
1550
|
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))})`;
|
|
933
1551
|
if (!full)
|
|
@@ -944,6 +1562,8 @@ server.registerTool("planner-task-show", {
|
|
|
944
1562
|
const sections = [summary];
|
|
945
1563
|
if (found.task.description?.trim())
|
|
946
1564
|
sections.push(found.task.description.trim());
|
|
1565
|
+
if (found.task.descriptionRef)
|
|
1566
|
+
sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
|
|
947
1567
|
if (snapshot || pendingDeviation) {
|
|
948
1568
|
sections.push([
|
|
949
1569
|
pendingDeviation
|
|
@@ -957,20 +1577,50 @@ server.registerTool("planner-task-show", {
|
|
|
957
1577
|
}
|
|
958
1578
|
if (log)
|
|
959
1579
|
sections.push(`Status log:\n${log}`);
|
|
960
|
-
|
|
1580
|
+
sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
|
|
1581
|
+
const result = text(sections.join("\n\n"), {
|
|
1582
|
+
task: {
|
|
1583
|
+
ref: taskCompositeRef(found.task, found.phase, features),
|
|
1584
|
+
description: found.task.description,
|
|
1585
|
+
descriptionRef: found.task.descriptionRef || "",
|
|
1586
|
+
acceptedDecisions: found.task.acceptedDecisions,
|
|
1587
|
+
},
|
|
1588
|
+
});
|
|
1589
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
|
|
1590
|
+
return result;
|
|
961
1591
|
});
|
|
962
1592
|
server.registerTool("planner-task-discuss", {
|
|
963
1593
|
description: "Persist task execution notes/checklist.",
|
|
964
1594
|
inputSchema: {
|
|
965
1595
|
task: z.string().min(1),
|
|
966
1596
|
description: z.string().optional(),
|
|
1597
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
967
1598
|
checklist: z.array(z.string()).optional(),
|
|
968
1599
|
},
|
|
969
|
-
}, async ({ task: ref, description, checklist }) => {
|
|
1600
|
+
}, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
|
|
970
1601
|
const st = await requireStore();
|
|
971
1602
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
972
1603
|
if (!found)
|
|
973
1604
|
return text(`Task not found: ${ref}`);
|
|
1605
|
+
const mutableFields = ["description", "descriptionRef", "checklist"];
|
|
1606
|
+
const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
|
|
1607
|
+
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
1608
|
+
if (receivedFields.length === 0) {
|
|
1609
|
+
return mutationNoFieldsFailure({
|
|
1610
|
+
entity: "task",
|
|
1611
|
+
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1612
|
+
operation: "discuss",
|
|
1613
|
+
mutableFields,
|
|
1614
|
+
retryCommand: `planner-task-discuss ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
|
|
1615
|
+
readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
|
|
1616
|
+
descriptionFallback: {
|
|
1617
|
+
entityId: found.task.id,
|
|
1618
|
+
inputField: "description",
|
|
1619
|
+
storedField: "task description",
|
|
1620
|
+
descriptionRefField: "descriptionRef",
|
|
1621
|
+
},
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
974
1624
|
let updatedTask;
|
|
975
1625
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
976
1626
|
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
@@ -978,8 +1628,13 @@ server.registerTool("planner-task-discuss", {
|
|
|
978
1628
|
return phase;
|
|
979
1629
|
if (description !== undefined)
|
|
980
1630
|
task.description = description.trim();
|
|
981
|
-
if (
|
|
982
|
-
|
|
1631
|
+
if (rawDescriptionRef !== undefined) {
|
|
1632
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
1633
|
+
if (descriptionRef)
|
|
1634
|
+
task.descriptionRef = descriptionRef;
|
|
1635
|
+
else
|
|
1636
|
+
delete task.descriptionRef;
|
|
1637
|
+
}
|
|
983
1638
|
if (checklist !== undefined)
|
|
984
1639
|
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
985
1640
|
task.updatedAt = nowISO();
|
|
@@ -989,7 +1644,49 @@ server.registerTool("planner-task-discuss", {
|
|
|
989
1644
|
});
|
|
990
1645
|
const features = (await st.loadFeatures()).features;
|
|
991
1646
|
const t = updatedTask ?? found.task;
|
|
992
|
-
return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}
|
|
1647
|
+
return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { task: t, discussed: true, updatedFields: receivedFields });
|
|
1648
|
+
});
|
|
1649
|
+
server.registerTool("planner-task-dependency-add", {
|
|
1650
|
+
description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
|
|
1651
|
+
inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1) },
|
|
1652
|
+
}, async ({ task: ref, dependsOn }, extra) => {
|
|
1653
|
+
const st = await requireStore();
|
|
1654
|
+
const features = (await st.loadFeatures()).features;
|
|
1655
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
1656
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, dependsOn);
|
|
1657
|
+
if (!found || !dependency)
|
|
1658
|
+
return { ...text("Dependency target not found.", { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }), isError: true };
|
|
1659
|
+
try {
|
|
1660
|
+
const result = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1661
|
+
await st.writeGenerated();
|
|
1662
|
+
return text(`✅ Dependency added: ${taskCompositeRef(found.task, found.phase, features)} depends on ${taskCompositeRef(dependency.task, dependency.phase, features)}.`, { updated: true, task: result });
|
|
1663
|
+
}
|
|
1664
|
+
catch (error) {
|
|
1665
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
1666
|
+
return { ...text(error instanceof Error ? error.message : "Dependency update failed.", { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }), isError: true };
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
server.registerTool("planner-task-dependency-delete", {
|
|
1670
|
+
description: "Remove a task dependency atomically after explicit confirmation.",
|
|
1671
|
+
inputSchema: { task: z.string().min(1), dependsOn: z.string().min(1), confirmed: z.boolean() },
|
|
1672
|
+
}, async ({ task: ref, dependsOn, confirmed }) => {
|
|
1673
|
+
if (!confirmed)
|
|
1674
|
+
return { ...text("Explicit confirmation is required to remove a dependency.", { updated: false, errorCode: "CONFIRMATION_REQUIRED" }), isError: true };
|
|
1675
|
+
const st = await requireStore();
|
|
1676
|
+
const features = (await st.loadFeatures()).features;
|
|
1677
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
1678
|
+
const dependency = findTaskByRef(await st.loadAllPhases(), features, dependsOn);
|
|
1679
|
+
if (!found || !dependency)
|
|
1680
|
+
return { ...text("Dependency target not found.", { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }), isError: true };
|
|
1681
|
+
try {
|
|
1682
|
+
const result = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
|
|
1683
|
+
await st.writeGenerated();
|
|
1684
|
+
return text("✅ Dependency removed.", { updated: true, task: result });
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
1688
|
+
return { ...text(error instanceof Error ? error.message : "Dependency deletion failed.", { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }), isError: true };
|
|
1689
|
+
}
|
|
993
1690
|
});
|
|
994
1691
|
server.registerTool("planner-task-update", {
|
|
995
1692
|
description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
|
|
@@ -998,11 +1695,15 @@ server.registerTool("planner-task-update", {
|
|
|
998
1695
|
title: z.string().optional(),
|
|
999
1696
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1000
1697
|
description: z.string().optional(),
|
|
1698
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
1699
|
+
notes: z.string().optional().describe("Implementation notes."),
|
|
1700
|
+
decisions: z.array(z.string()).optional().describe("Replace the task decisions list."),
|
|
1001
1701
|
motivation: z.string().optional(),
|
|
1002
1702
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
1003
1703
|
checklist: z.array(z.string()).optional().describe("Replace the task checklist (implementation steps, plain strings). Agents should tick steps via planner-task-checklist-toggle, not write DONE in titles."),
|
|
1704
|
+
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."),
|
|
1004
1705
|
},
|
|
1005
|
-
}, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
|
|
1706
|
+
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
|
|
1006
1707
|
const st = await requireStore();
|
|
1007
1708
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1008
1709
|
if (!found)
|
|
@@ -1020,6 +1721,31 @@ server.registerTool("planner-task-update", {
|
|
|
1020
1721
|
`Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
|
|
1021
1722
|
}
|
|
1022
1723
|
}
|
|
1724
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "decisions", "motivation", "priority", "checklist", "subtasks"];
|
|
1725
|
+
const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks };
|
|
1726
|
+
const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
|
|
1727
|
+
if (receivedFields.length === 0) {
|
|
1728
|
+
return mutationNoFieldsFailure({
|
|
1729
|
+
entity: "task",
|
|
1730
|
+
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1731
|
+
operation: "update",
|
|
1732
|
+
mutableFields,
|
|
1733
|
+
retryCommand: `planner-task-update ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
|
|
1734
|
+
readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
|
|
1735
|
+
descriptionFallback: {
|
|
1736
|
+
entityId: found.task.id,
|
|
1737
|
+
inputField: "description",
|
|
1738
|
+
storedField: "task description",
|
|
1739
|
+
descriptionRefField: "descriptionRef",
|
|
1740
|
+
},
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (subtasks !== undefined) {
|
|
1744
|
+
const existingIds = new Set(found.task.subtasks.map((item) => item.id));
|
|
1745
|
+
if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
|
|
1746
|
+
return { ...text("Subtask IDs must belong to the target task.", { updated: false, errorCode: "SUBTASK_ID_INVALID" }), isError: true };
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1023
1749
|
let updatedTask;
|
|
1024
1750
|
const timestamp = nowISO();
|
|
1025
1751
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -1032,6 +1758,32 @@ server.registerTool("planner-task-update", {
|
|
|
1032
1758
|
task.priority = priority;
|
|
1033
1759
|
if (description !== undefined)
|
|
1034
1760
|
task.description = description.trim();
|
|
1761
|
+
if (rawDescriptionRef !== undefined) {
|
|
1762
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
1763
|
+
if (descriptionRef)
|
|
1764
|
+
task.descriptionRef = descriptionRef;
|
|
1765
|
+
else
|
|
1766
|
+
delete task.descriptionRef;
|
|
1767
|
+
}
|
|
1768
|
+
if (notes !== undefined)
|
|
1769
|
+
task.notes = notes.trim();
|
|
1770
|
+
if (decisions !== undefined)
|
|
1771
|
+
task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
|
|
1772
|
+
if (checklist !== undefined) {
|
|
1773
|
+
task.checklist = checklist.map((item, index) => ({
|
|
1774
|
+
id: createChecklistItemId(task.id, index + 1, item),
|
|
1775
|
+
number: index + 1,
|
|
1776
|
+
title: item.trim(),
|
|
1777
|
+
checked: false,
|
|
1778
|
+
}));
|
|
1779
|
+
}
|
|
1780
|
+
if (subtasks !== undefined) {
|
|
1781
|
+
const existingIds = new Set(task.subtasks.map((item) => item.id));
|
|
1782
|
+
if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
|
|
1783
|
+
throw new PlanStoreError("Subtask IDs must belong to the target task.", undefined, { errorCode: "SUBTASK_ID_INVALID" });
|
|
1784
|
+
}
|
|
1785
|
+
task.subtasks = subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: item.status ?? "planned", createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? timestamp, updatedAt: timestamp }));
|
|
1786
|
+
}
|
|
1035
1787
|
if (status !== undefined && status !== task.status) {
|
|
1036
1788
|
// Record status change in the incremental statusLog.
|
|
1037
1789
|
const entry = {
|
|
@@ -1068,7 +1820,20 @@ server.registerTool("planner-task-update", {
|
|
|
1068
1820
|
}
|
|
1069
1821
|
}
|
|
1070
1822
|
}
|
|
1071
|
-
|
|
1823
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
1824
|
+
? await st.previewDescriptionReconciliation()
|
|
1825
|
+
: null;
|
|
1826
|
+
const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
|
|
1827
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1828
|
+
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
1829
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
1830
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
1831
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
|
|
1832
|
+
: "";
|
|
1833
|
+
const details = resumeRequired
|
|
1834
|
+
? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
|
|
1835
|
+
: { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
|
|
1836
|
+
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}${resumeNotice}`, details);
|
|
1072
1837
|
});
|
|
1073
1838
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1074
1839
|
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.",
|
|
@@ -1190,18 +1955,55 @@ server.registerTool("planner-task-delete", {
|
|
|
1190
1955
|
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
1191
1956
|
});
|
|
1192
1957
|
server.registerTool("planner-task-recommend", {
|
|
1193
|
-
description: "Return the harness-agnostic
|
|
1958
|
+
description: "Return the harness-agnostic next-work chain: active task if present, otherwise the lowest-priority ready feature → phase → task, plus bounded typed claims. Only persisted resume-ready handoffs are actionable; Markdown prose and terminal archives are not authority. Does not start or block work.",
|
|
1194
1959
|
inputSchema: {},
|
|
1195
1960
|
}, async () => {
|
|
1196
1961
|
const st = await requireStore();
|
|
1197
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1962
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
1963
|
+
st.loadFeatures(),
|
|
1964
|
+
st.loadAllPhases(),
|
|
1965
|
+
st.loadProject(),
|
|
1966
|
+
st.loadResume(),
|
|
1967
|
+
st.listHandoffs(),
|
|
1968
|
+
st.listArchivedHandoffs(),
|
|
1969
|
+
]);
|
|
1970
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
1971
|
+
handoffs: handoffs.map((handoff) => ({
|
|
1972
|
+
compositeRef: handoff.compositeRef,
|
|
1973
|
+
firstLine: handoff.firstLine,
|
|
1974
|
+
resumeReady: handoff.resumeReady,
|
|
1975
|
+
})),
|
|
1976
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
1977
|
+
});
|
|
1978
|
+
const { selection } = result;
|
|
1979
|
+
if (!selection.candidate)
|
|
1980
|
+
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")}` : ""}`, {
|
|
1981
|
+
kind: selection.kind,
|
|
1982
|
+
reason: selection.reason,
|
|
1983
|
+
activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
|
|
1984
|
+
activeTask: result.activeTask,
|
|
1985
|
+
nextFeature: result.nextFeature,
|
|
1986
|
+
nextPhase: result.nextPhase,
|
|
1987
|
+
nextTask: result.nextTask,
|
|
1988
|
+
claims: result.claims,
|
|
1989
|
+
selection,
|
|
1990
|
+
});
|
|
1991
|
+
const { candidate } = selection;
|
|
1202
1992
|
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1203
|
-
|
|
1204
|
-
|
|
1993
|
+
const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
1994
|
+
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
1995
|
+
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}`, {
|
|
1996
|
+
kind: selection.kind,
|
|
1997
|
+
taskId: candidate.task.id,
|
|
1998
|
+
phaseId: candidate.phase.id,
|
|
1999
|
+
featureId: candidate.feature?.id,
|
|
2000
|
+
deviation: selection.deviation,
|
|
2001
|
+
activeTask: result.activeTask,
|
|
2002
|
+
nextFeature: result.nextFeature,
|
|
2003
|
+
nextPhase: result.nextPhase,
|
|
2004
|
+
nextTask: result.nextTask,
|
|
2005
|
+
claims: result.claims,
|
|
2006
|
+
selection,
|
|
1205
2007
|
});
|
|
1206
2008
|
});
|
|
1207
2009
|
server.registerTool("planner-task-deviation", {
|
|
@@ -1279,7 +2081,7 @@ server.registerTool("planner-task-pause", {
|
|
|
1279
2081
|
].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
|
|
1280
2082
|
});
|
|
1281
2083
|
server.registerTool("planner-task-switch", {
|
|
1282
|
-
description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
|
|
2084
|
+
description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
|
|
1283
2085
|
inputSchema: {
|
|
1284
2086
|
from_task: z.string().min(1),
|
|
1285
2087
|
to_task: z.string().min(1),
|
|
@@ -1305,6 +2107,9 @@ server.registerTool("planner-task-switch", {
|
|
|
1305
2107
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
1306
2108
|
return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
|
|
1307
2109
|
}
|
|
2110
|
+
if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
|
|
2111
|
+
return text(`Task switch denied: source task is owned by another session.`);
|
|
2112
|
+
}
|
|
1308
2113
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
1309
2114
|
const linkedRequirements = [
|
|
1310
2115
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -1323,16 +2128,23 @@ server.registerTool("planner-task-switch", {
|
|
|
1323
2128
|
requirementIds: linkedRequirementIds,
|
|
1324
2129
|
};
|
|
1325
2130
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
1326
|
-
const
|
|
2131
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2132
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
2133
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
2134
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
1327
2135
|
const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
|
|
1328
2136
|
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
1329
2137
|
const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
|
|
2138
|
+
if (!projectGuidelinesReady) {
|
|
2139
|
+
const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
|
|
2140
|
+
return text(`Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.`, { projectGuidelinesReadState, contextEligibility, nextActions });
|
|
2141
|
+
}
|
|
1330
2142
|
if (!contextEligibility.eligible) {
|
|
1331
|
-
const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
|
|
2143
|
+
const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
|
|
1332
2144
|
return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
|
|
1333
2145
|
}
|
|
1334
2146
|
if (!requirementsReady) {
|
|
1335
|
-
return text(
|
|
2147
|
+
return text(`Task switch denied: ${requirementEligibility.reason}`, { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`planner-requirement-list with phaseRef=${targetPhaseRef}`, `Retry planner-task-switch to ${targetTaskRef}`] });
|
|
1336
2148
|
}
|
|
1337
2149
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
1338
2150
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -1346,7 +2158,7 @@ server.registerTool("planner-task-switch", {
|
|
|
1346
2158
|
resumeLocation: resume_location, howToResume: how_to_resume,
|
|
1347
2159
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
|
|
1348
2160
|
};
|
|
1349
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
2161
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
1350
2162
|
const record = {
|
|
1351
2163
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
1352
2164
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
|
|
@@ -1357,74 +2169,77 @@ server.registerTool("planner-task-switch", {
|
|
|
1357
2169
|
const sourceWasActive = source.task.status === "in-progress";
|
|
1358
2170
|
let sourceCheckpointed = false;
|
|
1359
2171
|
let targetStarted = false;
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
1367
|
-
if (!task)
|
|
1368
|
-
throw new Error(`Checkpointed source task disappeared: ${from_task}`);
|
|
1369
|
-
task.pauseSnapshot = snapshot;
|
|
1370
|
-
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
1371
|
-
task.updatedAt = timestamp;
|
|
1372
|
-
return phase;
|
|
1373
|
-
});
|
|
1374
|
-
}
|
|
1375
|
-
sourceCheckpointed = true;
|
|
1376
|
-
if (target.task.pauseSnapshot) {
|
|
1377
|
-
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
1378
|
-
}
|
|
1379
|
-
else {
|
|
1380
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
1381
|
-
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
1382
|
-
if (!task)
|
|
1383
|
-
throw new Error(`Temporary task disappeared: ${to_task}`);
|
|
1384
|
-
const previousStatus = task.status;
|
|
1385
|
-
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
1386
|
-
task.statusLog = [...task.statusLog, {
|
|
1387
|
-
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
1388
|
-
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
1389
|
-
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
1390
|
-
}];
|
|
1391
|
-
task.updatedAt = timestamp;
|
|
1392
|
-
phase.updatedAt = timestamp;
|
|
1393
|
-
return phase;
|
|
1394
|
-
});
|
|
1395
|
-
}
|
|
1396
|
-
targetStarted = true;
|
|
1397
|
-
await st.addWorkDeviation(record);
|
|
1398
|
-
if (target.task.pauseSnapshot) {
|
|
1399
|
-
const resumedDeviation = project.workDeviations
|
|
1400
|
-
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
1401
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
1402
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1403
|
-
if (resumedDeviation)
|
|
1404
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
catch (error) {
|
|
1408
|
-
if (targetStarted) {
|
|
1409
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
1410
|
-
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
1411
|
-
return phase;
|
|
1412
|
-
}).catch(() => { });
|
|
1413
|
-
}
|
|
1414
|
-
if (sourceCheckpointed) {
|
|
1415
|
-
if (sourceWasActive)
|
|
1416
|
-
await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
|
|
1417
|
-
else
|
|
2172
|
+
await st.runBatch(async () => {
|
|
2173
|
+
try {
|
|
2174
|
+
if (sourceWasActive) {
|
|
2175
|
+
await st.pauseTask(source.phase.id, source.task.id, snapshot);
|
|
2176
|
+
}
|
|
2177
|
+
else {
|
|
1418
2178
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
1419
|
-
|
|
2179
|
+
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
2180
|
+
if (!task)
|
|
2181
|
+
throw new Error(`Checkpointed source task disappeared: ${from_task}`);
|
|
2182
|
+
task.pauseSnapshot = snapshot;
|
|
2183
|
+
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
2184
|
+
task.updatedAt = timestamp;
|
|
2185
|
+
return phase;
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
sourceCheckpointed = true;
|
|
2189
|
+
if (target.task.pauseSnapshot) {
|
|
2190
|
+
await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
|
|
2191
|
+
}
|
|
2192
|
+
else {
|
|
2193
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
2194
|
+
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
2195
|
+
if (!task)
|
|
2196
|
+
throw new Error(`Temporary task disappeared: ${to_task}`);
|
|
2197
|
+
const previousStatus = task.status;
|
|
2198
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
2199
|
+
task.activeOwnerSession = plannerSessionId;
|
|
2200
|
+
task.statusLog = [...task.statusLog, {
|
|
2201
|
+
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
2202
|
+
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
2203
|
+
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
2204
|
+
}];
|
|
2205
|
+
task.updatedAt = timestamp;
|
|
2206
|
+
phase.updatedAt = timestamp;
|
|
2207
|
+
return phase;
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
targetStarted = true;
|
|
2211
|
+
await st.addWorkDeviation(record);
|
|
2212
|
+
if (target.task.pauseSnapshot) {
|
|
2213
|
+
const resumedDeviation = project.workDeviations
|
|
2214
|
+
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
2215
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
2216
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
2217
|
+
if (resumedDeviation)
|
|
2218
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
catch (error) {
|
|
2222
|
+
if (targetStarted) {
|
|
2223
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
2224
|
+
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
1420
2225
|
return phase;
|
|
1421
2226
|
}).catch(() => { });
|
|
2227
|
+
}
|
|
2228
|
+
if (sourceCheckpointed) {
|
|
2229
|
+
if (sourceWasActive)
|
|
2230
|
+
await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
|
|
2231
|
+
else
|
|
2232
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
2233
|
+
phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
|
|
2234
|
+
return phase;
|
|
2235
|
+
}).catch(() => { });
|
|
2236
|
+
}
|
|
2237
|
+
throw error;
|
|
1422
2238
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
await st.syncTaskStatusRollup(target.phase.id);
|
|
2239
|
+
await st.syncTaskStatusRollup(source.phase.id);
|
|
2240
|
+
if (target.phase.id !== source.phase.id)
|
|
2241
|
+
await st.syncTaskStatusRollup(target.phase.id);
|
|
2242
|
+
});
|
|
1428
2243
|
invalidateReads(plannerSessionId);
|
|
1429
2244
|
return writeAndSummarize(st, [
|
|
1430
2245
|
`🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
|
|
@@ -1433,8 +2248,28 @@ server.registerTool("planner-task-switch", {
|
|
|
1433
2248
|
`Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
|
|
1434
2249
|
].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
|
|
1435
2250
|
});
|
|
2251
|
+
server.registerTool("planner-task-reopen", {
|
|
2252
|
+
description: "Atomically reopen a completed task. Requires confirmed=true; retains completion evidence and never pauses another task.",
|
|
2253
|
+
inputSchema: { task: z.string().min(1), confirmed: z.boolean().optional().default(false) },
|
|
2254
|
+
}, async ({ task: ref, confirmed }, extra) => {
|
|
2255
|
+
const st = await requireStore();
|
|
2256
|
+
const features = (await st.loadFeatures()).features;
|
|
2257
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
2258
|
+
if (!found)
|
|
2259
|
+
return { ...text(`Task not found: ${ref}`, { reopened: false, errorCode: "TASK_NOT_FOUND" }), isError: true };
|
|
2260
|
+
try {
|
|
2261
|
+
const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed, ownerSessionId: plannerSessionIdFor(extra) });
|
|
2262
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
2263
|
+
await st.writeGenerated();
|
|
2264
|
+
return text(`✅ Task reopened: ${taskCompositeRef(reopened, found.phase, features)} — ${reopened.title} (in-progress)`, { reopened: true, task: reopened });
|
|
2265
|
+
}
|
|
2266
|
+
catch (error) {
|
|
2267
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
2268
|
+
return { ...text(`Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}`, { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }), isError: true };
|
|
2269
|
+
}
|
|
2270
|
+
});
|
|
1436
2271
|
server.registerTool("planner-task-start", {
|
|
1437
|
-
description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and
|
|
2272
|
+
description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Every denied start is an MCP isError result with started=false; NEVER claim work started unless structuredContent.started is true. Success is returned only after persisted in-progress state is verified.",
|
|
1438
2273
|
inputSchema: { task: z.string().min(1) },
|
|
1439
2274
|
}, async ({ task: ref }, extra) => {
|
|
1440
2275
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -1445,144 +2280,153 @@ server.registerTool("planner-task-start", {
|
|
|
1445
2280
|
catch {
|
|
1446
2281
|
return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
|
|
1447
2282
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
2283
|
+
return st.runBatch(async () => {
|
|
2284
|
+
const features = (await st.loadFeatures()).features;
|
|
2285
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
2286
|
+
if (!found)
|
|
2287
|
+
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."]));
|
|
2288
|
+
const taskRef = taskCompositeRef(found.task, found.phase, features);
|
|
2289
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
2290
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
2291
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
2292
|
+
if (found.task.status === "done")
|
|
2293
|
+
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 }));
|
|
2294
|
+
const project = await st.loadProject();
|
|
2295
|
+
const linkedRequirements = [
|
|
2296
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
2297
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
2298
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2299
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2300
|
+
const contextInput = {
|
|
2301
|
+
sessionId: plannerSessionId,
|
|
2302
|
+
taskId: found.task.id,
|
|
2303
|
+
phaseId: found.phase.id,
|
|
2304
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
2305
|
+
task: found.task,
|
|
2306
|
+
phase: found.phase,
|
|
2307
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
2308
|
+
requirements: linkedRequirements,
|
|
2309
|
+
requirementIds: linkedRequirementIds,
|
|
2310
|
+
};
|
|
2311
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2312
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2313
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
2314
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
2315
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
2316
|
+
if (!projectGuidelinesReady)
|
|
2317
|
+
return taskStartError(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { projectGuidelinesReadState, contextEligibility });
|
|
2318
|
+
if (!contextEligibility.eligible)
|
|
2319
|
+
return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, mcpContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry planner-task-start ${taskRef}`), { taskId: found.task.id }), { contextEligibility });
|
|
2320
|
+
if (!requirementsReady) {
|
|
2321
|
+
return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", `Linked requirements were not read; the task remains unchanged. ${requirementEligibility.reason}`, [`planner-requirement-list with phaseRef=${phaseRef}`, `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }), { requirementEligibility });
|
|
2322
|
+
}
|
|
2323
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2324
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2325
|
+
}
|
|
2326
|
+
if (found.task.status === "in-progress") {
|
|
2327
|
+
const outcome = taskStartSucceeded(found.task.id, true);
|
|
2328
|
+
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
|
|
2329
|
+
}
|
|
2330
|
+
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
2331
|
+
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
2332
|
+
if (!eligibility.eligible)
|
|
2333
|
+
return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
|
|
2334
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
2335
|
+
if (selection.kind === "conflict") {
|
|
2336
|
+
return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
|
|
2337
|
+
}
|
|
2338
|
+
if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
|
|
2339
|
+
const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
|
|
2340
|
+
return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use planner-task-switch to snapshot and pause ${activeRef}.`, `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
|
|
2341
|
+
}
|
|
2342
|
+
let resumeProposal;
|
|
2343
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
|
|
2344
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
2345
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
2346
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
2347
|
+
// machine-readable for the host/agent.
|
|
2348
|
+
const resumeTask = selection.candidate;
|
|
2349
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
2350
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
2351
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
2352
|
+
?? null;
|
|
2353
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
2354
|
+
ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
|
|
2355
|
+
title: resumeTask.task.title,
|
|
2356
|
+
taskId: resumeTask.task.id,
|
|
2357
|
+
phaseId: resumeTask.phase.id,
|
|
2358
|
+
snapshot: resumeSnapshot
|
|
2359
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
2360
|
+
: null,
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
2364
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
2365
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
2366
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
2367
|
+
]);
|
|
2368
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
|
|
2369
|
+
// A pending handoff is context, not a lock: task start RETAINS it. It is
|
|
2370
|
+
// archived only when the phase completes (auto) or the user explicitly
|
|
2371
|
+
// clears it.
|
|
2372
|
+
const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
|
|
2373
|
+
? selection.kind === "resume"
|
|
2374
|
+
? `\n\n${(() => {
|
|
2375
|
+
if (resumeProposal)
|
|
2376
|
+
return resumeProposal.text;
|
|
2377
|
+
return `⚠️ Resume advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`;
|
|
2378
|
+
})()}`
|
|
2379
|
+
: `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
|
|
2380
|
+
: "";
|
|
2381
|
+
const timestamp = nowISO();
|
|
2382
|
+
let updatedTask;
|
|
2383
|
+
if (found.task.pauseSnapshot) {
|
|
2384
|
+
updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp, plannerSessionId);
|
|
2385
|
+
}
|
|
2386
|
+
else {
|
|
2387
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
2388
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
2389
|
+
if (!task)
|
|
2390
|
+
return phase;
|
|
2391
|
+
const previousStatus = task.status;
|
|
2392
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
2393
|
+
const entry = {
|
|
2394
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
|
|
2395
|
+
date: timestamp,
|
|
2396
|
+
fromStatus: previousStatus,
|
|
2397
|
+
toStatus: "in-progress",
|
|
2398
|
+
title: `${previousStatus} → in-progress`,
|
|
2399
|
+
description: "",
|
|
2400
|
+
};
|
|
2401
|
+
task.activeOwnerSession = plannerSessionId;
|
|
2402
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2403
|
+
task.updatedAt = timestamp;
|
|
2404
|
+
phase.updatedAt = timestamp;
|
|
2405
|
+
updatedTask = task;
|
|
1548
2406
|
return phase;
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
2410
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
2411
|
+
if (approvedDeviation)
|
|
2412
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
2413
|
+
const resumedDeviation = project.workDeviations
|
|
2414
|
+
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
2415
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
2416
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
2417
|
+
if (resumedDeviation)
|
|
2418
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
2419
|
+
await st.writeGenerated();
|
|
2420
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
|
|
2421
|
+
if (!updatedTask || persistedTask?.status !== "in-progress") {
|
|
2422
|
+
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 }));
|
|
2423
|
+
}
|
|
2424
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
2425
|
+
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
|
|
2426
|
+
...outcome,
|
|
2427
|
+
task: persistedTask,
|
|
2428
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
1564
2429
|
});
|
|
1565
|
-
}
|
|
1566
|
-
await st.syncTaskStatusRollup(found.phase.id);
|
|
1567
|
-
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
1568
|
-
if (approvedDeviation)
|
|
1569
|
-
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
1570
|
-
const resumedDeviation = project.workDeviations
|
|
1571
|
-
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
1572
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
1573
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1574
|
-
if (resumedDeviation)
|
|
1575
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
1576
|
-
await st.writeGenerated();
|
|
1577
|
-
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
|
|
1578
|
-
if (!updatedTask || persistedTask?.status !== "in-progress") {
|
|
1579
|
-
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 }));
|
|
1580
|
-
}
|
|
1581
|
-
const outcome = taskStartSucceeded(persistedTask.id);
|
|
1582
|
-
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
|
|
1583
|
-
...outcome,
|
|
1584
|
-
task: persistedTask,
|
|
1585
|
-
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
1586
2430
|
});
|
|
1587
2431
|
});
|
|
1588
2432
|
server.registerTool("planner-task-complete", {
|
|
@@ -1658,47 +2502,415 @@ server.registerTool("planner-task-complete", {
|
|
|
1658
2502
|
return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
|
|
1659
2503
|
});
|
|
1660
2504
|
server.registerTool("planner-handoff-list", {
|
|
1661
|
-
description: "List
|
|
1662
|
-
|
|
2505
|
+
description: "List active handoffs as compact summary-only pages. Full bodies are never embedded; call planner-handoff-show for one exact phase.",
|
|
2506
|
+
inputSchema: {
|
|
2507
|
+
page: z.number().int().min(1).default(1).describe("1-based page number."),
|
|
2508
|
+
pageSize: z.number().int().min(1).max(25).default(10).describe("Summaries per page; maximum 25."),
|
|
2509
|
+
},
|
|
2510
|
+
}, async ({ page, pageSize }) => {
|
|
1663
2511
|
const st = await requireStore();
|
|
1664
2512
|
const list = await st.listHandoffs();
|
|
1665
2513
|
if (list.length === 0)
|
|
1666
|
-
return text("No phase handoffs set.");
|
|
1667
|
-
const
|
|
1668
|
-
|
|
2514
|
+
return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
|
|
2515
|
+
const result = boundedPage(list, page, pageSize);
|
|
2516
|
+
const { items, ...pageInfo } = result;
|
|
2517
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
2518
|
+
return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
|
|
1669
2519
|
});
|
|
1670
2520
|
server.registerTool("planner-handoff-show", {
|
|
1671
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
2521
|
+
description: "Read the active entity-scoped handoff of a phase, or its latest terminal archive, together with a bounded priority-ordered phase work map. Reread the canonical phase and relevant sibling tasks before proposing work so capability ownership is not duplicated. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
1672
2522
|
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
1673
2523
|
}, async ({ phaseRef }) => {
|
|
1674
2524
|
const st = await requireStore();
|
|
1675
2525
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
1676
2526
|
if (!r.ok)
|
|
1677
2527
|
return text(`❌ ${r.error}`);
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
2528
|
+
const phase = await st.loadPhase(r.phase.id);
|
|
2529
|
+
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
2530
|
+
if (!bounded.content.trim()) {
|
|
2531
|
+
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
2532
|
+
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
2533
|
+
if (archived) {
|
|
2534
|
+
const archivedContent = boundedHandoffForTransport(archived.content);
|
|
2535
|
+
const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
|
|
2536
|
+
const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
|
|
2537
|
+
return text([
|
|
2538
|
+
`Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
|
|
2539
|
+
...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
|
|
2540
|
+
"",
|
|
2541
|
+
archivedContent.content,
|
|
2542
|
+
].join("\n"), {
|
|
2543
|
+
phaseRef: r.compositeRef,
|
|
2544
|
+
phaseId: phase.id,
|
|
2545
|
+
active: false,
|
|
2546
|
+
archived: true,
|
|
2547
|
+
archiveReason: archived.reason,
|
|
2548
|
+
archivedAt: archived.archivedAt,
|
|
2549
|
+
archiveFile: archived.file,
|
|
2550
|
+
...archivedContent,
|
|
2551
|
+
supportingDocuments,
|
|
2552
|
+
empty: false,
|
|
2553
|
+
resumeReady: false,
|
|
2554
|
+
handoffAudit: phase.handoffAudit,
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
|
|
2558
|
+
}
|
|
2559
|
+
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
2560
|
+
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
2561
|
+
const contentHash = handoffContentHash(phase.handoff);
|
|
2562
|
+
const persistenceVerified = Boolean(phase.handoffAudit
|
|
2563
|
+
&& phase.handoffAudit.contentHash === contentHash
|
|
2564
|
+
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
2565
|
+
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
2566
|
+
const status = resumeReady
|
|
2567
|
+
? "Resume-ready: persisted read-back and source reconciliation completed."
|
|
2568
|
+
: "NOT resume-ready: after reading this entire persisted body, call planner-handoff-verify with its contentHash and a fresh comparison against every required source. If any gap exists, rewrite instead of verifying.";
|
|
2569
|
+
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}`, {
|
|
2570
|
+
phaseRef: r.compositeRef,
|
|
2571
|
+
phaseId: r.phase.id,
|
|
2572
|
+
phaseWorkMap,
|
|
2573
|
+
...bounded,
|
|
2574
|
+
contentHash,
|
|
2575
|
+
persistenceVerified,
|
|
2576
|
+
resumeReady,
|
|
2577
|
+
verificationRequired: !resumeReady,
|
|
2578
|
+
handoffAudit: phase.handoffAudit,
|
|
2579
|
+
});
|
|
2580
|
+
});
|
|
2581
|
+
server.registerTool("planner-handoff-verify", {
|
|
2582
|
+
description: "Finalize one persisted handoff as resume-ready only after planner-handoff-show has displayed the entire body and the agent has compared it again with conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer output. Any discovered omission blocks verification and requires prepare+rewrite.",
|
|
2583
|
+
inputSchema: {
|
|
2584
|
+
phaseRef: z.string().min(1),
|
|
2585
|
+
expectedContentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
2586
|
+
sourceReviews: z.array(z.object({
|
|
2587
|
+
source: z.enum(["conversation", "planner-entities", "working-tree", "verification-runtime", "peer-agent-output"]),
|
|
2588
|
+
detail: z.string().min(12),
|
|
2589
|
+
})),
|
|
2590
|
+
omissionsFound: z.array(z.string().min(1)),
|
|
2591
|
+
},
|
|
2592
|
+
}, async ({ phaseRef, expectedContentHash, sourceReviews, omissionsFound }) => {
|
|
2593
|
+
const st = await requireStore();
|
|
2594
|
+
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2595
|
+
if (!r.ok)
|
|
2596
|
+
return text(`❌ ${r.error}`, { resumeReady: false });
|
|
2597
|
+
try {
|
|
2598
|
+
const result = await st.verifyPhaseHandoffReadBack(r.phase.id, { expectedContentHash, sourceReviews, omissionsFound });
|
|
2599
|
+
await st.writeGenerated();
|
|
2600
|
+
return text(`✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.`, {
|
|
2601
|
+
phaseRef: r.compositeRef,
|
|
2602
|
+
...result,
|
|
2603
|
+
resumeReady: true,
|
|
2604
|
+
verificationRequired: false,
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
catch (error) {
|
|
2608
|
+
if (error instanceof HandoffContractError)
|
|
2609
|
+
return handoffContractFailure(error);
|
|
2610
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2611
|
+
return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
|
|
2612
|
+
}
|
|
1682
2613
|
});
|
|
1683
2614
|
server.registerTool("planner-requirement-list", {
|
|
1684
|
-
description: "List
|
|
1685
|
-
inputSchema: {
|
|
1686
|
-
|
|
2615
|
+
description: "List declarative product Requirements: user, business, or system outcomes. Requirements never contain coding standards, best practices, formatting, verification process, or other Project Guidelines. Pass phaseRef to deliver and attest only the Requirements linked to that phase or its owning feature; an unscoped inventory does not satisfy task-start read gates.",
|
|
2616
|
+
inputSchema: {
|
|
2617
|
+
phaseRef: z.string().min(1).optional().describe("Optional target phase ref. When supplied, return full target-relevant requirements and record only those delivered requirements as read."),
|
|
2618
|
+
},
|
|
2619
|
+
}, async ({ phaseRef }, extra) => {
|
|
1687
2620
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1688
2621
|
const st = await requireStore();
|
|
1689
2622
|
const requirements = await st.loadRequirements();
|
|
1690
|
-
|
|
1691
|
-
|
|
2623
|
+
if (!phaseRef?.trim()) {
|
|
2624
|
+
return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements", {
|
|
2625
|
+
requirements,
|
|
2626
|
+
attestedRequirementIds: [],
|
|
2627
|
+
readScope: "inventory",
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
const features = (await st.loadFeatures()).features;
|
|
2631
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, phaseRef.trim());
|
|
2632
|
+
if (!phase)
|
|
2633
|
+
return { ...text(`Phase not found: ${phaseRef}`, { attestedRequirementIds: [] }), isError: true };
|
|
2634
|
+
const delivered = [
|
|
2635
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2636
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2637
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2638
|
+
delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
|
|
2639
|
+
const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
2640
|
+
const body = delivered.map((requirement) => [
|
|
2641
|
+
`- ${requirement.id} — ${requirement.title}`,
|
|
2642
|
+
` Description: ${requirement.description || "None"}`,
|
|
2643
|
+
` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
|
|
2644
|
+
` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
|
|
2645
|
+
].join("\n")).join("\n");
|
|
2646
|
+
return text(body || `No requirements linked to ${targetRef}`, {
|
|
2647
|
+
requirements: { ...requirements, requirements: delivered },
|
|
2648
|
+
attestedRequirementIds: delivered.map((requirement) => requirement.id),
|
|
2649
|
+
readScope: "target",
|
|
2650
|
+
target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
|
|
2651
|
+
});
|
|
2652
|
+
});
|
|
2653
|
+
server.registerTool("planner-requirement-create", {
|
|
2654
|
+
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
|
+
inputSchema: {
|
|
2656
|
+
title: z.string().min(1),
|
|
2657
|
+
description: z.string().optional(),
|
|
2658
|
+
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2659
|
+
macroTasks: z.array(z.object({
|
|
2660
|
+
title: z.string().min(1),
|
|
2661
|
+
description: z.string().optional(),
|
|
2662
|
+
status: z.enum(STATUS_VALUES).optional(),
|
|
2663
|
+
})).optional(),
|
|
2664
|
+
},
|
|
2665
|
+
}, async ({ title, description, linkedPhaseIds, macroTasks }) => {
|
|
2666
|
+
const st = await requireStore();
|
|
2667
|
+
const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2668
|
+
if (!links.ok)
|
|
2669
|
+
return text(`❌ ${links.error}`, { created: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2670
|
+
const now = nowISO();
|
|
2671
|
+
let normalizedMacroTasks;
|
|
2672
|
+
try {
|
|
2673
|
+
normalizedMacroTasks = reconcileRequirementMacroTasks([], (macroTasks ?? []).map((task) => ({
|
|
2674
|
+
title: task.title,
|
|
2675
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
2676
|
+
status: task.status ?? "planned",
|
|
2677
|
+
})), now);
|
|
2678
|
+
}
|
|
2679
|
+
catch (error) {
|
|
2680
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
2681
|
+
return text(`❌ ${message}`, { created: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2682
|
+
}
|
|
2683
|
+
const requirement = {
|
|
2684
|
+
id: createRequirementId(),
|
|
2685
|
+
title: title.trim(),
|
|
2686
|
+
description: description?.trim() ?? "",
|
|
2687
|
+
macroTasks: normalizedMacroTasks,
|
|
2688
|
+
linkedPhaseIds: links.linkedPhaseIds,
|
|
2689
|
+
createdAt: now,
|
|
2690
|
+
updatedAt: now,
|
|
2691
|
+
sessionInfo: [],
|
|
2692
|
+
};
|
|
2693
|
+
await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
|
|
2694
|
+
await st.writeGenerated();
|
|
2695
|
+
return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
|
|
2696
|
+
});
|
|
2697
|
+
server.registerTool("planner-requirement-update", {
|
|
2698
|
+
description: "Update a Requirement’s product outcome, linked phases, or complete ordered macro-task list. 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. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
|
|
2699
|
+
inputSchema: {
|
|
2700
|
+
requirementId: z.string().min(1),
|
|
2701
|
+
title: z.string().min(1).optional(),
|
|
2702
|
+
description: z.string().optional(),
|
|
2703
|
+
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2704
|
+
macroTasks: z.array(z.object({
|
|
2705
|
+
id: z.string().min(1).optional(),
|
|
2706
|
+
title: z.string().min(1),
|
|
2707
|
+
description: z.string().optional(),
|
|
2708
|
+
status: z.enum(STATUS_VALUES),
|
|
2709
|
+
})).optional(),
|
|
2710
|
+
},
|
|
2711
|
+
}, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
|
|
2712
|
+
const st = await requireStore();
|
|
2713
|
+
const requirements = await st.loadRequirements();
|
|
2714
|
+
const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
|
|
2715
|
+
if (!current)
|
|
2716
|
+
return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
|
|
2717
|
+
if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
|
|
2718
|
+
return mutationNoFieldsFailure({
|
|
2719
|
+
entity: "requirement",
|
|
2720
|
+
ref: requirementId,
|
|
2721
|
+
operation: "update",
|
|
2722
|
+
mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
|
|
2723
|
+
retryCommand: `planner-requirement-update ${requirementId}`,
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2727
|
+
if (!links.ok)
|
|
2728
|
+
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2729
|
+
const now = nowISO();
|
|
2730
|
+
let next;
|
|
2731
|
+
try {
|
|
2732
|
+
next = await st.updateRequirement(current.id, (canonical) => ({
|
|
2733
|
+
...canonical,
|
|
2734
|
+
...(title !== undefined ? { title: title.trim() } : {}),
|
|
2735
|
+
...(description !== undefined ? { description: description.trim() } : {}),
|
|
2736
|
+
...(linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
|
|
2737
|
+
...(macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, macroTasks.map((task) => ({
|
|
2738
|
+
...(task.id !== undefined ? { id: task.id } : {}),
|
|
2739
|
+
title: task.title,
|
|
2740
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
2741
|
+
status: task.status,
|
|
2742
|
+
})), now) } : {}),
|
|
2743
|
+
updatedAt: now,
|
|
2744
|
+
}));
|
|
2745
|
+
}
|
|
2746
|
+
catch (error) {
|
|
2747
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
2748
|
+
return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2749
|
+
}
|
|
2750
|
+
await st.writeGenerated();
|
|
2751
|
+
return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
|
|
2752
|
+
});
|
|
2753
|
+
server.registerTool("planner-requirement-delete", {
|
|
2754
|
+
description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
|
|
2755
|
+
inputSchema: { requirementId: z.string().min(1) },
|
|
2756
|
+
}, async ({ requirementId }) => {
|
|
2757
|
+
const st = await requireStore();
|
|
2758
|
+
let deleted = false;
|
|
2759
|
+
await st.updateRequirements((document) => {
|
|
2760
|
+
const requirements = document.requirements.filter((requirement) => requirement.id !== requirementId);
|
|
2761
|
+
deleted = requirements.length !== document.requirements.length;
|
|
2762
|
+
return deleted ? { requirements } : document;
|
|
2763
|
+
});
|
|
2764
|
+
if (!deleted)
|
|
2765
|
+
return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
|
|
2766
|
+
await st.writeGenerated();
|
|
2767
|
+
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
|
|
2768
|
+
});
|
|
2769
|
+
function ideaPromotionTarget(st, type, ref) {
|
|
2770
|
+
return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
|
|
2771
|
+
const features = featuresDoc.features;
|
|
2772
|
+
if (type === "feature") {
|
|
2773
|
+
const feature = findFeatureByRef(features, ref);
|
|
2774
|
+
return feature ? { id: feature.id, ref: formatFeatureRef(feature.number) } : undefined;
|
|
2775
|
+
}
|
|
2776
|
+
if (type === "phase") {
|
|
2777
|
+
const phase = findPhaseByRef(phases, features, ref);
|
|
2778
|
+
return phase ? { id: phase.id, ref: formatPhaseRef(phase.number, features.find((item) => item.id === phase.featureId)?.number) } : undefined;
|
|
2779
|
+
}
|
|
2780
|
+
const found = findTaskByRef(phases, features, ref);
|
|
2781
|
+
return found ? { id: found.task.id, ref: `${formatPhaseRef(found.phase.number, features.find((item) => item.id === found.phase.featureId)?.number)}/T${String(found.task.number).padStart(3, "0")}` } : undefined;
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
server.registerTool("planner-idea-list", {
|
|
2785
|
+
description: "List top-level Ideas Inbox entries. Ideas never affect feature, phase, or task rollups.",
|
|
2786
|
+
inputSchema: {},
|
|
2787
|
+
}, async () => {
|
|
2788
|
+
const ideas = (await requireStore().then((st) => st.loadIdeas())).ideas;
|
|
2789
|
+
return text(ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas", { ideas });
|
|
2790
|
+
});
|
|
2791
|
+
server.registerTool("planner-idea-show", {
|
|
2792
|
+
description: "Show one Idea Inbox entry by I-number, short ID, UUID, or title.",
|
|
2793
|
+
inputSchema: { idea: z.string().min(1) },
|
|
2794
|
+
}, async ({ idea: ref }) => {
|
|
2795
|
+
const st = await requireStore();
|
|
2796
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2797
|
+
return idea ? text(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}`, { idea }) : text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
|
|
2798
|
+
});
|
|
2799
|
+
server.registerTool("planner-idea-create", {
|
|
2800
|
+
description: "Create a rollup-independent Ideas Inbox entry.",
|
|
2801
|
+
inputSchema: { title: z.string().min(1), description: z.string().optional() },
|
|
2802
|
+
}, async ({ title, description }) => {
|
|
2803
|
+
const st = await requireStore();
|
|
2804
|
+
const idea = await st.createIdea({ title, ...(description !== undefined ? { description } : {}) });
|
|
2805
|
+
await st.writeGenerated();
|
|
2806
|
+
return text(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, { idea, created: true });
|
|
2807
|
+
});
|
|
2808
|
+
server.registerTool("planner-idea-update", {
|
|
2809
|
+
description: "Update an unpromoted or promoted Idea Inbox entry title or description.",
|
|
2810
|
+
inputSchema: { idea: z.string().min(1), title: z.string().min(1).optional(), description: z.string().optional() },
|
|
2811
|
+
}, async ({ idea: ref, title, description }) => {
|
|
2812
|
+
const st = await requireStore();
|
|
2813
|
+
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2814
|
+
if (!current)
|
|
2815
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2816
|
+
if (title === undefined && description === undefined) {
|
|
2817
|
+
return mutationNoFieldsFailure({
|
|
2818
|
+
entity: "idea",
|
|
2819
|
+
ref,
|
|
2820
|
+
operation: "update",
|
|
2821
|
+
mutableFields: ["title", "description"],
|
|
2822
|
+
retryCommand: `planner-idea-update ${ref}`,
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2826
|
+
await st.writeGenerated();
|
|
2827
|
+
return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
|
|
2828
|
+
});
|
|
2829
|
+
server.registerTool("planner-idea-delete", {
|
|
2830
|
+
description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
|
|
2831
|
+
inputSchema: { idea: z.string().min(1), confirmed: z.boolean() },
|
|
2832
|
+
}, async ({ idea: ref, confirmed }) => {
|
|
2833
|
+
if (!confirmed)
|
|
2834
|
+
return text("Idea deletion requires confirmed=true after explicit user confirmation.", { errorCode: "CONFIRMATION_REQUIRED", deleted: false });
|
|
2835
|
+
const st = await requireStore();
|
|
2836
|
+
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2837
|
+
if (!current)
|
|
2838
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", deleted: false });
|
|
2839
|
+
await st.deleteIdea(current.id);
|
|
2840
|
+
await st.writeGenerated();
|
|
2841
|
+
return text(`Idea deleted: ${formatIdeaRef(current.number)}.`, { deleted: true, ideaRef: formatIdeaRef(current.number) });
|
|
2842
|
+
});
|
|
2843
|
+
server.registerTool("planner-idea-promotion-begin", {
|
|
2844
|
+
description: "Begin an Idea promotion discussion without persisting a target or promotion. Returns the project-local grill-me instructions; follow them one question at a time, then use planner-idea-promotion-finalize only after target confirmation.",
|
|
2845
|
+
inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]) },
|
|
2846
|
+
}, async ({ idea: ref, targetType }) => {
|
|
2847
|
+
const st = await requireStore();
|
|
2848
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2849
|
+
if (!idea)
|
|
2850
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
|
|
2851
|
+
if (idea.promotion)
|
|
2852
|
+
return text(`${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.`, { errorCode: "ALREADY_PROMOTED", idea });
|
|
2853
|
+
const features = (await st.loadFeatures()).features;
|
|
2854
|
+
const phases = await st.loadAllPhases();
|
|
2855
|
+
const recommendedFeature = features[0];
|
|
2856
|
+
const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
|
|
2857
|
+
const recommendation = targetType === "feature"
|
|
2858
|
+
? "Create a new feature after the discussion."
|
|
2859
|
+
: targetType === "phase"
|
|
2860
|
+
? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
|
|
2861
|
+
: recommendedPhase
|
|
2862
|
+
? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.`
|
|
2863
|
+
: "Create and confirm a parent feature and phase before promoting to a task.";
|
|
2864
|
+
return text(`Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}`, { idea, targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target through the normal feature/phase/task creation tool.", "Call planner-idea-promotion-finalize with discussionCompleted=true and confirmed=true."] });
|
|
2865
|
+
});
|
|
2866
|
+
server.registerTool("planner-idea-promotion-finalize", {
|
|
2867
|
+
description: "Record a promotion only after grill-me discussion, explicit target confirmation, and successful target creation. targetRef must be the already-created agreed target; no raw UUID is returned.",
|
|
2868
|
+
inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]), targetRef: z.string().min(1), discussionCompleted: z.boolean(), confirmed: z.boolean() },
|
|
2869
|
+
}, async ({ idea: ref, targetType, targetRef, discussionCompleted, confirmed }) => {
|
|
2870
|
+
if (!discussionCompleted || !confirmed)
|
|
2871
|
+
return text("Promotion requires discussionCompleted=true and confirmed=true; no target or promotion was persisted.", { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false });
|
|
2872
|
+
const st = await requireStore();
|
|
2873
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2874
|
+
if (!idea)
|
|
2875
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", promoted: false });
|
|
2876
|
+
const target = await ideaPromotionTarget(st, targetType, targetRef);
|
|
2877
|
+
if (!target)
|
|
2878
|
+
return text(`Confirmed ${targetType} target not found: ${targetRef}; no promotion was persisted.`, { errorCode: "TARGET_NOT_FOUND", promoted: false });
|
|
2879
|
+
const promoted = await st.promoteIdea(idea.id, { targetType, targetId: target.id });
|
|
2880
|
+
await st.writeGenerated();
|
|
2881
|
+
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
1692
2882
|
});
|
|
1693
2883
|
server.registerTool("planner-handoff-write", {
|
|
1694
|
-
description: "
|
|
2884
|
+
description: "Persist a reconciled handoff candidate with durable context synchronization and mandatory audits. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify source reconciliation succeeds. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
|
|
1695
2885
|
inputSchema: {
|
|
1696
2886
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
1697
2887
|
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
|
|
2888
|
+
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."),
|
|
1698
2889
|
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
1699
2890
|
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
1700
2891
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
1701
2892
|
reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
|
|
2893
|
+
completenessAudit: z.object({
|
|
2894
|
+
version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
2895
|
+
entries: z.array(z.object({
|
|
2896
|
+
category: z.string().min(1).describe(`One required category id: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}`),
|
|
2897
|
+
status: z.enum(["captured", "not-applicable"]),
|
|
2898
|
+
detail: z.string().min(1),
|
|
2899
|
+
})),
|
|
2900
|
+
}).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
|
|
2901
|
+
coldStartInventory: z.object({
|
|
2902
|
+
version: z.literal(HANDOFF_COLD_START_INVENTORY_VERSION),
|
|
2903
|
+
sourceReviews: z.array(z.object({ source: z.string().min(1), detail: z.string().min(1) })),
|
|
2904
|
+
entries: z.array(z.object({
|
|
2905
|
+
category: z.string().min(1).describe(`One required cold-start category id: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}`),
|
|
2906
|
+
items: z.array(z.string().min(1)),
|
|
2907
|
+
notApplicableReason: z.string().min(1).optional().describe("Optional explanation only; every category still requires a concrete item, using an explicit verified-negative statement when nothing exists."),
|
|
2908
|
+
})),
|
|
2909
|
+
}).optional().describe("Mandatory for confirmed writes. Every concrete item must appear verbatim in the handoff or a validated supporting document."),
|
|
2910
|
+
supportingDocuments: z.array(z.object({
|
|
2911
|
+
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
2912
|
+
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
2913
|
+
})).optional(),
|
|
1702
2914
|
taskUpdates: z.array(z.object({
|
|
1703
2915
|
taskRef: z.string().min(1),
|
|
1704
2916
|
completionSummary: z.string().min(1),
|
|
@@ -1716,7 +2928,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
1716
2928
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
1717
2929
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
1718
2930
|
},
|
|
1719
|
-
}, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2931
|
+
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
1720
2932
|
const st = await requireStore();
|
|
1721
2933
|
let body = content.trim();
|
|
1722
2934
|
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
@@ -1740,7 +2952,9 @@ server.registerTool("planner-handoff-write", {
|
|
|
1740
2952
|
if (!confirmed)
|
|
1741
2953
|
return text(`Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run planner-handoff-prepare with the exact phaseRef.`, { phaseRef: r.compositeRef, confirmationRequired: true });
|
|
1742
2954
|
if (expectedHandoffUpdatedAt === undefined)
|
|
1743
|
-
return
|
|
2955
|
+
return handoffPreflightFailure("HANDOFF_PREFLIGHT_REQUIRED", "Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
|
|
2956
|
+
if (!reason?.trim())
|
|
2957
|
+
return handoffPreflightFailure("HANDOFF_REASON_REQUIRED", "Handoff reason is required before persistence. Created at and Updated at are generated by the planner; do not add them to Markdown.", { requiredInputs: ["title", "reason"], generatedMetadata: ["Created at", "Updated at"] });
|
|
1744
2958
|
const phases = await st.loadAllPhases();
|
|
1745
2959
|
const features = (await st.loadFeatures()).features;
|
|
1746
2960
|
const resolvedTaskUpdates = [];
|
|
@@ -1759,9 +2973,13 @@ server.registerTool("planner-handoff-write", {
|
|
|
1759
2973
|
}
|
|
1760
2974
|
try {
|
|
1761
2975
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
2976
|
+
reason: reason.trim(),
|
|
1762
2977
|
content: body,
|
|
1763
2978
|
expectedHandoffUpdatedAt,
|
|
1764
2979
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2980
|
+
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2981
|
+
...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
|
|
2982
|
+
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
1765
2983
|
contextSync: {
|
|
1766
2984
|
taskUpdates: resolvedTaskUpdates,
|
|
1767
2985
|
...(phaseUpdate ? { phaseUpdate: {
|
|
@@ -1775,15 +2993,31 @@ server.registerTool("planner-handoff-write", {
|
|
|
1775
2993
|
},
|
|
1776
2994
|
});
|
|
1777
2995
|
await st.writeGenerated();
|
|
1778
|
-
return text(
|
|
2996
|
+
return text(`⚠️ Handoff candidate persisted on ${r.compositeRef}, but it is NOT resume-ready yet. Required next action: call planner-handoff-show ${r.compositeRef}, read the entire persisted body, compare it again with every required source, then call planner-handoff-verify with the returned contentHash. If that review finds any gap, run prepare+write again instead of verifying.`, {
|
|
2997
|
+
phaseRef: r.compositeRef,
|
|
2998
|
+
phaseId: r.phase.id,
|
|
2999
|
+
updatedTaskIds: result.updatedTaskIds,
|
|
3000
|
+
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
3001
|
+
handoffAudit: result.handoffAudit,
|
|
3002
|
+
persisted: true,
|
|
3003
|
+
resumeReady: false,
|
|
3004
|
+
verificationRequired: true,
|
|
3005
|
+
nextActions: [
|
|
3006
|
+
`Call planner-handoff-show ${r.compositeRef} and read the entire persisted body.`,
|
|
3007
|
+
"Compare the persisted body against conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer-agent output.",
|
|
3008
|
+
"If any omission exists, run planner-handoff-prepare and planner-handoff-write again; otherwise call planner-handoff-verify with the shown contentHash.",
|
|
3009
|
+
],
|
|
3010
|
+
});
|
|
1779
3011
|
}
|
|
1780
3012
|
catch (error) {
|
|
3013
|
+
if (error instanceof HandoffContractError)
|
|
3014
|
+
return handoffContractFailure(error);
|
|
1781
3015
|
const message = error instanceof Error ? error.message : String(error);
|
|
1782
3016
|
return text(`❌ Handoff refresh denied: ${message}`, { error: message });
|
|
1783
3017
|
}
|
|
1784
3018
|
});
|
|
1785
3019
|
server.registerTool("planner-handoff-prepare", {
|
|
1786
|
-
description: "Identify or audit the exact handoff target.
|
|
3020
|
+
description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the canonical scaffold, a bounded priority-ordered phase work map with sibling goals/dependencies/capability ownership, the current token, missing task evidence, mandatory audits, and the 24,000-character inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
|
|
1787
3021
|
inputSchema: { phaseRef: z.string().min(1).optional() },
|
|
1788
3022
|
}, async ({ phaseRef }) => {
|
|
1789
3023
|
if (!phaseRef)
|
|
@@ -1802,9 +3036,24 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
1802
3036
|
return text([
|
|
1803
3037
|
`Handoff preparation audit for ${r.compositeRef}`,
|
|
1804
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
|
+
"",
|
|
1805
3045
|
"Done tasks missing durable completion/verification evidence:",
|
|
1806
3046
|
missing,
|
|
1807
3047
|
"",
|
|
3048
|
+
`Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
|
|
3049
|
+
...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3050
|
+
"",
|
|
3051
|
+
`Required cold-start source review v${audit.coldStartInventoryVersion} — inspect and substantively summarize every source before drafting:`,
|
|
3052
|
+
...audit.coldStartSourceReviews.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3053
|
+
"Required cold-start inventory — every category needs a concrete item (use an explicit verified-negative statement when nothing exists), and every item must appear verbatim in the handoff or a validated supporting document:",
|
|
3054
|
+
...audit.coldStartInventoryCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
3055
|
+
`Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
|
|
3056
|
+
"",
|
|
1808
3057
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
1809
3058
|
audit.handoff.trim() || "(none)",
|
|
1810
3059
|
].join("\n"), { phaseRef: r.compositeRef, ...audit });
|
|
@@ -1833,47 +3082,93 @@ server.registerTool("planner-web", {
|
|
|
1833
3082
|
}, async ({ action }) => {
|
|
1834
3083
|
if (action === "start") {
|
|
1835
3084
|
if (webHandle) {
|
|
1836
|
-
|
|
3085
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3086
|
+
return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
|
|
1837
3087
|
}
|
|
1838
3088
|
const root = planRoot();
|
|
1839
3089
|
const storeCheck = new PlanStore(root);
|
|
1840
3090
|
if (!(await storeCheck.exists())) {
|
|
1841
|
-
return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first
|
|
3091
|
+
return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "", errorCode: "PLANNER_NOT_INITIALIZED" });
|
|
1842
3092
|
}
|
|
1843
3093
|
try {
|
|
1844
3094
|
// port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
|
|
1845
3095
|
// host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
|
|
1846
3096
|
webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
|
|
1847
|
-
|
|
3097
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3098
|
+
return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
|
|
1848
3099
|
}
|
|
1849
3100
|
catch (err) {
|
|
1850
3101
|
webHandle = null;
|
|
1851
|
-
|
|
3102
|
+
const web = { localUrl: "", error: err.message };
|
|
3103
|
+
return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
|
|
1852
3104
|
}
|
|
1853
3105
|
}
|
|
1854
3106
|
if (action === "stop") {
|
|
1855
3107
|
if (!webHandle)
|
|
1856
|
-
return text("planner-web not running.");
|
|
3108
|
+
return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
1857
3109
|
const stoppedUrl = webHandle.localUrl;
|
|
1858
3110
|
await webHandle.close().catch(() => { });
|
|
1859
3111
|
webHandle = null;
|
|
1860
|
-
return text(`planner-web stopped (was ${stoppedUrl})
|
|
3112
|
+
return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
1861
3113
|
}
|
|
1862
3114
|
// status
|
|
1863
3115
|
if (webHandle) {
|
|
1864
|
-
|
|
3116
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3117
|
+
return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`, plannerWebMetadata(web));
|
|
1865
3118
|
}
|
|
1866
|
-
return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
|
|
3119
|
+
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: "" });
|
|
1867
3120
|
});
|
|
1868
3121
|
server.registerTool("planner-load", {
|
|
1869
|
-
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project
|
|
1870
|
-
}, async () => {
|
|
3122
|
+
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap plus bounded canonical Accepted Decision agentContext (project, feature, phase, and task scopes). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap verbatim in that reply, including its final prominent Web UI line. 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).",
|
|
3123
|
+
}, async (extra) => {
|
|
1871
3124
|
const st = store();
|
|
1872
3125
|
if (!(await st.exists()))
|
|
1873
3126
|
return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
|
|
3127
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
3128
|
+
let preparation;
|
|
3129
|
+
try {
|
|
3130
|
+
preparation = await st.preparePlannerSession();
|
|
3131
|
+
}
|
|
3132
|
+
catch (error) {
|
|
3133
|
+
return text(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", loaded: false });
|
|
3134
|
+
}
|
|
3135
|
+
const project = preparation.project;
|
|
3136
|
+
if (project.projectGuidelines.content.trim()) {
|
|
3137
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
3138
|
+
}
|
|
3139
|
+
const plannerSkill = await st.syncPlannerSkill();
|
|
3140
|
+
await st.syncGrillMeSkill();
|
|
1874
3141
|
const web = await ensureWebStarted();
|
|
1875
3142
|
const recap = await buildRecap(st, web, { harness: "mcp" });
|
|
1876
|
-
|
|
3143
|
+
const plan = await st.loadAll();
|
|
3144
|
+
const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
|
|
3145
|
+
{ scope: "project", decisions: plan.project.acceptedDecisions },
|
|
3146
|
+
...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
|
|
3147
|
+
...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
|
|
3148
|
+
...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
|
|
3149
|
+
scope: `task ${taskCompositeRef(task, phase, plan.features.features)}`,
|
|
3150
|
+
decisions: task.acceptedDecisions,
|
|
3151
|
+
}))),
|
|
3152
|
+
]);
|
|
3153
|
+
const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
|
|
3154
|
+
return {
|
|
3155
|
+
content: [{ type: "text", text: visibleRecap }],
|
|
3156
|
+
structuredContent: {
|
|
3157
|
+
loaded: true,
|
|
3158
|
+
recap: { text: visibleRecap },
|
|
3159
|
+
webUi: plannerWebMetadata(web),
|
|
3160
|
+
preparation: preparation.legacyProjectContext,
|
|
3161
|
+
agentContext: {
|
|
3162
|
+
kind: "planner-usage-skill",
|
|
3163
|
+
content: plannerSkill.content,
|
|
3164
|
+
status: plannerSkill.status,
|
|
3165
|
+
customized: plannerSkill.customized,
|
|
3166
|
+
message: plannerSkill.message,
|
|
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
|
+
};
|
|
1877
3172
|
});
|
|
1878
3173
|
server.registerTool("planner-disable", {
|
|
1879
3174
|
description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",
|