@agent-plan/mcp 0.2.26 → 0.2.28
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 +1189 -513
- 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,43 +1740,70 @@ 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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1751
|
+
try {
|
|
1752
|
+
await st.updateTask(found.phase.id, found.task.id, (task) => {
|
|
1753
|
+
if (title !== undefined)
|
|
1754
|
+
task.title = title.trim();
|
|
1755
|
+
if (priority !== undefined)
|
|
1756
|
+
task.priority = priority;
|
|
1757
|
+
if (description !== undefined)
|
|
1758
|
+
task.description = description.trim();
|
|
1759
|
+
if (rawDescriptionRef !== undefined) {
|
|
1760
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
1761
|
+
if (descriptionRef)
|
|
1762
|
+
task.descriptionRef = descriptionRef;
|
|
1763
|
+
else
|
|
1764
|
+
delete task.descriptionRef;
|
|
1765
|
+
}
|
|
1766
|
+
if (notes !== undefined)
|
|
1767
|
+
task.notes = notes.trim();
|
|
1768
|
+
if (decisions !== undefined)
|
|
1769
|
+
task.decisions = decisions.map((item) => item.trim()).filter(Boolean);
|
|
1770
|
+
if (checklist !== undefined) {
|
|
1771
|
+
task.checklist = checklist.map((item, index) => ({
|
|
1772
|
+
id: createChecklistItemId(task.id, index + 1, item),
|
|
1773
|
+
number: index + 1,
|
|
1774
|
+
title: item.trim(),
|
|
1775
|
+
checked: false,
|
|
1776
|
+
}));
|
|
1777
|
+
}
|
|
1778
|
+
if (subtasks !== undefined) {
|
|
1779
|
+
const existingIds = new Set(task.subtasks.map((item) => item.id));
|
|
1780
|
+
if (subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id))) {
|
|
1781
|
+
throw new PlanStoreError("Subtask IDs must belong to the target task.", undefined, { errorCode: "SUBTASK_ID_INVALID" });
|
|
1782
|
+
}
|
|
1783
|
+
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 }));
|
|
1784
|
+
}
|
|
1785
|
+
if (status !== undefined && status !== task.status) {
|
|
1786
|
+
// Record status change in the incremental statusLog.
|
|
1787
|
+
const entry = {
|
|
1788
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-${status}`),
|
|
1789
|
+
date: timestamp,
|
|
1790
|
+
fromStatus: task.status,
|
|
1791
|
+
toStatus: status,
|
|
1792
|
+
title: motivation?.split("\n")[0]?.trim() || `${task.status} → ${status}`,
|
|
1793
|
+
description: motivation?.trim() || "",
|
|
1794
|
+
};
|
|
1795
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
1796
|
+
applyTaskLifecycleDates(task, status, timestamp);
|
|
1797
|
+
}
|
|
1798
|
+
task.updatedAt = timestamp;
|
|
1799
|
+
updatedTask = task;
|
|
1800
|
+
return task;
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
catch (error) {
|
|
1804
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
1805
|
+
return { ...text(`Task update failed: ${error instanceof Error ? error.message : "unknown error"}`, { updated: false, errorCode: details?.errorCode ?? "TASK_UPDATE_FAILED" }), isError: true };
|
|
1806
|
+
}
|
|
1386
1807
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
1387
1808
|
const features = (await st.loadFeatures()).features;
|
|
1388
1809
|
const t = updatedTask ?? found.task;
|
|
@@ -1401,7 +1822,20 @@ server.registerTool("planner-task-update", {
|
|
|
1401
1822
|
}
|
|
1402
1823
|
}
|
|
1403
1824
|
}
|
|
1404
|
-
|
|
1825
|
+
const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
|
|
1826
|
+
? await st.previewDescriptionReconciliation()
|
|
1827
|
+
: null;
|
|
1828
|
+
const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
|
|
1829
|
+
const staleParentRefs = descriptionFreshness?.diagnostics
|
|
1830
|
+
.filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
|
|
1831
|
+
.map((entry) => entry.ownerRef) ?? [];
|
|
1832
|
+
const freshnessNotice = staleParentRefs.length > 0
|
|
1833
|
+
? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run planner-description-freshness for the non-mutating reconciliation preview.`
|
|
1834
|
+
: "";
|
|
1835
|
+
const details = resumeRequired
|
|
1836
|
+
? { task: t, updated: true, updatedFields: receivedFields, resumeRequired, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) }
|
|
1837
|
+
: { task: t, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) };
|
|
1838
|
+
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
1839
|
});
|
|
1406
1840
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1407
1841
|
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 +1957,55 @@ server.registerTool("planner-task-delete", {
|
|
|
1523
1957
|
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
1524
1958
|
});
|
|
1525
1959
|
server.registerTool("planner-task-recommend", {
|
|
1526
|
-
description: "Return the harness-agnostic
|
|
1960
|
+
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
1961
|
inputSchema: {},
|
|
1528
1962
|
}, async () => {
|
|
1529
1963
|
const st = await requireStore();
|
|
1530
|
-
const [features, phases, project, resume] = await Promise.all([
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1964
|
+
const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
|
|
1965
|
+
st.loadFeatures(),
|
|
1966
|
+
st.loadAllPhases(),
|
|
1967
|
+
st.loadProject(),
|
|
1968
|
+
st.loadResume(),
|
|
1969
|
+
st.listHandoffs(),
|
|
1970
|
+
st.listArchivedHandoffs(),
|
|
1971
|
+
]);
|
|
1972
|
+
const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
|
|
1973
|
+
handoffs: handoffs.map((handoff) => ({
|
|
1974
|
+
compositeRef: handoff.compositeRef,
|
|
1975
|
+
firstLine: handoff.firstLine,
|
|
1976
|
+
resumeReady: handoff.resumeReady,
|
|
1977
|
+
})),
|
|
1978
|
+
archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
|
|
1979
|
+
});
|
|
1980
|
+
const { selection } = result;
|
|
1981
|
+
if (!selection.candidate)
|
|
1982
|
+
return text(`No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}`, {
|
|
1983
|
+
kind: selection.kind,
|
|
1984
|
+
reason: selection.reason,
|
|
1985
|
+
activeTaskIds: selection.activeCandidates?.map((candidate) => candidate.task.id) ?? [],
|
|
1986
|
+
activeTask: result.activeTask,
|
|
1987
|
+
nextFeature: result.nextFeature,
|
|
1988
|
+
nextPhase: result.nextPhase,
|
|
1989
|
+
nextTask: result.nextTask,
|
|
1990
|
+
claims: result.claims,
|
|
1991
|
+
selection,
|
|
1992
|
+
});
|
|
1993
|
+
const { candidate } = selection;
|
|
1535
1994
|
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1536
|
-
|
|
1537
|
-
|
|
1995
|
+
const activeRef = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
|
|
1996
|
+
const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
|
|
1997
|
+
return text(`${activeRef}Next work (${selection.kind}): ${ref} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}`, {
|
|
1998
|
+
kind: selection.kind,
|
|
1999
|
+
taskId: candidate.task.id,
|
|
2000
|
+
phaseId: candidate.phase.id,
|
|
2001
|
+
featureId: candidate.feature?.id,
|
|
2002
|
+
deviation: selection.deviation,
|
|
2003
|
+
activeTask: result.activeTask,
|
|
2004
|
+
nextFeature: result.nextFeature,
|
|
2005
|
+
nextPhase: result.nextPhase,
|
|
2006
|
+
nextTask: result.nextTask,
|
|
2007
|
+
claims: result.claims,
|
|
2008
|
+
selection,
|
|
1538
2009
|
});
|
|
1539
2010
|
});
|
|
1540
2011
|
server.registerTool("planner-task-deviation", {
|
|
@@ -1638,6 +2109,9 @@ server.registerTool("planner-task-switch", {
|
|
|
1638
2109
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
1639
2110
|
return text(`Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.`);
|
|
1640
2111
|
}
|
|
2112
|
+
if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
|
|
2113
|
+
return text(`Task switch denied: source task is owned by another session.`);
|
|
2114
|
+
}
|
|
1641
2115
|
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
1642
2116
|
const linkedRequirements = [
|
|
1643
2117
|
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
@@ -1656,7 +2130,8 @@ server.registerTool("planner-task-switch", {
|
|
|
1656
2130
|
requirementIds: linkedRequirementIds,
|
|
1657
2131
|
};
|
|
1658
2132
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
1659
|
-
const
|
|
2133
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2134
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
1660
2135
|
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1661
2136
|
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
1662
2137
|
const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
|
|
@@ -1671,7 +2146,7 @@ server.registerTool("planner-task-switch", {
|
|
|
1671
2146
|
return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
|
|
1672
2147
|
}
|
|
1673
2148
|
if (!requirementsReady) {
|
|
1674
|
-
return text(
|
|
2149
|
+
return text(`Task switch denied: ${requirementEligibility.reason}`, { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`planner-requirement-list with phaseRef=${targetPhaseRef}`, `Retry planner-task-switch to ${targetTaskRef}`] });
|
|
1675
2150
|
}
|
|
1676
2151
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
1677
2152
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -1685,7 +2160,7 @@ server.registerTool("planner-task-switch", {
|
|
|
1685
2160
|
resumeLocation: resume_location, howToResume: how_to_resume,
|
|
1686
2161
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: switched_by?.trim() ?? "",
|
|
1687
2162
|
};
|
|
1688
|
-
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
2163
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
1689
2164
|
const record = {
|
|
1690
2165
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
1691
2166
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason, snapshot,
|
|
@@ -1696,74 +2171,77 @@ server.registerTool("planner-task-switch", {
|
|
|
1696
2171
|
const sourceWasActive = source.task.status === "in-progress";
|
|
1697
2172
|
let sourceCheckpointed = false;
|
|
1698
2173
|
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
|
|
2174
|
+
await st.runBatch(async () => {
|
|
2175
|
+
try {
|
|
2176
|
+
if (sourceWasActive) {
|
|
2177
|
+
await st.pauseTask(source.phase.id, source.task.id, snapshot);
|
|
2178
|
+
}
|
|
2179
|
+
else {
|
|
1757
2180
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
1758
|
-
|
|
2181
|
+
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
2182
|
+
if (!task)
|
|
2183
|
+
throw new Error(`Checkpointed source task disappeared: ${from_task}`);
|
|
2184
|
+
task.pauseSnapshot = snapshot;
|
|
2185
|
+
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
2186
|
+
task.updatedAt = timestamp;
|
|
2187
|
+
return phase;
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
sourceCheckpointed = true;
|
|
2191
|
+
if (target.task.pauseSnapshot) {
|
|
2192
|
+
await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
|
|
2193
|
+
}
|
|
2194
|
+
else {
|
|
2195
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
2196
|
+
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
2197
|
+
if (!task)
|
|
2198
|
+
throw new Error(`Temporary task disappeared: ${to_task}`);
|
|
2199
|
+
const previousStatus = task.status;
|
|
2200
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
2201
|
+
task.activeOwnerSession = plannerSessionId;
|
|
2202
|
+
task.statusLog = [...task.statusLog, {
|
|
2203
|
+
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
2204
|
+
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
2205
|
+
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
2206
|
+
}];
|
|
2207
|
+
task.updatedAt = timestamp;
|
|
2208
|
+
phase.updatedAt = timestamp;
|
|
2209
|
+
return phase;
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
targetStarted = true;
|
|
2213
|
+
await st.addWorkDeviation(record);
|
|
2214
|
+
if (target.task.pauseSnapshot) {
|
|
2215
|
+
const resumedDeviation = project.workDeviations
|
|
2216
|
+
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
2217
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
2218
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
2219
|
+
if (resumedDeviation)
|
|
2220
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
catch (error) {
|
|
2224
|
+
if (targetStarted) {
|
|
2225
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
2226
|
+
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
1759
2227
|
return phase;
|
|
1760
2228
|
}).catch(() => { });
|
|
2229
|
+
}
|
|
2230
|
+
if (sourceCheckpointed) {
|
|
2231
|
+
if (sourceWasActive)
|
|
2232
|
+
await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
|
|
2233
|
+
else
|
|
2234
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
2235
|
+
phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
|
|
2236
|
+
return phase;
|
|
2237
|
+
}).catch(() => { });
|
|
2238
|
+
}
|
|
2239
|
+
throw error;
|
|
1761
2240
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
await st.syncTaskStatusRollup(target.phase.id);
|
|
2241
|
+
await st.syncTaskStatusRollup(source.phase.id);
|
|
2242
|
+
if (target.phase.id !== source.phase.id)
|
|
2243
|
+
await st.syncTaskStatusRollup(target.phase.id);
|
|
2244
|
+
});
|
|
1767
2245
|
invalidateReads(plannerSessionId);
|
|
1768
2246
|
return writeAndSummarize(st, [
|
|
1769
2247
|
`🔀 Task switched: ${taskCompositeRef(source.task, source.phase, features)} → ${taskCompositeRef(target.task, target.phase, features)}`,
|
|
@@ -1772,6 +2250,26 @@ server.registerTool("planner-task-switch", {
|
|
|
1772
2250
|
`Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.`,
|
|
1773
2251
|
].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
|
|
1774
2252
|
});
|
|
2253
|
+
server.registerTool("planner-task-reopen", {
|
|
2254
|
+
description: "Atomically reopen a completed task. Requires confirmed=true; retains completion evidence and never pauses another task.",
|
|
2255
|
+
inputSchema: { task: z.string().min(1), confirmed: z.boolean().optional().default(false) },
|
|
2256
|
+
}, async ({ task: ref, confirmed }, extra) => {
|
|
2257
|
+
const st = await requireStore();
|
|
2258
|
+
const features = (await st.loadFeatures()).features;
|
|
2259
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
2260
|
+
if (!found)
|
|
2261
|
+
return { ...text(`Task not found: ${ref}`, { reopened: false, errorCode: "TASK_NOT_FOUND" }), isError: true };
|
|
2262
|
+
try {
|
|
2263
|
+
const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed, ownerSessionId: plannerSessionIdFor(extra) });
|
|
2264
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
2265
|
+
await st.writeGenerated();
|
|
2266
|
+
return text(`✅ Task reopened: ${taskCompositeRef(reopened, found.phase, features)} — ${reopened.title} (in-progress)`, { reopened: true, task: reopened });
|
|
2267
|
+
}
|
|
2268
|
+
catch (error) {
|
|
2269
|
+
const details = error instanceof PlanStoreError ? error.details : undefined;
|
|
2270
|
+
return { ...text(`Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}`, { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }), isError: true };
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
1775
2273
|
server.registerTool("planner-task-start", {
|
|
1776
2274
|
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
2275
|
inputSchema: { task: z.string().min(1) },
|
|
@@ -1784,149 +2282,153 @@ server.registerTool("planner-task-start", {
|
|
|
1784
2282
|
catch {
|
|
1785
2283
|
return taskStartError(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry planner-task-start."]));
|
|
1786
2284
|
}
|
|
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
|
-
|
|
2285
|
+
return st.runBatch(async () => {
|
|
2286
|
+
const features = (await st.loadFeatures()).features;
|
|
2287
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
2288
|
+
if (!found)
|
|
2289
|
+
return taskStartError(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${ref}`, ["Resolve the task with planner-task-list or planner-task-show, then retry planner-task-start."]));
|
|
2290
|
+
const taskRef = taskCompositeRef(found.task, found.phase, features);
|
|
2291
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
2292
|
+
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
2293
|
+
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
2294
|
+
if (found.task.status === "done")
|
|
2295
|
+
return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-reopen with task=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry planner-task-start ${taskRef} only if reopening is not the intended operation.`], { taskId: found.task.id }));
|
|
2296
|
+
const project = await st.loadProject();
|
|
2297
|
+
const linkedRequirements = [
|
|
2298
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
2299
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
2300
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2301
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2302
|
+
const contextInput = {
|
|
2303
|
+
sessionId: plannerSessionId,
|
|
2304
|
+
taskId: found.task.id,
|
|
2305
|
+
phaseId: found.phase.id,
|
|
2306
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
2307
|
+
task: found.task,
|
|
2308
|
+
phase: found.phase,
|
|
2309
|
+
...(parentFeature ? { feature: parentFeature } : {}),
|
|
2310
|
+
requirements: linkedRequirements,
|
|
2311
|
+
requirementIds: linkedRequirementIds,
|
|
2312
|
+
};
|
|
2313
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2314
|
+
const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2315
|
+
const requirementsReady = requirementEligibility.eligible;
|
|
2316
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
2317
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
2318
|
+
if (!projectGuidelinesReady)
|
|
2319
|
+
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 });
|
|
2320
|
+
if (!contextEligibility.eligible)
|
|
2321
|
+
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 });
|
|
2322
|
+
if (!requirementsReady) {
|
|
2323
|
+
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 });
|
|
2324
|
+
}
|
|
2325
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2326
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2327
|
+
}
|
|
2328
|
+
if (found.task.status === "in-progress") {
|
|
2329
|
+
const outcome = taskStartSucceeded(found.task.id, true);
|
|
2330
|
+
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
|
|
2331
|
+
}
|
|
2332
|
+
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
2333
|
+
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
2334
|
+
if (!eligibility.eligible)
|
|
2335
|
+
return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
|
|
2336
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
|
|
2337
|
+
if (selection.kind === "conflict") {
|
|
2338
|
+
return taskStartError(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry planner-task-start ${taskRef}.`], { taskId: found.task.id }), { selection });
|
|
2339
|
+
}
|
|
2340
|
+
if (selection.kind === "active" && selection.candidate?.task.id !== found.task.id) {
|
|
2341
|
+
const activeRef = taskCompositeRef(selection.candidate.task, selection.candidate.phase, features);
|
|
2342
|
+
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 });
|
|
2343
|
+
}
|
|
2344
|
+
let resumeProposal;
|
|
2345
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== found.task.id) {
|
|
2346
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
2347
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
2348
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
2349
|
+
// machine-readable for the host/agent.
|
|
2350
|
+
const resumeTask = selection.candidate;
|
|
2351
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
2352
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
2353
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
2354
|
+
?? null;
|
|
2355
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
2356
|
+
ref: taskCompositeRef(resumeTask.task, resumeTask.phase, features),
|
|
2357
|
+
title: resumeTask.task.title,
|
|
2358
|
+
taskId: resumeTask.task.id,
|
|
2359
|
+
phaseId: resumeTask.phase.id,
|
|
2360
|
+
snapshot: resumeSnapshot
|
|
2361
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
2362
|
+
: null,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
2366
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
2367
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
2368
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
2369
|
+
]);
|
|
2370
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
|
|
2371
|
+
// A pending handoff is context, not a lock: task start RETAINS it. It is
|
|
2372
|
+
// archived only when the phase completes (auto) or the user explicitly
|
|
2373
|
+
// clears it.
|
|
2374
|
+
const advisory = selection.candidate && selection.candidate.task.id !== found.task.id
|
|
2375
|
+
? selection.kind === "resume"
|
|
2376
|
+
? `\n\n${(() => {
|
|
2377
|
+
if (resumeProposal)
|
|
2378
|
+
return resumeProposal.text;
|
|
2379
|
+
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.`;
|
|
2380
|
+
})()}`
|
|
2381
|
+
: `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
|
|
2382
|
+
: "";
|
|
2383
|
+
const timestamp = nowISO();
|
|
2384
|
+
let updatedTask;
|
|
2385
|
+
if (found.task.pauseSnapshot) {
|
|
2386
|
+
updatedTask = await st.resumeTask(found.phase.id, found.task.id, timestamp, plannerSessionId);
|
|
2387
|
+
}
|
|
2388
|
+
else {
|
|
2389
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
2390
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
2391
|
+
if (!task)
|
|
2392
|
+
return phase;
|
|
2393
|
+
const previousStatus = task.status;
|
|
2394
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
2395
|
+
const entry = {
|
|
2396
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${previousStatus}-in-progress`),
|
|
2397
|
+
date: timestamp,
|
|
2398
|
+
fromStatus: previousStatus,
|
|
2399
|
+
toStatus: "in-progress",
|
|
2400
|
+
title: `${previousStatus} → in-progress`,
|
|
2401
|
+
description: "",
|
|
2402
|
+
};
|
|
2403
|
+
task.activeOwnerSession = plannerSessionId;
|
|
2404
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2405
|
+
task.updatedAt = timestamp;
|
|
2406
|
+
phase.updatedAt = timestamp;
|
|
2407
|
+
updatedTask = task;
|
|
1892
2408
|
return phase;
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
2412
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
2413
|
+
if (approvedDeviation)
|
|
2414
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
2415
|
+
const resumedDeviation = project.workDeviations
|
|
2416
|
+
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
2417
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
2418
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
2419
|
+
if (resumedDeviation)
|
|
2420
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
2421
|
+
await st.writeGenerated();
|
|
2422
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === found.task.id);
|
|
2423
|
+
if (!updatedTask || persistedTask?.status !== "in-progress") {
|
|
2424
|
+
return taskStartError(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect planner-task-show for the persisted state and retry only after resolving the persistence failure."], { taskId: found.task.id }));
|
|
2425
|
+
}
|
|
2426
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
2427
|
+
return text(`✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}`, {
|
|
2428
|
+
...outcome,
|
|
2429
|
+
task: persistedTask,
|
|
2430
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
1908
2431
|
});
|
|
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
2432
|
});
|
|
1931
2433
|
});
|
|
1932
2434
|
server.registerTool("planner-task-complete", {
|
|
@@ -2014,11 +2516,11 @@ server.registerTool("planner-handoff-list", {
|
|
|
2014
2516
|
return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
|
|
2015
2517
|
const result = boundedPage(list, page, pageSize);
|
|
2016
2518
|
const { items, ...pageInfo } = result;
|
|
2017
|
-
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
|
|
2519
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
|
|
2018
2520
|
return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
|
|
2019
2521
|
});
|
|
2020
2522
|
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.",
|
|
2523
|
+
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
2524
|
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
2023
2525
|
}, async ({ phaseRef }) => {
|
|
2024
2526
|
const st = await requireStore();
|
|
@@ -2027,26 +2529,138 @@ server.registerTool("planner-handoff-show", {
|
|
|
2027
2529
|
return text(`❌ ${r.error}`);
|
|
2028
2530
|
const phase = await st.loadPhase(r.phase.id);
|
|
2029
2531
|
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
2030
|
-
if (!bounded.content.trim())
|
|
2031
|
-
|
|
2032
|
-
|
|
2532
|
+
if (!bounded.content.trim()) {
|
|
2533
|
+
const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
|
|
2534
|
+
&& new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
|
|
2535
|
+
if (archived) {
|
|
2536
|
+
const archivedContent = boundedHandoffForTransport(archived.content);
|
|
2537
|
+
const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
|
|
2538
|
+
const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
|
|
2539
|
+
return text([
|
|
2540
|
+
`Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
|
|
2541
|
+
...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
|
|
2542
|
+
"",
|
|
2543
|
+
archivedContent.content,
|
|
2544
|
+
].join("\n"), {
|
|
2545
|
+
phaseRef: r.compositeRef,
|
|
2546
|
+
phaseId: phase.id,
|
|
2547
|
+
active: false,
|
|
2548
|
+
archived: true,
|
|
2549
|
+
archiveReason: archived.reason,
|
|
2550
|
+
archivedAt: archived.archivedAt,
|
|
2551
|
+
archiveFile: archived.file,
|
|
2552
|
+
...archivedContent,
|
|
2553
|
+
supportingDocuments,
|
|
2554
|
+
empty: false,
|
|
2555
|
+
resumeReady: false,
|
|
2556
|
+
handoffAudit: phase.handoffAudit,
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit });
|
|
2560
|
+
}
|
|
2561
|
+
const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
|
|
2562
|
+
const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
|
|
2563
|
+
const contentHash = handoffContentHash(phase.handoff);
|
|
2564
|
+
const persistenceVerified = Boolean(phase.handoffAudit
|
|
2565
|
+
&& phase.handoffAudit.contentHash === contentHash
|
|
2566
|
+
&& phase.handoffAudit.contentLength === phase.handoff.length);
|
|
2567
|
+
const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
|
|
2568
|
+
const status = resumeReady
|
|
2569
|
+
? "Resume-ready: persisted compact capsule read-back completed."
|
|
2570
|
+
: "NOT resume-ready: after reading this persisted capsule, call planner-handoff-verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
|
|
2571
|
+
return text(`Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}`, {
|
|
2572
|
+
phaseRef: r.compositeRef,
|
|
2573
|
+
phaseId: r.phase.id,
|
|
2574
|
+
phaseWorkMap,
|
|
2575
|
+
...bounded,
|
|
2576
|
+
contentHash,
|
|
2577
|
+
persistenceVerified,
|
|
2578
|
+
resumeReady,
|
|
2579
|
+
verificationRequired: !resumeReady,
|
|
2580
|
+
handoffAudit: phase.handoffAudit,
|
|
2581
|
+
});
|
|
2582
|
+
});
|
|
2583
|
+
server.registerTool("planner-handoff-verify", {
|
|
2584
|
+
description: "Finalize one persisted handoff as resume-ready after planner-handoff-show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
|
|
2585
|
+
inputSchema: {
|
|
2586
|
+
phaseRef: z.string().min(1),
|
|
2587
|
+
expectedContentHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
2588
|
+
sourceReviews: z.array(z.object({
|
|
2589
|
+
source: z.enum(["conversation", "planner-entities", "working-tree", "verification-runtime", "peer-agent-output"]),
|
|
2590
|
+
detail: z.string().min(12),
|
|
2591
|
+
})).optional().describe("Optional legacy evidence; omitted for compact handoffs."),
|
|
2592
|
+
omissionsFound: z.array(z.string().min(1)).optional().describe("Optional read-back gaps; omit when the persisted capsule is complete."),
|
|
2593
|
+
},
|
|
2594
|
+
}, async ({ phaseRef, expectedContentHash, sourceReviews, omissionsFound }) => {
|
|
2595
|
+
const st = await requireStore();
|
|
2596
|
+
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2597
|
+
if (!r.ok)
|
|
2598
|
+
return text(`❌ ${r.error}`, { resumeReady: false });
|
|
2599
|
+
try {
|
|
2600
|
+
const result = await st.verifyPhaseHandoffReadBack(r.phase.id, {
|
|
2601
|
+
expectedContentHash,
|
|
2602
|
+
...(sourceReviews ? { sourceReviews } : {}),
|
|
2603
|
+
...(omissionsFound ? { omissionsFound } : {}),
|
|
2604
|
+
});
|
|
2605
|
+
await st.writeGenerated();
|
|
2606
|
+
return text(`✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.`, {
|
|
2607
|
+
phaseRef: r.compositeRef,
|
|
2608
|
+
...result,
|
|
2609
|
+
resumeReady: true,
|
|
2610
|
+
verificationRequired: false,
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
catch (error) {
|
|
2614
|
+
if (error instanceof HandoffContractError)
|
|
2615
|
+
return handoffContractFailure(error);
|
|
2616
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2617
|
+
return text(`❌ Handoff read-back verification denied: ${message}`, { resumeReady: false, error: message });
|
|
2618
|
+
}
|
|
2033
2619
|
});
|
|
2034
2620
|
server.registerTool("planner-requirement-list", {
|
|
2035
|
-
description: "List
|
|
2036
|
-
inputSchema: {
|
|
2037
|
-
|
|
2621
|
+
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.",
|
|
2622
|
+
inputSchema: {
|
|
2623
|
+
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."),
|
|
2624
|
+
},
|
|
2625
|
+
}, async ({ phaseRef }, extra) => {
|
|
2038
2626
|
const plannerSessionId = plannerSessionIdFor(extra);
|
|
2039
2627
|
const st = await requireStore();
|
|
2040
2628
|
const requirements = await st.loadRequirements();
|
|
2041
|
-
|
|
2042
|
-
|
|
2629
|
+
if (!phaseRef?.trim()) {
|
|
2630
|
+
return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements", {
|
|
2631
|
+
requirements,
|
|
2632
|
+
attestedRequirementIds: [],
|
|
2633
|
+
readScope: "inventory",
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
const features = (await st.loadFeatures()).features;
|
|
2637
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, phaseRef.trim());
|
|
2638
|
+
if (!phase)
|
|
2639
|
+
return { ...text(`Phase not found: ${phaseRef}`, { attestedRequirementIds: [] }), isError: true };
|
|
2640
|
+
const delivered = [
|
|
2641
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2642
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2643
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2644
|
+
delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
|
|
2645
|
+
const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
2646
|
+
const body = delivered.map((requirement) => [
|
|
2647
|
+
`- ${requirement.id} — ${requirement.title}`,
|
|
2648
|
+
` Description: ${requirement.description || "None"}`,
|
|
2649
|
+
` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
|
|
2650
|
+
` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
|
|
2651
|
+
].join("\n")).join("\n");
|
|
2652
|
+
return text(body || `No requirements linked to ${targetRef}`, {
|
|
2653
|
+
requirements: { ...requirements, requirements: delivered },
|
|
2654
|
+
attestedRequirementIds: delivered.map((requirement) => requirement.id),
|
|
2655
|
+
readScope: "target",
|
|
2656
|
+
target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
|
|
2657
|
+
});
|
|
2043
2658
|
});
|
|
2044
2659
|
server.registerTool("planner-requirement-create", {
|
|
2045
|
-
description: "Create a
|
|
2660
|
+
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
2661
|
inputSchema: {
|
|
2047
2662
|
title: z.string().min(1),
|
|
2048
2663
|
description: z.string().optional(),
|
|
2049
|
-
status: z.enum(STATUS_VALUES).optional(),
|
|
2050
2664
|
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2051
2665
|
macroTasks: z.array(z.object({
|
|
2052
2666
|
title: z.string().min(1),
|
|
@@ -2054,7 +2668,7 @@ server.registerTool("planner-requirement-create", {
|
|
|
2054
2668
|
status: z.enum(STATUS_VALUES).optional(),
|
|
2055
2669
|
})).optional(),
|
|
2056
2670
|
},
|
|
2057
|
-
}, async ({ title, description,
|
|
2671
|
+
}, async ({ title, description, linkedPhaseIds, macroTasks }) => {
|
|
2058
2672
|
const st = await requireStore();
|
|
2059
2673
|
const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2060
2674
|
if (!links.ok)
|
|
@@ -2076,25 +2690,22 @@ server.registerTool("planner-requirement-create", {
|
|
|
2076
2690
|
id: createRequirementId(),
|
|
2077
2691
|
title: title.trim(),
|
|
2078
2692
|
description: description?.trim() ?? "",
|
|
2079
|
-
status: status ?? "planned",
|
|
2080
2693
|
macroTasks: normalizedMacroTasks,
|
|
2081
2694
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
2082
2695
|
createdAt: now,
|
|
2083
2696
|
updatedAt: now,
|
|
2084
2697
|
sessionInfo: [],
|
|
2085
2698
|
};
|
|
2086
|
-
|
|
2087
|
-
await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
|
|
2699
|
+
await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
|
|
2088
2700
|
await st.writeGenerated();
|
|
2089
2701
|
return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
|
|
2090
2702
|
});
|
|
2091
2703
|
server.registerTool("planner-requirement-update", {
|
|
2092
|
-
description: "Update a
|
|
2704
|
+
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
2705
|
inputSchema: {
|
|
2094
2706
|
requirementId: z.string().min(1),
|
|
2095
2707
|
title: z.string().min(1).optional(),
|
|
2096
2708
|
description: z.string().optional(),
|
|
2097
|
-
status: z.enum(STATUS_VALUES).optional(),
|
|
2098
2709
|
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2099
2710
|
macroTasks: z.array(z.object({
|
|
2100
2711
|
id: z.string().min(1).optional(),
|
|
@@ -2103,41 +2714,45 @@ server.registerTool("planner-requirement-update", {
|
|
|
2103
2714
|
status: z.enum(STATUS_VALUES),
|
|
2104
2715
|
})).optional(),
|
|
2105
2716
|
},
|
|
2106
|
-
}, async ({ requirementId, title, description,
|
|
2717
|
+
}, async ({ requirementId, title, description, linkedPhaseIds, macroTasks }) => {
|
|
2107
2718
|
const st = await requireStore();
|
|
2108
2719
|
const requirements = await st.loadRequirements();
|
|
2109
2720
|
const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
|
|
2110
2721
|
if (!current)
|
|
2111
2722
|
return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
|
|
2723
|
+
if (title === undefined && description === undefined && linkedPhaseIds === undefined && macroTasks === undefined) {
|
|
2724
|
+
return mutationNoFieldsFailure({
|
|
2725
|
+
entity: "requirement",
|
|
2726
|
+
ref: requirementId,
|
|
2727
|
+
operation: "update",
|
|
2728
|
+
mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
|
|
2729
|
+
retryCommand: `planner-requirement-update ${requirementId}`,
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2112
2732
|
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2113
2733
|
if (!links.ok)
|
|
2114
2734
|
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2115
2735
|
const now = nowISO();
|
|
2116
|
-
let
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2736
|
+
let next;
|
|
2737
|
+
try {
|
|
2738
|
+
next = await st.updateRequirement(current.id, (canonical) => ({
|
|
2739
|
+
...canonical,
|
|
2740
|
+
...(title !== undefined ? { title: title.trim() } : {}),
|
|
2741
|
+
...(description !== undefined ? { description: description.trim() } : {}),
|
|
2742
|
+
...(linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
|
|
2743
|
+
...(macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, macroTasks.map((task) => ({
|
|
2744
|
+
...(task.id !== undefined ? { id: task.id } : {}),
|
|
2745
|
+
title: task.title,
|
|
2746
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
2747
|
+
status: task.status,
|
|
2748
|
+
})), now) } : {}),
|
|
2749
|
+
updatedAt: now,
|
|
2750
|
+
}));
|
|
2751
|
+
}
|
|
2752
|
+
catch (error) {
|
|
2753
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
2754
|
+
return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2130
2755
|
}
|
|
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
2756
|
await st.writeGenerated();
|
|
2142
2757
|
return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
|
|
2143
2758
|
});
|
|
@@ -2146,13 +2761,16 @@ server.registerTool("planner-requirement-delete", {
|
|
|
2146
2761
|
inputSchema: { requirementId: z.string().min(1) },
|
|
2147
2762
|
}, async ({ requirementId }) => {
|
|
2148
2763
|
const st = await requireStore();
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2764
|
+
let deleted = false;
|
|
2765
|
+
await st.updateRequirements((document) => {
|
|
2766
|
+
const requirements = document.requirements.filter((requirement) => requirement.id !== requirementId);
|
|
2767
|
+
deleted = requirements.length !== document.requirements.length;
|
|
2768
|
+
return deleted ? { requirements } : document;
|
|
2769
|
+
});
|
|
2770
|
+
if (!deleted)
|
|
2152
2771
|
return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
|
|
2153
|
-
await st.saveRequirements({ requirements: next });
|
|
2154
2772
|
await st.writeGenerated();
|
|
2155
|
-
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
|
|
2773
|
+
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: true, requirementId });
|
|
2156
2774
|
});
|
|
2157
2775
|
function ideaPromotionTarget(st, type, ref) {
|
|
2158
2776
|
return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
|
|
@@ -2201,8 +2819,15 @@ server.registerTool("planner-idea-update", {
|
|
|
2201
2819
|
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2202
2820
|
if (!current)
|
|
2203
2821
|
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2204
|
-
if (title === undefined && description === undefined)
|
|
2205
|
-
return
|
|
2822
|
+
if (title === undefined && description === undefined) {
|
|
2823
|
+
return mutationNoFieldsFailure({
|
|
2824
|
+
entity: "idea",
|
|
2825
|
+
ref,
|
|
2826
|
+
operation: "update",
|
|
2827
|
+
mutableFields: ["title", "description"],
|
|
2828
|
+
retryCommand: `planner-idea-update ${ref}`,
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2206
2831
|
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2207
2832
|
await st.writeGenerated();
|
|
2208
2833
|
return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
|
|
@@ -2262,10 +2887,11 @@ server.registerTool("planner-idea-promotion-finalize", {
|
|
|
2262
2887
|
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
2263
2888
|
});
|
|
2264
2889
|
server.registerTool("planner-handoff-write", {
|
|
2265
|
-
description: "
|
|
2890
|
+
description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate planner-handoff-show read-back and planner-handoff-verify succeeds. Run planner-handoff-prepare first; provide only resume-critical context and link optional extended detail from .planner/docs/ Markdown.",
|
|
2266
2891
|
inputSchema: {
|
|
2267
2892
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
2268
2893
|
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
|
|
2894
|
+
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
2895
|
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
2270
2896
|
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
2271
2897
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
@@ -2277,7 +2903,16 @@ server.registerTool("planner-handoff-write", {
|
|
|
2277
2903
|
status: z.enum(["captured", "not-applicable"]),
|
|
2278
2904
|
detail: z.string().min(1),
|
|
2279
2905
|
})),
|
|
2280
|
-
}).optional().describe("
|
|
2906
|
+
}).optional().describe("Optional legacy completeness evidence. Compact handoffs omit this duplicate prose."),
|
|
2907
|
+
coldStartInventory: z.object({
|
|
2908
|
+
version: z.literal(HANDOFF_COLD_START_INVENTORY_VERSION),
|
|
2909
|
+
sourceReviews: z.array(z.object({ source: z.string().min(1), detail: z.string().min(1) })),
|
|
2910
|
+
entries: z.array(z.object({
|
|
2911
|
+
category: z.string().min(1).describe(`One required cold-start category id: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}`),
|
|
2912
|
+
items: z.array(z.string().min(1)),
|
|
2913
|
+
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."),
|
|
2914
|
+
})),
|
|
2915
|
+
}).optional().describe("Optional legacy cold-start evidence. Compact handoffs omit this duplicate prose."),
|
|
2281
2916
|
supportingDocuments: z.array(z.object({
|
|
2282
2917
|
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
2283
2918
|
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
@@ -2299,7 +2934,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
2299
2934
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
2300
2935
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
2301
2936
|
},
|
|
2302
|
-
}, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2937
|
+
}, async ({ phaseRef, title, reason, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, coldStartInventory, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2303
2938
|
const st = await requireStore();
|
|
2304
2939
|
let body = content.trim();
|
|
2305
2940
|
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
@@ -2323,7 +2958,9 @@ server.registerTool("planner-handoff-write", {
|
|
|
2323
2958
|
if (!confirmed)
|
|
2324
2959
|
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
2960
|
if (expectedHandoffUpdatedAt === undefined)
|
|
2326
|
-
return
|
|
2961
|
+
return handoffPreflightFailure("HANDOFF_PREFLIGHT_REQUIRED", "Run planner-handoff-prepare first and pass expectedHandoffUpdatedAt.");
|
|
2962
|
+
if (!reason?.trim())
|
|
2963
|
+
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
2964
|
const phases = await st.loadAllPhases();
|
|
2328
2965
|
const features = (await st.loadFeatures()).features;
|
|
2329
2966
|
const resolvedTaskUpdates = [];
|
|
@@ -2342,10 +2979,12 @@ server.registerTool("planner-handoff-write", {
|
|
|
2342
2979
|
}
|
|
2343
2980
|
try {
|
|
2344
2981
|
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
2982
|
+
reason: reason.trim(),
|
|
2345
2983
|
content: body,
|
|
2346
2984
|
expectedHandoffUpdatedAt,
|
|
2347
2985
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2348
2986
|
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2987
|
+
...(coldStartInventory ? { coldStartInventory: coldStartInventory } : {}),
|
|
2349
2988
|
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
2350
2989
|
contextSync: {
|
|
2351
2990
|
taskUpdates: resolvedTaskUpdates,
|
|
@@ -2360,12 +2999,19 @@ server.registerTool("planner-handoff-write", {
|
|
|
2360
2999
|
},
|
|
2361
3000
|
});
|
|
2362
3001
|
await st.writeGenerated();
|
|
2363
|
-
return text(
|
|
3002
|
+
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 persisted compact capsule, then call planner-handoff-verify with the returned contentHash. Rewrite only if resume-critical context is missing.`, {
|
|
2364
3003
|
phaseRef: r.compositeRef,
|
|
2365
3004
|
phaseId: r.phase.id,
|
|
2366
3005
|
updatedTaskIds: result.updatedTaskIds,
|
|
2367
3006
|
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
2368
3007
|
handoffAudit: result.handoffAudit,
|
|
3008
|
+
persisted: true,
|
|
3009
|
+
resumeReady: false,
|
|
3010
|
+
verificationRequired: true,
|
|
3011
|
+
nextActions: [
|
|
3012
|
+
`Call planner-handoff-show ${r.compositeRef} and read the entire persisted body.`,
|
|
3013
|
+
"Call planner-handoff-verify with the shown contentHash; legacy sourceReviews and omissionsFound are optional because the planner derives omitted evidence from persisted state.",
|
|
3014
|
+
],
|
|
2369
3015
|
});
|
|
2370
3016
|
}
|
|
2371
3017
|
catch (error) {
|
|
@@ -2376,7 +3022,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
2376
3022
|
}
|
|
2377
3023
|
});
|
|
2378
3024
|
server.registerTool("planner-handoff-prepare", {
|
|
2379
|
-
description: "Identify or
|
|
3025
|
+
description: "Identify or prepare the exact handoff target. With a confirmed phaseRef, returns a compact resume scaffold, bounded priority-ordered phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work.",
|
|
2380
3026
|
inputSchema: { phaseRef: z.string().min(1).optional() },
|
|
2381
3027
|
}, async ({ phaseRef }) => {
|
|
2382
3028
|
if (!phaseRef)
|
|
@@ -2391,20 +3037,32 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
2391
3037
|
return text(`❌ ${r.error}`);
|
|
2392
3038
|
try {
|
|
2393
3039
|
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3040
|
+
const { completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...compactAudit } = audit;
|
|
2394
3041
|
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
2395
3042
|
return text([
|
|
2396
|
-
`Handoff preparation
|
|
3043
|
+
`Handoff preparation for ${r.compositeRef}`,
|
|
2397
3044
|
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3045
|
+
"Required human inputs before drafting:",
|
|
3046
|
+
...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
|
|
3047
|
+
"Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
|
|
3048
|
+
"Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
|
|
3049
|
+
audit.draftTemplate,
|
|
3050
|
+
"",
|
|
2398
3051
|
"Done tasks missing durable completion/verification evidence:",
|
|
2399
3052
|
missing,
|
|
2400
3053
|
"",
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
3054
|
+
"Compact handoff contract: include only the exact focus/resume point, current or partial state, constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
|
|
3055
|
+
"No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
|
|
3056
|
+
"Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
|
|
3057
|
+
`Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
|
|
2404
3058
|
"",
|
|
2405
3059
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
2406
3060
|
audit.handoff.trim() || "(none)",
|
|
2407
|
-
].join("\n"), {
|
|
3061
|
+
].join("\n"), {
|
|
3062
|
+
phaseRef: r.compositeRef,
|
|
3063
|
+
...compactAudit,
|
|
3064
|
+
evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
|
|
3065
|
+
});
|
|
2408
3066
|
}
|
|
2409
3067
|
catch (error) {
|
|
2410
3068
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2430,40 +3088,44 @@ server.registerTool("planner-web", {
|
|
|
2430
3088
|
}, async ({ action }) => {
|
|
2431
3089
|
if (action === "start") {
|
|
2432
3090
|
if (webHandle) {
|
|
2433
|
-
|
|
3091
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3092
|
+
return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
|
|
2434
3093
|
}
|
|
2435
3094
|
const root = planRoot();
|
|
2436
3095
|
const storeCheck = new PlanStore(root);
|
|
2437
3096
|
if (!(await storeCheck.exists())) {
|
|
2438
|
-
return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first
|
|
3097
|
+
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
3098
|
}
|
|
2440
3099
|
try {
|
|
2441
3100
|
// port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
|
|
2442
3101
|
// host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
|
|
2443
3102
|
webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
|
|
2444
|
-
|
|
3103
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3104
|
+
return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`, plannerWebMetadata(web));
|
|
2445
3105
|
}
|
|
2446
3106
|
catch (err) {
|
|
2447
3107
|
webHandle = null;
|
|
2448
|
-
|
|
3108
|
+
const web = { localUrl: "", error: err.message };
|
|
3109
|
+
return text(`planner-web start failed: ${web.error}`, plannerWebMetadata(web));
|
|
2449
3110
|
}
|
|
2450
3111
|
}
|
|
2451
3112
|
if (action === "stop") {
|
|
2452
3113
|
if (!webHandle)
|
|
2453
|
-
return text("planner-web not running.");
|
|
3114
|
+
return text("planner-web not running.", { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
2454
3115
|
const stoppedUrl = webHandle.localUrl;
|
|
2455
3116
|
await webHandle.close().catch(() => { });
|
|
2456
3117
|
webHandle = null;
|
|
2457
|
-
return text(`planner-web stopped (was ${stoppedUrl})
|
|
3118
|
+
return text(`planner-web stopped (was ${stoppedUrl}).`, { running: false, address: "", localUrl: "", lanUrl: "", host: "", port: 0, mode: "" });
|
|
2458
3119
|
}
|
|
2459
3120
|
// status
|
|
2460
3121
|
if (webHandle) {
|
|
2461
|
-
|
|
3122
|
+
const web = { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode, bindHost: webHandle.bindHost };
|
|
3123
|
+
return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`, plannerWebMetadata(web));
|
|
2462
3124
|
}
|
|
2463
|
-
return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
|
|
3125
|
+
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
3126
|
});
|
|
2465
3127
|
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
|
|
3128
|
+
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
3129
|
}, async (extra) => {
|
|
2468
3130
|
const st = store();
|
|
2469
3131
|
if (!(await st.exists()))
|
|
@@ -2484,10 +3146,23 @@ server.registerTool("planner-load", {
|
|
|
2484
3146
|
await st.syncGrillMeSkill();
|
|
2485
3147
|
const web = await ensureWebStarted();
|
|
2486
3148
|
const recap = await buildRecap(st, web, { harness: "mcp" });
|
|
3149
|
+
const plan = await st.loadAll();
|
|
3150
|
+
const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
|
|
3151
|
+
{ scope: "project", decisions: plan.project.acceptedDecisions },
|
|
3152
|
+
...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
|
|
3153
|
+
...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
|
|
3154
|
+
...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
|
|
3155
|
+
scope: `task ${taskCompositeRef(task, phase, plan.features.features)}`,
|
|
3156
|
+
decisions: task.acceptedDecisions,
|
|
3157
|
+
}))),
|
|
3158
|
+
]);
|
|
2487
3159
|
const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
|
|
2488
3160
|
return {
|
|
2489
3161
|
content: [{ type: "text", text: visibleRecap }],
|
|
2490
3162
|
structuredContent: {
|
|
3163
|
+
loaded: true,
|
|
3164
|
+
recap: { text: visibleRecap },
|
|
3165
|
+
webUi: plannerWebMetadata(web),
|
|
2491
3166
|
preparation: preparation.legacyProjectContext,
|
|
2492
3167
|
agentContext: {
|
|
2493
3168
|
kind: "planner-usage-skill",
|
|
@@ -2495,7 +3170,8 @@ server.registerTool("planner-load", {
|
|
|
2495
3170
|
status: plannerSkill.status,
|
|
2496
3171
|
customized: plannerSkill.customized,
|
|
2497
3172
|
message: plannerSkill.message,
|
|
2498
|
-
instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
|
|
3173
|
+
instruction: "Read and retain this project-local planner usage skill and Accepted Decision context. Do not quote it or the Accepted Decision context in the human-facing recap.",
|
|
3174
|
+
acceptedDecisions: acceptedDecisionContext,
|
|
2499
3175
|
},
|
|
2500
3176
|
},
|
|
2501
3177
|
};
|