@agent-plan/mcp 0.2.19-next.9 → 0.2.20
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 +456 -94
- 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":";AA+vCA,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, findTaskByRef, buildRecap,
|
|
7
|
+
import { PlanStore, ExportService, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
|
|
8
8
|
import { serve } from "@agent-plan/server";
|
|
9
|
-
import { createChecklistItemId, createFeatureId, createPhaseId,
|
|
9
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, 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) {
|
|
@@ -27,15 +27,10 @@ async function requireStore() {
|
|
|
27
27
|
const st = store();
|
|
28
28
|
if (!(await st.exists()))
|
|
29
29
|
throw new Error(`No .planner/ found at ${st.root}. Use planner-init first.`);
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
planBackfillDone = true;
|
|
33
|
-
await st.ensureShortIdsAndPriority().catch(() => { });
|
|
34
|
-
await migrateToGlobalSequence(st).catch(() => { });
|
|
35
|
-
}
|
|
30
|
+
// Opening a plan is read-only. Global migrations/backfills are explicit
|
|
31
|
+
// maintenance commands; running them here rewrites unrelated feature files.
|
|
36
32
|
return st;
|
|
37
33
|
}
|
|
38
|
-
let planBackfillDone = false;
|
|
39
34
|
// In-process web server handle, managed by the planner-web tool and planner-load.
|
|
40
35
|
// Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
|
|
41
36
|
let webHandle = null;
|
|
@@ -58,10 +53,50 @@ async function ensureWebStarted() {
|
|
|
58
53
|
}
|
|
59
54
|
function findFeatureByRef(features, ref) {
|
|
60
55
|
const normalized = ref.trim().toLowerCase();
|
|
61
|
-
|
|
56
|
+
if (!normalized)
|
|
57
|
+
return undefined;
|
|
58
|
+
const fMatch = normalized.match(/^f(\d+)$/);
|
|
59
|
+
if (fMatch) {
|
|
60
|
+
const n = parseInt(fMatch[1], 10);
|
|
61
|
+
const byNum = features.find((feature) => feature.number === n);
|
|
62
|
+
if (byNum)
|
|
63
|
+
return byNum;
|
|
64
|
+
}
|
|
65
|
+
return features.find((feature) => feature.shortId?.toLowerCase() === normalized)
|
|
66
|
+
?? features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
62
67
|
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
63
68
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized));
|
|
64
69
|
}
|
|
70
|
+
function resolveFeatureRefStrict(features, ref) {
|
|
71
|
+
const raw = ref.trim();
|
|
72
|
+
if (!raw)
|
|
73
|
+
return { ok: false, error: "Feature ref is required." };
|
|
74
|
+
const normalized = raw.toLowerCase();
|
|
75
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
76
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
77
|
+
: undefined;
|
|
78
|
+
if (byNumber)
|
|
79
|
+
return { ok: true, feature: byNumber };
|
|
80
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
81
|
+
if (byShortId)
|
|
82
|
+
return { ok: true, feature: byShortId };
|
|
83
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
84
|
+
if (byId)
|
|
85
|
+
return { ok: true, feature: byId };
|
|
86
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
87
|
+
if (exactName.length === 1)
|
|
88
|
+
return { ok: true, feature: exactName[0] };
|
|
89
|
+
if (exactName.length > 1)
|
|
90
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
91
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
92
|
+
if (partialName.length === 1)
|
|
93
|
+
return { ok: true, feature: partialName[0] };
|
|
94
|
+
if (partialName.length > 1)
|
|
95
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
96
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
97
|
+
}
|
|
98
|
+
/** Belt-and-suspenders validation: a resolved ref must be a real UUID and the target
|
|
99
|
+
* must still exist in the store before we allocate numbers or write. */
|
|
65
100
|
function featureNumberOfPhase(phase, features) {
|
|
66
101
|
return phase.featureId ? features.find((f) => f.id === phase.featureId)?.number : undefined;
|
|
67
102
|
}
|
|
@@ -97,16 +132,12 @@ async function resolvePhaseForHandoff(st, ref) {
|
|
|
97
132
|
if (ref && ref.trim()) {
|
|
98
133
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
99
134
|
}
|
|
100
|
-
else {
|
|
101
|
-
phase = phases.find((p) => p.status === "in-progress")
|
|
102
|
-
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
103
|
-
}
|
|
104
135
|
if (!phase) {
|
|
105
136
|
return {
|
|
106
137
|
ok: false,
|
|
107
138
|
error: ref && ref.trim()
|
|
108
139
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
109
|
-
: "
|
|
140
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
110
141
|
};
|
|
111
142
|
}
|
|
112
143
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -124,7 +155,7 @@ server.registerTool("planner-export", {
|
|
|
124
155
|
const markdown = exportService.exportToMarkdown(plan, full);
|
|
125
156
|
const fs = await import("node:fs/promises");
|
|
126
157
|
await fs.writeFile(join(st.root, "EXPORT.md"), markdown, "utf-8");
|
|
127
|
-
return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}
|
|
158
|
+
return text(`Project export generated. Summary results:\n\n${markdown.slice(0, 1000)}${markdown.length > 1000 ? "... (full report in .planner/EXPORT.md)" : ""}`);
|
|
128
159
|
});
|
|
129
160
|
server.registerTool("planner-authorize-bypass", {
|
|
130
161
|
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.",
|
|
@@ -165,26 +196,77 @@ server.registerTool("planner-init", {
|
|
|
165
196
|
return writeAndSummarize(st, `.planner/ initialized for "${project.name}"`);
|
|
166
197
|
});
|
|
167
198
|
server.registerTool("planner-show", {
|
|
168
|
-
description: "Show
|
|
199
|
+
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.",
|
|
169
200
|
}, async () => {
|
|
170
201
|
const st = await requireStore();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
`
|
|
180
|
-
|
|
202
|
+
const features = (await st.loadFeatures()).features;
|
|
203
|
+
const phases = await st.loadAllPhases();
|
|
204
|
+
const totalTasks = phases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
205
|
+
const project = await st.loadProject();
|
|
206
|
+
const manifest = await st.loadManifest();
|
|
207
|
+
const featureLines = features.map((feature) => {
|
|
208
|
+
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
209
|
+
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
210
|
+
return `- ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} — ${feature.name} (${feature.status}; ${featurePhases.length} phases, ${taskCount} tasks)`;
|
|
211
|
+
});
|
|
212
|
+
const summary = [
|
|
213
|
+
`📋 ${project.name}`,
|
|
214
|
+
`Description: ${project.description || "(not set)"}`,
|
|
215
|
+
`Goal: ${project.goal || "(not set)"}`,
|
|
216
|
+
`Features: ${features.length} | Phases: ${phases.length} | Tasks: ${totalTasks}`,
|
|
217
|
+
`Updated: ${manifest.updatedAt || "(unknown)"}`,
|
|
218
|
+
"",
|
|
219
|
+
"Features:",
|
|
220
|
+
featureLines.join("\n") || "(none)",
|
|
221
|
+
].join("\n");
|
|
222
|
+
// Compact structured overview (no full plan: avoids 1M+ char token overflow).
|
|
223
|
+
const overview = {
|
|
224
|
+
project: { name: project.name, description: project.description || null, goal: project.goal || null, updatedAt: manifest.updatedAt || null },
|
|
225
|
+
counts: { features: features.length, phases: phases.length, tasks: totalTasks },
|
|
226
|
+
features: features.map((feature) => ({
|
|
227
|
+
ref: formatFeatureRef(feature.number),
|
|
228
|
+
shortId: feature.shortId || null,
|
|
229
|
+
name: feature.name,
|
|
230
|
+
status: feature.status,
|
|
231
|
+
phases: phases.filter((p) => p.featureId === feature.id).length,
|
|
232
|
+
tasks: phases.filter((p) => p.featureId === feature.id).reduce((t, p) => t + p.tasks.length, 0),
|
|
233
|
+
})),
|
|
234
|
+
};
|
|
235
|
+
return text(summary, { overview });
|
|
181
236
|
});
|
|
182
237
|
server.registerTool("planner-repair", {
|
|
183
238
|
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.",
|
|
184
239
|
}, async () => {
|
|
185
240
|
const st = await requireStore();
|
|
186
241
|
const report = await st.repair();
|
|
187
|
-
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). Integrity: ${report.integrity.duplicatePhaseIds.length} duplicate, ${report.integrity.danglingPhaseIds.length} dangling.`, { report });
|
|
242
|
+
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 });
|
|
243
|
+
});
|
|
244
|
+
server.registerTool("planner-cleanup-orphan-phases", {
|
|
245
|
+
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.",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
confirm: z.boolean().optional().describe("Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)."),
|
|
248
|
+
},
|
|
249
|
+
}, async ({ confirm }) => {
|
|
250
|
+
const st = await requireStore();
|
|
251
|
+
const found = await st.listOrphanPhases();
|
|
252
|
+
if (!confirm) {
|
|
253
|
+
if (found.length === 0)
|
|
254
|
+
return text("No orphan phases found.", { found: [] });
|
|
255
|
+
const lines = [
|
|
256
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
257
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
258
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
259
|
+
];
|
|
260
|
+
return text(lines.join("\n"), { found, confirmRequired: true });
|
|
261
|
+
}
|
|
262
|
+
const report = await st.cleanupOrphanPhases();
|
|
263
|
+
if (report.removed.length === 0)
|
|
264
|
+
return text("No orphan phases found.", report);
|
|
265
|
+
const lines = [
|
|
266
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
267
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
268
|
+
];
|
|
269
|
+
return text(lines.join("\n"), report);
|
|
188
270
|
});
|
|
189
271
|
server.registerTool("planner-project-language", {
|
|
190
272
|
description: "Persist preferred languages for plan content and chat.",
|
|
@@ -311,15 +393,17 @@ server.registerTool("planner-feature-add", {
|
|
|
311
393
|
const timestamp = nowISO();
|
|
312
394
|
const effectiveStatus = status ?? "planned";
|
|
313
395
|
const existingFeatures = (await st.loadFeatures()).features;
|
|
314
|
-
const
|
|
396
|
+
const id = createFeatureId();
|
|
397
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
315
398
|
const priority = await st.nextPriority("feature");
|
|
316
399
|
const feature = {
|
|
317
|
-
id
|
|
318
|
-
number:
|
|
319
|
-
shortId,
|
|
400
|
+
id,
|
|
401
|
+
number: identity.number,
|
|
402
|
+
shortId: identity.shortId,
|
|
320
403
|
priority,
|
|
321
404
|
name: name.trim(),
|
|
322
405
|
description: description?.trim() ?? "",
|
|
406
|
+
descriptionUpdatedAt: timestamp,
|
|
323
407
|
status: effectiveStatus,
|
|
324
408
|
discussedAt: "",
|
|
325
409
|
contextReady: false,
|
|
@@ -331,6 +415,7 @@ server.registerTool("planner-feature-add", {
|
|
|
331
415
|
acceptedDecisions: [],
|
|
332
416
|
phaseIds: [],
|
|
333
417
|
dependsOn: [],
|
|
418
|
+
statusLog: [],
|
|
334
419
|
createdAt: timestamp,
|
|
335
420
|
updatedAt: timestamp,
|
|
336
421
|
};
|
|
@@ -346,13 +431,51 @@ server.registerTool("planner-feature-show", {
|
|
|
346
431
|
}, async ({ feature: ref, full }) => {
|
|
347
432
|
const st = await requireStore();
|
|
348
433
|
const features = (await st.loadFeatures()).features;
|
|
349
|
-
const
|
|
350
|
-
if (!
|
|
351
|
-
return text(
|
|
434
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
435
|
+
if (!resolvedFeature.ok)
|
|
436
|
+
return text(resolvedFeature.error);
|
|
437
|
+
const feature = resolvedFeature.feature;
|
|
352
438
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
353
439
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
354
440
|
return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
|
|
355
441
|
});
|
|
442
|
+
server.registerTool("planner-feature-discuss", {
|
|
443
|
+
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
444
|
+
inputSchema: {
|
|
445
|
+
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
446
|
+
description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
|
|
447
|
+
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
448
|
+
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
449
|
+
dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
|
|
450
|
+
},
|
|
451
|
+
}, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
|
|
452
|
+
const st = await requireStore();
|
|
453
|
+
const features = (await st.loadFeatures()).features;
|
|
454
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
455
|
+
if (!resolvedFeature.ok)
|
|
456
|
+
return text(resolvedFeature.error);
|
|
457
|
+
const feature = resolvedFeature.feature;
|
|
458
|
+
const updated = await st.updateFeatures((doc) => {
|
|
459
|
+
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
460
|
+
if (!target)
|
|
461
|
+
return doc;
|
|
462
|
+
if (description !== undefined)
|
|
463
|
+
target.description = description.trim();
|
|
464
|
+
if (workDone !== undefined)
|
|
465
|
+
target.workDone = workDone.trim();
|
|
466
|
+
if (workRemaining !== undefined)
|
|
467
|
+
target.workRemaining = workRemaining.trim();
|
|
468
|
+
if (dependencies !== undefined)
|
|
469
|
+
target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
|
|
470
|
+
target.discussedAt = nowISO();
|
|
471
|
+
target.contextReady = true;
|
|
472
|
+
target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
|
|
473
|
+
target.updatedAt = nowISO();
|
|
474
|
+
return doc;
|
|
475
|
+
});
|
|
476
|
+
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
477
|
+
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
|
|
478
|
+
});
|
|
356
479
|
server.registerTool("planner-feature-update", {
|
|
357
480
|
description: "Update a feature.",
|
|
358
481
|
inputSchema: {
|
|
@@ -369,9 +492,10 @@ server.registerTool("planner-feature-update", {
|
|
|
369
492
|
}, async ({ feature: ref, ...updates }) => {
|
|
370
493
|
const st = await requireStore();
|
|
371
494
|
const features = (await st.loadFeatures()).features;
|
|
372
|
-
const
|
|
373
|
-
if (!
|
|
374
|
-
return text(
|
|
495
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
496
|
+
if (!resolvedFeature.ok)
|
|
497
|
+
return text(resolvedFeature.error);
|
|
498
|
+
const feature = resolvedFeature.feature;
|
|
375
499
|
const updated = await st.updateFeatures((doc) => {
|
|
376
500
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
377
501
|
if (!target)
|
|
@@ -407,9 +531,10 @@ server.registerTool("planner-feature-delete", {
|
|
|
407
531
|
}, async ({ feature: ref, cascade }) => {
|
|
408
532
|
const st = await requireStore();
|
|
409
533
|
const features = (await st.loadFeatures()).features;
|
|
410
|
-
const
|
|
411
|
-
if (!
|
|
412
|
-
return text(
|
|
534
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
535
|
+
if (!resolvedFeature.ok)
|
|
536
|
+
return text(resolvedFeature.error);
|
|
537
|
+
const feature = resolvedFeature.feature;
|
|
413
538
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
414
539
|
await st.updateFeatures((doc) => {
|
|
415
540
|
doc.features = doc.features.filter((entry) => entry.id !== feature.id);
|
|
@@ -428,31 +553,40 @@ server.registerTool("planner-feature-delete", {
|
|
|
428
553
|
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) });
|
|
429
554
|
});
|
|
430
555
|
server.registerTool("planner-phase-add", {
|
|
431
|
-
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.",
|
|
556
|
+
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.",
|
|
432
557
|
inputSchema: {
|
|
433
558
|
title: z.string().min(1),
|
|
434
|
-
feature: z.string().optional().describe("Feature ref. Accepts F00x/P00x/T00x composite, bare
|
|
559
|
+
feature: z.string().min(1).optional().describe("Feature ref (required). Accepts F00x/P00x/T00x composite, bare F00x (global), 5-char shortId, UUID, or title."),
|
|
435
560
|
summary: z.string().optional().describe("One-line summary of the phase"),
|
|
436
561
|
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."),
|
|
437
562
|
},
|
|
438
563
|
}, async ({ title, feature: featureRef, summary, description }) => {
|
|
439
564
|
const st = await requireStore();
|
|
565
|
+
if (!featureRef?.trim())
|
|
566
|
+
return text("feature is required: a phase must belong to a feature.");
|
|
440
567
|
const featuresDoc = await st.loadFeatures();
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
443
|
-
return text(
|
|
568
|
+
const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
|
|
569
|
+
if (!resolvedFeature.ok)
|
|
570
|
+
return text(resolvedFeature.error);
|
|
571
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
572
|
+
if (!featureValidation.ok)
|
|
573
|
+
return text(featureValidation.error);
|
|
574
|
+
const feature = resolvedFeature.feature;
|
|
575
|
+
if (!feature)
|
|
576
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
|
|
444
577
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
445
578
|
let phase;
|
|
446
579
|
await withFeatureLock(lockKey, async () => {
|
|
447
580
|
const phases = await st.loadAllPhases();
|
|
448
581
|
const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
|
|
449
582
|
const timestamp = nowISO();
|
|
450
|
-
const
|
|
583
|
+
const id = createPhaseId();
|
|
584
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
451
585
|
const priority = await st.nextPriority("phase", feature?.id);
|
|
452
586
|
phase = {
|
|
453
|
-
id
|
|
454
|
-
number:
|
|
455
|
-
shortId,
|
|
587
|
+
id,
|
|
588
|
+
number: identity.number,
|
|
589
|
+
shortId: identity.shortId,
|
|
456
590
|
priority,
|
|
457
591
|
slug: normalizeSlug(title),
|
|
458
592
|
title: title.trim(),
|
|
@@ -463,6 +597,7 @@ server.registerTool("planner-phase-add", {
|
|
|
463
597
|
contextReadyReason: "",
|
|
464
598
|
summary: summary?.trim() ?? "",
|
|
465
599
|
description: description?.trim() ?? "",
|
|
600
|
+
descriptionUpdatedAt: timestamp,
|
|
466
601
|
notes: "",
|
|
467
602
|
goals: [],
|
|
468
603
|
nonGoals: [],
|
|
@@ -481,6 +616,7 @@ server.registerTool("planner-phase-add", {
|
|
|
481
616
|
handoffUpdatedAt: "",
|
|
482
617
|
handoffReadAt: "",
|
|
483
618
|
handoffHistory: [],
|
|
619
|
+
statusLog: [],
|
|
484
620
|
};
|
|
485
621
|
await st.savePhase(phase);
|
|
486
622
|
if (feature) {
|
|
@@ -498,7 +634,7 @@ server.registerTool("planner-phase-add", {
|
|
|
498
634
|
return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
499
635
|
});
|
|
500
636
|
server.registerTool("planner-phase-show", {
|
|
501
|
-
description: "Show a phase by id or name.",
|
|
637
|
+
description: "Show a phase by id or name, including derived linked requirements in the full view.",
|
|
502
638
|
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).") },
|
|
503
639
|
}, async ({ phase: ref, full }) => {
|
|
504
640
|
const st = await requireStore();
|
|
@@ -506,8 +642,24 @@ server.registerTool("planner-phase-show", {
|
|
|
506
642
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
507
643
|
if (!phase)
|
|
508
644
|
return text(`Phase not found: ${ref}`);
|
|
509
|
-
const
|
|
510
|
-
|
|
645
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
646
|
+
const reqCount = linkedRequirements.length;
|
|
647
|
+
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"}` : ""})`;
|
|
648
|
+
const requirementsBlock = reqCount > 0
|
|
649
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
650
|
+
: "";
|
|
651
|
+
return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
|
|
652
|
+
phase: {
|
|
653
|
+
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
654
|
+
shortId: phase.shortId,
|
|
655
|
+
title: phase.title,
|
|
656
|
+
summary: phase.summary,
|
|
657
|
+
status: phase.status,
|
|
658
|
+
taskCount: phase.tasks.length,
|
|
659
|
+
...(full ? { description: phase.description } : {}),
|
|
660
|
+
},
|
|
661
|
+
linkedRequirements,
|
|
662
|
+
});
|
|
511
663
|
});
|
|
512
664
|
server.registerTool("planner-phase-discuss", {
|
|
513
665
|
description: "Persist phase discovery fields and mark phase planned.",
|
|
@@ -603,35 +755,57 @@ server.registerTool("planner-phase-delete", {
|
|
|
603
755
|
server.registerTool("planner-task-add", {
|
|
604
756
|
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.",
|
|
605
757
|
inputSchema: {
|
|
758
|
+
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."),
|
|
606
759
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
607
760
|
title: z.string().min(1),
|
|
608
761
|
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."),
|
|
609
762
|
checklist: z.array(z.string()).optional(),
|
|
610
763
|
},
|
|
611
|
-
}, async ({ phase: ref, title, description, checklist }) => {
|
|
764
|
+
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
612
765
|
const st = await requireStore();
|
|
613
|
-
const
|
|
766
|
+
const features = (await st.loadFeatures()).features;
|
|
767
|
+
if (!featureRef?.trim())
|
|
768
|
+
return text("feature is required: a task must belong to a feature.");
|
|
769
|
+
const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
|
|
770
|
+
if (!resolvedFeature.ok)
|
|
771
|
+
return text(resolvedFeature.error);
|
|
772
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
773
|
+
if (!featureValidation.ok)
|
|
774
|
+
return text(featureValidation.error);
|
|
775
|
+
const feature = resolvedFeature.feature;
|
|
776
|
+
if (!feature)
|
|
777
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
|
|
778
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
614
779
|
if (!found)
|
|
615
780
|
return text(`Phase not found: ${ref}`);
|
|
781
|
+
if (found.featureId !== feature.id)
|
|
782
|
+
return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
|
|
783
|
+
const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
|
|
784
|
+
if (!phaseValidation.ok)
|
|
785
|
+
return text(phaseValidation.error);
|
|
786
|
+
const existingPhase = await st.loadPhase(found.id);
|
|
787
|
+
if (!existingPhase)
|
|
788
|
+
return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
|
|
616
789
|
const timestamp = nowISO();
|
|
617
790
|
const taskId = createTaskId();
|
|
618
|
-
const
|
|
791
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
619
792
|
const priority = await st.nextPriority("task", found.id);
|
|
620
793
|
const task = {
|
|
621
794
|
id: taskId,
|
|
622
795
|
phaseId: found.id,
|
|
623
|
-
number:
|
|
624
|
-
shortId,
|
|
796
|
+
number: identity.number,
|
|
797
|
+
shortId: identity.shortId,
|
|
625
798
|
priority,
|
|
626
799
|
shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
|
|
627
800
|
title: title.trim(),
|
|
628
801
|
status: "planned",
|
|
629
802
|
description: description?.trim() ?? "",
|
|
803
|
+
descriptionUpdatedAt: timestamp,
|
|
630
804
|
notes: "",
|
|
631
805
|
statusLog: [],
|
|
632
806
|
decisions: [],
|
|
633
807
|
acceptedDecisions: [],
|
|
634
|
-
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
|
|
808
|
+
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
|
|
635
809
|
subtasks: [],
|
|
636
810
|
dependsOn: [],
|
|
637
811
|
startedAt: "",
|
|
@@ -645,8 +819,8 @@ server.registerTool("planner-task-add", {
|
|
|
645
819
|
entry.updatedAt = timestamp;
|
|
646
820
|
return entry;
|
|
647
821
|
});
|
|
648
|
-
const
|
|
649
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found,
|
|
822
|
+
const finalFeatures = (await st.loadFeatures()).features;
|
|
823
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
650
824
|
});
|
|
651
825
|
server.registerTool("planner-task-show", {
|
|
652
826
|
description: "Show a task by id or name.",
|
|
@@ -683,7 +857,9 @@ server.registerTool("planner-task-discuss", {
|
|
|
683
857
|
if (description !== undefined)
|
|
684
858
|
task.description = description.trim();
|
|
685
859
|
if (checklist !== undefined)
|
|
686
|
-
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
|
|
860
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
861
|
+
if (checklist !== undefined)
|
|
862
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
687
863
|
task.updatedAt = nowISO();
|
|
688
864
|
phase.updatedAt = task.updatedAt;
|
|
689
865
|
updatedTask = task;
|
|
@@ -702,8 +878,9 @@ server.registerTool("planner-task-update", {
|
|
|
702
878
|
description: z.string().optional(),
|
|
703
879
|
motivation: z.string().optional(),
|
|
704
880
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
881
|
+
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."),
|
|
705
882
|
},
|
|
706
|
-
}, async ({ task: ref, title, status, description, motivation, priority }) => {
|
|
883
|
+
}, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
|
|
707
884
|
const st = await requireStore();
|
|
708
885
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
709
886
|
if (!found)
|
|
@@ -750,6 +927,107 @@ server.registerTool("planner-task-update", {
|
|
|
750
927
|
const t = updatedTask ?? found.task;
|
|
751
928
|
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
|
|
752
929
|
});
|
|
930
|
+
server.registerTool("planner-task-checklist-toggle", {
|
|
931
|
+
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.",
|
|
932
|
+
inputSchema: {
|
|
933
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
934
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
935
|
+
checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
|
|
936
|
+
},
|
|
937
|
+
}, async ({ task: ref, item, checked }) => {
|
|
938
|
+
const st = await requireStore();
|
|
939
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
940
|
+
if (!found)
|
|
941
|
+
return text(`Task not found: ${ref}`);
|
|
942
|
+
let result = "";
|
|
943
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
944
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
945
|
+
if (!task) {
|
|
946
|
+
result = `Task not found: ${ref}`;
|
|
947
|
+
return phase;
|
|
948
|
+
}
|
|
949
|
+
const ck = task.checklist ?? [];
|
|
950
|
+
if (ck.length === 0) {
|
|
951
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
952
|
+
return phase;
|
|
953
|
+
}
|
|
954
|
+
const target = toggleChecklistItem(ck, item, checked);
|
|
955
|
+
if (!target) {
|
|
956
|
+
result = `No checklist item matching "${item}".`;
|
|
957
|
+
return phase;
|
|
958
|
+
}
|
|
959
|
+
task.updatedAt = nowISO();
|
|
960
|
+
phase.updatedAt = nowISO();
|
|
961
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
962
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
963
|
+
return phase;
|
|
964
|
+
});
|
|
965
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
966
|
+
});
|
|
967
|
+
server.registerTool("planner-task-checklist-add", {
|
|
968
|
+
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.",
|
|
969
|
+
inputSchema: {
|
|
970
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
971
|
+
title: z.string().min(1).describe("Checklist item text (a single step)"),
|
|
972
|
+
},
|
|
973
|
+
}, async ({ task: ref, title }) => {
|
|
974
|
+
const st = await requireStore();
|
|
975
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
976
|
+
if (!found)
|
|
977
|
+
return text(`Task not found: ${ref}`);
|
|
978
|
+
let result = "";
|
|
979
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
980
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
981
|
+
if (!task) {
|
|
982
|
+
result = `Task not found: ${ref}`;
|
|
983
|
+
return phase;
|
|
984
|
+
}
|
|
985
|
+
const ck = task.checklist ?? [];
|
|
986
|
+
const item = addChecklistItem(ck, task.id, title);
|
|
987
|
+
ck.push(item);
|
|
988
|
+
task.checklist = ck;
|
|
989
|
+
task.updatedAt = nowISO();
|
|
990
|
+
phase.updatedAt = nowISO();
|
|
991
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
992
|
+
return phase;
|
|
993
|
+
});
|
|
994
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
995
|
+
});
|
|
996
|
+
server.registerTool("planner-task-checklist-remove", {
|
|
997
|
+
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.",
|
|
998
|
+
inputSchema: {
|
|
999
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
1000
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
1001
|
+
},
|
|
1002
|
+
}, async ({ task: ref, item }) => {
|
|
1003
|
+
const st = await requireStore();
|
|
1004
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1005
|
+
if (!found)
|
|
1006
|
+
return text(`Task not found: ${ref}`);
|
|
1007
|
+
let result = "";
|
|
1008
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
1009
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
1010
|
+
if (!task) {
|
|
1011
|
+
result = `Task not found: ${ref}`;
|
|
1012
|
+
return phase;
|
|
1013
|
+
}
|
|
1014
|
+
const ck = task.checklist ?? [];
|
|
1015
|
+
if (ck.length === 0) {
|
|
1016
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
1017
|
+
return phase;
|
|
1018
|
+
}
|
|
1019
|
+
const removed = removeChecklistItem(ck, item);
|
|
1020
|
+
if (!removed) {
|
|
1021
|
+
result = `No checklist item matching "${item}".`;
|
|
1022
|
+
return phase;
|
|
1023
|
+
}
|
|
1024
|
+
task.updatedAt = nowISO();
|
|
1025
|
+
phase.updatedAt = nowISO();
|
|
1026
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
1027
|
+
return phase;
|
|
1028
|
+
});
|
|
1029
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
1030
|
+
});
|
|
753
1031
|
server.registerTool("planner-task-delete", {
|
|
754
1032
|
description: "Delete a task.",
|
|
755
1033
|
inputSchema: { task: z.string().min(1) },
|
|
@@ -768,27 +1046,79 @@ server.registerTool("planner-task-delete", {
|
|
|
768
1046
|
const tdFeatures = (await st.loadFeatures()).features;
|
|
769
1047
|
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
770
1048
|
});
|
|
1049
|
+
server.registerTool("planner-task-recommend", {
|
|
1050
|
+
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.",
|
|
1051
|
+
inputSchema: {},
|
|
1052
|
+
}, async () => {
|
|
1053
|
+
const st = await requireStore();
|
|
1054
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1055
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1056
|
+
if (!result.candidate)
|
|
1057
|
+
return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
|
|
1058
|
+
const { candidate } = result;
|
|
1059
|
+
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1060
|
+
return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
|
|
1061
|
+
kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
server.registerTool("planner-task-deviation", {
|
|
1065
|
+
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.",
|
|
1066
|
+
inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
|
|
1067
|
+
}, async ({ temporary_task, resume_task, reason }) => {
|
|
1068
|
+
const st = await requireStore();
|
|
1069
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1070
|
+
const temporary = findTaskByRef(phases, features.features, temporary_task);
|
|
1071
|
+
if (!temporary)
|
|
1072
|
+
return text(`Task not found: ${temporary_task}`);
|
|
1073
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1074
|
+
const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
|
|
1075
|
+
if (!resume)
|
|
1076
|
+
return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
|
|
1077
|
+
if (temporary.task.id === resume.task.id)
|
|
1078
|
+
return text("A temporary task must differ from its resume target.");
|
|
1079
|
+
if (temporary.task.status !== "planned")
|
|
1080
|
+
return text(`Temporary task is not startable: ${temporary.task.status}.`);
|
|
1081
|
+
const timestamp = nowISO();
|
|
1082
|
+
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: "" };
|
|
1083
|
+
await st.addWorkDeviation(record);
|
|
1084
|
+
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 });
|
|
1085
|
+
});
|
|
771
1086
|
server.registerTool("planner-task-start", {
|
|
772
|
-
description: "Set a task to in-progress.",
|
|
1087
|
+
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.",
|
|
773
1088
|
inputSchema: { task: z.string().min(1) },
|
|
774
1089
|
}, async ({ task: ref }) => {
|
|
775
1090
|
const st = await requireStore();
|
|
776
|
-
const
|
|
1091
|
+
const features = (await st.loadFeatures()).features;
|
|
1092
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
777
1093
|
if (!found)
|
|
778
1094
|
return text(`Task not found: ${ref}`);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1095
|
+
if (found.task.status === "in-progress")
|
|
1096
|
+
return text(`Task ${found.task.id} is already in-progress.`);
|
|
1097
|
+
if (found.task.status === "done")
|
|
1098
|
+
return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
|
|
1099
|
+
const project = await st.loadProject();
|
|
1100
|
+
const phases = await st.loadAllPhases();
|
|
1101
|
+
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
1102
|
+
if (!eligibility.eligible)
|
|
1103
|
+
return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
|
|
1104
|
+
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
1105
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
1106
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
1107
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
1108
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
1109
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
1110
|
+
]);
|
|
1111
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
1112
|
+
// A pending handoff is context, not a lock: task start RETAINS it. It is
|
|
1113
|
+
// archived only when the phase completes (auto) or the user explicitly
|
|
1114
|
+
// clears it.
|
|
1115
|
+
const advisory = selection.kind === "conflict"
|
|
1116
|
+
? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
|
|
1117
|
+
: selection.candidate && selection.candidate.task.id !== found.task.id
|
|
1118
|
+
? selection.kind === "active"
|
|
1119
|
+
? `\n\n⚠️ Active-task advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is already in progress. Explicit task request honored.`
|
|
1120
|
+
: `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
|
|
1121
|
+
: "";
|
|
792
1122
|
const timestamp = nowISO();
|
|
793
1123
|
let updatedTask;
|
|
794
1124
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -813,9 +1143,11 @@ server.registerTool("planner-task-start", {
|
|
|
813
1143
|
return phase;
|
|
814
1144
|
});
|
|
815
1145
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
816
|
-
const
|
|
1146
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
1147
|
+
if (approvedDeviation)
|
|
1148
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
817
1149
|
const t = updatedTask ?? found.task;
|
|
818
|
-
return writeAndSummarize(st,
|
|
1150
|
+
return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
|
|
819
1151
|
});
|
|
820
1152
|
server.registerTool("planner-task-complete", {
|
|
821
1153
|
description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
|
|
@@ -860,6 +1192,11 @@ server.registerTool("planner-task-complete", {
|
|
|
860
1192
|
return phase;
|
|
861
1193
|
});
|
|
862
1194
|
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
1195
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
1196
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
|
|
1197
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1198
|
+
if (completedDeviation)
|
|
1199
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
|
|
863
1200
|
const features = (await st.loadFeatures()).features;
|
|
864
1201
|
const t = updatedTask ?? found.task;
|
|
865
1202
|
return writeAndSummarize(st, `✅ Task completed: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (done)${t.shortId ? ` · ${t.shortId}` : ""}${clearedRef ? ` — phase handoff auto-cleared (${clearedRef})` : ""}`);
|
|
@@ -875,8 +1212,8 @@ server.registerTool("planner-handoff-list", {
|
|
|
875
1212
|
return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
|
|
876
1213
|
});
|
|
877
1214
|
server.registerTool("planner-handoff-show", {
|
|
878
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef
|
|
879
|
-
inputSchema: { phaseRef: z.string().
|
|
1215
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
1216
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
880
1217
|
}, async ({ phaseRef }) => {
|
|
881
1218
|
const st = await requireStore();
|
|
882
1219
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
@@ -888,36 +1225,61 @@ server.registerTool("planner-handoff-show", {
|
|
|
888
1225
|
return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
889
1226
|
});
|
|
890
1227
|
server.registerTool("planner-handoff-write", {
|
|
891
|
-
description: "Write/refresh the entity-scoped handoff on a phase.
|
|
1228
|
+
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.",
|
|
892
1229
|
inputSchema: {
|
|
893
|
-
phaseRef: z.string().
|
|
1230
|
+
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
1231
|
+
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'."),
|
|
1232
|
+
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
894
1233
|
content: z.string().min(1).describe("Handoff text (markdown)."),
|
|
895
1234
|
},
|
|
896
|
-
}, async ({ phaseRef, content }) => {
|
|
1235
|
+
}, async ({ phaseRef, title, confirmed, content }) => {
|
|
897
1236
|
const st = await requireStore();
|
|
1237
|
+
let body = content.trim();
|
|
1238
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
1239
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
1240
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
1241
|
+
if (!title && isGeneric) {
|
|
1242
|
+
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.");
|
|
1243
|
+
}
|
|
1244
|
+
if (title && title.trim()) {
|
|
1245
|
+
const t = title.trim();
|
|
1246
|
+
const lines = body.split(/\r?\n/);
|
|
1247
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
1248
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
1249
|
+
lines[firstIdx] = `# ${t}`;
|
|
1250
|
+
body = lines.join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
body = `# ${t}\n\n${body}`;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
898
1256
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
899
1257
|
if (!r.ok)
|
|
900
1258
|
return text(`❌ ${r.error}`);
|
|
901
|
-
|
|
1259
|
+
if (!confirmed)
|
|
1260
|
+
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 });
|
|
1261
|
+
await st.setPhaseHandoff(r.phase.id, body);
|
|
902
1262
|
return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
903
1263
|
});
|
|
904
1264
|
server.registerTool("planner-handoff-prepare", {
|
|
905
|
-
description: "
|
|
1265
|
+
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.",
|
|
906
1266
|
}, async () => text([
|
|
907
|
-
"
|
|
908
|
-
"
|
|
909
|
-
"
|
|
1267
|
+
"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.",
|
|
1268
|
+
"Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
|
|
1269
|
+
"After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
|
|
1270
|
+
"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.",
|
|
1271
|
+
"Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
|
|
910
1272
|
].join("\n")));
|
|
911
1273
|
server.registerTool("planner-handoff-clear", {
|
|
912
|
-
description: "Clear the entity-scoped handoff of a phase
|
|
913
|
-
inputSchema: { phaseRef: z.string().
|
|
1274
|
+
description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
|
|
1275
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
|
|
914
1276
|
}, async ({ phaseRef }) => {
|
|
915
1277
|
const st = await requireStore();
|
|
916
1278
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
917
1279
|
if (!r.ok)
|
|
918
1280
|
return text(`❌ ${r.error}`);
|
|
919
1281
|
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
920
|
-
return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
1282
|
+
return text(`✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
921
1283
|
});
|
|
922
1284
|
server.registerTool("planner-web", {
|
|
923
1285
|
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.",
|
|
@@ -960,7 +1322,7 @@ server.registerTool("planner-web", {
|
|
|
960
1322
|
return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
|
|
961
1323
|
});
|
|
962
1324
|
server.registerTool("planner-load", {
|
|
963
|
-
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
|
|
1325
|
+
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).",
|
|
964
1326
|
}, async () => {
|
|
965
1327
|
const st = store();
|
|
966
1328
|
if (!(await st.exists()))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
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/core": "0.2.
|
|
45
|
-
"@agent-plan/server": "0.2.
|
|
46
|
+
"@agent-plan/core": "0.2.20",
|
|
47
|
+
"@agent-plan/server": "0.2.20"
|
|
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
|
+
}
|