@agent-plan/mcp 0.2.19-next.8 → 0.2.19
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 +453 -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":";AA4vCA,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,12 +393,13 @@ 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() ?? "",
|
|
@@ -331,6 +414,7 @@ server.registerTool("planner-feature-add", {
|
|
|
331
414
|
acceptedDecisions: [],
|
|
332
415
|
phaseIds: [],
|
|
333
416
|
dependsOn: [],
|
|
417
|
+
statusLog: [],
|
|
334
418
|
createdAt: timestamp,
|
|
335
419
|
updatedAt: timestamp,
|
|
336
420
|
};
|
|
@@ -346,13 +430,51 @@ server.registerTool("planner-feature-show", {
|
|
|
346
430
|
}, async ({ feature: ref, full }) => {
|
|
347
431
|
const st = await requireStore();
|
|
348
432
|
const features = (await st.loadFeatures()).features;
|
|
349
|
-
const
|
|
350
|
-
if (!
|
|
351
|
-
return text(
|
|
433
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
434
|
+
if (!resolvedFeature.ok)
|
|
435
|
+
return text(resolvedFeature.error);
|
|
436
|
+
const feature = resolvedFeature.feature;
|
|
352
437
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
353
438
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
354
439
|
return text(full ? `${summary}\n\n${feature.description || ""}` : summary);
|
|
355
440
|
});
|
|
441
|
+
server.registerTool("planner-feature-discuss", {
|
|
442
|
+
description: "Persist feature discovery/governance fields and mark the feature context as ready.",
|
|
443
|
+
inputSchema: {
|
|
444
|
+
feature: z.string().min(1).describe("Feature ref. Accepts F00x, shortId, UUID, or title."),
|
|
445
|
+
description: z.string().optional().describe("Current implementation state, scope, and goals for this feature."),
|
|
446
|
+
workDone: z.string().optional().describe("What is already implemented / decided."),
|
|
447
|
+
workRemaining: z.string().optional().describe("What still needs to be done."),
|
|
448
|
+
dependencies: z.array(z.string()).optional().describe("Cross-feature or external dependencies."),
|
|
449
|
+
},
|
|
450
|
+
}, async ({ feature: ref, description, workDone, workRemaining, dependencies }) => {
|
|
451
|
+
const st = await requireStore();
|
|
452
|
+
const features = (await st.loadFeatures()).features;
|
|
453
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
454
|
+
if (!resolvedFeature.ok)
|
|
455
|
+
return text(resolvedFeature.error);
|
|
456
|
+
const feature = resolvedFeature.feature;
|
|
457
|
+
const updated = await st.updateFeatures((doc) => {
|
|
458
|
+
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
459
|
+
if (!target)
|
|
460
|
+
return doc;
|
|
461
|
+
if (description !== undefined)
|
|
462
|
+
target.description = description.trim();
|
|
463
|
+
if (workDone !== undefined)
|
|
464
|
+
target.workDone = workDone.trim();
|
|
465
|
+
if (workRemaining !== undefined)
|
|
466
|
+
target.workRemaining = workRemaining.trim();
|
|
467
|
+
if (dependencies !== undefined)
|
|
468
|
+
target.dependsOn = dependencies.map((item) => item.trim()).filter(Boolean);
|
|
469
|
+
target.discussedAt = nowISO();
|
|
470
|
+
target.contextReady = true;
|
|
471
|
+
target.contextReadyReason = "Updated through planner-feature-discuss MCP tool.";
|
|
472
|
+
target.updatedAt = nowISO();
|
|
473
|
+
return doc;
|
|
474
|
+
});
|
|
475
|
+
const result = updated.features.find((entry) => entry.id === feature.id);
|
|
476
|
+
return writeAndSummarize(st, `✅ Feature discussed/updated: ${formatFeatureRef(result.number)} — ${result.name}${result.shortId ? ` · ${result.shortId}` : ""}`);
|
|
477
|
+
});
|
|
356
478
|
server.registerTool("planner-feature-update", {
|
|
357
479
|
description: "Update a feature.",
|
|
358
480
|
inputSchema: {
|
|
@@ -369,9 +491,10 @@ server.registerTool("planner-feature-update", {
|
|
|
369
491
|
}, async ({ feature: ref, ...updates }) => {
|
|
370
492
|
const st = await requireStore();
|
|
371
493
|
const features = (await st.loadFeatures()).features;
|
|
372
|
-
const
|
|
373
|
-
if (!
|
|
374
|
-
return text(
|
|
494
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
495
|
+
if (!resolvedFeature.ok)
|
|
496
|
+
return text(resolvedFeature.error);
|
|
497
|
+
const feature = resolvedFeature.feature;
|
|
375
498
|
const updated = await st.updateFeatures((doc) => {
|
|
376
499
|
const target = doc.features.find((entry) => entry.id === feature.id);
|
|
377
500
|
if (!target)
|
|
@@ -407,9 +530,10 @@ server.registerTool("planner-feature-delete", {
|
|
|
407
530
|
}, async ({ feature: ref, cascade }) => {
|
|
408
531
|
const st = await requireStore();
|
|
409
532
|
const features = (await st.loadFeatures()).features;
|
|
410
|
-
const
|
|
411
|
-
if (!
|
|
412
|
-
return text(
|
|
533
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
534
|
+
if (!resolvedFeature.ok)
|
|
535
|
+
return text(resolvedFeature.error);
|
|
536
|
+
const feature = resolvedFeature.feature;
|
|
413
537
|
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
414
538
|
await st.updateFeatures((doc) => {
|
|
415
539
|
doc.features = doc.features.filter((entry) => entry.id !== feature.id);
|
|
@@ -428,31 +552,40 @@ server.registerTool("planner-feature-delete", {
|
|
|
428
552
|
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
553
|
});
|
|
430
554
|
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.",
|
|
555
|
+
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
556
|
inputSchema: {
|
|
433
557
|
title: z.string().min(1),
|
|
434
|
-
feature: z.string().optional().describe("Feature ref. Accepts F00x/P00x/T00x composite, bare
|
|
558
|
+
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
559
|
summary: z.string().optional().describe("One-line summary of the phase"),
|
|
436
560
|
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
561
|
},
|
|
438
562
|
}, async ({ title, feature: featureRef, summary, description }) => {
|
|
439
563
|
const st = await requireStore();
|
|
564
|
+
if (!featureRef?.trim())
|
|
565
|
+
return text("feature is required: a phase must belong to a feature.");
|
|
440
566
|
const featuresDoc = await st.loadFeatures();
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
443
|
-
return text(
|
|
567
|
+
const resolvedFeature = resolveFeatureRefStrict(featuresDoc.features, featureRef);
|
|
568
|
+
if (!resolvedFeature.ok)
|
|
569
|
+
return text(resolvedFeature.error);
|
|
570
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
571
|
+
if (!featureValidation.ok)
|
|
572
|
+
return text(featureValidation.error);
|
|
573
|
+
const feature = resolvedFeature.feature;
|
|
574
|
+
if (!feature)
|
|
575
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create phase.`);
|
|
444
576
|
const lockKey = feature?.id ?? "__unscoped__";
|
|
445
577
|
let phase;
|
|
446
578
|
await withFeatureLock(lockKey, async () => {
|
|
447
579
|
const phases = await st.loadAllPhases();
|
|
448
580
|
const featurePhases = feature ? phases.filter((phase) => phase.featureId === feature.id) : phases;
|
|
449
581
|
const timestamp = nowISO();
|
|
450
|
-
const
|
|
582
|
+
const id = createPhaseId();
|
|
583
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
451
584
|
const priority = await st.nextPriority("phase", feature?.id);
|
|
452
585
|
phase = {
|
|
453
|
-
id
|
|
454
|
-
number:
|
|
455
|
-
shortId,
|
|
586
|
+
id,
|
|
587
|
+
number: identity.number,
|
|
588
|
+
shortId: identity.shortId,
|
|
456
589
|
priority,
|
|
457
590
|
slug: normalizeSlug(title),
|
|
458
591
|
title: title.trim(),
|
|
@@ -481,6 +614,7 @@ server.registerTool("planner-phase-add", {
|
|
|
481
614
|
handoffUpdatedAt: "",
|
|
482
615
|
handoffReadAt: "",
|
|
483
616
|
handoffHistory: [],
|
|
617
|
+
statusLog: [],
|
|
484
618
|
};
|
|
485
619
|
await st.savePhase(phase);
|
|
486
620
|
if (feature) {
|
|
@@ -498,7 +632,7 @@ server.registerTool("planner-phase-add", {
|
|
|
498
632
|
return writeAndSummarize(st, `✅ Phase created: ${formatPhaseRef(phase.number, feature?.number)} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}`);
|
|
499
633
|
});
|
|
500
634
|
server.registerTool("planner-phase-show", {
|
|
501
|
-
description: "Show a phase by id or name.",
|
|
635
|
+
description: "Show a phase by id or name, including derived linked requirements in the full view.",
|
|
502
636
|
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
637
|
}, async ({ phase: ref, full }) => {
|
|
504
638
|
const st = await requireStore();
|
|
@@ -506,8 +640,24 @@ server.registerTool("planner-phase-show", {
|
|
|
506
640
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
507
641
|
if (!phase)
|
|
508
642
|
return text(`Phase not found: ${ref}`);
|
|
509
|
-
const
|
|
510
|
-
|
|
643
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
644
|
+
const reqCount = linkedRequirements.length;
|
|
645
|
+
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"}` : ""})`;
|
|
646
|
+
const requirementsBlock = reqCount > 0
|
|
647
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
648
|
+
: "";
|
|
649
|
+
return text(full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary, {
|
|
650
|
+
phase: {
|
|
651
|
+
ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
|
|
652
|
+
shortId: phase.shortId,
|
|
653
|
+
title: phase.title,
|
|
654
|
+
summary: phase.summary,
|
|
655
|
+
status: phase.status,
|
|
656
|
+
taskCount: phase.tasks.length,
|
|
657
|
+
...(full ? { description: phase.description } : {}),
|
|
658
|
+
},
|
|
659
|
+
linkedRequirements,
|
|
660
|
+
});
|
|
511
661
|
});
|
|
512
662
|
server.registerTool("planner-phase-discuss", {
|
|
513
663
|
description: "Persist phase discovery fields and mark phase planned.",
|
|
@@ -603,25 +753,46 @@ server.registerTool("planner-phase-delete", {
|
|
|
603
753
|
server.registerTool("planner-task-add", {
|
|
604
754
|
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
755
|
inputSchema: {
|
|
756
|
+
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
757
|
phase: z.string().min(1).describe("Phase ref. Accepts F00x/P00x/T00x composite, bare P00x/T00x (global), 5-char shortId, UUID, or title."),
|
|
607
758
|
title: z.string().min(1),
|
|
608
759
|
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
760
|
checklist: z.array(z.string()).optional(),
|
|
610
761
|
},
|
|
611
|
-
}, async ({ phase: ref, title, description, checklist }) => {
|
|
762
|
+
}, async ({ feature: featureRef, phase: ref, title, description, checklist }) => {
|
|
612
763
|
const st = await requireStore();
|
|
613
|
-
const
|
|
764
|
+
const features = (await st.loadFeatures()).features;
|
|
765
|
+
if (!featureRef?.trim())
|
|
766
|
+
return text("feature is required: a task must belong to a feature.");
|
|
767
|
+
const resolvedFeature = resolveFeatureRefStrict(features, featureRef);
|
|
768
|
+
if (!resolvedFeature.ok)
|
|
769
|
+
return text(resolvedFeature.error);
|
|
770
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
771
|
+
if (!featureValidation.ok)
|
|
772
|
+
return text(featureValidation.error);
|
|
773
|
+
const feature = resolvedFeature.feature;
|
|
774
|
+
if (!feature)
|
|
775
|
+
return text(`Resolved feature ${featureRef} no longer exists. Refusing to create task.`);
|
|
776
|
+
const found = findPhaseByRef(await st.loadAllPhases(), features, ref);
|
|
614
777
|
if (!found)
|
|
615
778
|
return text(`Phase not found: ${ref}`);
|
|
779
|
+
if (found.featureId !== feature.id)
|
|
780
|
+
return text(`Phase ${formatPhaseRef(found.number, featureNumberOfPhase(found, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.`);
|
|
781
|
+
const phaseValidation = await validateResolvedTarget("phase", found.id, () => st.loadPhase(found.id).catch(() => undefined));
|
|
782
|
+
if (!phaseValidation.ok)
|
|
783
|
+
return text(phaseValidation.error);
|
|
784
|
+
const existingPhase = await st.loadPhase(found.id);
|
|
785
|
+
if (!existingPhase)
|
|
786
|
+
return text(`Resolved phase ${found.id} no longer exists. Refusing to create task.`);
|
|
616
787
|
const timestamp = nowISO();
|
|
617
788
|
const taskId = createTaskId();
|
|
618
|
-
const
|
|
789
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
619
790
|
const priority = await st.nextPriority("task", found.id);
|
|
620
791
|
const task = {
|
|
621
792
|
id: taskId,
|
|
622
793
|
phaseId: found.id,
|
|
623
|
-
number:
|
|
624
|
-
shortId,
|
|
794
|
+
number: identity.number,
|
|
795
|
+
shortId: identity.shortId,
|
|
625
796
|
priority,
|
|
626
797
|
shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
|
|
627
798
|
title: title.trim(),
|
|
@@ -631,7 +802,7 @@ server.registerTool("planner-task-add", {
|
|
|
631
802
|
statusLog: [],
|
|
632
803
|
decisions: [],
|
|
633
804
|
acceptedDecisions: [],
|
|
634
|
-
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), title: item, checked: false })),
|
|
805
|
+
checklist: (checklist ?? []).map((item, index) => ({ id: createChecklistItemId(taskId, index + 1, item), number: index + 1, title: item, checked: false })),
|
|
635
806
|
subtasks: [],
|
|
636
807
|
dependsOn: [],
|
|
637
808
|
startedAt: "",
|
|
@@ -645,8 +816,8 @@ server.registerTool("planner-task-add", {
|
|
|
645
816
|
entry.updatedAt = timestamp;
|
|
646
817
|
return entry;
|
|
647
818
|
});
|
|
648
|
-
const
|
|
649
|
-
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found,
|
|
819
|
+
const finalFeatures = (await st.loadFeatures()).features;
|
|
820
|
+
return writeAndSummarize(st, `✅ Task created: ${taskCompositeRef(task, found, finalFeatures)} — ${task.title} (planned)${task.shortId ? ` · ${task.shortId}` : ""}`);
|
|
650
821
|
});
|
|
651
822
|
server.registerTool("planner-task-show", {
|
|
652
823
|
description: "Show a task by id or name.",
|
|
@@ -683,7 +854,9 @@ server.registerTool("planner-task-discuss", {
|
|
|
683
854
|
if (description !== undefined)
|
|
684
855
|
task.description = description.trim();
|
|
685
856
|
if (checklist !== undefined)
|
|
686
|
-
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), title: item, checked: false }));
|
|
857
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
858
|
+
if (checklist !== undefined)
|
|
859
|
+
task.checklist = checklist.map((item, index) => ({ id: createChecklistItemId(task.id, index + 1, item), number: index + 1, title: item, checked: false }));
|
|
687
860
|
task.updatedAt = nowISO();
|
|
688
861
|
phase.updatedAt = task.updatedAt;
|
|
689
862
|
updatedTask = task;
|
|
@@ -702,8 +875,9 @@ server.registerTool("planner-task-update", {
|
|
|
702
875
|
description: z.string().optional(),
|
|
703
876
|
motivation: z.string().optional(),
|
|
704
877
|
priority: z.number().int().nonnegative().optional().describe("Display order within the phase (lower = higher)."),
|
|
878
|
+
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
879
|
},
|
|
706
|
-
}, async ({ task: ref, title, status, description, motivation, priority }) => {
|
|
880
|
+
}, async ({ task: ref, title, status, description, motivation, priority, checklist }) => {
|
|
707
881
|
const st = await requireStore();
|
|
708
882
|
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
709
883
|
if (!found)
|
|
@@ -750,6 +924,107 @@ server.registerTool("planner-task-update", {
|
|
|
750
924
|
const t = updatedTask ?? found.task;
|
|
751
925
|
return writeAndSummarize(st, `✅ Task updated: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (${t.status})${t.shortId ? ` · ${t.shortId}` : ""}`);
|
|
752
926
|
});
|
|
927
|
+
server.registerTool("planner-task-checklist-toggle", {
|
|
928
|
+
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.",
|
|
929
|
+
inputSchema: {
|
|
930
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
931
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
932
|
+
checked: z.boolean().optional().describe("Set explicitly: true=done, false=open. Omit to toggle."),
|
|
933
|
+
},
|
|
934
|
+
}, async ({ task: ref, item, checked }) => {
|
|
935
|
+
const st = await requireStore();
|
|
936
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
937
|
+
if (!found)
|
|
938
|
+
return text(`Task not found: ${ref}`);
|
|
939
|
+
let result = "";
|
|
940
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
941
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
942
|
+
if (!task) {
|
|
943
|
+
result = `Task not found: ${ref}`;
|
|
944
|
+
return phase;
|
|
945
|
+
}
|
|
946
|
+
const ck = task.checklist ?? [];
|
|
947
|
+
if (ck.length === 0) {
|
|
948
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
949
|
+
return phase;
|
|
950
|
+
}
|
|
951
|
+
const target = toggleChecklistItem(ck, item, checked);
|
|
952
|
+
if (!target) {
|
|
953
|
+
result = `No checklist item matching "${item}".`;
|
|
954
|
+
return phase;
|
|
955
|
+
}
|
|
956
|
+
task.updatedAt = nowISO();
|
|
957
|
+
phase.updatedAt = nowISO();
|
|
958
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
959
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
960
|
+
return phase;
|
|
961
|
+
});
|
|
962
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
963
|
+
});
|
|
964
|
+
server.registerTool("planner-task-checklist-add", {
|
|
965
|
+
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.",
|
|
966
|
+
inputSchema: {
|
|
967
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
968
|
+
title: z.string().min(1).describe("Checklist item text (a single step)"),
|
|
969
|
+
},
|
|
970
|
+
}, async ({ task: ref, title }) => {
|
|
971
|
+
const st = await requireStore();
|
|
972
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
973
|
+
if (!found)
|
|
974
|
+
return text(`Task not found: ${ref}`);
|
|
975
|
+
let result = "";
|
|
976
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
977
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
978
|
+
if (!task) {
|
|
979
|
+
result = `Task not found: ${ref}`;
|
|
980
|
+
return phase;
|
|
981
|
+
}
|
|
982
|
+
const ck = task.checklist ?? [];
|
|
983
|
+
const item = addChecklistItem(ck, task.id, title);
|
|
984
|
+
ck.push(item);
|
|
985
|
+
task.checklist = ck;
|
|
986
|
+
task.updatedAt = nowISO();
|
|
987
|
+
phase.updatedAt = nowISO();
|
|
988
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
989
|
+
return phase;
|
|
990
|
+
});
|
|
991
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
992
|
+
});
|
|
993
|
+
server.registerTool("planner-task-checklist-remove", {
|
|
994
|
+
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.",
|
|
995
|
+
inputSchema: {
|
|
996
|
+
task: z.string().min(1).describe("Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title"),
|
|
997
|
+
item: z.string().min(1).describe("Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)"),
|
|
998
|
+
},
|
|
999
|
+
}, async ({ task: ref, item }) => {
|
|
1000
|
+
const st = await requireStore();
|
|
1001
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, ref);
|
|
1002
|
+
if (!found)
|
|
1003
|
+
return text(`Task not found: ${ref}`);
|
|
1004
|
+
let result = "";
|
|
1005
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
1006
|
+
const task = phase.tasks.find((entry) => entry.id === found.task.id);
|
|
1007
|
+
if (!task) {
|
|
1008
|
+
result = `Task not found: ${ref}`;
|
|
1009
|
+
return phase;
|
|
1010
|
+
}
|
|
1011
|
+
const ck = task.checklist ?? [];
|
|
1012
|
+
if (ck.length === 0) {
|
|
1013
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
1014
|
+
return phase;
|
|
1015
|
+
}
|
|
1016
|
+
const removed = removeChecklistItem(ck, item);
|
|
1017
|
+
if (!removed) {
|
|
1018
|
+
result = `No checklist item matching "${item}".`;
|
|
1019
|
+
return phase;
|
|
1020
|
+
}
|
|
1021
|
+
task.updatedAt = nowISO();
|
|
1022
|
+
phase.updatedAt = nowISO();
|
|
1023
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
1024
|
+
return phase;
|
|
1025
|
+
});
|
|
1026
|
+
return writeAndSummarize(st, `✅ ${result}`);
|
|
1027
|
+
});
|
|
753
1028
|
server.registerTool("planner-task-delete", {
|
|
754
1029
|
description: "Delete a task.",
|
|
755
1030
|
inputSchema: { task: z.string().min(1) },
|
|
@@ -768,27 +1043,79 @@ server.registerTool("planner-task-delete", {
|
|
|
768
1043
|
const tdFeatures = (await st.loadFeatures()).features;
|
|
769
1044
|
return writeAndSummarize(st, `Task deleted: ${taskCompositeRef(found.task, found.phase, tdFeatures)}${found.task.shortId ? ` · ${found.task.shortId}` : ""}`, { deleted: found.task.id });
|
|
770
1045
|
});
|
|
1046
|
+
server.registerTool("planner-task-recommend", {
|
|
1047
|
+
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.",
|
|
1048
|
+
inputSchema: {},
|
|
1049
|
+
}, async () => {
|
|
1050
|
+
const st = await requireStore();
|
|
1051
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1052
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1053
|
+
if (!result.candidate)
|
|
1054
|
+
return text(`No task recommendation: ${result.reason}`, { kind: result.kind, reason: result.reason, activeTaskIds: result.activeCandidates?.map((candidate) => candidate.task.id) ?? [] });
|
|
1055
|
+
const { candidate } = result;
|
|
1056
|
+
const ref = taskCompositeRef(candidate.task, candidate.phase, features.features);
|
|
1057
|
+
return text(`Recommended (${result.kind}): ${ref} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}`, {
|
|
1058
|
+
kind: result.kind, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, deviation: result.deviation,
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
server.registerTool("planner-task-deviation", {
|
|
1062
|
+
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.",
|
|
1063
|
+
inputSchema: { temporary_task: z.string().min(1), resume_task: z.string().min(1).optional(), reason: z.string().min(1) },
|
|
1064
|
+
}, async ({ temporary_task, resume_task, reason }) => {
|
|
1065
|
+
const st = await requireStore();
|
|
1066
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
1067
|
+
const temporary = findTaskByRef(phases, features.features, temporary_task);
|
|
1068
|
+
if (!temporary)
|
|
1069
|
+
return text(`Task not found: ${temporary_task}`);
|
|
1070
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
1071
|
+
const resume = resume_task ? findTaskByRef(phases, features.features, resume_task) : selected.candidate;
|
|
1072
|
+
if (!resume)
|
|
1073
|
+
return text(`No resume task is available. Provide resume_task explicitly. ${selected.reason}`);
|
|
1074
|
+
if (temporary.task.id === resume.task.id)
|
|
1075
|
+
return text("A temporary task must differ from its resume target.");
|
|
1076
|
+
if (temporary.task.status !== "planned")
|
|
1077
|
+
return text(`Temporary task is not startable: ${temporary.task.status}.`);
|
|
1078
|
+
const timestamp = nowISO();
|
|
1079
|
+
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: "" };
|
|
1080
|
+
await st.addWorkDeviation(record);
|
|
1081
|
+
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 });
|
|
1082
|
+
});
|
|
771
1083
|
server.registerTool("planner-task-start", {
|
|
772
|
-
description: "Set a task to in-progress.",
|
|
1084
|
+
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
1085
|
inputSchema: { task: z.string().min(1) },
|
|
774
1086
|
}, async ({ task: ref }) => {
|
|
775
1087
|
const st = await requireStore();
|
|
776
|
-
const
|
|
1088
|
+
const features = (await st.loadFeatures()).features;
|
|
1089
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, ref);
|
|
777
1090
|
if (!found)
|
|
778
1091
|
return text(`Task not found: ${ref}`);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1092
|
+
if (found.task.status === "in-progress")
|
|
1093
|
+
return text(`Task ${found.task.id} is already in-progress.`);
|
|
1094
|
+
if (found.task.status === "done")
|
|
1095
|
+
return text(`Task ${found.task.id} is done. Use planner-task-update to reopen.`);
|
|
1096
|
+
const project = await st.loadProject();
|
|
1097
|
+
const phases = await st.loadAllPhases();
|
|
1098
|
+
const eligibility = checkExplicitTaskStart(features, phases, found.task.id, project.workDeviations);
|
|
1099
|
+
if (!eligibility.eligible)
|
|
1100
|
+
return text(`Task start denied: ${eligibility.reason}`, { reason: eligibility.reason });
|
|
1101
|
+
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
1102
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
1103
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
1104
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
1105
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
1106
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
1107
|
+
]);
|
|
1108
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
1109
|
+
// A pending handoff is context, not a lock: task start RETAINS it. It is
|
|
1110
|
+
// archived only when the phase completes (auto) or the user explicitly
|
|
1111
|
+
// clears it.
|
|
1112
|
+
const advisory = selection.kind === "conflict"
|
|
1113
|
+
? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
|
|
1114
|
+
: selection.candidate && selection.candidate.task.id !== found.task.id
|
|
1115
|
+
? selection.kind === "active"
|
|
1116
|
+
? `\n\n⚠️ Active-task advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is already in progress. Explicit task request honored.`
|
|
1117
|
+
: `\n\n⚠️ Priority advisory: ${taskCompositeRef(selection.candidate.task, selection.candidate.phase, features)} is the automatic recommendation. Explicit task request honored.`
|
|
1118
|
+
: "";
|
|
792
1119
|
const timestamp = nowISO();
|
|
793
1120
|
let updatedTask;
|
|
794
1121
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -813,9 +1140,11 @@ server.registerTool("planner-task-start", {
|
|
|
813
1140
|
return phase;
|
|
814
1141
|
});
|
|
815
1142
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
816
|
-
const
|
|
1143
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === found.task.id && deviation.state === "approved");
|
|
1144
|
+
if (approvedDeviation)
|
|
1145
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", timestamp);
|
|
817
1146
|
const t = updatedTask ?? found.task;
|
|
818
|
-
return writeAndSummarize(st,
|
|
1147
|
+
return writeAndSummarize(st, `✅ Task started: ${taskCompositeRef(t, found.phase, features)} — ${t.title} (in-progress)${t.shortId ? ` · ${t.shortId}` : ""}${phaseContext}${advisory}`);
|
|
819
1148
|
});
|
|
820
1149
|
server.registerTool("planner-task-complete", {
|
|
821
1150
|
description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
|
|
@@ -860,6 +1189,11 @@ server.registerTool("planner-task-complete", {
|
|
|
860
1189
|
return phase;
|
|
861
1190
|
});
|
|
862
1191
|
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
1192
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
1193
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === found.task.id)
|
|
1194
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
1195
|
+
if (completedDeviation)
|
|
1196
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", timestamp);
|
|
863
1197
|
const features = (await st.loadFeatures()).features;
|
|
864
1198
|
const t = updatedTask ?? found.task;
|
|
865
1199
|
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 +1209,8 @@ server.registerTool("planner-handoff-list", {
|
|
|
875
1209
|
return text(`Phase handoffs (${list.length}):\n${lines.join("\n")}`, { count: list.length, handoffs: list });
|
|
876
1210
|
});
|
|
877
1211
|
server.registerTool("planner-handoff-show", {
|
|
878
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef
|
|
879
|
-
inputSchema: { phaseRef: z.string().
|
|
1212
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef is required; never infer a phase from in-progress status or stale context.",
|
|
1213
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when ambiguous.") },
|
|
880
1214
|
}, async ({ phaseRef }) => {
|
|
881
1215
|
const st = await requireStore();
|
|
882
1216
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
@@ -888,36 +1222,61 @@ server.registerTool("planner-handoff-show", {
|
|
|
888
1222
|
return text(`Handoff for ${r.compositeRef}:\n\n${content}`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
889
1223
|
});
|
|
890
1224
|
server.registerTool("planner-handoff-write", {
|
|
891
|
-
description: "Write/refresh the entity-scoped handoff on a phase.
|
|
1225
|
+
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
1226
|
inputSchema: {
|
|
893
|
-
phaseRef: z.string().
|
|
1227
|
+
phaseRef: z.string().min(1).describe("Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title."),
|
|
1228
|
+
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'."),
|
|
1229
|
+
confirmed: z.boolean().describe("Set true only after the user explicitly confirms the proposed feature+phase target."),
|
|
894
1230
|
content: z.string().min(1).describe("Handoff text (markdown)."),
|
|
895
1231
|
},
|
|
896
|
-
}, async ({ phaseRef, content }) => {
|
|
1232
|
+
}, async ({ phaseRef, title, confirmed, content }) => {
|
|
897
1233
|
const st = await requireStore();
|
|
1234
|
+
let body = content.trim();
|
|
1235
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
1236
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
1237
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
1238
|
+
if (!title && isGeneric) {
|
|
1239
|
+
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.");
|
|
1240
|
+
}
|
|
1241
|
+
if (title && title.trim()) {
|
|
1242
|
+
const t = title.trim();
|
|
1243
|
+
const lines = body.split(/\r?\n/);
|
|
1244
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
1245
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
1246
|
+
lines[firstIdx] = `# ${t}`;
|
|
1247
|
+
body = lines.join("\n");
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
body = `# ${t}\n\n${body}`;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
898
1253
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
899
1254
|
if (!r.ok)
|
|
900
1255
|
return text(`❌ ${r.error}`);
|
|
901
|
-
|
|
1256
|
+
if (!confirmed)
|
|
1257
|
+
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 });
|
|
1258
|
+
await st.setPhaseHandoff(r.phase.id, body);
|
|
902
1259
|
return text(`✅ Wrote handoff on ${r.compositeRef}.`, { phaseRef: r.compositeRef, phaseId: r.phase.id });
|
|
903
1260
|
});
|
|
904
1261
|
server.registerTool("planner-handoff-prepare", {
|
|
905
|
-
description: "
|
|
1262
|
+
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
1263
|
}, async () => text([
|
|
907
|
-
"
|
|
908
|
-
"
|
|
909
|
-
"
|
|
1264
|
+
"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.",
|
|
1265
|
+
"Before calling planner-handoff-write, tell the user: 'I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?' Wait for explicit confirmation.",
|
|
1266
|
+
"After confirmation, call planner-handoff-write with that exact phaseRef. If the phase is done/canceled, do not write an operational handoff.",
|
|
1267
|
+
"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.",
|
|
1268
|
+
"Stored on phase.handoff; .planner/HANDOFF.md is deprecated.",
|
|
910
1269
|
].join("\n")));
|
|
911
1270
|
server.registerTool("planner-handoff-clear", {
|
|
912
|
-
description: "Clear the entity-scoped handoff of a phase
|
|
913
|
-
inputSchema: { phaseRef: z.string().
|
|
1271
|
+
description: "Clear/archive the entity-scoped handoff of a phase. phaseRef is required; never infer a phase automatically.",
|
|
1272
|
+
inputSchema: { phaseRef: z.string().min(1).describe("Exact phase ref: P00x | P00x(F00x) | UUID | title.") },
|
|
914
1273
|
}, async ({ phaseRef }) => {
|
|
915
1274
|
const st = await requireStore();
|
|
916
1275
|
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
917
1276
|
if (!r.ok)
|
|
918
1277
|
return text(`❌ ${r.error}`);
|
|
919
1278
|
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 });
|
|
1279
|
+
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
1280
|
});
|
|
922
1281
|
server.registerTool("planner-web", {
|
|
923
1282
|
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 +1319,7 @@ server.registerTool("planner-web", {
|
|
|
960
1319
|
return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
|
|
961
1320
|
});
|
|
962
1321
|
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
|
|
1322
|
+
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
1323
|
}, async () => {
|
|
965
1324
|
const st = store();
|
|
966
1325
|
if (!(await st.exists()))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/mcp",
|
|
3
|
-
"version": "0.2.19
|
|
3
|
+
"version": "0.2.19",
|
|
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",
|
|
47
|
+
"@agent-plan/core": "0.2.19"
|
|
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
|
+
}
|