@agent-plan/mcp 0.2.26 → 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 +1142 -472
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ 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, findIdeaByRef, 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
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"];
|
|
@@ -28,6 +28,9 @@ function handoffContractFailure(error) {
|
|
|
28
28
|
structuredContent: { errorCode: error.code, ...error.details },
|
|
29
29
|
};
|
|
30
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
|
+
}
|
|
31
34
|
function boundedHandoffForTransport(content) {
|
|
32
35
|
if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
|
|
33
36
|
return { content, fullLength: content.length, truncated: false };
|
|
@@ -89,6 +92,15 @@ function mutationNoFieldsFailure(input) {
|
|
|
89
92
|
structuredContent: { ...outcome },
|
|
90
93
|
};
|
|
91
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
|
+
}
|
|
92
104
|
function legacyContextMigrationSummary(preview) {
|
|
93
105
|
return [
|
|
94
106
|
`Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
|
|
@@ -119,10 +131,12 @@ function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
|
|
|
119
131
|
return [`planner-task-show ${refs.task} with full=true`];
|
|
120
132
|
if (read.kind === "phase")
|
|
121
133
|
return [`planner-phase-show ${refs.phase} with full=true`];
|
|
122
|
-
|
|
134
|
+
if (read.kind === "feature")
|
|
135
|
+
return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
|
|
136
|
+
return [];
|
|
123
137
|
}));
|
|
124
138
|
if (requirementReadRequired)
|
|
125
|
-
actions.push(
|
|
139
|
+
actions.push(`planner-requirement-list with phaseRef=${refs.phase}`);
|
|
126
140
|
actions.push(retryAction);
|
|
127
141
|
return actions;
|
|
128
142
|
}
|
|
@@ -150,21 +164,44 @@ async function requireStore() {
|
|
|
150
164
|
// In-process web server handle, managed by the planner-web tool and planner-load.
|
|
151
165
|
// Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
|
|
152
166
|
let webHandle = null;
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
192
|
async function ensureWebStarted() {
|
|
156
193
|
if (webHandle)
|
|
157
|
-
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
|
|
194
|
+
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
158
195
|
const root = planRoot();
|
|
159
196
|
const st = new PlanStore(root);
|
|
160
197
|
if (!(await st.exists()))
|
|
161
|
-
return { localUrl: "" };
|
|
198
|
+
return { localUrl: "", error: "Planner workspace does not exist." };
|
|
162
199
|
try {
|
|
163
200
|
webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
|
|
164
|
-
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
|
|
201
|
+
return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
165
202
|
}
|
|
166
|
-
catch {
|
|
167
|
-
return { localUrl: "" };
|
|
203
|
+
catch (error) {
|
|
204
|
+
return { localUrl: "", error: error instanceof Error ? error.message : String(error) };
|
|
168
205
|
}
|
|
169
206
|
}
|
|
170
207
|
function findFeatureByRef(features, ref) {
|
|
@@ -219,6 +256,33 @@ function featureNumberOfPhase(phase, features) {
|
|
|
219
256
|
function taskCompositeRef(task, phase, features) {
|
|
220
257
|
return `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${String(task.number).padStart(3, "0")}`;
|
|
221
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
|
+
}
|
|
222
286
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
223
287
|
const previousStatus = task.status;
|
|
224
288
|
if (nextStatus === "in-progress" && !task.startedAt)
|
|
@@ -275,17 +339,22 @@ async function resolvePhaseForHandoff(st, ref) {
|
|
|
275
339
|
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
276
340
|
}
|
|
277
341
|
server.registerTool("planner-version", {
|
|
278
|
-
description: "Report
|
|
342
|
+
description: "Report loaded Agent Plan MCP/core runtime provenance plus plan schema and allocation compatibility. Available without an initialized .planner/ workspace.",
|
|
279
343
|
}, async () => {
|
|
344
|
+
const packages = runtimePackagesDiagnostic([MCP_PACKAGE, CORE_PACKAGE]);
|
|
345
|
+
const capabilities = runtimeCapabilities();
|
|
280
346
|
const versions = {
|
|
281
347
|
[MCP_PACKAGE.name]: MCP_PACKAGE.version,
|
|
282
348
|
[CORE_PACKAGE.name]: CORE_PACKAGE.version,
|
|
283
349
|
};
|
|
284
350
|
return text([
|
|
285
|
-
"Agent Plan runtime
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 });
|
|
289
358
|
});
|
|
290
359
|
server.registerTool("planner-export", {
|
|
291
360
|
description: "Export the project plan as a Markdown report. Supports a concise summary or full hierarchical detail.",
|
|
@@ -340,13 +409,26 @@ server.registerTool("planner-init", {
|
|
|
340
409
|
return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
|
|
341
410
|
});
|
|
342
411
|
server.registerTool("planner-show", {
|
|
343
|
-
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.",
|
|
344
413
|
}, async () => {
|
|
345
414
|
const st = await requireStore();
|
|
346
415
|
const features = (await st.loadFeatures()).features;
|
|
347
416
|
const phases = await st.loadAllPhases();
|
|
348
|
-
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";
|
|
349
430
|
const project = await st.loadProject();
|
|
431
|
+
const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: project.acceptedDecisions }]);
|
|
350
432
|
const manifest = await st.loadManifest();
|
|
351
433
|
const featureLines = features.map((feature) => {
|
|
352
434
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
@@ -358,7 +440,11 @@ server.registerTool("planner-show", {
|
|
|
358
440
|
`Description: ${project.description || "(not set)"}`,
|
|
359
441
|
...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
|
|
360
442
|
`Goal: ${project.goal || "(not set)"}`,
|
|
443
|
+
projectDecisionContext.content,
|
|
361
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")}`,
|
|
362
448
|
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
363
449
|
"",
|
|
364
450
|
"Features:",
|
|
@@ -366,8 +452,17 @@ server.registerTool("planner-show", {
|
|
|
366
452
|
].join("\n");
|
|
367
453
|
// Compact structured overview (no full plan: avoids 1M+ char token overflow).
|
|
368
454
|
const overview = {
|
|
369
|
-
project: {
|
|
370
|
-
|
|
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,
|
|
371
466
|
features: features.map((feature) => ({
|
|
372
467
|
ref: formatFeatureRef(feature.number),
|
|
373
468
|
shortId: feature.shortId || null,
|
|
@@ -379,6 +474,16 @@ server.registerTool("planner-show", {
|
|
|
379
474
|
};
|
|
380
475
|
return text(summary, { overview });
|
|
381
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
|
+
});
|
|
382
487
|
server.registerTool("planner-repair", {
|
|
383
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.",
|
|
384
489
|
}, async () => {
|
|
@@ -421,12 +526,11 @@ server.registerTool("planner-project-language", {
|
|
|
421
526
|
},
|
|
422
527
|
}, async ({ contentLanguage, chatLanguage }) => {
|
|
423
528
|
const st = await requireStore();
|
|
424
|
-
const project = await st.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
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
|
+
}));
|
|
430
534
|
return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
|
|
431
535
|
});
|
|
432
536
|
server.registerTool("planner-project-discuss", {
|
|
@@ -485,8 +589,28 @@ server.registerTool("planner-project-discuss", {
|
|
|
485
589
|
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
486
590
|
if (params.decisions !== undefined)
|
|
487
591
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
488
|
-
await st.
|
|
489
|
-
|
|
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 });
|
|
490
614
|
});
|
|
491
615
|
server.registerTool("planner-project-guidelines-show", {
|
|
492
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.",
|
|
@@ -511,12 +635,10 @@ server.registerTool("planner-project-guidelines-update", {
|
|
|
511
635
|
}, async ({ content }) => {
|
|
512
636
|
const st = await requireStore();
|
|
513
637
|
const project = await st.loadProject();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
};
|
|
519
|
-
await st.saveProject(project);
|
|
638
|
+
await st.updateProject((current) => ({
|
|
639
|
+
...current,
|
|
640
|
+
projectGuidelines: { ...current.projectGuidelines, content: content.trim() },
|
|
641
|
+
}), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
|
|
520
642
|
const refreshed = await st.loadProject();
|
|
521
643
|
return writeAndSummarize(st, refreshed.projectGuidelines.content
|
|
522
644
|
? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
|
|
@@ -634,6 +756,123 @@ server.registerTool("planner-task-list", {
|
|
|
634
756
|
const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
|
|
635
757
|
return text(out.join("\n") || "No tasks", { tasks: items });
|
|
636
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
|
+
}
|
|
875
|
+
});
|
|
637
876
|
server.registerTool("planner-feature-add", {
|
|
638
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.",
|
|
639
878
|
inputSchema: {
|
|
@@ -643,44 +882,46 @@ server.registerTool("planner-feature-add", {
|
|
|
643
882
|
},
|
|
644
883
|
}, async ({ name, description, status }) => {
|
|
645
884
|
const st = await requireStore();
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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}` : ""}`);
|
|
679
921
|
});
|
|
680
|
-
return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
|
|
681
922
|
});
|
|
682
923
|
server.registerTool("planner-feature-show", {
|
|
683
|
-
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.",
|
|
684
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).") },
|
|
685
926
|
}, async ({ feature: ref, full }, extra) => {
|
|
686
927
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -690,12 +931,22 @@ server.registerTool("planner-feature-show", {
|
|
|
690
931
|
if (!resolvedFeature.ok)
|
|
691
932
|
return text(resolvedFeature.error);
|
|
692
933
|
const feature = resolvedFeature.feature;
|
|
693
|
-
if (full)
|
|
694
|
-
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
695
934
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
696
935
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
697
936
|
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
698
|
-
|
|
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;
|
|
699
950
|
});
|
|
700
951
|
server.registerTool("planner-feature-discuss", {
|
|
701
952
|
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
@@ -848,20 +1099,20 @@ server.registerTool("planner-feature-delete", {
|
|
|
848
1099
|
return text(resolvedFeature.error);
|
|
849
1100
|
const feature = resolvedFeature.feature;
|
|
850
1101
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
851
|
-
await st.
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
+
}
|
|
863
1114
|
}
|
|
864
|
-
}
|
|
1115
|
+
});
|
|
865
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) });
|
|
866
1117
|
});
|
|
867
1118
|
server.registerTool("planner-phase-add", {
|
|
@@ -888,7 +1139,7 @@ server.registerTool("planner-phase-add", {
|
|
|
888
1139
|
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
|
|
889
1140
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
890
1141
|
let phase;
|
|
891
|
-
await withFeatureLock(lockKey, async () => {
|
|
1142
|
+
await st.runBatch(() => withFeatureLock(lockKey, async () => {
|
|
892
1143
|
const phases = await st.loadAllPhases();
|
|
893
1144
|
const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
|
|
894
1145
|
const timestamp = nowISO();
|
|
@@ -942,13 +1193,13 @@ server.registerTool("planner-phase-add", {
|
|
|
942
1193
|
});
|
|
943
1194
|
}
|
|
944
1195
|
await st.writeGenerated();
|
|
945
|
-
});
|
|
1196
|
+
}));
|
|
946
1197
|
if (!phase)
|
|
947
1198
|
return text("Phase creation failed.");
|
|
948
1199
|
return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
949
1200
|
});
|
|
950
1201
|
server.registerTool("planner-phase-show", {
|
|
951
|
-
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.",
|
|
952
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).") },
|
|
953
1204
|
}, async ({ phase: ref, full }, extra) => {
|
|
954
1205
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -957,16 +1208,27 @@ server.registerTool("planner-phase-show", {
|
|
|
957
1208
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
958
1209
|
if (!phase)
|
|
959
1210
|
return text(`Phase not found: ${ref}`);
|
|
960
|
-
if (full)
|
|
961
|
-
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
962
1211
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
963
1212
|
const reqCount = linkedRequirements.length;
|
|
964
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"}` : ""})`;
|
|
965
1214
|
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
966
1215
|
const requirementsBlock = reqCount > 0
|
|
967
|
-
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}
|
|
1216
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
|
|
968
1217
|
: "";
|
|
969
|
-
|
|
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}`, {
|
|
970
1232
|
phase: {
|
|
971
1233
|
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
972
1234
|
shortId: phase.shortId,
|
|
@@ -974,22 +1236,28 @@ server.registerTool("planner-phase-show", {
|
|
|
974
1236
|
summary: phase.summary,
|
|
975
1237
|
status: phase.status,
|
|
976
1238
|
taskCount: phase.tasks.length,
|
|
977
|
-
|
|
1239
|
+
description: phase.description,
|
|
1240
|
+
descriptionRef: phase.descriptionRef || "",
|
|
1241
|
+
acceptedDecisions: phase.acceptedDecisions,
|
|
978
1242
|
},
|
|
979
1243
|
linkedRequirements,
|
|
980
1244
|
});
|
|
1245
|
+
markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
|
|
1246
|
+
return result;
|
|
981
1247
|
});
|
|
982
1248
|
server.registerTool("planner-phase-discuss", {
|
|
983
|
-
description: "Persist phase discovery fields and mark
|
|
1249
|
+
description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
|
|
984
1250
|
inputSchema: {
|
|
985
1251
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
986
|
-
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."),
|
|
987
1254
|
summary: z.string().optional(),
|
|
988
1255
|
scope: z.string().optional(),
|
|
989
1256
|
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
990
1257
|
nonGoals: z.array(z.string()).optional(),
|
|
991
1258
|
dependencies: z.array(z.string()).optional(),
|
|
992
1259
|
risks: z.array(z.string()).optional(),
|
|
1260
|
+
openQuestions: z.array(z.string()).optional(),
|
|
993
1261
|
completionCriteria: z.array(z.string()).optional(),
|
|
994
1262
|
},
|
|
995
1263
|
}, async ({ phase: ref, ...updates }) => {
|
|
@@ -998,7 +1266,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
998
1266
|
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
999
1267
|
if (!found)
|
|
1000
1268
|
return text(`Phase not found: ${ref}`);
|
|
1001
|
-
const mutableFields = ["goal", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "completionCriteria"];
|
|
1269
|
+
const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
|
|
1002
1270
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1003
1271
|
if (receivedFields.length === 0) {
|
|
1004
1272
|
return mutationNoFieldsFailure({
|
|
@@ -1017,7 +1285,9 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1017
1285
|
});
|
|
1018
1286
|
}
|
|
1019
1287
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
1020
|
-
if (updates.
|
|
1288
|
+
if (updates.goals !== undefined)
|
|
1289
|
+
entry.goals = updates.goals.map((item) => item.trim()).filter(Boolean);
|
|
1290
|
+
else if (updates.goal !== undefined)
|
|
1021
1291
|
entry.goals = [updates.goal.trim()].filter(Boolean);
|
|
1022
1292
|
if (updates.summary !== undefined)
|
|
1023
1293
|
entry.summary = updates.summary.trim();
|
|
@@ -1036,19 +1306,20 @@ server.registerTool("planner-phase-discuss", {
|
|
|
1036
1306
|
entry.dependencies = updates.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
1037
1307
|
if (updates.risks !== undefined)
|
|
1038
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);
|
|
1039
1311
|
if (updates.completionCriteria !== undefined)
|
|
1040
1312
|
entry.completionCriteria = updates.completionCriteria.map((item) => item.trim()).filter(Boolean);
|
|
1041
|
-
entry.status = "planned";
|
|
1042
1313
|
entry.discussedAt = nowISO();
|
|
1043
1314
|
entry.contextReady = true;
|
|
1044
1315
|
entry.contextReadyReason = "Updated through planner-phase-discuss MCP tool.";
|
|
1045
1316
|
entry.updatedAt = nowISO();
|
|
1046
1317
|
return entry;
|
|
1047
1318
|
});
|
|
1048
|
-
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 });
|
|
1049
1320
|
});
|
|
1050
1321
|
server.registerTool("planner-phase-update", {
|
|
1051
|
-
description: "Update phase fields.",
|
|
1322
|
+
description: "Update phase fields, including parent relinking and planning metadata. Derived status remains read-only.",
|
|
1052
1323
|
inputSchema: {
|
|
1053
1324
|
phase: z.string().min(1),
|
|
1054
1325
|
title: z.string().optional(),
|
|
@@ -1056,7 +1327,15 @@ server.registerTool("planner-phase-update", {
|
|
|
1056
1327
|
summary: z.string().optional(),
|
|
1057
1328
|
description: z.string().optional(),
|
|
1058
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."),
|
|
1059
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(),
|
|
1060
1339
|
},
|
|
1061
1340
|
}, async ({ phase: ref, ...updates }) => {
|
|
1062
1341
|
const st = await requireStore();
|
|
@@ -1067,7 +1346,7 @@ server.registerTool("planner-phase-update", {
|
|
|
1067
1346
|
if (updates.status !== undefined) {
|
|
1068
1347
|
return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
|
|
1069
1348
|
}
|
|
1070
|
-
const mutableFields = ["title", "summary", "description", "descriptionRef", "priority"];
|
|
1349
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
|
|
1071
1350
|
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1072
1351
|
if (receivedFields.length === 0) {
|
|
1073
1352
|
return mutationNoFieldsFailure({
|
|
@@ -1085,26 +1364,83 @@ server.registerTool("planner-phase-update", {
|
|
|
1085
1364
|
},
|
|
1086
1365
|
});
|
|
1087
1366
|
}
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (updates.
|
|
1092
|
-
|
|
1093
|
-
if (updates.description !== undefined)
|
|
1094
|
-
entry.description = updates.description.trim();
|
|
1095
|
-
if (updates.descriptionRef !== undefined) {
|
|
1096
|
-
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
1097
|
-
if (descriptionRef)
|
|
1098
|
-
entry.descriptionRef = descriptionRef;
|
|
1099
|
-
else
|
|
1100
|
-
delete entry.descriptionRef;
|
|
1367
|
+
const previousFeatureId = found.featureId;
|
|
1368
|
+
let nextFeatureId = previousFeatureId;
|
|
1369
|
+
if (updates.featureId !== undefined) {
|
|
1370
|
+
if (updates.featureId.trim() === "") {
|
|
1371
|
+
nextFeatureId = undefined;
|
|
1101
1372
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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();
|
|
1106
1431
|
});
|
|
1107
|
-
|
|
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 } : {}) });
|
|
1108
1444
|
});
|
|
1109
1445
|
server.registerTool("planner-phase-delete", {
|
|
1110
1446
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -1114,11 +1450,13 @@ server.registerTool("planner-phase-delete", {
|
|
|
1114
1450
|
const phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1115
1451
|
if (!phase)
|
|
1116
1452
|
return text(`Phase not found: ${ref}`);
|
|
1117
|
-
await st.
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
+
});
|
|
1122
1460
|
});
|
|
1123
1461
|
const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
|
|
1124
1462
|
return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
|
|
@@ -1134,70 +1472,73 @@ server.registerTool("planner-task-add", {
|
|
|
1134
1472
|
},
|
|
1135
1473
|
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
1136
1474
|
const st = await requireStore();
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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}` : ""}`);
|
|
1195
1538
|
});
|
|
1196
|
-
const finalFeatures = (await st.loadFeatures()).features;
|
|
1197
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
1198
1539
|
});
|
|
1199
1540
|
server.registerTool("planner-task-show", {
|
|
1200
|
-
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.",
|
|
1201
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).") },
|
|
1202
1543
|
}, async ({ task: ref, full }, extra) => {
|
|
1203
1544
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
@@ -1205,8 +1546,6 @@ server.registerTool("planner-task-show", {
|
|
|
1205
1546
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1206
1547
|
if (!found)
|
|
1207
1548
|
return text(`Task not found: ${ref}`);
|
|
1208
|
-
if (full)
|
|
1209
|
-
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
1210
1549
|
const features = (await st.loadFeatures()).features;
|
|
1211
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))})`;
|
|
1212
1551
|
if (!full)
|
|
@@ -1238,7 +1577,17 @@ server.registerTool("planner-task-show", {
|
|
|
1238
1577
|
}
|
|
1239
1578
|
if (log)
|
|
1240
1579
|
sections.push(`Status log:\n${log}`);
|
|
1241
|
-
|
|
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;
|
|
1242
1591
|
});
|
|
1243
1592
|
server.registerTool("planner-task-discuss", {
|
|
1244
1593
|
description: "Persist task execution notes/checklist.",
|
|
@@ -1297,6 +1646,48 @@ server.registerTool("planner-task-discuss", {
|
|
|
1297
1646
|
const t = updatedTask ?? found.task;
|
|
1298
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 });
|
|
1299
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
|
+
}
|
|
1690
|
+
});
|
|
1300
1691
|
server.registerTool("planner-task-update", {
|
|
1301
1692
|
description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
|
|
1302
1693
|
inputSchema: {
|
|
@@ -1305,11 +1696,14 @@ server.registerTool("planner-task-update", {
|
|
|
1305
1696
|
status: z.enum(STATUS_VALUES).optional(),
|
|
1306
1697
|
description: z.string().optional(),
|
|
1307
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."),
|
|
1308
1701
|
motivation: z.string().optional(),
|
|
1309
1702
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
1310
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."),
|
|
1311
1705
|
},
|
|
1312
|
-
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist }) => {
|
|
1706
|
+
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, notes, decisions, motivation, priority, checklist, subtasks }) => {
|
|
1313
1707
|
const st = await requireStore();
|
|
1314
1708
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1315
1709
|
if (!found)
|
|
@@ -1327,8 +1721,8 @@ server.registerTool("planner-task-update", {
|
|
|
1327
1721
|
`Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
|
|
1328
1722
|
}
|
|
1329
1723
|
}
|
|
1330
|
-
const mutableFields = ["title", "status", "description", "descriptionRef", "motivation", "priority", "checklist"];
|
|
1331
|
-
const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist };
|
|
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 };
|
|
1332
1726
|
const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
|
|
1333
1727
|
if (receivedFields.length === 0) {
|
|
1334
1728
|
return mutationNoFieldsFailure({
|
|
@@ -1346,6 +1740,12 @@ server.registerTool("planner-task-update", {
|
|
|
1346
1740
|
},
|
|
1347
1741
|
});
|
|
1348
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
|
+
}
|
|
1349
1749
|
let updatedTask;
|
|
1350
1750
|
const timestamp = nowISO();
|
|
1351
1751
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -1365,6 +1765,25 @@ server.registerTool("planner-task-update", {
|
|
|
1365
1765
|
else
|
|
1366
1766
|
delete task.descriptionRef;
|
|
1367
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
|
+
}
|
|
1368
1787
|
if (status !== undefined && status !== task.status) {
|
|
1369
1788
|
// Record status change in the incremental statusLog.
|
|
1370
1789
|
const entry = {
|
|
@@ -1401,7 +1820,20 @@ server.registerTool("planner-task-update", {
|
|
|
1401
1820
|
}
|
|
1402
1821
|
}
|
|
1403
1822
|
}
|
|
1404
|
-
|
|
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);
|
|
1405
1837
|
});
|
|
1406
1838
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1407
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.",
|
|
@@ -1523,18 +1955,55 @@ server.registerTool("planner-task-delete", {
|
|
|
1523
1955
|
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
1524
1956
|
});
|
|
1525
1957
|
server.registerTool("planner-task-recommend", {
|
|
1526
|
-
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.",
|
|
1527
1959
|
inputSchema: {},
|
|
1528
1960
|
}, async () => {
|
|
1529
1961
|
const st = await requireStore();
|
|
1530
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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;
|
|
1535
1992
|
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1536
|
-
|
|
1537
|
-
|
|
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,
|
|
1538
2007
|
});
|
|
1539
2008
|
});
|
|
1540
2009
|
server.registerTool("planner-task-deviation", {
|
|
@@ -1638,6 +2107,9 @@ server.registerTool("planner-task-switch", {
|
|
|
1638
2107
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
1639
2108
|
return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
|
|
1640
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
|
+
}
|
|
1641
2113
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
1642
2114
|
const linkedRequirements = [
|
|
1643
2115
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -1656,7 +2128,8 @@ server.registerTool("planner-task-switch", {
|
|
|
1656
2128
|
requirementIds: linkedRequirementIds,
|
|
1657
2129
|
};
|
|
1658
2130
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
1659
|
-
const
|
|
2131
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2132
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
1660
2133
|
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1661
2134
|
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
1662
2135
|
const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
|
|
@@ -1671,7 +2144,7 @@ server.registerTool("planner-task-switch", {
|
|
|
1671
2144
|
return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
|
|
1672
2145
|
}
|
|
1673
2146
|
if (!requirementsReady) {
|
|
1674
|
-
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}`] });
|
|
1675
2148
|
}
|
|
1676
2149
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
1677
2150
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -1685,7 +2158,7 @@ server.registerTool("planner-task-switch", {
|
|
|
1685
2158
|
resumeLocation: resume_location, howToResume: how_to_resume,
|
|
1686
2159
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
|
|
1687
2160
|
};
|
|
1688
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
2161
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
1689
2162
|
const record = {
|
|
1690
2163
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
1691
2164
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
|
|
@@ -1696,74 +2169,77 @@ server.registerTool("planner-task-switch", {
|
|
|
1696
2169
|
const sourceWasActive = source.task.status === "in-progress";
|
|
1697
2170
|
let sourceCheckpointed = false;
|
|
1698
2171
|
let targetStarted = false;
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
1706
|
-
if (!task)
|
|
1707
|
-
throw new Error(`Checkpointed source task disappeared: ${from_task}`);
|
|
1708
|
-
task.pauseSnapshot = snapshot;
|
|
1709
|
-
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
1710
|
-
task.updatedAt = timestamp;
|
|
1711
|
-
return phase;
|
|
1712
|
-
});
|
|
1713
|
-
}
|
|
1714
|
-
sourceCheckpointed = true;
|
|
1715
|
-
if (target.task.pauseSnapshot) {
|
|
1716
|
-
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
1717
|
-
}
|
|
1718
|
-
else {
|
|
1719
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
1720
|
-
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
1721
|
-
if (!task)
|
|
1722
|
-
throw new Error(`Temporary task disappeared: ${to_task}`);
|
|
1723
|
-
const previousStatus = task.status;
|
|
1724
|
-
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
1725
|
-
task.statusLog = [...task.statusLog, {
|
|
1726
|
-
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
1727
|
-
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
1728
|
-
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
1729
|
-
}];
|
|
1730
|
-
task.updatedAt = timestamp;
|
|
1731
|
-
phase.updatedAt = timestamp;
|
|
1732
|
-
return phase;
|
|
1733
|
-
});
|
|
1734
|
-
}
|
|
1735
|
-
targetStarted = true;
|
|
1736
|
-
await st.addWorkDeviation(record);
|
|
1737
|
-
if (target.task.pauseSnapshot) {
|
|
1738
|
-
const resumedDeviation = project.workDeviations
|
|
1739
|
-
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
1740
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
1741
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1742
|
-
if (resumedDeviation)
|
|
1743
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
catch (error) {
|
|
1747
|
-
if (targetStarted) {
|
|
1748
|
-
await st.updatePhase(target.phase.id, (phase) => {
|
|
1749
|
-
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
1750
|
-
return phase;
|
|
1751
|
-
}).catch(() => { });
|
|
1752
|
-
}
|
|
1753
|
-
if (sourceCheckpointed) {
|
|
1754
|
-
if (sourceWasActive)
|
|
1755
|
-
await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
|
|
1756
|
-
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 {
|
|
1757
2178
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
1758
|
-
|
|
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);
|
|
1759
2225
|
return phase;
|
|
1760
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;
|
|
1761
2238
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
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
|
+
});
|
|
1767
2243
|
invalidateReads(plannerSessionId);
|
|
1768
2244
|
return writeAndSummarize(st, [
|
|
1769
2245
|
`🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
|
|
@@ -1772,6 +2248,26 @@ server.registerTool("planner-task-switch", {
|
|
|
1772
2248
|
`Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
|
|
1773
2249
|
].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
|
|
1774
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
|
+
});
|
|
1775
2271
|
server.registerTool("planner-task-start", {
|
|
1776
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.",
|
|
1777
2273
|
inputSchema: { task: z.string().min(1) },
|
|
@@ -1784,149 +2280,153 @@ server.registerTool("planner-task-start", {
|
|
|
1784
2280
|
catch {
|
|
1785
2281
|
return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
|
|
1786
2282
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
:
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
updatedTask
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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;
|
|
1892
2406
|
return phase;
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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 } : {}),
|
|
1908
2429
|
});
|
|
1909
|
-
}
|
|
1910
|
-
await st.syncTaskStatusRollup(found.phase.id);
|
|
1911
|
-
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
1912
|
-
if (approvedDeviation)
|
|
1913
|
-
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
1914
|
-
const resumedDeviation = project.workDeviations
|
|
1915
|
-
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
1916
|
-
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
1917
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1918
|
-
if (resumedDeviation)
|
|
1919
|
-
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
1920
|
-
await st.writeGenerated();
|
|
1921
|
-
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
|
|
1922
|
-
if (!updatedTask || persistedTask?.status !== "in-progress") {
|
|
1923
|
-
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 }));
|
|
1924
|
-
}
|
|
1925
|
-
const outcome = taskStartSucceeded(persistedTask.id);
|
|
1926
|
-
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
|
|
1927
|
-
...outcome,
|
|
1928
|
-
task: persistedTask,
|
|
1929
|
-
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
1930
2430
|
});
|
|
1931
2431
|
});
|
|
1932
2432
|
server.registerTool("planner-task-complete", {
|
|
@@ -2014,11 +2514,11 @@ server.registerTool("planner-handoff-list", {
|
|
|
2014
2514
|
return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
|
|
2015
2515
|
const result = boundedPage(list, page, pageSize);
|
|
2016
2516
|
const { items, ...pageInfo } = result;
|
|
2017
|
-
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
|
|
2517
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
2018
2518
|
return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
|
|
2019
2519
|
});
|
|
2020
2520
|
server.registerTool("planner-handoff-show", {
|
|
2021
|
-
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.",
|
|
2022
2522
|
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
2023
2523
|
}, async ({ phaseRef }) => {
|
|
2024
2524
|
const st = await requireStore();
|
|
@@ -2027,26 +2527,134 @@ server.registerTool("planner-handoff-show", {
|
|
|
2027
2527
|
return text(`❌ ${r.error}`);
|
|
2028
2528
|
const phase = await st.loadPhase(r.phase.id);
|
|
2029
2529
|
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
2030
|
-
if (!bounded.content.trim())
|
|
2031
|
-
|
|
2032
|
-
|
|
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
|
+
}
|
|
2033
2613
|
});
|
|
2034
2614
|
server.registerTool("planner-requirement-list", {
|
|
2035
|
-
description: "List
|
|
2036
|
-
inputSchema: {
|
|
2037
|
-
|
|
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) => {
|
|
2038
2620
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
2039
2621
|
const st = await requireStore();
|
|
2040
2622
|
const requirements = await st.loadRequirements();
|
|
2041
|
-
|
|
2042
|
-
|
|
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
|
+
});
|
|
2043
2652
|
});
|
|
2044
2653
|
server.registerTool("planner-requirement-create", {
|
|
2045
|
-
description: "Create a
|
|
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.",
|
|
2046
2655
|
inputSchema: {
|
|
2047
2656
|
title: z.string().min(1),
|
|
2048
2657
|
description: z.string().optional(),
|
|
2049
|
-
status: z.enum(STATUS_VALUES).optional(),
|
|
2050
2658
|
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2051
2659
|
macroTasks: z.array(z.object({
|
|
2052
2660
|
title: z.string().min(1),
|
|
@@ -2054,7 +2662,7 @@ server.registerTool("planner-requirement-create", {
|
|
|
2054
2662
|
status: z.enum(STATUS_VALUES).optional(),
|
|
2055
2663
|
})).optional(),
|
|
2056
2664
|
},
|
|
2057
|
-
}, async ({ title, description,
|
|
2665
|
+
}, async ({ title, description, linkedPhaseIds, macroTasks }) => {
|
|
2058
2666
|
const st = await requireStore();
|
|
2059
2667
|
const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2060
2668
|
if (!links.ok)
|
|
@@ -2076,25 +2684,22 @@ server.registerTool("planner-requirement-create", {
|
|
|
2076
2684
|
id: createRequirementId(),
|
|
2077
2685
|
title: title.trim(),
|
|
2078
2686
|
description: description?.trim() ?? "",
|
|
2079
|
-
status: status ?? "planned",
|
|
2080
2687
|
macroTasks: normalizedMacroTasks,
|
|
2081
2688
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
2082
2689
|
createdAt: now,
|
|
2083
2690
|
updatedAt: now,
|
|
2084
2691
|
sessionInfo: [],
|
|
2085
2692
|
};
|
|
2086
|
-
|
|
2087
|
-
await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
|
|
2693
|
+
await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
|
|
2088
2694
|
await st.writeGenerated();
|
|
2089
2695
|
return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
|
|
2090
2696
|
});
|
|
2091
2697
|
server.registerTool("planner-requirement-update", {
|
|
2092
|
-
description: "Update a
|
|
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.",
|
|
2093
2699
|
inputSchema: {
|
|
2094
2700
|
requirementId: z.string().min(1),
|
|
2095
2701
|
title: z.string().min(1).optional(),
|
|
2096
2702
|
description: z.string().optional(),
|
|
2097
|
-
status: z.enum(STATUS_VALUES).optional(),
|
|
2098
2703
|
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2099
2704
|
macroTasks: z.array(z.object({
|
|
2100
2705
|
id: z.string().min(1).optional(),
|
|
@@ -2103,41 +2708,45 @@ server.registerTool("planner-requirement-update", {
|
|
|
2103
2708
|
status: z.enum(STATUS_VALUES),
|
|
2104
2709
|
})).optional(),
|
|
2105
2710
|
},
|
|
2106
|
-
}, async ({ requirementId, title, description,
|
|
2711
|
+
}, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
|
|
2107
2712
|
const st = await requireStore();
|
|
2108
2713
|
const requirements = await st.loadRequirements();
|
|
2109
2714
|
const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
|
|
2110
2715
|
if (!current)
|
|
2111
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
|
+
}
|
|
2112
2726
|
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2113
2727
|
if (!links.ok)
|
|
2114
2728
|
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2115
2729
|
const now = nowISO();
|
|
2116
|
-
let
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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" });
|
|
2130
2749
|
}
|
|
2131
|
-
const next = {
|
|
2132
|
-
...current,
|
|
2133
|
-
...(title !== undefined ? { title: title.trim() } : {}),
|
|
2134
|
-
...(description !== undefined ? { description: description.trim() } : {}),
|
|
2135
|
-
...(status !== undefined ? { status } : {}),
|
|
2136
|
-
linkedPhaseIds: links.linkedPhaseIds,
|
|
2137
|
-
macroTasks: normalizedMacroTasks,
|
|
2138
|
-
updatedAt: now,
|
|
2139
|
-
};
|
|
2140
|
-
await st.saveRequirements({ requirements: requirements.requirements.map((requirement) => requirement.id === current.id ? next : requirement) });
|
|
2141
2750
|
await st.writeGenerated();
|
|
2142
2751
|
return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
|
|
2143
2752
|
});
|
|
@@ -2146,13 +2755,16 @@ server.registerTool("planner-requirement-delete", {
|
|
|
2146
2755
|
inputSchema: { requirementId: z.string().min(1) },
|
|
2147
2756
|
}, async ({ requirementId }) => {
|
|
2148
2757
|
const st = await requireStore();
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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)
|
|
2152
2765
|
return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
|
|
2153
|
-
await st.saveRequirements({ requirements: next });
|
|
2154
2766
|
await st.writeGenerated();
|
|
2155
|
-
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
|
|
2767
|
+
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
|
|
2156
2768
|
});
|
|
2157
2769
|
function ideaPromotionTarget(st, type, ref) {
|
|
2158
2770
|
return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
|
|
@@ -2201,8 +2813,15 @@ server.registerTool("planner-idea-update", {
|
|
|
2201
2813
|
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2202
2814
|
if (!current)
|
|
2203
2815
|
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2204
|
-
if (title === undefined && description === undefined)
|
|
2205
|
-
return
|
|
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
|
+
}
|
|
2206
2825
|
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2207
2826
|
await st.writeGenerated();
|
|
2208
2827
|
return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
|
|
@@ -2262,10 +2881,11 @@ server.registerTool("planner-idea-promotion-finalize", {
|
|
|
2262
2881
|
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
2263
2882
|
});
|
|
2264
2883
|
server.registerTool("planner-handoff-write", {
|
|
2265
|
-
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.",
|
|
2266
2885
|
inputSchema: {
|
|
2267
2886
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
2268
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."),
|
|
2269
2889
|
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
2270
2890
|
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
2271
2891
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
@@ -2278,6 +2898,15 @@ server.registerTool("planner-handoff-write", {
|
|
|
2278
2898
|
detail: z.string().min(1),
|
|
2279
2899
|
})),
|
|
2280
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."),
|
|
2281
2910
|
supportingDocuments: z.array(z.object({
|
|
2282
2911
|
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
2283
2912
|
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
@@ -2299,7 +2928,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
2299
2928
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
2300
2929
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
2301
2930
|
},
|
|
2302
|
-
}, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2931
|
+
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2303
2932
|
const st = await requireStore();
|
|
2304
2933
|
let body = content.trim();
|
|
2305
2934
|
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
@@ -2323,7 +2952,9 @@ server.registerTool("planner-handoff-write", {
|
|
|
2323
2952
|
if (!confirmed)
|
|
2324
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 });
|
|
2325
2954
|
if (expectedHandoffUpdatedAt === undefined)
|
|
2326
|
-
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"] });
|
|
2327
2958
|
const phases = await st.loadAllPhases();
|
|
2328
2959
|
const features = (await st.loadFeatures()).features;
|
|
2329
2960
|
const resolvedTaskUpdates = [];
|
|
@@ -2342,10 +2973,12 @@ server.registerTool("planner-handoff-write", {
|
|
|
2342
2973
|
}
|
|
2343
2974
|
try {
|
|
2344
2975
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
2976
|
+
reason: reason.trim(),
|
|
2345
2977
|
content: body,
|
|
2346
2978
|
expectedHandoffUpdatedAt,
|
|
2347
2979
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2348
2980
|
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2981
|
+
...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
|
|
2349
2982
|
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
2350
2983
|
contextSync: {
|
|
2351
2984
|
taskUpdates: resolvedTaskUpdates,
|
|
@@ -2360,12 +2993,20 @@ server.registerTool("planner-handoff-write", {
|
|
|
2360
2993
|
},
|
|
2361
2994
|
});
|
|
2362
2995
|
await st.writeGenerated();
|
|
2363
|
-
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.`, {
|
|
2364
2997
|
phaseRef: r.compositeRef,
|
|
2365
2998
|
phaseId: r.phase.id,
|
|
2366
2999
|
updatedTaskIds: result.updatedTaskIds,
|
|
2367
3000
|
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
2368
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
|
+
],
|
|
2369
3010
|
});
|
|
2370
3011
|
}
|
|
2371
3012
|
catch (error) {
|
|
@@ -2376,7 +3017,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
2376
3017
|
}
|
|
2377
3018
|
});
|
|
2378
3019
|
server.registerTool("planner-handoff-prepare", {
|
|
2379
|
-
description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the current token, missing task evidence, mandatory
|
|
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.",
|
|
2380
3021
|
inputSchema: { phaseRef: z.string().min(1).optional() },
|
|
2381
3022
|
}, async ({ phaseRef }) => {
|
|
2382
3023
|
if (!phaseRef)
|
|
@@ -2395,11 +3036,22 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
2395
3036
|
return text([
|
|
2396
3037
|
`Handoff preparation audit for ${r.compositeRef}`,
|
|
2397
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
|
+
"",
|
|
2398
3045
|
"Done tasks missing durable completion/verification evidence:",
|
|
2399
3046
|
missing,
|
|
2400
3047
|
"",
|
|
2401
3048
|
`Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
|
|
2402
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}`),
|
|
2403
3055
|
`Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
|
|
2404
3056
|
"",
|
|
2405
3057
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
@@ -2430,40 +3082,44 @@ server.registerTool("planner-web", {
|
|
|
2430
3082
|
}, async ({ action }) => {
|
|
2431
3083
|
if (action === "start") {
|
|
2432
3084
|
if (webHandle) {
|
|
2433
|
-
|
|
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));
|
|
2434
3087
|
}
|
|
2435
3088
|
const root = planRoot();
|
|
2436
3089
|
const storeCheck = new PlanStore(root);
|
|
2437
3090
|
if (!(await storeCheck.exists())) {
|
|
2438
|
-
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" });
|
|
2439
3092
|
}
|
|
2440
3093
|
try {
|
|
2441
3094
|
// port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
|
|
2442
3095
|
// host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
|
|
2443
3096
|
webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
|
|
2444
|
-
|
|
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));
|
|
2445
3099
|
}
|
|
2446
3100
|
catch (err) {
|
|
2447
3101
|
webHandle = null;
|
|
2448
|
-
|
|
3102
|
+
const web = { localUrl: "", error: err.message };
|
|
3103
|
+
return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
|
|
2449
3104
|
}
|
|
2450
3105
|
}
|
|
2451
3106
|
if (action === "stop") {
|
|
2452
3107
|
if (!webHandle)
|
|
2453
|
-
return text("planner-web not running.");
|
|
3108
|
+
return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
2454
3109
|
const stoppedUrl = webHandle.localUrl;
|
|
2455
3110
|
await webHandle.close().catch(() => { });
|
|
2456
3111
|
webHandle = null;
|
|
2457
|
-
return text(`planner-web stopped (was ${stoppedUrl})
|
|
3112
|
+
return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
2458
3113
|
}
|
|
2459
3114
|
// status
|
|
2460
3115
|
if (webHandle) {
|
|
2461
|
-
|
|
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));
|
|
2462
3118
|
}
|
|
2463
|
-
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: "" });
|
|
2464
3120
|
});
|
|
2465
3121
|
server.registerTool("planner-load", {
|
|
2466
|
-
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project
|
|
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).",
|
|
2467
3123
|
}, async (extra) => {
|
|
2468
3124
|
const st = store();
|
|
2469
3125
|
if (!(await st.exists()))
|
|
@@ -2484,10 +3140,23 @@ server.registerTool("planner-load", {
|
|
|
2484
3140
|
await st.syncGrillMeSkill();
|
|
2485
3141
|
const web = await ensureWebStarted();
|
|
2486
3142
|
const recap = await buildRecap(st, web, { harness: "mcp" });
|
|
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
|
+
]);
|
|
2487
3153
|
const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
|
|
2488
3154
|
return {
|
|
2489
3155
|
content: [{ type: "text", text: visibleRecap }],
|
|
2490
3156
|
structuredContent: {
|
|
3157
|
+
loaded: true,
|
|
3158
|
+
recap: { text: visibleRecap },
|
|
3159
|
+
webUi: plannerWebMetadata(web),
|
|
2491
3160
|
preparation: preparation.legacyProjectContext,
|
|
2492
3161
|
agentContext: {
|
|
2493
3162
|
kind: "planner-usage-skill",
|
|
@@ -2495,7 +3164,8 @@ server.registerTool("planner-load", {
|
|
|
2495
3164
|
status: plannerSkill.status,
|
|
2496
3165
|
customized: plannerSkill.customized,
|
|
2497
3166
|
message: plannerSkill.message,
|
|
2498
|
-
instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
|
|
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,
|
|
2499
3169
|
},
|
|
2500
3170
|
},
|
|
2501
3171
|
};
|