@agent-plan/mcp 0.2.24 → 0.2.26
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 +765 -80
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -5,16 +5,99 @@ import * as z from "zod/v4";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
-
import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
|
|
8
|
+
import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, HandoffContractError } from "@agent-plan/core";
|
|
9
9
|
import { serve } from "@agent-plan/server";
|
|
10
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
10
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
11
11
|
const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
12
12
|
const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
|
|
13
|
-
const
|
|
14
|
-
startReadSession(
|
|
13
|
+
const fallbackPlannerSessionId = `mcp-process:${randomUUID()}`;
|
|
14
|
+
startReadSession(fallbackPlannerSessionId);
|
|
15
|
+
function plannerSessionIdFor(extra) {
|
|
16
|
+
const transportSessionId = extra?.sessionId?.trim();
|
|
17
|
+
const sessionId = transportSessionId ? `mcp:${transportSessionId}` : fallbackPlannerSessionId;
|
|
18
|
+
startReadSession(sessionId);
|
|
19
|
+
return sessionId;
|
|
20
|
+
}
|
|
15
21
|
function text(textValue, structuredContent) {
|
|
16
22
|
return structuredContent ? { content: [{ type: "text", text: textValue }], structuredContent } : { content: [{ type: "text", text: textValue }] };
|
|
17
23
|
}
|
|
24
|
+
function handoffContractFailure(error) {
|
|
25
|
+
return {
|
|
26
|
+
isError: true,
|
|
27
|
+
content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
|
|
28
|
+
structuredContent: { errorCode: error.code, ...error.details },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function boundedHandoffForTransport(content) {
|
|
32
|
+
if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
|
|
33
|
+
return { content, fullLength: content.length, truncated: false };
|
|
34
|
+
const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
|
|
35
|
+
return {
|
|
36
|
+
content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
|
|
37
|
+
fullLength: content.length,
|
|
38
|
+
truncated: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function boundedPage(items, page, pageSize) {
|
|
42
|
+
const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
|
|
43
|
+
const totalPages = Math.max(1, Math.ceil(items.length / size));
|
|
44
|
+
const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
|
|
45
|
+
return {
|
|
46
|
+
items: items.slice((currentPage - 1) * size, currentPage * size),
|
|
47
|
+
page: currentPage,
|
|
48
|
+
pageSize: size,
|
|
49
|
+
total: items.length,
|
|
50
|
+
totalPages,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
54
|
+
const childKind = entity === "feature" ? "phases" : "tasks";
|
|
55
|
+
const retryAction = entity === "feature"
|
|
56
|
+
? `Retry planner-feature-update ${ref} without the status field if you need to update metadata.`
|
|
57
|
+
: `Retry planner-phase-update ${ref} without the status field if you need to update metadata.`;
|
|
58
|
+
const nextActions = [
|
|
59
|
+
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
|
|
60
|
+
retryAction,
|
|
61
|
+
];
|
|
62
|
+
return {
|
|
63
|
+
isError: true,
|
|
64
|
+
content: [{ type: "text", text: [
|
|
65
|
+
`❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
|
|
66
|
+
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
|
|
67
|
+
`Attempted status: ${attemptedStatus}`,
|
|
68
|
+
`Effective derived status: ${effectiveStatus}`,
|
|
69
|
+
"No planner data was changed.",
|
|
70
|
+
"Next required actions:",
|
|
71
|
+
...nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
72
|
+
].join("\n") }],
|
|
73
|
+
structuredContent: {
|
|
74
|
+
updated: false,
|
|
75
|
+
errorCode: "DERIVED_STATUS_READ_ONLY",
|
|
76
|
+
entity,
|
|
77
|
+
ref,
|
|
78
|
+
attemptedStatus,
|
|
79
|
+
effectiveStatus,
|
|
80
|
+
nextActions,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function mutationNoFieldsFailure(input) {
|
|
85
|
+
const outcome = noMutableFieldsReceived(input);
|
|
86
|
+
return {
|
|
87
|
+
isError: true,
|
|
88
|
+
content: [{ type: "text", text: outcome.message }],
|
|
89
|
+
structuredContent: { ...outcome },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function legacyContextMigrationSummary(preview) {
|
|
93
|
+
return [
|
|
94
|
+
`Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
|
|
95
|
+
`Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
|
|
96
|
+
`Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
|
|
97
|
+
`Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
|
|
98
|
+
`Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
18
101
|
function taskStartError(outcome, structuredContent = {}) {
|
|
19
102
|
return {
|
|
20
103
|
isError: true,
|
|
@@ -29,6 +112,20 @@ function taskStartError(outcome, structuredContent = {}) {
|
|
|
29
112
|
structuredContent: { ...outcome, ...structuredContent },
|
|
30
113
|
};
|
|
31
114
|
}
|
|
115
|
+
function mcpContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
|
|
116
|
+
const actions = projectGuidelinesReadRequired ? ["planner-project-guidelines-show"] : [];
|
|
117
|
+
actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
|
|
118
|
+
if (read.kind === "task")
|
|
119
|
+
return [`planner-task-show ${refs.task} with full=true`];
|
|
120
|
+
if (read.kind === "phase")
|
|
121
|
+
return [`planner-phase-show ${refs.phase} with full=true`];
|
|
122
|
+
return refs.feature ? [`planner-feature-show ${refs.feature} with full=true`] : [];
|
|
123
|
+
}));
|
|
124
|
+
if (requirementReadRequired)
|
|
125
|
+
actions.push("planner-requirement-list");
|
|
126
|
+
actions.push(retryAction);
|
|
127
|
+
return actions;
|
|
128
|
+
}
|
|
32
129
|
const MCP_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/mcp");
|
|
33
130
|
const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
|
|
34
131
|
function nowISO() {
|
|
@@ -144,6 +241,21 @@ const server = new McpServer({
|
|
|
144
241
|
name: "agent-plan-planner",
|
|
145
242
|
version: MCP_PACKAGE.version,
|
|
146
243
|
});
|
|
244
|
+
async function resolveRequirementPhaseRefs(st, refs) {
|
|
245
|
+
if (!refs || refs.length === 0)
|
|
246
|
+
return { ok: false, error: "linkedPhaseIds must contain at least one phase reference." };
|
|
247
|
+
const [phases, features] = await Promise.all([st.loadAllPhases(), st.loadFeatures()]);
|
|
248
|
+
const linkedPhaseIds = [];
|
|
249
|
+
for (const rawRef of refs) {
|
|
250
|
+
const ref = rawRef.trim();
|
|
251
|
+
const phase = ref ? findPhaseByRef(phases, features.features, ref) : undefined;
|
|
252
|
+
if (!phase)
|
|
253
|
+
return { ok: false, error: `Linked phase not found: ${rawRef}` };
|
|
254
|
+
if (!linkedPhaseIds.includes(phase.id))
|
|
255
|
+
linkedPhaseIds.push(phase.id);
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, linkedPhaseIds };
|
|
258
|
+
}
|
|
147
259
|
async function resolvePhaseForHandoff(st, ref) {
|
|
148
260
|
const phases = await st.loadAllPhases();
|
|
149
261
|
const features = (await st.loadFeatures()).features;
|
|
@@ -244,6 +356,7 @@ server.registerTool("planner-show", {
|
|
|
244
356
|
const summary = [
|
|
245
357
|
`📋 ${project.name}`,
|
|
246
358
|
`Description: ${project.description || "(not set)"}`,
|
|
359
|
+
...(project.descriptionRef ? [`Description ref: ${project.descriptionRef}`] : []),
|
|
247
360
|
`Goal: ${project.goal || "(not set)"}`,
|
|
248
361
|
`Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
|
|
249
362
|
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
@@ -253,7 +366,7 @@ server.registerTool("planner-show", {
|
|
|
253
366
|
].join("\n");
|
|
254
367
|
// Compact structured overview (no full plan: avoids 1M+ char token overflow).
|
|
255
368
|
const overview = {
|
|
256
|
-
project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
|
|
369
|
+
project: { name: project.name, description: project.description || null, descriptionRef: project.descriptionRef || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
|
|
257
370
|
counts: { features: features.length, phases: phases.length, tasks: totalTasks },
|
|
258
371
|
features: features.map((feature) => ({
|
|
259
372
|
ref: formatFeatureRef(feature.number),
|
|
@@ -319,6 +432,8 @@ server.registerTool("planner-project-language", {
|
|
|
319
432
|
server.registerTool("planner-project-discuss", {
|
|
320
433
|
description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
|
|
321
434
|
inputSchema: {
|
|
435
|
+
description: z.string().optional(),
|
|
436
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full project description."),
|
|
322
437
|
goal: z.string().optional(),
|
|
323
438
|
scope: z.array(z.string()).optional(),
|
|
324
439
|
outOfScope: z.array(z.string()).optional(),
|
|
@@ -330,6 +445,32 @@ server.registerTool("planner-project-discuss", {
|
|
|
330
445
|
}, async (params) => {
|
|
331
446
|
const st = await requireStore();
|
|
332
447
|
const project = await st.loadProject();
|
|
448
|
+
const mutableFields = ["description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
|
|
449
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
450
|
+
if (receivedFields.length === 0) {
|
|
451
|
+
return mutationNoFieldsFailure({
|
|
452
|
+
entity: "project",
|
|
453
|
+
ref: project.name,
|
|
454
|
+
operation: "discuss",
|
|
455
|
+
mutableFields,
|
|
456
|
+
retryCommand: "planner-project-discuss with a concise description summary and the intended metadata",
|
|
457
|
+
readBackCommand: "planner-show",
|
|
458
|
+
descriptionFallback: {
|
|
459
|
+
inputField: "description",
|
|
460
|
+
storedField: "project description",
|
|
461
|
+
descriptionRefField: "descriptionRef",
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (params.description !== undefined)
|
|
466
|
+
project.description = params.description.trim();
|
|
467
|
+
if (params.descriptionRef !== undefined) {
|
|
468
|
+
const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
|
|
469
|
+
if (descriptionRef)
|
|
470
|
+
project.descriptionRef = descriptionRef;
|
|
471
|
+
else
|
|
472
|
+
delete project.descriptionRef;
|
|
473
|
+
}
|
|
333
474
|
if (params.goal !== undefined)
|
|
334
475
|
project.goal = params.goal.trim();
|
|
335
476
|
if (params.scope !== undefined)
|
|
@@ -345,25 +486,83 @@ server.registerTool("planner-project-discuss", {
|
|
|
345
486
|
if (params.decisions !== undefined)
|
|
346
487
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
347
488
|
await st.saveProject(project);
|
|
348
|
-
return writeAndSummarize(st, `Project discussed/updated: ${project.name}
|
|
489
|
+
return writeAndSummarize(st, `Project discussed/updated: ${project.name}. Fields saved: ${receivedFields.join(", ")}.`, { project, discussed: true, updatedFields: receivedFields });
|
|
490
|
+
});
|
|
491
|
+
server.registerTool("planner-project-guidelines-show", {
|
|
492
|
+
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.",
|
|
493
|
+
}, async (extra) => {
|
|
494
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
495
|
+
const st = await requireStore();
|
|
496
|
+
const project = await st.loadProject();
|
|
497
|
+
if (project.projectGuidelines.content.trim()) {
|
|
498
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
499
|
+
}
|
|
500
|
+
const refreshed = await st.loadProject();
|
|
501
|
+
const content = refreshed.projectGuidelines.content.trim();
|
|
502
|
+
return text(content
|
|
503
|
+
? ["## Project Guidelines", content].join("\n\n")
|
|
504
|
+
: "No project guidelines set.", { projectGuidelines: refreshed.projectGuidelines, readRecorded: Boolean(content) });
|
|
505
|
+
});
|
|
506
|
+
server.registerTool("planner-project-guidelines-update", {
|
|
507
|
+
description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
|
|
508
|
+
inputSchema: {
|
|
509
|
+
content: z.string().describe("Full Project Guidelines markdown/text. Pass an empty string to clear the section."),
|
|
510
|
+
},
|
|
511
|
+
}, async ({ content }) => {
|
|
512
|
+
const st = await requireStore();
|
|
513
|
+
const project = await st.loadProject();
|
|
514
|
+
project.projectGuidelines = {
|
|
515
|
+
content: content.trim(),
|
|
516
|
+
updatedAt: project.projectGuidelines.updatedAt,
|
|
517
|
+
sessionInfo: project.projectGuidelines.sessionInfo,
|
|
518
|
+
};
|
|
519
|
+
await st.saveProject(project);
|
|
520
|
+
const refreshed = await st.loadProject();
|
|
521
|
+
return writeAndSummarize(st, refreshed.projectGuidelines.content
|
|
522
|
+
? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
|
|
523
|
+
: "Project Guidelines cleared.", { updated: true, projectGuidelines: refreshed.projectGuidelines });
|
|
524
|
+
});
|
|
525
|
+
server.registerTool("planner-project-context-migrate", {
|
|
526
|
+
description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
|
|
527
|
+
inputSchema: {
|
|
528
|
+
apply: z.boolean().default(false).describe("False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields."),
|
|
529
|
+
},
|
|
530
|
+
}, async ({ apply }) => {
|
|
531
|
+
const st = await requireStore();
|
|
532
|
+
const preview = await st.previewLegacyProjectContextMigration();
|
|
533
|
+
if (!apply) {
|
|
534
|
+
return text([`Project context migration preview (no changes applied).`, legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n"), { applied: false, preview });
|
|
535
|
+
}
|
|
536
|
+
const result = await st.migrateLegacyProjectContext();
|
|
537
|
+
return writeAndSummarize(st, [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n"), { ...result });
|
|
349
538
|
});
|
|
350
539
|
server.registerTool("planner-feature-list", {
|
|
351
|
-
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
|
|
540
|
+
description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Pass featureRef to filter. Use this to discover refs cheaply — do NOT read .planner/ files or planner-plan-get full=true to find entities.",
|
|
352
541
|
inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
|
|
353
542
|
}, async ({ featureRef }) => {
|
|
354
543
|
const st = await requireStore();
|
|
355
544
|
const allFeatures = (await st.loadFeatures()).features;
|
|
356
545
|
const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
|
|
357
546
|
const phases = await st.loadAllPhases();
|
|
358
|
-
const
|
|
547
|
+
const items = features.map((feature) => {
|
|
359
548
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
360
549
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
361
|
-
return
|
|
550
|
+
return {
|
|
551
|
+
id: feature.id,
|
|
552
|
+
ref: formatFeatureRef(feature.number),
|
|
553
|
+
shortId: feature.shortId,
|
|
554
|
+
name: feature.name,
|
|
555
|
+
status: feature.status,
|
|
556
|
+
priority: feature.priority,
|
|
557
|
+
phaseCount: featurePhases.length,
|
|
558
|
+
taskCount,
|
|
559
|
+
};
|
|
362
560
|
});
|
|
363
|
-
|
|
561
|
+
const lines = items.map((feature) => `- ${feature.ref}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}${feature.priority > 0 ? `; priority ${feature.priority}` : ""}; ${feature.phaseCount} phases, ${feature.taskCount} tasks)`);
|
|
562
|
+
return text(lines.join("\n") || "No features", { features: items });
|
|
364
563
|
});
|
|
365
564
|
server.registerTool("planner-phase-list", {
|
|
366
|
-
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
565
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
367
566
|
inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name)."), status: z.string().optional().describe("Optional: filter by status name.") },
|
|
368
567
|
}, async ({ featureRef, status }) => {
|
|
369
568
|
const st = await requireStore();
|
|
@@ -377,15 +576,27 @@ server.registerTool("planner-phase-list", {
|
|
|
377
576
|
}
|
|
378
577
|
if (status)
|
|
379
578
|
phases = phases.filter((p) => p.status === status);
|
|
380
|
-
const
|
|
579
|
+
const items = phases.map((phase) => {
|
|
381
580
|
const fNum = featureNumberOfPhase(phase, features);
|
|
382
|
-
|
|
383
|
-
|
|
581
|
+
return {
|
|
582
|
+
id: phase.id,
|
|
583
|
+
ref: formatPhaseRef(phase.number, fNum),
|
|
584
|
+
shortId: phase.shortId,
|
|
585
|
+
title: phase.title,
|
|
586
|
+
status: phase.status,
|
|
587
|
+
priority: phase.priority,
|
|
588
|
+
taskCount: phase.tasks.length,
|
|
589
|
+
featureNumber: fNum,
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
const lines = items.map((phase) => {
|
|
593
|
+
const fTag = phase.featureNumber !== undefined ? ` [F${String(phase.featureNumber).padStart(3, "0")}]` : "";
|
|
594
|
+
return `- ${phase.ref}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}${phase.priority > 0 ? `; priority ${phase.priority}` : ""}; ${phase.taskCount} tasks)${fTag}`;
|
|
384
595
|
});
|
|
385
|
-
return text(lines.join("\n") || "No phases");
|
|
596
|
+
return text(lines.join("\n") || "No phases", { phases: items });
|
|
386
597
|
});
|
|
387
598
|
server.registerTool("planner-task-list", {
|
|
388
|
-
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
599
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Cheap discovery — do NOT read .planner/ files or planner-plan-get full=true.",
|
|
389
600
|
inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
|
|
390
601
|
}, async ({ featureRef, phaseRef, status }) => {
|
|
391
602
|
const st = await requireStore();
|
|
@@ -403,15 +614,25 @@ server.registerTool("planner-task-list", {
|
|
|
403
614
|
return text(`Phase not found: ${phaseRef}`);
|
|
404
615
|
phases = [p];
|
|
405
616
|
}
|
|
406
|
-
const
|
|
617
|
+
const items = [];
|
|
407
618
|
for (const phase of phases) {
|
|
408
619
|
for (const task of phase.tasks) {
|
|
409
620
|
if (status && task.status !== status)
|
|
410
621
|
continue;
|
|
411
|
-
|
|
622
|
+
items.push({
|
|
623
|
+
id: task.id,
|
|
624
|
+
ref: taskCompositeRef(task, phase, features),
|
|
625
|
+
shortId: task.shortId,
|
|
626
|
+
title: task.title,
|
|
627
|
+
status: task.status,
|
|
628
|
+
priority: task.priority,
|
|
629
|
+
phaseId: phase.id,
|
|
630
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
631
|
+
});
|
|
412
632
|
}
|
|
413
633
|
}
|
|
414
|
-
|
|
634
|
+
const out = items.map((task) => `- ${task.ref}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
|
|
635
|
+
return text(out.join("\n") || "No tasks", { tasks: items });
|
|
415
636
|
});
|
|
416
637
|
server.registerTool("planner-feature-add", {
|
|
417
638
|
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.",
|
|
@@ -461,7 +682,8 @@ server.registerTool("planner-feature-add", {
|
|
|
461
682
|
server.registerTool("planner-feature-show", {
|
|
462
683
|
description: "Show a feature by id or name.",
|
|
463
684
|
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).") },
|
|
464
|
-
}, async ({ feature: ref, full }) => {
|
|
685
|
+
}, async ({ feature: ref, full }, extra) => {
|
|
686
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
465
687
|
const st = await requireStore();
|
|
466
688
|
const features = (await st.loadFeatures()).features;
|
|
467
689
|
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
@@ -472,30 +694,58 @@ server.registerTool("planner-feature-show", {
|
|
|
472
694
|
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
473
695
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
474
696
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
475
|
-
|
|
697
|
+
const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
|
|
698
|
+
return text(full ? `${summary}\n\n${feature.description || ""}${descriptionRefBlock}` : summary, full ? { feature: { ref: formatFeatureRef(feature.number), description: feature.description, descriptionRef: feature.descriptionRef || "" } } : undefined);
|
|
476
699
|
});
|
|
477
700
|
server.registerTool("planner-feature-discuss", {
|
|
478
701
|
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
479
702
|
inputSchema: {
|
|
480
703
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
481
704
|
description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
|
|
705
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
482
706
|
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
483
707
|
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
484
708
|
dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
|
|
485
709
|
},
|
|
486
|
-
}, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
|
|
710
|
+
}, async ({ feature: ref, description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies }) => {
|
|
487
711
|
const st = await requireStore();
|
|
488
712
|
const features = (await st.loadFeatures()).features;
|
|
489
713
|
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
490
714
|
if (!resolvedFeature.ok)
|
|
491
715
|
return text(resolvedFeature.error);
|
|
492
716
|
const feature = resolvedFeature.feature;
|
|
717
|
+
const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
|
|
718
|
+
const discussInputs = { description, descriptionRef: rawDescriptionRef, workDone, workRemaining, dependencies };
|
|
719
|
+
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
720
|
+
if (receivedFields.length === 0) {
|
|
721
|
+
return mutationNoFieldsFailure({
|
|
722
|
+
entity: "feature",
|
|
723
|
+
ref: formatFeatureRef(feature.number),
|
|
724
|
+
operation: "discuss",
|
|
725
|
+
mutableFields,
|
|
726
|
+
retryCommand: `planner-feature-discuss ${formatFeatureRef(feature.number)}`,
|
|
727
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
|
|
728
|
+
descriptionFallback: {
|
|
729
|
+
entityId: feature.id,
|
|
730
|
+
inputField: "description",
|
|
731
|
+
storedField: "feature description",
|
|
732
|
+
descriptionRefField: "descriptionRef",
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
}
|
|
493
736
|
const updated = await st.updateFeatures((doc) => {
|
|
494
737
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
495
738
|
if (!target)
|
|
496
739
|
return doc;
|
|
497
740
|
if (description !== undefined)
|
|
498
741
|
target.description = description.trim();
|
|
742
|
+
if (rawDescriptionRef !== undefined) {
|
|
743
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
744
|
+
if (descriptionRef)
|
|
745
|
+
target.descriptionRef = descriptionRef;
|
|
746
|
+
else
|
|
747
|
+
delete target.descriptionRef;
|
|
748
|
+
}
|
|
499
749
|
if (workDone !== undefined)
|
|
500
750
|
target.workDone = workDone.trim();
|
|
501
751
|
if (workRemaining !== undefined)
|
|
@@ -509,7 +759,7 @@ server.registerTool("planner-feature-discuss", {
|
|
|
509
759
|
return doc;
|
|
510
760
|
});
|
|
511
761
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
512
|
-
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}
|
|
762
|
+
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, discussed: true, updatedFields: receivedFields });
|
|
513
763
|
});
|
|
514
764
|
server.registerTool("planner-feature-update", {
|
|
515
765
|
description: "Update a feature.",
|
|
@@ -517,6 +767,7 @@ server.registerTool("planner-feature-update", {
|
|
|
517
767
|
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
518
768
|
name: z.string().optional(),
|
|
519
769
|
description: z.string().optional(),
|
|
770
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full feature description."),
|
|
520
771
|
status: z.enum(STATUS_VALUES).optional(),
|
|
521
772
|
workDone: z.string().optional(),
|
|
522
773
|
workRemaining: z.string().optional(),
|
|
@@ -531,6 +782,27 @@ server.registerTool("planner-feature-update", {
|
|
|
531
782
|
if (!resolvedFeature.ok)
|
|
532
783
|
return text(resolvedFeature.error);
|
|
533
784
|
const feature = resolvedFeature.feature;
|
|
785
|
+
if (updates.status !== undefined) {
|
|
786
|
+
return derivedStatusReadOnlyResult("feature", formatFeatureRef(feature.number), updates.status, feature.status);
|
|
787
|
+
}
|
|
788
|
+
const mutableFields = ["name", "description", "descriptionRef", "workDone", "workRemaining", "startDate", "endDate", "priority"];
|
|
789
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
790
|
+
if (receivedFields.length === 0) {
|
|
791
|
+
return mutationNoFieldsFailure({
|
|
792
|
+
entity: "feature",
|
|
793
|
+
ref: formatFeatureRef(feature.number),
|
|
794
|
+
operation: "update",
|
|
795
|
+
mutableFields,
|
|
796
|
+
retryCommand: `planner-feature-update ${formatFeatureRef(feature.number)}`,
|
|
797
|
+
readBackCommand: `planner-feature-show ${formatFeatureRef(feature.number)} with full=true`,
|
|
798
|
+
descriptionFallback: {
|
|
799
|
+
entityId: feature.id,
|
|
800
|
+
inputField: "description",
|
|
801
|
+
storedField: "feature description",
|
|
802
|
+
descriptionRefField: "descriptionRef",
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
}
|
|
534
806
|
const updated = await st.updateFeatures((doc) => {
|
|
535
807
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
536
808
|
if (!target)
|
|
@@ -539,8 +811,13 @@ server.registerTool("planner-feature-update", {
|
|
|
539
811
|
target.name = updates.name.trim();
|
|
540
812
|
if (updates.description !== undefined)
|
|
541
813
|
target.description = updates.description.trim();
|
|
542
|
-
if (updates.
|
|
543
|
-
|
|
814
|
+
if (updates.descriptionRef !== undefined) {
|
|
815
|
+
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
816
|
+
if (descriptionRef)
|
|
817
|
+
target.descriptionRef = descriptionRef;
|
|
818
|
+
else
|
|
819
|
+
delete target.descriptionRef;
|
|
820
|
+
}
|
|
544
821
|
if (updates.workDone !== undefined)
|
|
545
822
|
target.workDone = updates.workDone.trim();
|
|
546
823
|
if (updates.workRemaining !== undefined)
|
|
@@ -555,7 +832,7 @@ server.registerTool("planner-feature-update", {
|
|
|
555
832
|
return doc;
|
|
556
833
|
});
|
|
557
834
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
558
|
-
return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}
|
|
835
|
+
return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { feature: result, updated: true, updatedFields: receivedFields });
|
|
559
836
|
});
|
|
560
837
|
server.registerTool("planner-feature-delete", {
|
|
561
838
|
description: "Delete a feature. By default, phases are unlinked rather than deleted.",
|
|
@@ -649,6 +926,7 @@ server.registerTool("planner-phase-add", {
|
|
|
649
926
|
updatedAt: timestamp,
|
|
650
927
|
handoff: "",
|
|
651
928
|
handoffUpdatedAt: "",
|
|
929
|
+
handoffAudit: null,
|
|
652
930
|
handoffReadAt: "",
|
|
653
931
|
handoffHistory: [],
|
|
654
932
|
statusLog: [],
|
|
@@ -672,7 +950,8 @@ server.registerTool("planner-phase-add", {
|
|
|
672
950
|
server.registerTool("planner-phase-show", {
|
|
673
951
|
description: "Show a phase by id or name, including derived linked requirements in the full view.",
|
|
674
952
|
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).") },
|
|
675
|
-
}, async ({ phase: ref, full }) => {
|
|
953
|
+
}, async ({ phase: ref, full }, extra) => {
|
|
954
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
676
955
|
const st = await requireStore();
|
|
677
956
|
const features = (await st.loadFeatures()).features;
|
|
678
957
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
@@ -683,10 +962,11 @@ server.registerTool("planner-phase-show", {
|
|
|
683
962
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
684
963
|
const reqCount = linkedRequirements.length;
|
|
685
964
|
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
|
+
const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
|
|
686
966
|
const requirementsBlock = reqCount > 0
|
|
687
967
|
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
688
968
|
: "";
|
|
689
|
-
return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
|
|
969
|
+
return text(full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary, {
|
|
690
970
|
phase: {
|
|
691
971
|
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
692
972
|
shortId: phase.shortId,
|
|
@@ -694,7 +974,7 @@ server.registerTool("planner-phase-show", {
|
|
|
694
974
|
summary: phase.summary,
|
|
695
975
|
status: phase.status,
|
|
696
976
|
taskCount: phase.tasks.length,
|
|
697
|
-
...(full ? { description: phase.description } : {}),
|
|
977
|
+
...(full ? { description: phase.description, descriptionRef: phase.descriptionRef || "" } : {}),
|
|
698
978
|
},
|
|
699
979
|
linkedRequirements,
|
|
700
980
|
});
|
|
@@ -706,6 +986,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
706
986
|
goal: z.string().optional(),
|
|
707
987
|
summary: z.string().optional(),
|
|
708
988
|
scope: z.string().optional(),
|
|
989
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
709
990
|
nonGoals: z.array(z.string()).optional(),
|
|
710
991
|
dependencies: z.array(z.string()).optional(),
|
|
711
992
|
risks: z.array(z.string()).optional(),
|
|
@@ -717,6 +998,24 @@ server.registerTool("planner-phase-discuss", {
|
|
|
717
998
|
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
718
999
|
if (!found)
|
|
719
1000
|
return text(`Phase not found: ${ref}`);
|
|
1001
|
+
const mutableFields = ["goal", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "completionCriteria"];
|
|
1002
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1003
|
+
if (receivedFields.length === 0) {
|
|
1004
|
+
return mutationNoFieldsFailure({
|
|
1005
|
+
entity: "phase",
|
|
1006
|
+
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1007
|
+
operation: "discuss",
|
|
1008
|
+
mutableFields,
|
|
1009
|
+
retryCommand: `planner-phase-discuss ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
|
|
1010
|
+
readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
|
|
1011
|
+
descriptionFallback: {
|
|
1012
|
+
entityId: found.id,
|
|
1013
|
+
inputField: "scope",
|
|
1014
|
+
storedField: "phase description",
|
|
1015
|
+
descriptionRefField: "descriptionRef",
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
720
1019
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
721
1020
|
if (updates.goal !== undefined)
|
|
722
1021
|
entry.goals = [updates.goal.trim()].filter(Boolean);
|
|
@@ -724,6 +1023,13 @@ server.registerTool("planner-phase-discuss", {
|
|
|
724
1023
|
entry.summary = updates.summary.trim();
|
|
725
1024
|
if (updates.scope !== undefined)
|
|
726
1025
|
entry.description = updates.scope.trim();
|
|
1026
|
+
if (updates.descriptionRef !== undefined) {
|
|
1027
|
+
const descriptionRef = normalizeDescriptionRef(updates.descriptionRef);
|
|
1028
|
+
if (descriptionRef)
|
|
1029
|
+
entry.descriptionRef = descriptionRef;
|
|
1030
|
+
else
|
|
1031
|
+
delete entry.descriptionRef;
|
|
1032
|
+
}
|
|
727
1033
|
if (updates.nonGoals !== undefined)
|
|
728
1034
|
entry.nonGoals = updates.nonGoals.map((item) => item.trim()).filter(Boolean);
|
|
729
1035
|
if (updates.dependencies !== undefined)
|
|
@@ -739,7 +1045,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
739
1045
|
entry.updatedAt = nowISO();
|
|
740
1046
|
return entry;
|
|
741
1047
|
});
|
|
742
|
-
return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}
|
|
1048
|
+
return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, discussed: true, updatedFields: receivedFields });
|
|
743
1049
|
});
|
|
744
1050
|
server.registerTool("planner-phase-update", {
|
|
745
1051
|
description: "Update phase fields.",
|
|
@@ -749,6 +1055,7 @@ server.registerTool("planner-phase-update", {
|
|
|
749
1055
|
status: z.enum(PHASE_STATUS_VALUES).optional(),
|
|
750
1056
|
summary: z.string().optional(),
|
|
751
1057
|
description: z.string().optional(),
|
|
1058
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full phase description."),
|
|
752
1059
|
priority: z.number().int().nonnegative().optional().describe("Display order within the feature (lower = higher)."),
|
|
753
1060
|
},
|
|
754
1061
|
}, async ({ phase: ref, ...updates }) => {
|
|
@@ -757,21 +1064,47 @@ server.registerTool("planner-phase-update", {
|
|
|
757
1064
|
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
758
1065
|
if (!found)
|
|
759
1066
|
return text(`Phase not found: ${ref}`);
|
|
1067
|
+
if (updates.status !== undefined) {
|
|
1068
|
+
return derivedStatusReadOnlyResult("phase", formatPhaseRef(found.number, featureNumberOfPhase(found, features)), updates.status, found.status);
|
|
1069
|
+
}
|
|
1070
|
+
const mutableFields = ["title", "summary", "description", "descriptionRef", "priority"];
|
|
1071
|
+
const receivedFields = mutableFields.filter((field) => updates[field] !== undefined);
|
|
1072
|
+
if (receivedFields.length === 0) {
|
|
1073
|
+
return mutationNoFieldsFailure({
|
|
1074
|
+
entity: "phase",
|
|
1075
|
+
ref: formatPhaseRef(found.number, featureNumberOfPhase(found, features)),
|
|
1076
|
+
operation: "update",
|
|
1077
|
+
mutableFields,
|
|
1078
|
+
retryCommand: `planner-phase-update ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))}`,
|
|
1079
|
+
readBackCommand: `planner-phase-show ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} with full=true`,
|
|
1080
|
+
descriptionFallback: {
|
|
1081
|
+
entityId: found.id,
|
|
1082
|
+
inputField: "description",
|
|
1083
|
+
storedField: "phase description",
|
|
1084
|
+
descriptionRefField: "descriptionRef",
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
760
1088
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
761
1089
|
if (updates.title !== undefined)
|
|
762
1090
|
entry.title = updates.title.trim();
|
|
763
|
-
if (updates.status !== undefined)
|
|
764
|
-
entry.status = updates.status;
|
|
765
1091
|
if (updates.summary !== undefined)
|
|
766
1092
|
entry.summary = updates.summary.trim();
|
|
767
1093
|
if (updates.description !== undefined)
|
|
768
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;
|
|
1101
|
+
}
|
|
769
1102
|
if (updates.priority !== undefined)
|
|
770
1103
|
entry.priority = updates.priority;
|
|
771
1104
|
entry.updatedAt = nowISO();
|
|
772
1105
|
return entry;
|
|
773
1106
|
});
|
|
774
|
-
return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}
|
|
1107
|
+
return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`, { phase, updated: true, updatedFields: receivedFields });
|
|
775
1108
|
});
|
|
776
1109
|
server.registerTool("planner-phase-delete", {
|
|
777
1110
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -866,7 +1199,8 @@ server.registerTool("planner-task-add", {
|
|
|
866
1199
|
server.registerTool("planner-task-show", {
|
|
867
1200
|
description: "Show a task by id or name.",
|
|
868
1201
|
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).") },
|
|
869
|
-
}, async ({ task: ref, full }) => {
|
|
1202
|
+
}, async ({ task: ref, full }, extra) => {
|
|
1203
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
870
1204
|
const st = await requireStore();
|
|
871
1205
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
872
1206
|
if (!found)
|
|
@@ -889,6 +1223,8 @@ server.registerTool("planner-task-show", {
|
|
|
889
1223
|
const sections = [summary];
|
|
890
1224
|
if (found.task.description?.trim())
|
|
891
1225
|
sections.push(found.task.description.trim());
|
|
1226
|
+
if (found.task.descriptionRef)
|
|
1227
|
+
sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
|
|
892
1228
|
if (snapshot || pendingDeviation) {
|
|
893
1229
|
sections.push([
|
|
894
1230
|
pendingDeviation
|
|
@@ -902,20 +1238,40 @@ server.registerTool("planner-task-show", {
|
|
|
902
1238
|
}
|
|
903
1239
|
if (log)
|
|
904
1240
|
sections.push(`Status log:\n${log}`);
|
|
905
|
-
return text(sections.join("\n\n"));
|
|
1241
|
+
return text(sections.join("\n\n"), { task: { ref: taskCompositeRef(found.task, found.phase, features), description: found.task.description, descriptionRef: found.task.descriptionRef || "" } });
|
|
906
1242
|
});
|
|
907
1243
|
server.registerTool("planner-task-discuss", {
|
|
908
1244
|
description: "Persist task execution notes/checklist.",
|
|
909
1245
|
inputSchema: {
|
|
910
1246
|
task: z.string().min(1),
|
|
911
1247
|
description: z.string().optional(),
|
|
1248
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
912
1249
|
checklist: z.array(z.string()).optional(),
|
|
913
1250
|
},
|
|
914
|
-
}, async ({ task: ref, description, checklist }) => {
|
|
1251
|
+
}, async ({ task: ref, description, descriptionRef: rawDescriptionRef, checklist }) => {
|
|
915
1252
|
const st = await requireStore();
|
|
916
1253
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
917
1254
|
if (!found)
|
|
918
1255
|
return text(`Task not found: ${ref}`);
|
|
1256
|
+
const mutableFields = ["description", "descriptionRef", "checklist"];
|
|
1257
|
+
const discussInputs = { description, descriptionRef: rawDescriptionRef, checklist };
|
|
1258
|
+
const receivedFields = mutableFields.filter((field) => discussInputs[field] !== undefined);
|
|
1259
|
+
if (receivedFields.length === 0) {
|
|
1260
|
+
return mutationNoFieldsFailure({
|
|
1261
|
+
entity: "task",
|
|
1262
|
+
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1263
|
+
operation: "discuss",
|
|
1264
|
+
mutableFields,
|
|
1265
|
+
retryCommand: `planner-task-discuss ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
|
|
1266
|
+
readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
|
|
1267
|
+
descriptionFallback: {
|
|
1268
|
+
entityId: found.task.id,
|
|
1269
|
+
inputField: "description",
|
|
1270
|
+
storedField: "task description",
|
|
1271
|
+
descriptionRefField: "descriptionRef",
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
919
1275
|
let updatedTask;
|
|
920
1276
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
921
1277
|
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
@@ -923,8 +1279,13 @@ server.registerTool("planner-task-discuss", {
|
|
|
923
1279
|
return phase;
|
|
924
1280
|
if (description !== undefined)
|
|
925
1281
|
task.description = description.trim();
|
|
926
|
-
if (
|
|
927
|
-
|
|
1282
|
+
if (rawDescriptionRef !== undefined) {
|
|
1283
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
1284
|
+
if (descriptionRef)
|
|
1285
|
+
task.descriptionRef = descriptionRef;
|
|
1286
|
+
else
|
|
1287
|
+
delete task.descriptionRef;
|
|
1288
|
+
}
|
|
928
1289
|
if (checklist !== undefined)
|
|
929
1290
|
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
930
1291
|
task.updatedAt = nowISO();
|
|
@@ -934,7 +1295,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
934
1295
|
});
|
|
935
1296
|
const features = (await st.loadFeatures()).features;
|
|
936
1297
|
const t = updatedTask ?? found.task;
|
|
937
|
-
return writeAndSummarize(st, `✅ Task discussed/updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}
|
|
1298
|
+
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 });
|
|
938
1299
|
});
|
|
939
1300
|
server.registerTool("planner-task-update", {
|
|
940
1301
|
description: "Update task fields. A motivation is REQUIRED when changing to blocked, canceled, deferred, rejected, waiting, or back to planned from another status.",
|
|
@@ -943,11 +1304,12 @@ server.registerTool("planner-task-update", {
|
|
|
943
1304
|
title: z.string().optional(),
|
|
944
1305
|
status: z.enum(STATUS_VALUES).optional(),
|
|
945
1306
|
description: z.string().optional(),
|
|
1307
|
+
descriptionRef: z.string().optional().describe("Optional markdown reference under .planner/docs/ for the full task description."),
|
|
946
1308
|
motivation: z.string().optional(),
|
|
947
1309
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
948
1310
|
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."),
|
|
949
1311
|
},
|
|
950
|
-
}, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
|
|
1312
|
+
}, async ({ task: ref, title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist }) => {
|
|
951
1313
|
const st = await requireStore();
|
|
952
1314
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
953
1315
|
if (!found)
|
|
@@ -965,6 +1327,25 @@ server.registerTool("planner-task-update", {
|
|
|
965
1327
|
`Provide the \"motivation\" parameter with a detailed explanation of why this change is needed.`);
|
|
966
1328
|
}
|
|
967
1329
|
}
|
|
1330
|
+
const mutableFields = ["title", "status", "description", "descriptionRef", "motivation", "priority", "checklist"];
|
|
1331
|
+
const updateInputs = { title, status, description, descriptionRef: rawDescriptionRef, motivation, priority, checklist };
|
|
1332
|
+
const receivedFields = mutableFields.filter((field) => updateInputs[field] !== undefined);
|
|
1333
|
+
if (receivedFields.length === 0) {
|
|
1334
|
+
return mutationNoFieldsFailure({
|
|
1335
|
+
entity: "task",
|
|
1336
|
+
ref: taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features),
|
|
1337
|
+
operation: "update",
|
|
1338
|
+
mutableFields,
|
|
1339
|
+
retryCommand: `planner-task-update ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)}`,
|
|
1340
|
+
readBackCommand: `planner-task-show ${taskCompositeRef(found.task, found.phase, (await st.loadFeatures()).features)} with full=true`,
|
|
1341
|
+
descriptionFallback: {
|
|
1342
|
+
entityId: found.task.id,
|
|
1343
|
+
inputField: "description",
|
|
1344
|
+
storedField: "task description",
|
|
1345
|
+
descriptionRefField: "descriptionRef",
|
|
1346
|
+
},
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
968
1349
|
let updatedTask;
|
|
969
1350
|
const timestamp = nowISO();
|
|
970
1351
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -977,6 +1358,13 @@ server.registerTool("planner-task-update", {
|
|
|
977
1358
|
task.priority = priority;
|
|
978
1359
|
if (description !== undefined)
|
|
979
1360
|
task.description = description.trim();
|
|
1361
|
+
if (rawDescriptionRef !== undefined) {
|
|
1362
|
+
const descriptionRef = normalizeDescriptionRef(rawDescriptionRef);
|
|
1363
|
+
if (descriptionRef)
|
|
1364
|
+
task.descriptionRef = descriptionRef;
|
|
1365
|
+
else
|
|
1366
|
+
delete task.descriptionRef;
|
|
1367
|
+
}
|
|
980
1368
|
if (status !== undefined && status !== task.status) {
|
|
981
1369
|
// Record status change in the incremental statusLog.
|
|
982
1370
|
const entry = {
|
|
@@ -1013,7 +1401,7 @@ server.registerTool("planner-task-update", {
|
|
|
1013
1401
|
}
|
|
1014
1402
|
}
|
|
1015
1403
|
}
|
|
1016
|
-
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } :
|
|
1404
|
+
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${resumeNotice}`, resumeRequired ? { task: t, updated: true, updatedFields: receivedFields, resumeRequired } : { task: t, updated: true, updatedFields: receivedFields });
|
|
1017
1405
|
});
|
|
1018
1406
|
server.registerTool("planner-task-checklist-toggle", {
|
|
1019
1407
|
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.",
|
|
@@ -1186,7 +1574,8 @@ server.registerTool("planner-task-pause", {
|
|
|
1186
1574
|
how_to_resume: z.string().min(1),
|
|
1187
1575
|
paused_by: z.string().optional(),
|
|
1188
1576
|
},
|
|
1189
|
-
}, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }) => {
|
|
1577
|
+
}, async ({ task: ref, reason, what_was_being_done, resume_location, how_to_resume, paused_by }, extra) => {
|
|
1578
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1190
1579
|
const st = await requireStore();
|
|
1191
1580
|
const features = (await st.loadFeatures()).features;
|
|
1192
1581
|
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
@@ -1223,7 +1612,7 @@ server.registerTool("planner-task-pause", {
|
|
|
1223
1612
|
].join("\n"), { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) });
|
|
1224
1613
|
});
|
|
1225
1614
|
server.registerTool("planner-task-switch", {
|
|
1226
|
-
description: "Atomically switch from active work to a temporary task, even outside normal priority order.
|
|
1615
|
+
description: "Atomically switch from active work to a temporary task, even outside normal priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes planner-project-guidelines-show, read that section and keep it in working memory while doing task work. Pause the source with a mandatory snapshot, start the target, and push a durable LIFO return target.",
|
|
1227
1616
|
inputSchema: {
|
|
1228
1617
|
from_task: z.string().min(1),
|
|
1229
1618
|
to_task: z.string().min(1),
|
|
@@ -1233,7 +1622,8 @@ server.registerTool("planner-task-switch", {
|
|
|
1233
1622
|
how_to_resume: z.string().min(1),
|
|
1234
1623
|
switched_by: z.string().optional(),
|
|
1235
1624
|
},
|
|
1236
|
-
}, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }) => {
|
|
1625
|
+
}, async ({ from_task, to_task, reason, what_was_being_done, resume_location, how_to_resume, switched_by }, extra) => {
|
|
1626
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1237
1627
|
const st = await requireStore();
|
|
1238
1628
|
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
1239
1629
|
const features = featuresDoc.features;
|
|
@@ -1266,10 +1656,22 @@ server.registerTool("planner-task-switch", {
|
|
|
1266
1656
|
requirementIds: linkedRequirementIds,
|
|
1267
1657
|
};
|
|
1268
1658
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1659
|
+
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
1660
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1661
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
1662
|
+
const targetTaskRef = taskCompositeRef(target.task, target.phase, features);
|
|
1663
|
+
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
1664
|
+
const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
|
|
1665
|
+
if (!projectGuidelinesReady) {
|
|
1666
|
+
const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
|
|
1667
|
+
return text(`Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.`, { projectGuidelinesReadState, contextEligibility, nextActions });
|
|
1668
|
+
}
|
|
1669
|
+
if (!contextEligibility.eligible) {
|
|
1670
|
+
const nextActions = mcpContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry planner-task-switch to ${targetTaskRef}`);
|
|
1671
|
+
return text(`Task switch denied: context reads required. ${contextEligibility.reason}`, { ...contextEligibility, nextActions });
|
|
1672
|
+
}
|
|
1673
|
+
if (!requirementsReady) {
|
|
1674
|
+
return text("Task switch denied: read the requirements linked to the target phase and feature before switching.", { requirementIds: linkedRequirementIds, nextActions: ["planner-requirement-list", `Retry planner-task-switch to ${targetTaskRef}`] });
|
|
1273
1675
|
}
|
|
1274
1676
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
1275
1677
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
@@ -1371,9 +1773,10 @@ server.registerTool("planner-task-switch", {
|
|
|
1371
1773
|
].join("\n"), { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id });
|
|
1372
1774
|
});
|
|
1373
1775
|
server.registerTool("planner-task-start", {
|
|
1374
|
-
description: "Set a task to in-progress or resume checkpointed work.
|
|
1776
|
+
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.",
|
|
1375
1777
|
inputSchema: { task: z.string().min(1) },
|
|
1376
|
-
}, async ({ task: ref }) => {
|
|
1778
|
+
}, async ({ task: ref }, extra) => {
|
|
1779
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1377
1780
|
let st;
|
|
1378
1781
|
try {
|
|
1379
1782
|
st = await requireStore();
|
|
@@ -1389,12 +1792,9 @@ server.registerTool("planner-task-start", {
|
|
|
1389
1792
|
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
1390
1793
|
const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
1391
1794
|
const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
|
|
1392
|
-
if (found.task.status === "in-progress") {
|
|
1393
|
-
const outcome = taskStartSucceeded(found.task.id, true);
|
|
1394
|
-
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
|
|
1395
|
-
}
|
|
1396
1795
|
if (found.task.status === "done")
|
|
1397
|
-
return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `
|
|
1796
|
+
return taskStartError(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use planner-task-update with status=planned and a motivation to reopen ${taskRef}.`, `Retry planner-task-start ${taskRef}. If it then reports read prerequisites, perform only the missing or stale nextActions before retrying again.`], { taskId: found.task.id }));
|
|
1797
|
+
const project = await st.loadProject();
|
|
1398
1798
|
const linkedRequirements = [
|
|
1399
1799
|
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
1400
1800
|
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
@@ -1412,21 +1812,24 @@ server.registerTool("planner-task-start", {
|
|
|
1412
1812
|
requirementIds: linkedRequirementIds,
|
|
1413
1813
|
};
|
|
1414
1814
|
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
1815
|
+
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
1816
|
+
const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
|
|
1817
|
+
const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
|
|
1818
|
+
if (!projectGuidelinesReady)
|
|
1819
|
+
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 });
|
|
1415
1820
|
if (!contextEligibility.eligible)
|
|
1416
|
-
return taskStartError(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`,
|
|
1417
|
-
|
|
1418
|
-
`planner-phase-show ${phaseRef} with full=true`,
|
|
1419
|
-
...(featureRef ? [`planner-feature-show ${featureRef} with full=true`] : []),
|
|
1420
|
-
...(linkedRequirementIds.length > 0 ? ["planner-requirement-list"] : []),
|
|
1421
|
-
`Retry planner-task-start ${taskRef}`,
|
|
1422
|
-
], { taskId: found.task.id }), { contextEligibility });
|
|
1423
|
-
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
1821
|
+
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 });
|
|
1822
|
+
if (!requirementsReady) {
|
|
1424
1823
|
return taskStartError(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["planner-requirement-list", `Retry planner-task-start ${taskRef}`], { taskId: found.task.id, requirementIds: linkedRequirementIds }));
|
|
1425
1824
|
}
|
|
1426
1825
|
if (!hasValidSessionAttestation(contextInput)) {
|
|
1427
1826
|
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: found.phase.id, taskId: found.task.id, ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
1428
1827
|
}
|
|
1429
|
-
|
|
1828
|
+
if (found.task.status === "in-progress") {
|
|
1829
|
+
const outcome = taskStartSucceeded(found.task.id, true);
|
|
1830
|
+
return text(`✅ Task already started: ${taskRef} — ${found.task.title} (in-progress)\nstarted: true`, { ...outcome, task: found.task });
|
|
1831
|
+
}
|
|
1832
|
+
const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
|
|
1430
1833
|
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
1431
1834
|
if (!eligibility.eligible)
|
|
1432
1835
|
return taskStartError(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry planner-task-start."], { taskId: found.task.id }), { eligibility });
|
|
@@ -1599,14 +2002,20 @@ server.registerTool("planner-task-complete", {
|
|
|
1599
2002
|
return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}${resumeNotice}`, resumeRequired ? { resumeRequired } : undefined);
|
|
1600
2003
|
});
|
|
1601
2004
|
server.registerTool("planner-handoff-list", {
|
|
1602
|
-
description: "List
|
|
1603
|
-
|
|
2005
|
+
description: "List active handoffs as compact summary-only pages. Full bodies are never embedded; call planner-handoff-show for one exact phase.",
|
|
2006
|
+
inputSchema: {
|
|
2007
|
+
page: z.number().int().min(1).default(1).describe("1-based page number."),
|
|
2008
|
+
pageSize: z.number().int().min(1).max(25).default(10).describe("Summaries per page; maximum 25."),
|
|
2009
|
+
},
|
|
2010
|
+
}, async ({ page, pageSize }) => {
|
|
1604
2011
|
const st = await requireStore();
|
|
1605
2012
|
const list = await st.listHandoffs();
|
|
1606
2013
|
if (list.length === 0)
|
|
1607
|
-
return text("No phase handoffs set.");
|
|
1608
|
-
const
|
|
1609
|
-
|
|
2014
|
+
return text("No phase handoffs set.", { count: 0, total: 0, page: 1, pageSize, totalPages: 1, handoffs: [] });
|
|
2015
|
+
const result = boundedPage(list, page, pageSize);
|
|
2016
|
+
const { items, ...pageInfo } = result;
|
|
2017
|
+
const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
|
|
2018
|
+
return text(`Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}`, { count: items.length, ...pageInfo, handoffs: items });
|
|
1610
2019
|
});
|
|
1611
2020
|
server.registerTool("planner-handoff-show", {
|
|
1612
2021
|
description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
@@ -1616,22 +2025,244 @@ server.registerTool("planner-handoff-show", {
|
|
|
1616
2025
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
1617
2026
|
if (!r.ok)
|
|
1618
2027
|
return text(`❌ ${r.error}`);
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2028
|
+
const phase = await st.loadPhase(r.phase.id);
|
|
2029
|
+
const bounded = boundedHandoffForTransport(phase.handoff);
|
|
2030
|
+
if (!bounded.content.trim())
|
|
2031
|
+
return text(`No handoff set on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, handoffAudit: phase.handoffAudit });
|
|
2032
|
+
return text(`Handoff for ${r.compositeRef}:\n\n${bounded.content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit });
|
|
1623
2033
|
});
|
|
1624
2034
|
server.registerTool("planner-requirement-list", {
|
|
1625
2035
|
description: "List all top-level requirements in requirements.json. Reading this list records the requirements as read for the start/resume read-enforcement (explicit requirement read, required alongside feature/phase reads).",
|
|
1626
2036
|
inputSchema: {},
|
|
1627
|
-
}, async () => {
|
|
2037
|
+
}, async (_args, extra) => {
|
|
2038
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
1628
2039
|
const st = await requireStore();
|
|
1629
2040
|
const requirements = await st.loadRequirements();
|
|
1630
2041
|
requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
|
|
1631
2042
|
return text(requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements", { requirements });
|
|
1632
2043
|
});
|
|
2044
|
+
server.registerTool("planner-requirement-create", {
|
|
2045
|
+
description: "Create a top-level requirement with optional nested macro tasks. Requirement and macro-task IDs/timestamps are assigned by the planner.",
|
|
2046
|
+
inputSchema: {
|
|
2047
|
+
title: z.string().min(1),
|
|
2048
|
+
description: z.string().optional(),
|
|
2049
|
+
status: z.enum(STATUS_VALUES).optional(),
|
|
2050
|
+
linkedPhaseIds: z.array(z.string().min(1)),
|
|
2051
|
+
macroTasks: z.array(z.object({
|
|
2052
|
+
title: z.string().min(1),
|
|
2053
|
+
description: z.string().optional(),
|
|
2054
|
+
status: z.enum(STATUS_VALUES).optional(),
|
|
2055
|
+
})).optional(),
|
|
2056
|
+
},
|
|
2057
|
+
}, async ({ title, description, status, linkedPhaseIds, macroTasks }) => {
|
|
2058
|
+
const st = await requireStore();
|
|
2059
|
+
const links = await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2060
|
+
if (!links.ok)
|
|
2061
|
+
return text(`❌ ${links.error}`, { created: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2062
|
+
const now = nowISO();
|
|
2063
|
+
let normalizedMacroTasks;
|
|
2064
|
+
try {
|
|
2065
|
+
normalizedMacroTasks = reconcileRequirementMacroTasks([], (macroTasks ?? []).map((task) => ({
|
|
2066
|
+
title: task.title,
|
|
2067
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
2068
|
+
status: task.status ?? "planned",
|
|
2069
|
+
})), now);
|
|
2070
|
+
}
|
|
2071
|
+
catch (error) {
|
|
2072
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
2073
|
+
return text(`❌ ${message}`, { created: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2074
|
+
}
|
|
2075
|
+
const requirement = {
|
|
2076
|
+
id: createRequirementId(),
|
|
2077
|
+
title: title.trim(),
|
|
2078
|
+
description: description?.trim() ?? "",
|
|
2079
|
+
status: status ?? "planned",
|
|
2080
|
+
macroTasks: normalizedMacroTasks,
|
|
2081
|
+
linkedPhaseIds: links.linkedPhaseIds,
|
|
2082
|
+
createdAt: now,
|
|
2083
|
+
updatedAt: now,
|
|
2084
|
+
sessionInfo: [],
|
|
2085
|
+
};
|
|
2086
|
+
const requirements = await st.loadRequirements();
|
|
2087
|
+
await st.saveRequirements({ requirements: [...requirements.requirements, requirement] });
|
|
2088
|
+
await st.writeGenerated();
|
|
2089
|
+
return writeAndSummarize(st, `Requirement created: ${requirement.id}.`, { requirement, created: true });
|
|
2090
|
+
});
|
|
2091
|
+
server.registerTool("planner-requirement-update", {
|
|
2092
|
+
description: "Update a requirement’s human-owned title, description, status, linked phases, or complete ordered macro-task list. Existing macro-task IDs and timestamps remain planner-managed; omit an ID to add a task.",
|
|
2093
|
+
inputSchema: {
|
|
2094
|
+
requirementId: z.string().min(1),
|
|
2095
|
+
title: z.string().min(1).optional(),
|
|
2096
|
+
description: z.string().optional(),
|
|
2097
|
+
status: z.enum(STATUS_VALUES).optional(),
|
|
2098
|
+
linkedPhaseIds: z.array(z.string().min(1)).optional(),
|
|
2099
|
+
macroTasks: z.array(z.object({
|
|
2100
|
+
id: z.string().min(1).optional(),
|
|
2101
|
+
title: z.string().min(1),
|
|
2102
|
+
description: z.string().optional(),
|
|
2103
|
+
status: z.enum(STATUS_VALUES),
|
|
2104
|
+
})).optional(),
|
|
2105
|
+
},
|
|
2106
|
+
}, async ({ requirementId, title, description, status, linkedPhaseIds, macroTasks }) => {
|
|
2107
|
+
const st = await requireStore();
|
|
2108
|
+
const requirements = await st.loadRequirements();
|
|
2109
|
+
const current = requirements.requirements.find((requirement) => requirement.id === requirementId);
|
|
2110
|
+
if (!current)
|
|
2111
|
+
return text(`❌ Requirement not found: ${requirementId}`, { updated: false, errorCode: "NOT_FOUND" });
|
|
2112
|
+
const links = linkedPhaseIds === undefined ? { ok: true, linkedPhaseIds: current.linkedPhaseIds } : await resolveRequirementPhaseRefs(st, linkedPhaseIds);
|
|
2113
|
+
if (!links.ok)
|
|
2114
|
+
return text(`❌ ${links.error}`, { updated: false, errorCode: "REQUIREMENT_PHASE_LINK_INVALID" });
|
|
2115
|
+
const now = nowISO();
|
|
2116
|
+
let normalizedMacroTasks = current.macroTasks;
|
|
2117
|
+
if (macroTasks !== undefined) {
|
|
2118
|
+
try {
|
|
2119
|
+
normalizedMacroTasks = reconcileRequirementMacroTasks(current.macroTasks, macroTasks.map((task) => ({
|
|
2120
|
+
...(task.id !== undefined ? { id: task.id } : {}),
|
|
2121
|
+
title: task.title,
|
|
2122
|
+
...(task.description !== undefined ? { description: task.description } : {}),
|
|
2123
|
+
status: task.status,
|
|
2124
|
+
})), now);
|
|
2125
|
+
}
|
|
2126
|
+
catch (error) {
|
|
2127
|
+
const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
|
|
2128
|
+
return text(`❌ ${message}`, { updated: false, errorCode: "REQUIREMENT_MACRO_TASK_INVALID" });
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
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
|
+
await st.writeGenerated();
|
|
2142
|
+
return writeAndSummarize(st, `Requirement updated: ${next.id}.`, { requirement: next, updated: true });
|
|
2143
|
+
});
|
|
2144
|
+
server.registerTool("planner-requirement-delete", {
|
|
2145
|
+
description: "Permanently delete a top-level requirement. Confirm the intended requirement first.",
|
|
2146
|
+
inputSchema: { requirementId: z.string().min(1) },
|
|
2147
|
+
}, async ({ requirementId }) => {
|
|
2148
|
+
const st = await requireStore();
|
|
2149
|
+
const requirements = await st.loadRequirements();
|
|
2150
|
+
const next = requirements.requirements.filter((requirement) => requirement.id !== requirementId);
|
|
2151
|
+
if (next.length === requirements.requirements.length)
|
|
2152
|
+
return text(`❌ Requirement not found: ${requirementId}`, { deleted: false, errorCode: "NOT_FOUND" });
|
|
2153
|
+
await st.saveRequirements({ requirements: next });
|
|
2154
|
+
await st.writeGenerated();
|
|
2155
|
+
return writeAndSummarize(st, `Requirement deleted: ${requirementId}.`, { deleted: requirementId });
|
|
2156
|
+
});
|
|
2157
|
+
function ideaPromotionTarget(st, type, ref) {
|
|
2158
|
+
return Promise.all([st.loadFeatures(), st.loadAllPhases()]).then(([featuresDoc, phases]) => {
|
|
2159
|
+
const features = featuresDoc.features;
|
|
2160
|
+
if (type === "feature") {
|
|
2161
|
+
const feature = findFeatureByRef(features, ref);
|
|
2162
|
+
return feature ? { id: feature.id, ref: formatFeatureRef(feature.number) } : undefined;
|
|
2163
|
+
}
|
|
2164
|
+
if (type === "phase") {
|
|
2165
|
+
const phase = findPhaseByRef(phases, features, ref);
|
|
2166
|
+
return phase ? { id: phase.id, ref: formatPhaseRef(phase.number, features.find((item) => item.id === phase.featureId)?.number) } : undefined;
|
|
2167
|
+
}
|
|
2168
|
+
const found = findTaskByRef(phases, features, ref);
|
|
2169
|
+
return found ? { id: found.task.id, ref: `${formatPhaseRef(found.phase.number, features.find((item) => item.id === found.phase.featureId)?.number)}/T${String(found.task.number).padStart(3, "0")}` } : undefined;
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
server.registerTool("planner-idea-list", {
|
|
2173
|
+
description: "List top-level Ideas Inbox entries. Ideas never affect feature, phase, or task rollups.",
|
|
2174
|
+
inputSchema: {},
|
|
2175
|
+
}, async () => {
|
|
2176
|
+
const ideas = (await requireStore().then((st) => st.loadIdeas())).ideas;
|
|
2177
|
+
return text(ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas", { ideas });
|
|
2178
|
+
});
|
|
2179
|
+
server.registerTool("planner-idea-show", {
|
|
2180
|
+
description: "Show one Idea Inbox entry by I-number, short ID, UUID, or title.",
|
|
2181
|
+
inputSchema: { idea: z.string().min(1) },
|
|
2182
|
+
}, async ({ idea: ref }) => {
|
|
2183
|
+
const st = await requireStore();
|
|
2184
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2185
|
+
return idea ? text(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}`, { idea }) : text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
|
|
2186
|
+
});
|
|
2187
|
+
server.registerTool("planner-idea-create", {
|
|
2188
|
+
description: "Create a rollup-independent Ideas Inbox entry.",
|
|
2189
|
+
inputSchema: { title: z.string().min(1), description: z.string().optional() },
|
|
2190
|
+
}, async ({ title, description }) => {
|
|
2191
|
+
const st = await requireStore();
|
|
2192
|
+
const idea = await st.createIdea({ title, ...(description !== undefined ? { description } : {}) });
|
|
2193
|
+
await st.writeGenerated();
|
|
2194
|
+
return text(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, { idea, created: true });
|
|
2195
|
+
});
|
|
2196
|
+
server.registerTool("planner-idea-update", {
|
|
2197
|
+
description: "Update an unpromoted or promoted Idea Inbox entry title or description.",
|
|
2198
|
+
inputSchema: { idea: z.string().min(1), title: z.string().min(1).optional(), description: z.string().optional() },
|
|
2199
|
+
}, async ({ idea: ref, title, description }) => {
|
|
2200
|
+
const st = await requireStore();
|
|
2201
|
+
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2202
|
+
if (!current)
|
|
2203
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", updated: false });
|
|
2204
|
+
if (title === undefined && description === undefined)
|
|
2205
|
+
return text("No mutable idea fields were received.", { errorCode: "NO_MUTABLE_FIELDS_RECEIVED", updated: false });
|
|
2206
|
+
const idea = await st.updateIdea(current.id, { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}) });
|
|
2207
|
+
await st.writeGenerated();
|
|
2208
|
+
return text(`Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}`, { idea, updated: true });
|
|
2209
|
+
});
|
|
2210
|
+
server.registerTool("planner-idea-delete", {
|
|
2211
|
+
description: "Delete an Idea Inbox entry. Confirm the exact idea with the user before calling.",
|
|
2212
|
+
inputSchema: { idea: z.string().min(1), confirmed: z.boolean() },
|
|
2213
|
+
}, async ({ idea: ref, confirmed }) => {
|
|
2214
|
+
if (!confirmed)
|
|
2215
|
+
return text("Idea deletion requires confirmed=true after explicit user confirmation.", { errorCode: "CONFIRMATION_REQUIRED", deleted: false });
|
|
2216
|
+
const st = await requireStore();
|
|
2217
|
+
const current = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2218
|
+
if (!current)
|
|
2219
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", deleted: false });
|
|
2220
|
+
await st.deleteIdea(current.id);
|
|
2221
|
+
await st.writeGenerated();
|
|
2222
|
+
return text(`Idea deleted: ${formatIdeaRef(current.number)}.`, { deleted: true, ideaRef: formatIdeaRef(current.number) });
|
|
2223
|
+
});
|
|
2224
|
+
server.registerTool("planner-idea-promotion-begin", {
|
|
2225
|
+
description: "Begin an Idea promotion discussion without persisting a target or promotion. Returns the project-local grill-me instructions; follow them one question at a time, then use planner-idea-promotion-finalize only after target confirmation.",
|
|
2226
|
+
inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]) },
|
|
2227
|
+
}, async ({ idea: ref, targetType }) => {
|
|
2228
|
+
const st = await requireStore();
|
|
2229
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2230
|
+
if (!idea)
|
|
2231
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND" });
|
|
2232
|
+
if (idea.promotion)
|
|
2233
|
+
return text(`${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.`, { errorCode: "ALREADY_PROMOTED", idea });
|
|
2234
|
+
const features = (await st.loadFeatures()).features;
|
|
2235
|
+
const phases = await st.loadAllPhases();
|
|
2236
|
+
const recommendedFeature = features[0];
|
|
2237
|
+
const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
|
|
2238
|
+
const recommendation = targetType === "feature"
|
|
2239
|
+
? "Create a new feature after the discussion."
|
|
2240
|
+
: targetType === "phase"
|
|
2241
|
+
? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
|
|
2242
|
+
: recommendedPhase
|
|
2243
|
+
? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.`
|
|
2244
|
+
: "Create and confirm a parent feature and phase before promoting to a task.";
|
|
2245
|
+
return text(`Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}`, { idea, targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target through the normal feature/phase/task creation tool.", "Call planner-idea-promotion-finalize with discussionCompleted=true and confirmed=true."] });
|
|
2246
|
+
});
|
|
2247
|
+
server.registerTool("planner-idea-promotion-finalize", {
|
|
2248
|
+
description: "Record a promotion only after grill-me discussion, explicit target confirmation, and successful target creation. targetRef must be the already-created agreed target; no raw UUID is returned.",
|
|
2249
|
+
inputSchema: { idea: z.string().min(1), targetType: z.enum(["feature", "phase", "task"]), targetRef: z.string().min(1), discussionCompleted: z.boolean(), confirmed: z.boolean() },
|
|
2250
|
+
}, async ({ idea: ref, targetType, targetRef, discussionCompleted, confirmed }) => {
|
|
2251
|
+
if (!discussionCompleted || !confirmed)
|
|
2252
|
+
return text("Promotion requires discussionCompleted=true and confirmed=true; no target or promotion was persisted.", { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false });
|
|
2253
|
+
const st = await requireStore();
|
|
2254
|
+
const idea = findIdeaByRef((await st.loadIdeas()).ideas, ref);
|
|
2255
|
+
if (!idea)
|
|
2256
|
+
return text(`Idea not found: ${ref}`, { errorCode: "NOT_FOUND", promoted: false });
|
|
2257
|
+
const target = await ideaPromotionTarget(st, targetType, targetRef);
|
|
2258
|
+
if (!target)
|
|
2259
|
+
return text(`Confirmed ${targetType} target not found: ${targetRef}; no promotion was persisted.`, { errorCode: "TARGET_NOT_FOUND", promoted: false });
|
|
2260
|
+
const promoted = await st.promoteIdea(idea.id, { targetType, targetId: target.id });
|
|
2261
|
+
await st.writeGenerated();
|
|
2262
|
+
return text(`Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}`, { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef });
|
|
2263
|
+
});
|
|
1633
2264
|
server.registerTool("planner-handoff-write", {
|
|
1634
|
-
description: "Reconcile
|
|
2265
|
+
description: "Reconcile one active handoff with durable context synchronization and a mandatory versioned completeness audit. Run planner-handoff-prepare first; link extended detail from committed .planner/docs/ Markdown.",
|
|
1635
2266
|
inputSchema: {
|
|
1636
2267
|
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
1637
2268
|
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work."),
|
|
@@ -1639,6 +2270,18 @@ server.registerTool("planner-handoff-write", {
|
|
|
1639
2270
|
content: z.string().min(1).describe("Full reconciled handoff text (markdown)."),
|
|
1640
2271
|
expectedHandoffUpdatedAt: z.string().optional().describe("Exact token returned by planner-handoff-prepare; empty when no handoff exists."),
|
|
1641
2272
|
reconciledExistingHandoff: z.boolean().optional().describe("Confirm that still-relevant existing handoff information was retained."),
|
|
2273
|
+
completenessAudit: z.object({
|
|
2274
|
+
version: z.literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
|
|
2275
|
+
entries: z.array(z.object({
|
|
2276
|
+
category: z.string().min(1).describe(`One required category id: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}`),
|
|
2277
|
+
status: z.enum(["captured", "not-applicable"]),
|
|
2278
|
+
detail: z.string().min(1),
|
|
2279
|
+
})),
|
|
2280
|
+
}).optional().describe("Mandatory for confirmed writes; every category from planner-handoff-prepare must be present."),
|
|
2281
|
+
supportingDocuments: z.array(z.object({
|
|
2282
|
+
path: z.string().min(1).describe("Committed Markdown path under .planner/docs/."),
|
|
2283
|
+
description: z.string().min(1).describe("What the document contains and why the next agent needs it."),
|
|
2284
|
+
})).optional(),
|
|
1642
2285
|
taskUpdates: z.array(z.object({
|
|
1643
2286
|
taskRef: z.string().min(1),
|
|
1644
2287
|
completionSummary: z.string().min(1),
|
|
@@ -1656,7 +2299,7 @@ server.registerTool("planner-handoff-write", {
|
|
|
1656
2299
|
featureUpdate: z.object({ workDone: z.string().min(1), workRemaining: z.string().min(1) }).optional(),
|
|
1657
2300
|
featureNoUpdateReason: z.string().min(1).optional(),
|
|
1658
2301
|
},
|
|
1659
|
-
}, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
2302
|
+
}, async ({ phaseRef, title, confirmed, content, expectedHandoffUpdatedAt, reconciledExistingHandoff, completenessAudit, supportingDocuments, taskUpdates, phaseUpdate, phaseNoUpdateReason, featureUpdate, featureNoUpdateReason }) => {
|
|
1660
2303
|
const st = await requireStore();
|
|
1661
2304
|
let body = content.trim();
|
|
1662
2305
|
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
@@ -1702,6 +2345,8 @@ server.registerTool("planner-handoff-write", {
|
|
|
1702
2345
|
content: body,
|
|
1703
2346
|
expectedHandoffUpdatedAt,
|
|
1704
2347
|
reconciledExistingHandoff: reconciledExistingHandoff === true,
|
|
2348
|
+
...(completenessAudit ? { completenessAudit: completenessAudit } : {}),
|
|
2349
|
+
...(supportingDocuments ? { supportingDocuments } : {}),
|
|
1705
2350
|
contextSync: {
|
|
1706
2351
|
taskUpdates: resolvedTaskUpdates,
|
|
1707
2352
|
...(phaseUpdate ? { phaseUpdate: {
|
|
@@ -1715,15 +2360,23 @@ server.registerTool("planner-handoff-write", {
|
|
|
1715
2360
|
},
|
|
1716
2361
|
});
|
|
1717
2362
|
await st.writeGenerated();
|
|
1718
|
-
return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, {
|
|
2363
|
+
return text(`✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).`, {
|
|
2364
|
+
phaseRef: r.compositeRef,
|
|
2365
|
+
phaseId: r.phase.id,
|
|
2366
|
+
updatedTaskIds: result.updatedTaskIds,
|
|
2367
|
+
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
2368
|
+
handoffAudit: result.handoffAudit,
|
|
2369
|
+
});
|
|
1719
2370
|
}
|
|
1720
2371
|
catch (error) {
|
|
2372
|
+
if (error instanceof HandoffContractError)
|
|
2373
|
+
return handoffContractFailure(error);
|
|
1721
2374
|
const message = error instanceof Error ? error.message : String(error);
|
|
1722
2375
|
return text(`❌ Handoff refresh denied: ${message}`, { error: message });
|
|
1723
2376
|
}
|
|
1724
2377
|
});
|
|
1725
2378
|
server.registerTool("planner-handoff-prepare", {
|
|
1726
|
-
description: "Identify or audit the exact handoff target.
|
|
2379
|
+
description: "Identify or audit the exact handoff target. With a confirmed phaseRef, returns the current token, missing task evidence, mandatory completeness categories, and the 24,000-character inline budget.",
|
|
1727
2380
|
inputSchema: { phaseRef: z.string().min(1).optional() },
|
|
1728
2381
|
}, async ({ phaseRef }) => {
|
|
1729
2382
|
if (!phaseRef)
|
|
@@ -1745,6 +2398,10 @@ server.registerTool("planner-handoff-prepare", {
|
|
|
1745
2398
|
"Done tasks missing durable completion/verification evidence:",
|
|
1746
2399
|
missing,
|
|
1747
2400
|
"",
|
|
2401
|
+
`Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
|
|
2402
|
+
...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
|
|
2403
|
+
`Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
|
|
2404
|
+
"",
|
|
1748
2405
|
"Existing active handoff (reconcile all still-relevant content):",
|
|
1749
2406
|
audit.handoff.trim() || "(none)",
|
|
1750
2407
|
].join("\n"), { phaseRef: r.compositeRef, ...audit });
|
|
@@ -1807,13 +2464,41 @@ server.registerTool("planner-web", {
|
|
|
1807
2464
|
});
|
|
1808
2465
|
server.registerTool("planner-load", {
|
|
1809
2466
|
description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). 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).",
|
|
1810
|
-
}, async () => {
|
|
2467
|
+
}, async (extra) => {
|
|
1811
2468
|
const st = store();
|
|
1812
2469
|
if (!(await st.exists()))
|
|
1813
2470
|
return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
|
|
2471
|
+
const plannerSessionId = plannerSessionIdFor(extra);
|
|
2472
|
+
let preparation;
|
|
2473
|
+
try {
|
|
2474
|
+
preparation = await st.preparePlannerSession();
|
|
2475
|
+
}
|
|
2476
|
+
catch (error) {
|
|
2477
|
+
return text(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", loaded: false });
|
|
2478
|
+
}
|
|
2479
|
+
const project = preparation.project;
|
|
2480
|
+
if (project.projectGuidelines.content.trim()) {
|
|
2481
|
+
await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
|
|
2482
|
+
}
|
|
2483
|
+
const plannerSkill = await st.syncPlannerSkill();
|
|
2484
|
+
await st.syncGrillMeSkill();
|
|
1814
2485
|
const web = await ensureWebStarted();
|
|
1815
2486
|
const recap = await buildRecap(st, web, { harness: "mcp" });
|
|
1816
|
-
|
|
2487
|
+
const visibleRecap = preparation.changed ? `${preparation.legacyProjectContext.summary}\n\n${recap}` : recap;
|
|
2488
|
+
return {
|
|
2489
|
+
content: [{ type: "text", text: visibleRecap }],
|
|
2490
|
+
structuredContent: {
|
|
2491
|
+
preparation: preparation.legacyProjectContext,
|
|
2492
|
+
agentContext: {
|
|
2493
|
+
kind: "planner-usage-skill",
|
|
2494
|
+
content: plannerSkill.content,
|
|
2495
|
+
status: plannerSkill.status,
|
|
2496
|
+
customized: plannerSkill.customized,
|
|
2497
|
+
message: plannerSkill.message,
|
|
2498
|
+
instruction: "Read and retain this project-local planner usage skill. Do not quote it in the human-facing recap.",
|
|
2499
|
+
},
|
|
2500
|
+
},
|
|
2501
|
+
};
|
|
1817
2502
|
});
|
|
1818
2503
|
server.registerTool("planner-disable", {
|
|
1819
2504
|
description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",
|