@agent-plan/mcp 0.2.19-next.2 → 0.2.19-next.21
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 +529 -135
- package/package.json +5 -3
- package/server.json +15 -0
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAkvCA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
-
import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
|
|
7
|
+
import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, migrateToGlobalSequence, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, recommendNextTask } from "@agent-plan/core";
|
|
8
8
|
import { serve } from "@agent-plan/server";
|
|
9
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
|
|
9
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
10
10
|
const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
11
11
|
const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
|
|
12
12
|
function text(textValue, structuredContent) {
|
|
@@ -31,6 +31,7 @@ async function requireStore() {
|
|
|
31
31
|
if (!planBackfillDone) {
|
|
32
32
|
planBackfillDone = true;
|
|
33
33
|
await st.ensureShortIdsAndPriority().catch(() => { });
|
|
34
|
+
await migrateToGlobalSequence(st).catch(() => { });
|
|
34
35
|
}
|
|
35
36
|
return st;
|
|
36
37
|
}
|
|
@@ -56,39 +57,51 @@ async function ensureWebStarted() {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
function findFeatureByRef(features, ref) {
|
|
59
|
-
const normalized = ref.trim().toLowerCase();
|
|
60
|
-
return features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
61
|
-
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
62
|
-
?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
|
|
63
|
-
}
|
|
64
|
-
function findTaskByRef(phases, ref) {
|
|
65
60
|
const normalized = ref.trim().toLowerCase();
|
|
66
61
|
if (!normalized)
|
|
67
62
|
return undefined;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for (const phase of phases) {
|
|
75
|
-
if (phase.number !== pNum)
|
|
76
|
-
continue;
|
|
77
|
-
const task = phase.tasks.find((t) => t.number === tNum);
|
|
78
|
-
if (task)
|
|
79
|
-
return { phase, task };
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
for (const phase of phases) {
|
|
83
|
-
const task = phase.tasks.find((entry) => entry.id.toLowerCase() === normalized
|
|
84
|
-
|| (entry.shortId && entry.shortId.toLowerCase() === normalized)
|
|
85
|
-
|| entry.title.toLowerCase() === normalized
|
|
86
|
-
|| entry.title.toLowerCase().includes(normalized));
|
|
87
|
-
if (task)
|
|
88
|
-
return { phase, task };
|
|
63
|
+
const fMatch = normalized.match(/^f(\d+)$/);
|
|
64
|
+
if (fMatch) {
|
|
65
|
+
const n = parseInt(fMatch[1], 10);
|
|
66
|
+
const byNum = features.find((feature) => feature.number === n);
|
|
67
|
+
if (byNum)
|
|
68
|
+
return byNum;
|
|
89
69
|
}
|
|
90
|
-
return
|
|
70
|
+
return features.find((feature) => feature.shortId?.toLowerCase() === normalized)
|
|
71
|
+
?? features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
72
|
+
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
73
|
+
?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
|
|
74
|
+
}
|
|
75
|
+
function resolveFeatureRefStrict(features, ref) {
|
|
76
|
+
const raw = ref.trim();
|
|
77
|
+
if (!raw)
|
|
78
|
+
return { ok: false, error: "Feature ref is required." };
|
|
79
|
+
const normalized = raw.toLowerCase();
|
|
80
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
81
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
82
|
+
: undefined;
|
|
83
|
+
if (byNumber)
|
|
84
|
+
return { ok: true, feature: byNumber };
|
|
85
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
86
|
+
if (byShortId)
|
|
87
|
+
return { ok: true, feature: byShortId };
|
|
88
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
89
|
+
if (byId)
|
|
90
|
+
return { ok: true, feature: byId };
|
|
91
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
92
|
+
if (exactName.length === 1)
|
|
93
|
+
return { ok: true, feature: exactName[0] };
|
|
94
|
+
if (exactName.length > 1)
|
|
95
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
96
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
97
|
+
if (partialName.length === 1)
|
|
98
|
+
return { ok: true, feature: partialName[0] };
|
|
99
|
+
if (partialName.length > 1)
|
|
100
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
101
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
91
102
|
}
|
|
103
|
+
/** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
|
|
104
|
+
* must still exist in the store before we allocate numbers or write. */
|
|
92
105
|
function featureNumberOfPhase(phase, features) {
|
|
93
106
|
return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
|
|
94
107
|
}
|
|
@@ -124,16 +137,12 @@ async function resolvePhaseForHandoff(st, ref) {
|
|
|
124
137
|
if (ref && ref.trim()) {
|
|
125
138
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
126
139
|
}
|
|
127
|
-
else {
|
|
128
|
-
phase = phases.find((p) => p.status === "in-progress")
|
|
129
|
-
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
130
|
-
}
|
|
131
140
|
if (!phase) {
|
|
132
141
|
return {
|
|
133
142
|
ok: false,
|
|
134
143
|
error: ref && ref.trim()
|
|
135
144
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
136
|
-
: "
|
|
145
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
148
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -151,7 +160,7 @@ server.registerTool("planner-export", {
|
|
|
151
160
|
const markdown = exportService.exportToMarkdown(plan, full);
|
|
152
161
|
const fs = await import("node:fs/promises");
|
|
153
162
|
await fs.writeFile(join(st.root, "EXPORT.md"), markdown, "utf-8");
|
|
154
|
-
return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}
|
|
163
|
+
return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`);
|
|
155
164
|
});
|
|
156
165
|
server.registerTool("planner-authorize-bypass", {
|
|
157
166
|
description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write tools can proceed even when no task is in-progress. Use ONLY after the user explicitly authorizes proceeding without a task. Harness-agnostic: stored in resume.json so all adapters (Pi, Claude Code, Codex, ...) respect it.",
|
|
@@ -189,29 +198,80 @@ server.registerTool("planner-init", {
|
|
|
189
198
|
if (goal !== undefined)
|
|
190
199
|
project.goal = goal.trim();
|
|
191
200
|
await st.saveProject(project);
|
|
192
|
-
return writeAndSummarize(st, `.planner/ initialized for "${project.name}"
|
|
201
|
+
return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
|
|
193
202
|
});
|
|
194
203
|
server.registerTool("planner-show", {
|
|
195
|
-
description: "Show
|
|
204
|
+
description: "Show a compact planner overview: project metadata, counts, and a one-line summary of each feature (F00x · shortId — name (status; N phases, M tasks)). Does NOT include full descriptions, phases, or tasks — use planner-feature-list / planner-phase-list / planner-task-list for discovery and planner-*-show for full detail of a single entity. Keeps the result small to avoid token overflow.",
|
|
196
205
|
}, async () => {
|
|
197
206
|
const st = await requireStore();
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`
|
|
207
|
-
|
|
207
|
+
const features = (await st.loadFeatures()).features;
|
|
208
|
+
const phases = await st.loadAllPhases();
|
|
209
|
+
const totalTasks = phases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
210
|
+
const project = await st.loadProject();
|
|
211
|
+
const manifest = await st.loadManifest();
|
|
212
|
+
const featureLines = features.map((feature) => {
|
|
213
|
+
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
214
|
+
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
215
|
+
return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
|
|
216
|
+
});
|
|
217
|
+
const summary = [
|
|
218
|
+
`📋 ${project.name}`,
|
|
219
|
+
`Description: ${project.description || "(not set)"}`,
|
|
220
|
+
`Goal: ${project.goal || "(not set)"}`,
|
|
221
|
+
`Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
|
|
222
|
+
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
223
|
+
"",
|
|
224
|
+
"Features:",
|
|
225
|
+
featureLines.join("\n") || "(none)",
|
|
226
|
+
].join("\n");
|
|
227
|
+
// Compact structured overview (no full plan: avoids 1M+ char token overflow).
|
|
228
|
+
const overview = {
|
|
229
|
+
project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
|
|
230
|
+
counts: { features: features.length, phases: phases.length, tasks: totalTasks },
|
|
231
|
+
features: features.map((feature) => ({
|
|
232
|
+
ref: formatFeatureRef(feature.number),
|
|
233
|
+
shortId: feature.shortId || null,
|
|
234
|
+
name: feature.name,
|
|
235
|
+
status: feature.status,
|
|
236
|
+
phases: phases.filter((p) => p.featureId === feature.id).length,
|
|
237
|
+
tasks: phases.filter((p) => p.featureId === feature.id).reduce((t, p) => t + p.tasks.length, 0),
|
|
238
|
+
})),
|
|
239
|
+
};
|
|
240
|
+
return text(summary, { overview });
|
|
208
241
|
});
|
|
209
242
|
server.registerTool("planner-repair", {
|
|
210
|
-
description: "Repair dangling feature→phase references and report integrity.",
|
|
243
|
+
description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug where tasks land in the wrong phase file), and report integrity.",
|
|
211
244
|
}, async () => {
|
|
212
245
|
const st = await requireStore();
|
|
213
246
|
const report = await st.repair();
|
|
214
|
-
return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
|
|
247
|
+
return text(`Repair done: renamed ${report.migrated.renamed}, repaired ${report.migrated.repaired} refs, inferred ${report.migrated.inferred}. Containment: ${report.containment.changed} phase files rewritten (${report.containment.tasks} tasks scanned, ${report.containment.orphan} orphan). Handoffs: archived ${report.handoffs.archived} stale completed/canceled handoff(s). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
|
|
248
|
+
});
|
|
249
|
+
server.registerTool("planner-cleanup-orphan-phases", {
|
|
250
|
+
description: "Discover phase files that no longer resolve to a valid owning feature, and optionally delete them. Run once with confirm=false (default) to inspect, then rerun with confirm=true to remove them.",
|
|
251
|
+
inputSchema: {
|
|
252
|
+
confirm: z.boolean().optional().describe("Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)."),
|
|
253
|
+
},
|
|
254
|
+
}, async ({ confirm }) => {
|
|
255
|
+
const st = await requireStore();
|
|
256
|
+
const found = await st.listOrphanPhases();
|
|
257
|
+
if (!confirm) {
|
|
258
|
+
if (found.length === 0)
|
|
259
|
+
return text("No orphan phases found.", { found: [] });
|
|
260
|
+
const lines = [
|
|
261
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
262
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
263
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
264
|
+
];
|
|
265
|
+
return text(lines.join("\n"), { found, confirmRequired: true });
|
|
266
|
+
}
|
|
267
|
+
const report = await st.cleanupOrphanPhases();
|
|
268
|
+
if (report.removed.length === 0)
|
|
269
|
+
return text("No orphan phases found.", report);
|
|
270
|
+
const lines = [
|
|
271
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
272
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
273
|
+
];
|
|
274
|
+
return text(lines.join("\n"), report);
|
|
215
275
|
});
|
|
216
276
|
server.registerTool("planner-project-language", {
|
|
217
277
|
description: "Persist preferred languages for plan content and chat.",
|
|
@@ -227,7 +287,7 @@ server.registerTool("planner-project-language", {
|
|
|
227
287
|
if (chatLanguage !== undefined)
|
|
228
288
|
project.chatLanguage = chatLanguage.trim();
|
|
229
289
|
await st.saveProject(project);
|
|
230
|
-
return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}
|
|
290
|
+
return writeAndSummarize(st, `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}`);
|
|
231
291
|
});
|
|
232
292
|
server.registerTool("planner-project-discuss", {
|
|
233
293
|
description: "Record/update project-level discovery fields. This is the MCP equivalent of grouped project discuss without Pi UI prompts.",
|
|
@@ -258,20 +318,73 @@ server.registerTool("planner-project-discuss", {
|
|
|
258
318
|
if (params.decisions !== undefined)
|
|
259
319
|
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
260
320
|
await st.saveProject(project);
|
|
261
|
-
return writeAndSummarize(st, `Project discussed/updated: ${project.name}
|
|
321
|
+
return writeAndSummarize(st, `Project discussed/updated: ${project.name}`);
|
|
262
322
|
});
|
|
263
323
|
server.registerTool("planner-feature-list", {
|
|
264
|
-
description: "List features
|
|
265
|
-
|
|
324
|
+
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.",
|
|
325
|
+
inputSchema: { featureRef: z.string().optional().describe("Optional: filter to one feature (F00x/shortId/UUID/name).") },
|
|
326
|
+
}, async ({ featureRef }) => {
|
|
266
327
|
const st = await requireStore();
|
|
267
|
-
const
|
|
328
|
+
const allFeatures = (await st.loadFeatures()).features;
|
|
329
|
+
const features = featureRef ? allFeatures.filter((f) => f.id === findFeatureByRef(allFeatures, featureRef)?.id) : allFeatures;
|
|
268
330
|
const phases = await st.loadAllPhases();
|
|
269
331
|
const lines = features.map((feature) => {
|
|
270
332
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
271
333
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
272
|
-
return `- ${feature.
|
|
334
|
+
return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
|
|
273
335
|
});
|
|
274
|
-
return text(lines.join("\n") || "No features"
|
|
336
|
+
return text(lines.join("\n") || "No features");
|
|
337
|
+
});
|
|
338
|
+
server.registerTool("planner-phase-list", {
|
|
339
|
+
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.",
|
|
340
|
+
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.") },
|
|
341
|
+
}, async ({ featureRef, status }) => {
|
|
342
|
+
const st = await requireStore();
|
|
343
|
+
const features = (await st.loadFeatures()).features;
|
|
344
|
+
let phases = await st.loadAllPhases();
|
|
345
|
+
if (featureRef) {
|
|
346
|
+
const f = findFeatureByRef(features, featureRef);
|
|
347
|
+
if (!f)
|
|
348
|
+
return text(`Feature not found: ${featureRef}`);
|
|
349
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
350
|
+
}
|
|
351
|
+
if (status)
|
|
352
|
+
phases = phases.filter((p) => p.status === status);
|
|
353
|
+
const lines = phases.map((phase) => {
|
|
354
|
+
const fNum = featureNumberOfPhase(phase, features);
|
|
355
|
+
const fTag = fNum !== undefined ? ` [F${String(fNum).padStart(3, "0")}]` : "";
|
|
356
|
+
return `- ${formatPhaseRef(phase.number, fNum)}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.status}; ${phase.tasks.length} tasks)${fTag}`;
|
|
357
|
+
});
|
|
358
|
+
return text(lines.join("\n") || "No phases");
|
|
359
|
+
});
|
|
360
|
+
server.registerTool("planner-task-list", {
|
|
361
|
+
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.",
|
|
362
|
+
inputSchema: { featureRef: z.string().optional(), phaseRef: z.string().optional(), status: z.string().optional().describe("Optional: filter by status name.") },
|
|
363
|
+
}, async ({ featureRef, phaseRef, status }) => {
|
|
364
|
+
const st = await requireStore();
|
|
365
|
+
const features = (await st.loadFeatures()).features;
|
|
366
|
+
let phases = await st.loadAllPhases();
|
|
367
|
+
if (featureRef) {
|
|
368
|
+
const f = findFeatureByRef(features, featureRef);
|
|
369
|
+
if (!f)
|
|
370
|
+
return text(`Feature not found: ${featureRef}`);
|
|
371
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
372
|
+
}
|
|
373
|
+
if (phaseRef) {
|
|
374
|
+
const p = findPhaseByRef(phases, features, phaseRef);
|
|
375
|
+
if (!p)
|
|
376
|
+
return text(`Phase not found: ${phaseRef}`);
|
|
377
|
+
phases = [p];
|
|
378
|
+
}
|
|
379
|
+
const out = [];
|
|
380
|
+
for (const phase of phases) {
|
|
381
|
+
for (const task of phase.tasks) {
|
|
382
|
+
if (status && task.status !== status)
|
|
383
|
+
continue;
|
|
384
|
+
out.push(`- ${taskCompositeRef(task, phase, features)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return text(out.join("\n") || "No tasks");
|
|
275
388
|
});
|
|
276
389
|
server.registerTool("planner-feature-add", {
|
|
277
390
|
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.",
|
|
@@ -289,7 +402,7 @@ server.registerTool("planner-feature-add", {
|
|
|
289
402
|
const priority = await st.nextPriority("feature");
|
|
290
403
|
const feature = {
|
|
291
404
|
id: createFeatureId(),
|
|
292
|
-
number:
|
|
405
|
+
number: await st.allocFeatureNumber(),
|
|
293
406
|
shortId,
|
|
294
407
|
priority,
|
|
295
408
|
name: name.trim(),
|
|
@@ -305,6 +418,7 @@ server.registerTool("planner-feature-add", {
|
|
|
305
418
|
acceptedDecisions: [],
|
|
306
419
|
phaseIds: [],
|
|
307
420
|
dependsOn: [],
|
|
421
|
+
statusLog: [],
|
|
308
422
|
createdAt: timestamp,
|
|
309
423
|
updatedAt: timestamp,
|
|
310
424
|
};
|
|
@@ -312,24 +426,63 @@ server.registerTool("planner-feature-add", {
|
|
|
312
426
|
doc.features.push(feature);
|
|
313
427
|
return doc;
|
|
314
428
|
});
|
|
315
|
-
return writeAndSummarize(st,
|
|
429
|
+
return writeAndSummarize(st, `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}`);
|
|
316
430
|
});
|
|
317
431
|
server.registerTool("planner-feature-show", {
|
|
318
432
|
description: "Show a feature by id or name.",
|
|
319
|
-
inputSchema: { feature: z.string().min(1).describe("Feature
|
|
320
|
-
}, async ({ feature: ref }) => {
|
|
433
|
+
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).") },
|
|
434
|
+
}, async ({ feature: ref, full }) => {
|
|
321
435
|
const st = await requireStore();
|
|
322
436
|
const features = (await st.loadFeatures()).features;
|
|
323
|
-
const
|
|
324
|
-
if (!
|
|
325
|
-
return text(
|
|
437
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
438
|
+
if (!resolvedFeature.ok)
|
|
439
|
+
return text(resolvedFeature.error);
|
|
440
|
+
const feature = resolvedFeature.feature;
|
|
326
441
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
327
|
-
|
|
442
|
+
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
443
|
+
return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
|
|
444
|
+
});
|
|
445
|
+
server.registerTool("planner-feature-discuss", {
|
|
446
|
+
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
447
|
+
inputSchema: {
|
|
448
|
+
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
449
|
+
description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
|
|
450
|
+
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
451
|
+
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
452
|
+
dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
|
|
453
|
+
},
|
|
454
|
+
}, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
|
|
455
|
+
const st = await requireStore();
|
|
456
|
+
const features = (await st.loadFeatures()).features;
|
|
457
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
458
|
+
if (!resolvedFeature.ok)
|
|
459
|
+
return text(resolvedFeature.error);
|
|
460
|
+
const feature = resolvedFeature.feature;
|
|
461
|
+
const updated = await st.updateFeatures((doc) => {
|
|
462
|
+
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
463
|
+
if (!target)
|
|
464
|
+
return doc;
|
|
465
|
+
if (description !== undefined)
|
|
466
|
+
target.description = description.trim();
|
|
467
|
+
if (workDone !== undefined)
|
|
468
|
+
target.workDone = workDone.trim();
|
|
469
|
+
if (workRemaining !== undefined)
|
|
470
|
+
target.workRemaining = workRemaining.trim();
|
|
471
|
+
if (dependencies !== undefined)
|
|
472
|
+
target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
|
|
473
|
+
target.discussedAt = nowISO();
|
|
474
|
+
target.contextReady = true;
|
|
475
|
+
target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
|
|
476
|
+
target.updatedAt = nowISO();
|
|
477
|
+
return doc;
|
|
478
|
+
});
|
|
479
|
+
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
480
|
+
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
|
|
328
481
|
});
|
|
329
482
|
server.registerTool("planner-feature-update", {
|
|
330
483
|
description: "Update a feature.",
|
|
331
484
|
inputSchema: {
|
|
332
|
-
feature: z.string().min(1).describe("Feature
|
|
485
|
+
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
333
486
|
name: z.string().optional(),
|
|
334
487
|
description: z.string().optional(),
|
|
335
488
|
status: z.enum(STATUS_VALUES).optional(),
|
|
@@ -342,9 +495,10 @@ server.registerTool("planner-feature-update", {
|
|
|
342
495
|
}, async ({ feature: ref, ...updates }) => {
|
|
343
496
|
const st = await requireStore();
|
|
344
497
|
const features = (await st.loadFeatures()).features;
|
|
345
|
-
const
|
|
346
|
-
if (!
|
|
347
|
-
return text(
|
|
498
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
499
|
+
if (!resolvedFeature.ok)
|
|
500
|
+
return text(resolvedFeature.error);
|
|
501
|
+
const feature = resolvedFeature.feature;
|
|
348
502
|
const updated = await st.updateFeatures((doc) => {
|
|
349
503
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
350
504
|
if (!target)
|
|
@@ -369,20 +523,21 @@ server.registerTool("planner-feature-update", {
|
|
|
369
523
|
return doc;
|
|
370
524
|
});
|
|
371
525
|
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
372
|
-
return writeAndSummarize(st,
|
|
526
|
+
return writeAndSummarize(st, `✅ Feature updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
|
|
373
527
|
});
|
|
374
528
|
server.registerTool("planner-feature-delete", {
|
|
375
529
|
description: "Delete a feature. By default, phases are unlinked rather than deleted.",
|
|
376
530
|
inputSchema: {
|
|
377
|
-
feature: z.string().min(1).describe("Feature
|
|
531
|
+
feature: z.string().min(1).describe("Feature ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
378
532
|
cascade: z.boolean().optional().describe("Also delete phases belonging to this feature"),
|
|
379
533
|
},
|
|
380
534
|
}, async ({ feature: ref, cascade }) => {
|
|
381
535
|
const st = await requireStore();
|
|
382
536
|
const features = (await st.loadFeatures()).features;
|
|
383
|
-
const
|
|
384
|
-
if (!
|
|
385
|
-
return text(
|
|
537
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
538
|
+
if (!resolvedFeature.ok)
|
|
539
|
+
return text(resolvedFeature.error);
|
|
540
|
+
const feature = resolvedFeature.feature;
|
|
386
541
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
387
542
|
await st.updateFeatures((doc) => {
|
|
388
543
|
doc.features = doc.features.filter((entry) => entry.id !== feature.id);
|
|
@@ -401,19 +556,27 @@ server.registerTool("planner-feature-delete", {
|
|
|
401
556
|
return writeAndSummarize(st, `Feature deleted: ${feature.id}${cascade ? `; deleted ${phases.length} phases` : `; unlinked ${phases.length} phases`}`, { deleted: feature.id, affectedPhases: phases.length, cascade: Boolean(cascade) });
|
|
402
557
|
});
|
|
403
558
|
server.registerTool("planner-phase-add", {
|
|
404
|
-
description: "Create a phase with a rich description. REQUIRED: description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
|
|
559
|
+
description: "Create a phase linked to a feature with a rich description. REQUIRED: feature ref and description. The description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment.",
|
|
405
560
|
inputSchema: {
|
|
406
561
|
title: z.string().min(1),
|
|
407
|
-
feature: z.string().optional().describe("Feature
|
|
562
|
+
feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
|
|
408
563
|
summary: z.string().optional().describe("One-line summary of the phase"),
|
|
409
564
|
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner."),
|
|
410
565
|
},
|
|
411
566
|
}, async ({ title, feature: featureRef, summary, description }) => {
|
|
412
567
|
const st = await requireStore();
|
|
568
|
+
if (!featureRef?.trim())
|
|
569
|
+
return text("feature is required: a phase must belong to a feature.");
|
|
413
570
|
const featuresDoc = await st.loadFeatures();
|
|
414
|
-
const
|
|
415
|
-
if (
|
|
416
|
-
return text(
|
|
571
|
+
const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
|
|
572
|
+
if (!resolvedFeature.ok)
|
|
573
|
+
return text(resolvedFeature.error);
|
|
574
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
575
|
+
if (!featureValidation.ok)
|
|
576
|
+
return text(featureValidation.error);
|
|
577
|
+
const feature = resolvedFeature.feature;
|
|
578
|
+
if (!feature)
|
|
579
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
|
|
417
580
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
418
581
|
let phase;
|
|
419
582
|
await withFeatureLock(lockKey, async () => {
|
|
@@ -424,7 +587,7 @@ server.registerTool("planner-phase-add", {
|
|
|
424
587
|
const priority = await st.nextPriority("phase", feature?.id);
|
|
425
588
|
phase = {
|
|
426
589
|
id: createPhaseId(),
|
|
427
|
-
number:
|
|
590
|
+
number: await st.allocPhaseNumber(),
|
|
428
591
|
shortId,
|
|
429
592
|
priority,
|
|
430
593
|
slug: normalizeSlug(title),
|
|
@@ -452,6 +615,9 @@ server.registerTool("planner-phase-add", {
|
|
|
452
615
|
updatedAt: timestamp,
|
|
453
616
|
handoff: "",
|
|
454
617
|
handoffUpdatedAt: "",
|
|
618
|
+
handoffReadAt: "",
|
|
619
|
+
handoffHistory: [],
|
|
620
|
+
statusLog: [],
|
|
455
621
|
};
|
|
456
622
|
await st.savePhase(phase);
|
|
457
623
|
if (feature) {
|
|
@@ -466,22 +632,29 @@ server.registerTool("planner-phase-add", {
|
|
|
466
632
|
});
|
|
467
633
|
if (!phase)
|
|
468
634
|
return text("Phase creation failed.");
|
|
469
|
-
return writeAndSummarize(st,
|
|
635
|
+
return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
470
636
|
});
|
|
471
637
|
server.registerTool("planner-phase-show", {
|
|
472
|
-
description: "Show a phase by id or name.",
|
|
473
|
-
inputSchema: { phase: z.string().min(1).describe("Phase
|
|
474
|
-
}, async ({ phase: ref }) => {
|
|
638
|
+
description: "Show a phase by id or name, including derived linked requirements in the full view.",
|
|
639
|
+
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).") },
|
|
640
|
+
}, async ({ phase: ref, full }) => {
|
|
475
641
|
const st = await requireStore();
|
|
476
|
-
const
|
|
642
|
+
const features = (await st.loadFeatures()).features;
|
|
643
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
477
644
|
if (!phase)
|
|
478
645
|
return text(`Phase not found: ${ref}`);
|
|
479
|
-
|
|
646
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
647
|
+
const reqCount = linkedRequirements.length;
|
|
648
|
+
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"}` : ""})`;
|
|
649
|
+
const requirementsBlock = reqCount > 0
|
|
650
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
651
|
+
: "";
|
|
652
|
+
return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, { linkedRequirements });
|
|
480
653
|
});
|
|
481
654
|
server.registerTool("planner-phase-discuss", {
|
|
482
655
|
description: "Persist phase discovery fields and mark phase planned.",
|
|
483
656
|
inputSchema: {
|
|
484
|
-
phase: z.string().min(1).describe("Phase
|
|
657
|
+
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
485
658
|
goal: z.string().optional(),
|
|
486
659
|
summary: z.string().optional(),
|
|
487
660
|
scope: z.string().optional(),
|
|
@@ -492,7 +665,8 @@ server.registerTool("planner-phase-discuss", {
|
|
|
492
665
|
},
|
|
493
666
|
}, async ({ phase: ref, ...updates }) => {
|
|
494
667
|
const st = await requireStore();
|
|
495
|
-
const
|
|
668
|
+
const features = (await st.loadFeatures()).features;
|
|
669
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
496
670
|
if (!found)
|
|
497
671
|
return text(`Phase not found: ${ref}`);
|
|
498
672
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
@@ -517,7 +691,7 @@ server.registerTool("planner-phase-discuss", {
|
|
|
517
691
|
entry.updatedAt = nowISO();
|
|
518
692
|
return entry;
|
|
519
693
|
});
|
|
520
|
-
return writeAndSummarize(st,
|
|
694
|
+
return writeAndSummarize(st, `✅ Phase discussed/planned: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
521
695
|
});
|
|
522
696
|
server.registerTool("planner-phase-update", {
|
|
523
697
|
description: "Update phase fields.",
|
|
@@ -531,7 +705,8 @@ server.registerTool("planner-phase-update", {
|
|
|
531
705
|
},
|
|
532
706
|
}, async ({ phase: ref, ...updates }) => {
|
|
533
707
|
const st = await requireStore();
|
|
534
|
-
const
|
|
708
|
+
const features = (await st.loadFeatures()).features;
|
|
709
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
535
710
|
if (!found)
|
|
536
711
|
return text(`Phase not found: ${ref}`);
|
|
537
712
|
const phase = await st.updatePhase(found.id, (entry) => {
|
|
@@ -548,7 +723,7 @@ server.registerTool("planner-phase-update", {
|
|
|
548
723
|
entry.updatedAt = nowISO();
|
|
549
724
|
return entry;
|
|
550
725
|
});
|
|
551
|
-
return writeAndSummarize(st,
|
|
726
|
+
return writeAndSummarize(st, `✅ Phase updated: ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
552
727
|
});
|
|
553
728
|
server.registerTool("planner-phase-delete", {
|
|
554
729
|
description: "Delete a phase and unlink it from features.",
|
|
@@ -564,21 +739,43 @@ server.registerTool("planner-phase-delete", {
|
|
|
564
739
|
feature.phaseIds = feature.phaseIds.filter((id) => id !== phase.id);
|
|
565
740
|
return doc;
|
|
566
741
|
});
|
|
567
|
-
|
|
742
|
+
const dFeat = (await st.loadFeatures()).features.find((f) => f.id === phase.featureId);
|
|
743
|
+
return writeAndSummarize(st, `Phase deleted: ${formatPhaseRef(phase.number, dFeat?.number)}${phase.shortId ? ` · ${phase.shortId}` : ""}`, { deleted: phase.id });
|
|
568
744
|
});
|
|
569
745
|
server.registerTool("planner-task-add", {
|
|
570
746
|
description: "Create a task with a rich description. REQUIRED: description must include code references (file:line), what already exists vs what needs to be built, specific structs/traits/systems to modify, concrete implementation steps, and edge cases to handle. The description is the execution context for agents; one-liners cause misalignment.",
|
|
571
747
|
inputSchema: {
|
|
572
|
-
|
|
748
|
+
feature: z.string().min(1).optional().describe("Feature ref the task's phase belongs to. Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title. REQUIRED."),
|
|
749
|
+
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
573
750
|
title: z.string().min(1),
|
|
574
751
|
description: z.string().min(50, "Description must be at least 50 characters — include code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Prefix with 'design-only' for pre-implementation design tasks.").describe("Required code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner."),
|
|
575
752
|
checklist: z.array(z.string()).optional(),
|
|
576
753
|
},
|
|
577
|
-
}, async ({ phase: ref, title, description, checklist }) => {
|
|
754
|
+
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
578
755
|
const st = await requireStore();
|
|
579
|
-
const
|
|
756
|
+
const features = (await st.loadFeatures()).features;
|
|
757
|
+
if (!featureRef?.trim())
|
|
758
|
+
return text("feature is required: a task must belong to a feature.");
|
|
759
|
+
const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
|
|
760
|
+
if (!resolvedFeature.ok)
|
|
761
|
+
return text(resolvedFeature.error);
|
|
762
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
763
|
+
if (!featureValidation.ok)
|
|
764
|
+
return text(featureValidation.error);
|
|
765
|
+
const feature = resolvedFeature.feature;
|
|
766
|
+
if (!feature)
|
|
767
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
|
|
768
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
580
769
|
if (!found)
|
|
581
770
|
return text(`Phase not found: ${ref}`);
|
|
771
|
+
if (found.featureId !== feature.id)
|
|
772
|
+
return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
|
|
773
|
+
const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
|
|
774
|
+
if (!phaseValidation.ok)
|
|
775
|
+
return text(phaseValidation.error);
|
|
776
|
+
const existingPhase = await st.loadPhase(found.id);
|
|
777
|
+
if (!existingPhase)
|
|
778
|
+
return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
|
|
582
779
|
const timestamp = nowISO();
|
|
583
780
|
const taskId = createTaskId();
|
|
584
781
|
const shortId = createShortId(await st.assignedShortIds());
|
|
@@ -586,7 +783,7 @@ server.registerTool("planner-task-add", {
|
|
|
586
783
|
const task = {
|
|
587
784
|
id: taskId,
|
|
588
785
|
phaseId: found.id,
|
|
589
|
-
number:
|
|
786
|
+
number: await st.allocTaskNumber(),
|
|
590
787
|
shortId,
|
|
591
788
|
priority,
|
|
592
789
|
shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
|
|
@@ -597,7 +794,7 @@ server.registerTool("planner-task-add", {
|
|
|
597
794
|
statusLog: [],
|
|
598
795
|
decisions: [],
|
|
599
796
|
acceptedDecisions: [],
|
|
600
|
-
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
|
|
797
|
+
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
|
|
601
798
|
subtasks: [],
|
|
602
799
|
dependsOn: [],
|
|
603
800
|
startedAt: "",
|
|
@@ -611,18 +808,23 @@ server.registerTool("planner-task-add", {
|
|
|
611
808
|
entry.updatedAt = timestamp;
|
|
612
809
|
return entry;
|
|
613
810
|
});
|
|
614
|
-
const
|
|
615
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found,
|
|
811
|
+
const finalFeatures = (await st.loadFeatures()).features;
|
|
812
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
616
813
|
});
|
|
617
814
|
server.registerTool("planner-task-show", {
|
|
618
815
|
description: "Show a task by id or name.",
|
|
619
|
-
inputSchema: { task: z.string().min(1).describe("Task
|
|
620
|
-
}, async ({ task: ref }) => {
|
|
816
|
+
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 + statusLog. Default: compact identity only (saves tokens).") },
|
|
817
|
+
}, async ({ task: ref, full }) => {
|
|
621
818
|
const st = await requireStore();
|
|
622
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
819
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
623
820
|
if (!found)
|
|
624
821
|
return text(`Task not found: ${ref}`);
|
|
625
|
-
|
|
822
|
+
const features = (await st.loadFeatures()).features;
|
|
823
|
+
const summary = `${found.task.title} — ${taskCompositeRef(found.task, found.phase, features)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status}; phase ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))})`;
|
|
824
|
+
if (!full)
|
|
825
|
+
return text(summary);
|
|
826
|
+
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
827
|
+
return text(`${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}`);
|
|
626
828
|
});
|
|
627
829
|
server.registerTool("planner-task-discuss", {
|
|
628
830
|
description: "Persist task execution notes/checklist.",
|
|
@@ -633,7 +835,7 @@ server.registerTool("planner-task-discuss", {
|
|
|
633
835
|
},
|
|
634
836
|
}, async ({ task: ref, description, checklist }) => {
|
|
635
837
|
const st = await requireStore();
|
|
636
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
838
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
637
839
|
if (!found)
|
|
638
840
|
return text(`Task not found: ${ref}`);
|
|
639
841
|
let updatedTask;
|
|
@@ -644,7 +846,9 @@ server.registerTool("planner-task-discuss", {
|
|
|
644
846
|
if (description !== undefined)
|
|
645
847
|
task.description = description.trim();
|
|
646
848
|
if (checklist !== undefined)
|
|
647
|
-
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
|
|
849
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
850
|
+
if (checklist !== undefined)
|
|
851
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
648
852
|
task.updatedAt = nowISO();
|
|
649
853
|
phase.updatedAt = task.updatedAt;
|
|
650
854
|
updatedTask = task;
|
|
@@ -663,10 +867,11 @@ server.registerTool("planner-task-update", {
|
|
|
663
867
|
description: z.string().optional(),
|
|
664
868
|
motivation: z.string().optional(),
|
|
665
869
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
870
|
+
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."),
|
|
666
871
|
},
|
|
667
|
-
}, async ({ task: ref, title, status, description, motivation, priority }) => {
|
|
872
|
+
}, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
|
|
668
873
|
const st = await requireStore();
|
|
669
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
874
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
670
875
|
if (!found)
|
|
671
876
|
return text(`Task not found: ${ref}`);
|
|
672
877
|
// Validate motivation requirement for status transitions.
|
|
@@ -711,12 +916,113 @@ server.registerTool("planner-task-update", {
|
|
|
711
916
|
const t = updatedTask ?? found.task;
|
|
712
917
|
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
|
|
713
918
|
});
|
|
919
|
+
server.registerTool("planner-task-checklist-toggle", {
|
|
920
|
+
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.",
|
|
921
|
+
inputSchema: {
|
|
922
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
923
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
924
|
+
checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
|
|
925
|
+
},
|
|
926
|
+
}, async ({ task: ref, item, checked }) => {
|
|
927
|
+
const st = await requireStore();
|
|
928
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
929
|
+
if (!found)
|
|
930
|
+
return text(`Task not found: ${ref}`);
|
|
931
|
+
let result = "";
|
|
932
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
933
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
934
|
+
if (!task) {
|
|
935
|
+
result = `Task not found: ${ref}`;
|
|
936
|
+
return phase;
|
|
937
|
+
}
|
|
938
|
+
const ck = task.checklist ?? [];
|
|
939
|
+
if (ck.length === 0) {
|
|
940
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
941
|
+
return phase;
|
|
942
|
+
}
|
|
943
|
+
const target = toggleChecklistItem(ck, item, checked);
|
|
944
|
+
if (!target) {
|
|
945
|
+
result = `No checklist item matching "${item}".`;
|
|
946
|
+
return phase;
|
|
947
|
+
}
|
|
948
|
+
task.updatedAt = nowISO();
|
|
949
|
+
phase.updatedAt = nowISO();
|
|
950
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
951
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
952
|
+
return phase;
|
|
953
|
+
});
|
|
954
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
955
|
+
});
|
|
956
|
+
server.registerTool("planner-task-checklist-add", {
|
|
957
|
+
description: "Add a single checklist item (a 'step') to a task without rewriting the list. The new item is appended as C{n} (next progressive number, stable id, unchecked). Use this to subdivide a task into smaller steps INSTEAD of spawning sub-tasks — the checklist keeps description, notes, statusLog and steps concentrated in one task (sub-tasks disperse context). Also use for granular adds instead of replacing the whole checklist via planner-task-update.",
|
|
958
|
+
inputSchema: {
|
|
959
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
960
|
+
title: z.string().min(1).describe("Checklist item text (a single step)"),
|
|
961
|
+
},
|
|
962
|
+
}, async ({ task: ref, title }) => {
|
|
963
|
+
const st = await requireStore();
|
|
964
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
965
|
+
if (!found)
|
|
966
|
+
return text(`Task not found: ${ref}`);
|
|
967
|
+
let result = "";
|
|
968
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
969
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
970
|
+
if (!task) {
|
|
971
|
+
result = `Task not found: ${ref}`;
|
|
972
|
+
return phase;
|
|
973
|
+
}
|
|
974
|
+
const ck = task.checklist ?? [];
|
|
975
|
+
const item = addChecklistItem(ck, task.id, title);
|
|
976
|
+
ck.push(item);
|
|
977
|
+
task.checklist = ck;
|
|
978
|
+
task.updatedAt = nowISO();
|
|
979
|
+
phase.updatedAt = nowISO();
|
|
980
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
981
|
+
return phase;
|
|
982
|
+
});
|
|
983
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
984
|
+
});
|
|
985
|
+
server.registerTool("planner-task-checklist-remove", {
|
|
986
|
+
description: "Remove a single checklist item (a 'step') from a task by C{n} (e.g. C2), item id, or title (case-insensitive). Remaining items are renumbered C1..Cn for readability; their stable ids are preserved. Use this for granular removes instead of replacing the whole checklist via planner-task-update.",
|
|
987
|
+
inputSchema: {
|
|
988
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
989
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
990
|
+
},
|
|
991
|
+
}, async ({ task: ref, item }) => {
|
|
992
|
+
const st = await requireStore();
|
|
993
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
994
|
+
if (!found)
|
|
995
|
+
return text(`Task not found: ${ref}`);
|
|
996
|
+
let result = "";
|
|
997
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
998
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
999
|
+
if (!task) {
|
|
1000
|
+
result = `Task not found: ${ref}`;
|
|
1001
|
+
return phase;
|
|
1002
|
+
}
|
|
1003
|
+
const ck = task.checklist ?? [];
|
|
1004
|
+
if (ck.length === 0) {
|
|
1005
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
1006
|
+
return phase;
|
|
1007
|
+
}
|
|
1008
|
+
const removed = removeChecklistItem(ck, item);
|
|
1009
|
+
if (!removed) {
|
|
1010
|
+
result = `No checklist item matching "${item}".`;
|
|
1011
|
+
return phase;
|
|
1012
|
+
}
|
|
1013
|
+
task.updatedAt = nowISO();
|
|
1014
|
+
phase.updatedAt = nowISO();
|
|
1015
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
1016
|
+
return phase;
|
|
1017
|
+
});
|
|
1018
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
1019
|
+
});
|
|
714
1020
|
server.registerTool("planner-task-delete", {
|
|
715
1021
|
description: "Delete a task.",
|
|
716
1022
|
inputSchema: { task: z.string().min(1) },
|
|
717
1023
|
}, async ({ task: ref }) => {
|
|
718
1024
|
const st = await requireStore();
|
|
719
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
1025
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
720
1026
|
if (!found)
|
|
721
1027
|
return text(`Task not found: ${ref}`);
|
|
722
1028
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -726,23 +1032,79 @@ server.registerTool("planner-task-delete", {
|
|
|
726
1032
|
return phase;
|
|
727
1033
|
});
|
|
728
1034
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
729
|
-
|
|
1035
|
+
const tdFeatures = (await st.loadFeatures()).features;
|
|
1036
|
+
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
1037
|
+
});
|
|
1038
|
+
server.registerTool("planner-task-recommend", {
|
|
1039
|
+
description: "Return the harness-agnostic recommended task: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports dependency, availability, and approved deviation/resume context without starting or blocking work.",
|
|
1040
|
+
inputSchema: {},
|
|
1041
|
+
}, async () => {
|
|
1042
|
+
const st = await requireStore();
|
|
1043
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1044
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1045
|
+
if (!result.candidate)
|
|
1046
|
+
return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
|
|
1047
|
+
const { candidate } = result;
|
|
1048
|
+
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1049
|
+
return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
|
|
1050
|
+
kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
server.registerTool("planner-task-deviation", {
|
|
1054
|
+
description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task and never starts, pauses, or blocks work; use normal lifecycle tools for those transitions.",
|
|
1055
|
+
inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
|
|
1056
|
+
}, async ({ temporary_task, resume_task, reason }) => {
|
|
1057
|
+
const st = await requireStore();
|
|
1058
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1059
|
+
const temporary = findTaskByRef(phases, features.features, temporary_task);
|
|
1060
|
+
if (!temporary)
|
|
1061
|
+
return text(`Task not found: ${temporary_task}`);
|
|
1062
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1063
|
+
const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
|
|
1064
|
+
if (!resume)
|
|
1065
|
+
return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
|
|
1066
|
+
if (temporary.task.id === resume.task.id)
|
|
1067
|
+
return text("A temporary task must differ from its resume target.");
|
|
1068
|
+
if (temporary.task.status !== "planned")
|
|
1069
|
+
return text(`Temporary task is not startable: ${temporary.task.status}.`);
|
|
1070
|
+
const timestamp = nowISO();
|
|
1071
|
+
const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
|
|
1072
|
+
await st.addWorkDeviation(record);
|
|
1073
|
+
return writeAndSummarize(st, `✅ Approved deviation: ${taskCompositeRef(temporary.task, temporary.phase, features.features)} temporarily overrides ${taskCompositeRef(resume.task, resume.phase, features.features)}. Resume target retained.`, { deviation: record });
|
|
730
1074
|
});
|
|
731
1075
|
server.registerTool("planner-task-start", {
|
|
732
|
-
description: "Set a task to in-progress.",
|
|
1076
|
+
description: "Set a task to in-progress. BEFORE calling, use planner-task-show with full=true and planner-phase-show with full=true for the parent phase, including linked requirements. Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Returns a phase/requirements briefing.",
|
|
733
1077
|
inputSchema: { task: z.string().min(1) },
|
|
734
1078
|
}, async ({ task: ref }) => {
|
|
735
1079
|
const st = await requireStore();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// small modification after writing a handoff should start without forcing
|
|
739
|
-
// deletion, which would lose context).
|
|
740
|
-
const handoffNotice = (await st.listHandoffs()).length > 0
|
|
741
|
-
? "ℹ️ One or more phases have a pending handoff — if relevant to the task you're starting, read it with planner-handoff-show <ref>, then clear with planner-handoff-clear. Proceeding with task start.\n"
|
|
742
|
-
: "";
|
|
743
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
1080
|
+
const features = (await st.loadFeatures()).features;
|
|
1081
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
744
1082
|
if (!found)
|
|
745
1083
|
return text(`Task not found: ${ref}`);
|
|
1084
|
+
if (found.task.status === "in-progress")
|
|
1085
|
+
return text(`Task ${found.task.id} is already in-progress.`);
|
|
1086
|
+
if (found.task.status === "done")
|
|
1087
|
+
return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
|
|
1088
|
+
const project = await st.loadProject();
|
|
1089
|
+
const selection = recommendNextTask(features, await st.loadAllPhases(), project.workDeviations);
|
|
1090
|
+
if (!selection.candidate || selection.candidate.task.id !== found.task.id) {
|
|
1091
|
+
const recommended = selection.candidate ? ` Start ${selection.candidate.task.id} instead, or record a user-approved planner-task-deviation first.` : "";
|
|
1092
|
+
return text(`Task start denied: ${selection.reason}.${recommended}`, {
|
|
1093
|
+
kind: selection.kind,
|
|
1094
|
+
reason: selection.reason,
|
|
1095
|
+
recommendedTaskId: selection.candidate?.task.id,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
1099
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
1100
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
1101
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
1102
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
1103
|
+
]);
|
|
1104
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
1105
|
+
// A pending handoff is context, not a lock: task start RETAINS it. It is
|
|
1106
|
+
// archived only when the phase completes (auto) or the user explicitly
|
|
1107
|
+
// clears it.
|
|
746
1108
|
const timestamp = nowISO();
|
|
747
1109
|
let updatedTask;
|
|
748
1110
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -767,9 +1129,11 @@ server.registerTool("planner-task-start", {
|
|
|
767
1129
|
return phase;
|
|
768
1130
|
});
|
|
769
1131
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
770
|
-
|
|
1132
|
+
if (selection.deviation?.temporaryTaskId === found.task.id && selection.deviation.state === "approved") {
|
|
1133
|
+
await st.setWorkDeviationState(selection.deviation.id, "active", timestamp);
|
|
1134
|
+
}
|
|
771
1135
|
const t = updatedTask ?? found.task;
|
|
772
|
-
return writeAndSummarize(st,
|
|
1136
|
+
return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}`);
|
|
773
1137
|
});
|
|
774
1138
|
server.registerTool("planner-task-complete", {
|
|
775
1139
|
description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
|
|
@@ -780,7 +1144,7 @@ server.registerTool("planner-task-complete", {
|
|
|
780
1144
|
},
|
|
781
1145
|
}, async ({ task: ref, force, description_update }) => {
|
|
782
1146
|
const st = await requireStore();
|
|
783
|
-
const found = findTaskByRef(await st.loadAllPhases(), ref);
|
|
1147
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
784
1148
|
if (!found)
|
|
785
1149
|
return text(`Task not found: ${ref}`);
|
|
786
1150
|
const unchecked = found.task.checklist.filter((item) => !item.checked);
|
|
@@ -814,6 +1178,11 @@ server.registerTool("planner-task-complete", {
|
|
|
814
1178
|
return phase;
|
|
815
1179
|
});
|
|
816
1180
|
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
1181
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
1182
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
|
|
1183
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1184
|
+
if (completedDeviation)
|
|
1185
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
|
|
817
1186
|
const features = (await st.loadFeatures()).features;
|
|
818
1187
|
const t = updatedTask ?? found.task;
|
|
819
1188
|
return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}`);
|
|
@@ -829,8 +1198,8 @@ server.registerTool("planner-handoff-list", {
|
|
|
829
1198
|
return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
|
|
830
1199
|
});
|
|
831
1200
|
server.registerTool("planner-handoff-show", {
|
|
832
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef
|
|
833
|
-
inputSchema: { phaseRef: z.string().
|
|
1201
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
1202
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
834
1203
|
}, async ({ phaseRef }) => {
|
|
835
1204
|
const st = await requireStore();
|
|
836
1205
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
@@ -842,36 +1211,61 @@ server.registerTool("planner-handoff-show", {
|
|
|
842
1211
|
return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
843
1212
|
});
|
|
844
1213
|
server.registerTool("planner-handoff-write", {
|
|
845
|
-
description: "Write/refresh the entity-scoped handoff on a phase.
|
|
1214
|
+
description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Never guess from in-progress status or stale context; completed/canceled phases reject new handoffs. Pass content (markdown) and a meaningful title.",
|
|
846
1215
|
inputSchema: {
|
|
847
|
-
phaseRef: z.string().
|
|
1216
|
+
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
1217
|
+
title: z.string().min(3).optional().describe("Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'."),
|
|
1218
|
+
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
848
1219
|
content: z.string().min(1).describe("Handoff text (markdown)."),
|
|
849
1220
|
},
|
|
850
|
-
}, async ({ phaseRef, content }) => {
|
|
1221
|
+
}, async ({ phaseRef, title, confirmed, content }) => {
|
|
851
1222
|
const st = await requireStore();
|
|
1223
|
+
let body = content.trim();
|
|
1224
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
1225
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
1226
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
1227
|
+
if (!title && isGeneric) {
|
|
1228
|
+
return text("❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted.");
|
|
1229
|
+
}
|
|
1230
|
+
if (title && title.trim()) {
|
|
1231
|
+
const t = title.trim();
|
|
1232
|
+
const lines = body.split(/\r?\n/);
|
|
1233
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
1234
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
1235
|
+
lines[firstIdx] = `# ${t}`;
|
|
1236
|
+
body = lines.join("\n");
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
body = `# ${t}\n\n${body}`;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
852
1242
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
853
1243
|
if (!r.ok)
|
|
854
1244
|
return text(`❌ ${r.error}`);
|
|
855
|
-
|
|
1245
|
+
if (!confirmed)
|
|
1246
|
+
return text(`Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.`, { phaseRef: r.compositeRef, confirmationRequired: true });
|
|
1247
|
+
await st.setPhaseHandoff(r.phase.id, body);
|
|
856
1248
|
return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
857
1249
|
});
|
|
858
1250
|
server.registerTool("planner-handoff-prepare", {
|
|
859
|
-
description: "
|
|
1251
|
+
description: "Prepare a canonical handoff proposal. Do not write until the agent has identified the exact feature+phase from the conversation and the user has confirmed that target.",
|
|
860
1252
|
}, async () => text([
|
|
861
|
-
"
|
|
862
|
-
"
|
|
863
|
-
"
|
|
1253
|
+
"First identify the exact feature and phase actually discussed/worked on in this session. Do not use the first in-progress phase and do not target a phase that just became done.",
|
|
1254
|
+
"Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
|
|
1255
|
+
"After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
|
|
1256
|
+
"Pass a meaningful title. Required sections: Created at, Updated at, Reason, Current focus, What was being done, How to resume, Files touched, Blockers, Next steps, Recent decisions, Reminder.",
|
|
1257
|
+
"Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
|
|
864
1258
|
].join("\n")));
|
|
865
1259
|
server.registerTool("planner-handoff-clear", {
|
|
866
|
-
description: "Clear the entity-scoped handoff of a phase
|
|
867
|
-
inputSchema: { phaseRef: z.string().
|
|
1260
|
+
description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
|
|
1261
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
|
|
868
1262
|
}, async ({ phaseRef }) => {
|
|
869
1263
|
const st = await requireStore();
|
|
870
1264
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
871
1265
|
if (!r.ok)
|
|
872
1266
|
return text(`❌ ${r.error}`);
|
|
873
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
874
|
-
return text(`✅ Cleared handoff on ${r.compositeRef}
|
|
1267
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
1268
|
+
return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
875
1269
|
});
|
|
876
1270
|
server.registerTool("planner-web", {
|
|
877
1271
|
description: "Manage the planner web dashboard (start/status/stop) from MCP stdio. Runs an in-process HTTP+WS server bound to LAN; it lives as long as this MCP process. plan-root comes from AGENT_PLAN_ROOT or cwd()/.planner.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/mcp",
|
|
3
|
-
"version": "0.2.19-next.
|
|
3
|
+
"version": "0.2.19-next.21",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
"dist/**/*.js",
|
|
18
18
|
"dist/**/*.d.ts",
|
|
19
19
|
"dist/**/*.d.ts.map",
|
|
20
|
+
"server.json",
|
|
20
21
|
"README.md",
|
|
21
22
|
"LICENSE"
|
|
22
23
|
],
|
|
24
|
+
"mcpName": "io.github.ovidius72/agent-plan",
|
|
23
25
|
"publishConfig": {
|
|
24
26
|
"access": "public"
|
|
25
27
|
},
|
|
@@ -41,8 +43,8 @@
|
|
|
41
43
|
"dependencies": {
|
|
42
44
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
|
43
45
|
"zod": "^3.24.0",
|
|
44
|
-
"@agent-plan/
|
|
45
|
-
"@agent-plan/
|
|
46
|
+
"@agent-plan/server": "0.2.19-next.21",
|
|
47
|
+
"@agent-plan/core": "0.2.19-next.21"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
48
50
|
"build": "tsc -p tsconfig.json",
|
package/server.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "io.github.ovidius72/agent-plan",
|
|
3
|
+
"version": "0.2.19-next.18",
|
|
4
|
+
"description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/ovidius72/agent-planner.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/ovidius72/agent-planner#readme",
|
|
11
|
+
"package": {
|
|
12
|
+
"type": "npm",
|
|
13
|
+
"name": "@agent-plan/mcp"
|
|
14
|
+
}
|
|
15
|
+
}
|