@agent-plan/pi-adapter 0.2.19-next.2 → 0.2.19-next.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 +791 -277
- package/package.json +3 -3
- package/web-ui-dist/assets/index-Cxg9j9PL.css +1 -0
- package/web-ui-dist/assets/index-DVspmkeP.js +293 -0
- package/web-ui-dist/index.html +3 -2
- package/web-ui-dist/assets/index-CZ27vQH6.js +0 -211
- package/web-ui-dist/assets/index-Jy8Yy6I4.css +0 -1
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
|
|
13
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
14
14
|
import { join, dirname } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
@@ -105,11 +105,13 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
105
105
|
{ value: "init", label: "init", description: "Initialize planner in this project" },
|
|
106
106
|
{ value: "show", label: "show", description: "Show planner overview" },
|
|
107
107
|
{ value: "repair", label: "repair", description: "Repair planner integrity" },
|
|
108
|
+
{ value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
|
|
108
109
|
{ value: "project discuss", label: "project discuss", description: "Run project discovery" },
|
|
109
110
|
{ value: "project language", label: "project language", description: "Set persistent language preferences" },
|
|
110
111
|
{ value: "feature list", label: "feature list", description: "List features" },
|
|
111
112
|
{ value: "feature add", label: "feature add", description: "Create a feature" },
|
|
112
113
|
{ value: "feature show", label: "feature show", description: "Show a feature" },
|
|
114
|
+
{ value: "feature discuss", label: "feature discuss", description: "Discuss a feature" },
|
|
113
115
|
{ value: "feature update", label: "feature update", description: "Update a feature" },
|
|
114
116
|
{ value: "feature delete", label: "feature delete", description: "Delete a feature" },
|
|
115
117
|
{ value: "phase add", label: "phase add", description: "Add a phase" },
|
|
@@ -124,6 +126,9 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
124
126
|
{ value: "task delete", label: "task delete", description: "Delete a task" },
|
|
125
127
|
{ value: "task start", label: "task start", description: "Mark a task in-progress" },
|
|
126
128
|
{ value: "task complete", label: "task complete", description: "Mark a task done" },
|
|
129
|
+
{ value: "task checklist-add", label: "task checklist-add", description: "Add a checklist step: /planner task checklist-add <T00x> <title>" },
|
|
130
|
+
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
131
|
+
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
127
132
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
128
133
|
{ value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
|
|
129
134
|
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
|
|
@@ -250,6 +255,34 @@ function phaseLabel(phase) {
|
|
|
250
255
|
function taskLabel(task) {
|
|
251
256
|
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
252
257
|
}
|
|
258
|
+
function resolveFeatureRefStrict(features, ref) {
|
|
259
|
+
const raw = ref.trim();
|
|
260
|
+
if (!raw)
|
|
261
|
+
return { ok: false, error: "Feature ref is required." };
|
|
262
|
+
const normalized = raw.toLowerCase();
|
|
263
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
264
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
265
|
+
: undefined;
|
|
266
|
+
if (byNumber)
|
|
267
|
+
return { ok: true, feature: byNumber };
|
|
268
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
269
|
+
if (byShortId)
|
|
270
|
+
return { ok: true, feature: byShortId };
|
|
271
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
272
|
+
if (byId)
|
|
273
|
+
return { ok: true, feature: byId };
|
|
274
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
275
|
+
if (exactName.length === 1)
|
|
276
|
+
return { ok: true, feature: exactName[0] };
|
|
277
|
+
if (exactName.length > 1)
|
|
278
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
279
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
280
|
+
if (partialName.length === 1)
|
|
281
|
+
return { ok: true, feature: partialName[0] };
|
|
282
|
+
if (partialName.length > 1)
|
|
283
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
284
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
285
|
+
}
|
|
253
286
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
254
287
|
const [plan, resume, activity] = await Promise.all([
|
|
255
288
|
st.loadAll(),
|
|
@@ -352,8 +385,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
352
385
|
".planner/features/",
|
|
353
386
|
currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
|
|
354
387
|
latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
|
|
355
|
-
".planner/resume.json",
|
|
356
|
-
".planner/generated/PLAN.md",
|
|
388
|
+
".planner/.local/resume.json",
|
|
389
|
+
".planner/.local/generated/PLAN.md",
|
|
357
390
|
].filter(Boolean);
|
|
358
391
|
const recentActivityLines = recentActivity.length > 0
|
|
359
392
|
? recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`)
|
|
@@ -379,7 +412,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
379
412
|
? ["", `## Current Task Statuses (phase ${currentPhase.id})`, ...currentPhaseTaskLines]
|
|
380
413
|
: [];
|
|
381
414
|
return [
|
|
382
|
-
|
|
415
|
+
`# ${overrides?.title?.trim() || `Handoff — ${reason}`}`,
|
|
383
416
|
"",
|
|
384
417
|
`Created at: ${createdAt}`,
|
|
385
418
|
`Updated at: ${updatedAt}`,
|
|
@@ -416,7 +449,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
416
449
|
...recentActivityLines,
|
|
417
450
|
"",
|
|
418
451
|
"## Reminder",
|
|
419
|
-
"-
|
|
452
|
+
"- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/.local/handoff-archive/) or the phase completes. No need to clear it manually.",
|
|
420
453
|
].join("\n");
|
|
421
454
|
}
|
|
422
455
|
async function writeProjectHandoff(st, reason, overrides) {
|
|
@@ -512,6 +545,9 @@ function statusIcon(status) {
|
|
|
512
545
|
};
|
|
513
546
|
return icons[status] ?? "❓";
|
|
514
547
|
}
|
|
548
|
+
function pad(n) {
|
|
549
|
+
return String(n).padStart(3, "0");
|
|
550
|
+
}
|
|
515
551
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
516
552
|
const previousStatus = task.status;
|
|
517
553
|
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
@@ -744,9 +780,10 @@ export default function planPiExtension(pi) {
|
|
|
744
780
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
745
781
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
746
782
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
747
|
-
// until the safety-net timeout).
|
|
748
|
-
//
|
|
749
|
-
//
|
|
783
|
+
// until the safety-net timeout). Tracked upstream via planner task
|
|
784
|
+
// T151 · EBRST. The planner is always DISABLED at startup; the user
|
|
785
|
+
// enables it with '/planner load' (which also starts the web UI on LAN
|
|
786
|
+
// and triggers the resume summary showing the URL).
|
|
750
787
|
let enablePlanner = false;
|
|
751
788
|
if (exists) {
|
|
752
789
|
ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
|
|
@@ -757,6 +794,10 @@ export default function planPiExtension(pi) {
|
|
|
757
794
|
const handoffs = await st.listHandoffs().catch(() => []);
|
|
758
795
|
if (handoffs.length > 0) {
|
|
759
796
|
ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
|
|
797
|
+
// Mark each handoff as read (recap presented). Do NOT clear — content is
|
|
798
|
+
// kept until a task in the phase starts (auto-archived) or phase completes.
|
|
799
|
+
for (const h of handoffs)
|
|
800
|
+
await st.markHandoffRead(h.phaseId).catch(() => { });
|
|
760
801
|
}
|
|
761
802
|
}
|
|
762
803
|
// If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
|
|
@@ -935,6 +976,7 @@ export default function planPiExtension(pi) {
|
|
|
935
976
|
"init",
|
|
936
977
|
"show",
|
|
937
978
|
"repair",
|
|
979
|
+
"cleanup-orphans",
|
|
938
980
|
"project discuss",
|
|
939
981
|
"project language",
|
|
940
982
|
"feature list",
|
|
@@ -968,8 +1010,8 @@ export default function planPiExtension(pi) {
|
|
|
968
1010
|
"load",
|
|
969
1011
|
"stop",
|
|
970
1012
|
];
|
|
971
|
-
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
972
|
-
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner
|
|
1013
|
+
const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1014
|
+
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
|
|
973
1015
|
"Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
|
|
974
1016
|
if (!a) {
|
|
975
1017
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
@@ -1125,6 +1167,34 @@ export default function planPiExtension(pi) {
|
|
|
1125
1167
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1126
1168
|
?? null;
|
|
1127
1169
|
}
|
|
1170
|
+
function resolveFeatureRef(features, ref) {
|
|
1171
|
+
const raw = ref.trim();
|
|
1172
|
+
if (!raw)
|
|
1173
|
+
return { ok: false, error: "Feature ref is required." };
|
|
1174
|
+
const normalized = raw.toLowerCase();
|
|
1175
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
1176
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (byNumber)
|
|
1179
|
+
return { ok: true, feature: byNumber };
|
|
1180
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
1181
|
+
if (byShortId)
|
|
1182
|
+
return { ok: true, feature: byShortId };
|
|
1183
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
1184
|
+
if (byId)
|
|
1185
|
+
return { ok: true, feature: byId };
|
|
1186
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
1187
|
+
if (exactName.length == 1)
|
|
1188
|
+
return { ok: true, feature: exactName[0] };
|
|
1189
|
+
if (exactName.length > 1)
|
|
1190
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
1191
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
1192
|
+
if (partialName.length == 1)
|
|
1193
|
+
return { ok: true, feature: partialName[0] };
|
|
1194
|
+
if (partialName.length > 1)
|
|
1195
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
1196
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
1197
|
+
}
|
|
1128
1198
|
function findPhaseByRef(phases, ref) {
|
|
1129
1199
|
const normalized = ref.trim().toLowerCase();
|
|
1130
1200
|
if (!normalized)
|
|
@@ -1142,19 +1212,6 @@ export default function planPiExtension(pi) {
|
|
|
1142
1212
|
?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
|
|
1143
1213
|
?? null;
|
|
1144
1214
|
}
|
|
1145
|
-
function findTaskByRef(phases, ref) {
|
|
1146
|
-
const normalized = ref.trim().toLowerCase();
|
|
1147
|
-
if (!normalized)
|
|
1148
|
-
return null;
|
|
1149
|
-
for (const phase of phases) {
|
|
1150
|
-
const task = phase.tasks.find((entryTask) => entryTask.id.toLowerCase() === normalized
|
|
1151
|
-
|| entryTask.title.toLowerCase() === normalized
|
|
1152
|
-
|| entryTask.title.toLowerCase().includes(normalized));
|
|
1153
|
-
if (task)
|
|
1154
|
-
return { phase, task };
|
|
1155
|
-
}
|
|
1156
|
-
return null;
|
|
1157
|
-
}
|
|
1158
1215
|
function profile_packageManager(pkg, lockfile) {
|
|
1159
1216
|
if (pkg?.packageManager)
|
|
1160
1217
|
return pkg.packageManager;
|
|
@@ -1327,7 +1384,7 @@ export default function planPiExtension(pi) {
|
|
|
1327
1384
|
// ═══════════════════════════════════════════════════════════════
|
|
1328
1385
|
if (a === "feature") {
|
|
1329
1386
|
if (!b) {
|
|
1330
|
-
ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
|
|
1387
|
+
ctx.ui.notify("feature actions: list | add [name] | show [id|name] | discuss [id|name] | update [id|name] | delete [id|name]", "info");
|
|
1331
1388
|
return;
|
|
1332
1389
|
}
|
|
1333
1390
|
if (b === "list") {
|
|
@@ -1359,7 +1416,7 @@ export default function planPiExtension(pi) {
|
|
|
1359
1416
|
return;
|
|
1360
1417
|
}
|
|
1361
1418
|
const now = nowISO();
|
|
1362
|
-
const featureNumber =
|
|
1419
|
+
const featureNumber = await st.allocFeatureNumber();
|
|
1363
1420
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1364
1421
|
const priority = await st.nextPriority("feature");
|
|
1365
1422
|
const feature = {
|
|
@@ -1380,6 +1437,7 @@ export default function planPiExtension(pi) {
|
|
|
1380
1437
|
acceptedDecisions: [],
|
|
1381
1438
|
phaseIds: [],
|
|
1382
1439
|
dependsOn: [],
|
|
1440
|
+
statusLog: [],
|
|
1383
1441
|
createdAt: now,
|
|
1384
1442
|
updatedAt: now,
|
|
1385
1443
|
};
|
|
@@ -1388,7 +1446,7 @@ export default function planPiExtension(pi) {
|
|
|
1388
1446
|
return doc;
|
|
1389
1447
|
});
|
|
1390
1448
|
await st.writeGenerated();
|
|
1391
|
-
ctx.ui.notify(`Feature created: ${feature.
|
|
1449
|
+
ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1392
1450
|
return;
|
|
1393
1451
|
}
|
|
1394
1452
|
if (b === "show") {
|
|
@@ -1417,6 +1475,44 @@ export default function planPiExtension(pi) {
|
|
|
1417
1475
|
].filter(Boolean).join("\n"), "info");
|
|
1418
1476
|
return;
|
|
1419
1477
|
}
|
|
1478
|
+
if (b === "discuss") {
|
|
1479
|
+
const featuresDoc = await st.loadFeatures();
|
|
1480
|
+
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
1481
|
+
if (!feature) {
|
|
1482
|
+
feature = await pickFeature();
|
|
1483
|
+
if (!feature) {
|
|
1484
|
+
ctx.ui.notify("No features to discuss. Create one first.", "warning");
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
|
|
1489
|
+
const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
|
|
1490
|
+
const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
|
|
1491
|
+
const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
|
|
1492
|
+
const featureId = feature.id;
|
|
1493
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
1494
|
+
const target = doc.features.find((entry) => entry.id === featureId);
|
|
1495
|
+
if (!target)
|
|
1496
|
+
return doc;
|
|
1497
|
+
if (description !== undefined)
|
|
1498
|
+
target.description = description.trim();
|
|
1499
|
+
if (workDone !== undefined)
|
|
1500
|
+
target.workDone = workDone.trim();
|
|
1501
|
+
if (workRemaining !== undefined)
|
|
1502
|
+
target.workRemaining = workRemaining.trim();
|
|
1503
|
+
if (dependencies !== undefined)
|
|
1504
|
+
target.dependsOn = splitCsv(dependencies);
|
|
1505
|
+
target.discussedAt = nowISO();
|
|
1506
|
+
target.contextReady = true;
|
|
1507
|
+
target.contextReadyReason = "Updated through /planner feature discuss.";
|
|
1508
|
+
target.updatedAt = nowISO();
|
|
1509
|
+
return doc;
|
|
1510
|
+
});
|
|
1511
|
+
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1512
|
+
await st.writeGenerated();
|
|
1513
|
+
ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1420
1516
|
if (b === "update") {
|
|
1421
1517
|
const featuresDoc = await st.loadFeatures();
|
|
1422
1518
|
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
@@ -1469,7 +1565,7 @@ export default function planPiExtension(pi) {
|
|
|
1469
1565
|
});
|
|
1470
1566
|
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1471
1567
|
await st.writeGenerated();
|
|
1472
|
-
ctx.ui.notify(`Feature updated: ${feature.
|
|
1568
|
+
ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
|
|
1473
1569
|
return;
|
|
1474
1570
|
}
|
|
1475
1571
|
if (b === "delete") {
|
|
@@ -1515,7 +1611,7 @@ export default function planPiExtension(pi) {
|
|
|
1515
1611
|
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1516
1612
|
return;
|
|
1517
1613
|
}
|
|
1518
|
-
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
|
|
1614
|
+
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
1519
1615
|
return;
|
|
1520
1616
|
}
|
|
1521
1617
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1546,7 +1642,7 @@ export default function planPiExtension(pi) {
|
|
|
1546
1642
|
await withFeatureLock(feature.id, async () => {
|
|
1547
1643
|
const phases = await st.loadAllPhases();
|
|
1548
1644
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1549
|
-
const number =
|
|
1645
|
+
const number = await st.allocPhaseNumber();
|
|
1550
1646
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1551
1647
|
const id = createPhaseId();
|
|
1552
1648
|
const shortId = createShortId(await st.assignedShortIds());
|
|
@@ -1557,6 +1653,9 @@ export default function planPiExtension(pi) {
|
|
|
1557
1653
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1558
1654
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1559
1655
|
handoff: "", handoffUpdatedAt: "",
|
|
1656
|
+
handoffReadAt: "",
|
|
1657
|
+
handoffHistory: [],
|
|
1658
|
+
statusLog: [],
|
|
1560
1659
|
};
|
|
1561
1660
|
await st.savePhase(phase);
|
|
1562
1661
|
await st.updateFeatures((doc) => {
|
|
@@ -1571,7 +1670,7 @@ export default function planPiExtension(pi) {
|
|
|
1571
1670
|
});
|
|
1572
1671
|
if (!phase)
|
|
1573
1672
|
return;
|
|
1574
|
-
ctx.ui.notify(`Phase created: ${phase.
|
|
1673
|
+
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
1575
1674
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1576
1675
|
if (isYes(startDiscuss)) {
|
|
1577
1676
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
@@ -1596,7 +1695,7 @@ export default function planPiExtension(pi) {
|
|
|
1596
1695
|
phase.status = "discovery";
|
|
1597
1696
|
phase.updatedAt = nowISO();
|
|
1598
1697
|
await st.savePhase(phase);
|
|
1599
|
-
ctx.ui.notify(`Discovery for ${phase.
|
|
1698
|
+
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1600
1699
|
// ── Recap: give full context before planning tasks ──
|
|
1601
1700
|
const recapPlan = await st.loadAll();
|
|
1602
1701
|
const recapProject = recapPlan.project;
|
|
@@ -1661,7 +1760,7 @@ export default function planPiExtension(pi) {
|
|
|
1661
1760
|
phase.updatedAt = nowISO();
|
|
1662
1761
|
await st.savePhase(phase);
|
|
1663
1762
|
await st.writeGenerated();
|
|
1664
|
-
ctx.ui.notify(`Phase ${phase.
|
|
1763
|
+
ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1665
1764
|
return;
|
|
1666
1765
|
}
|
|
1667
1766
|
if (b === "show") {
|
|
@@ -1709,7 +1808,7 @@ export default function planPiExtension(pi) {
|
|
|
1709
1808
|
}
|
|
1710
1809
|
await st.deletePhase(phase.id);
|
|
1711
1810
|
await st.writeGenerated();
|
|
1712
|
-
ctx.ui.notify(`Phase deleted: ${phase.
|
|
1811
|
+
ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1713
1812
|
return;
|
|
1714
1813
|
}
|
|
1715
1814
|
if (b === "update") {
|
|
@@ -1761,7 +1860,7 @@ export default function planPiExtension(pi) {
|
|
|
1761
1860
|
phase.updatedAt = nowISO();
|
|
1762
1861
|
await st.savePhase(phase);
|
|
1763
1862
|
await st.writeGenerated();
|
|
1764
|
-
ctx.ui.notify(`Phase updated: ${phase.
|
|
1863
|
+
ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1765
1864
|
return;
|
|
1766
1865
|
}
|
|
1767
1866
|
ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
|
|
@@ -1772,7 +1871,7 @@ export default function planPiExtension(pi) {
|
|
|
1772
1871
|
// ═══════════════════════════════════════════════════════════════
|
|
1773
1872
|
if (a === "task") {
|
|
1774
1873
|
if (!b) {
|
|
1775
|
-
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
|
|
1874
|
+
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
|
|
1776
1875
|
return;
|
|
1777
1876
|
}
|
|
1778
1877
|
if (b === "add") {
|
|
@@ -1787,7 +1886,7 @@ export default function planPiExtension(pi) {
|
|
|
1787
1886
|
return;
|
|
1788
1887
|
}
|
|
1789
1888
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1790
|
-
const taskNum =
|
|
1889
|
+
const taskNum = await st.allocTaskNumber();
|
|
1791
1890
|
const taskId = createTaskId();
|
|
1792
1891
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1793
1892
|
const priority = await st.nextPriority("task", phase.id);
|
|
@@ -1819,7 +1918,7 @@ export default function planPiExtension(pi) {
|
|
|
1819
1918
|
}
|
|
1820
1919
|
if (b === "discuss") {
|
|
1821
1920
|
const phases = await st.loadAllPhases();
|
|
1822
|
-
let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
|
|
1921
|
+
let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
|
|
1823
1922
|
if (!resolved) {
|
|
1824
1923
|
const phase = await pickPhase();
|
|
1825
1924
|
if (!phase) {
|
|
@@ -1843,7 +1942,7 @@ export default function planPiExtension(pi) {
|
|
|
1843
1942
|
if (description?.trim())
|
|
1844
1943
|
task.description = description.trim();
|
|
1845
1944
|
if (checklistSeed?.trim()) {
|
|
1846
|
-
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
1945
|
+
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
|
|
1847
1946
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
1848
1947
|
title: itemTitle,
|
|
1849
1948
|
checked: false,
|
|
@@ -1853,14 +1952,14 @@ export default function planPiExtension(pi) {
|
|
|
1853
1952
|
phase.updatedAt = nowISO();
|
|
1854
1953
|
await st.savePhase(phase);
|
|
1855
1954
|
await st.writeGenerated();
|
|
1856
|
-
ctx.ui.notify(`Task ${task.
|
|
1955
|
+
ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
|
|
1857
1956
|
return;
|
|
1858
1957
|
}
|
|
1859
1958
|
if (b === "show") {
|
|
1860
1959
|
let phase = null;
|
|
1861
1960
|
let task = null;
|
|
1862
1961
|
if (subArgs.trim()) {
|
|
1863
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1962
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
1864
1963
|
phase = resolved?.phase ?? null;
|
|
1865
1964
|
task = resolved?.task ?? null;
|
|
1866
1965
|
}
|
|
@@ -1922,7 +2021,7 @@ export default function planPiExtension(pi) {
|
|
|
1922
2021
|
phase.updatedAt = nowISO();
|
|
1923
2022
|
await st.savePhase(phase);
|
|
1924
2023
|
await st.writeGenerated();
|
|
1925
|
-
ctx.ui.notify(`Task deleted: ${task.
|
|
2024
|
+
ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
1926
2025
|
return;
|
|
1927
2026
|
}
|
|
1928
2027
|
if (b === "update") {
|
|
@@ -1971,14 +2070,14 @@ export default function planPiExtension(pi) {
|
|
|
1971
2070
|
phase.updatedAt = now;
|
|
1972
2071
|
await st.savePhase(phase);
|
|
1973
2072
|
await st.writeGenerated();
|
|
1974
|
-
ctx.ui.notify(`Task updated: ${task.
|
|
2073
|
+
ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
1975
2074
|
return;
|
|
1976
2075
|
}
|
|
1977
2076
|
if (b === "start") {
|
|
1978
2077
|
let phase = null;
|
|
1979
2078
|
let task = null;
|
|
1980
2079
|
if (subArgs.trim()) {
|
|
1981
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2080
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
1982
2081
|
phase = resolved?.phase ?? null;
|
|
1983
2082
|
task = resolved?.task ?? null;
|
|
1984
2083
|
}
|
|
@@ -2002,12 +2101,16 @@ export default function planPiExtension(pi) {
|
|
|
2002
2101
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2003
2102
|
return;
|
|
2004
2103
|
}
|
|
2005
|
-
//
|
|
2006
|
-
//
|
|
2007
|
-
//
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
ctx.ui.notify("
|
|
2104
|
+
// Auto-archive the handoff on THIS task's phase (reason: task-started) —
|
|
2105
|
+
// the agent is now actively working, so the captured context is consumed.
|
|
2106
|
+
// Other phases' handoffs are left untouched (informational, non-blocking).
|
|
2107
|
+
if ((phase.handoff ?? "") !== "") {
|
|
2108
|
+
await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
|
|
2109
|
+
ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/.local/handoff-archive/.", "info");
|
|
2110
|
+
}
|
|
2111
|
+
const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
|
|
2112
|
+
if (_otherHandoffs.length > 0) {
|
|
2113
|
+
ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
|
|
2011
2114
|
}
|
|
2012
2115
|
const now = nowISO();
|
|
2013
2116
|
// Record status change in the incremental statusLog.
|
|
@@ -2026,14 +2129,14 @@ export default function planPiExtension(pi) {
|
|
|
2026
2129
|
phase.updatedAt = now;
|
|
2027
2130
|
await st.savePhase(phase);
|
|
2028
2131
|
await st.writeGenerated();
|
|
2029
|
-
ctx.ui.notify(`✅ Task started: ${task.
|
|
2132
|
+
ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2030
2133
|
return;
|
|
2031
2134
|
}
|
|
2032
2135
|
if (b === "complete") {
|
|
2033
2136
|
let phase = null;
|
|
2034
2137
|
let task = null;
|
|
2035
2138
|
if (subArgs.trim()) {
|
|
2036
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2139
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
2037
2140
|
phase = resolved?.phase ?? null;
|
|
2038
2141
|
task = resolved?.task ?? null;
|
|
2039
2142
|
}
|
|
@@ -2081,10 +2184,78 @@ export default function planPiExtension(pi) {
|
|
|
2081
2184
|
phase.updatedAt = now;
|
|
2082
2185
|
await st.savePhase(phase);
|
|
2083
2186
|
await st.writeGenerated();
|
|
2084
|
-
ctx.ui.notify(`✅ Task completed: ${task.
|
|
2187
|
+
ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
2191
|
+
if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
|
|
2192
|
+
const [taskRef, ...itemParts] = rest;
|
|
2193
|
+
const itemText = itemParts.join(" ").trim();
|
|
2194
|
+
if (!taskRef || !itemText) {
|
|
2195
|
+
ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
|
|
2199
|
+
if (!resolved) {
|
|
2200
|
+
ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
let checked;
|
|
2204
|
+
if (b === "checklist-toggle") {
|
|
2205
|
+
const m = itemText.match(/\s+(on|off)$/i);
|
|
2206
|
+
if (m) {
|
|
2207
|
+
checked = m[1].toLowerCase() === "on";
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
|
|
2211
|
+
let msg = "";
|
|
2212
|
+
await st.updatePhase(resolved.phase.id, (phase) => {
|
|
2213
|
+
const task = phase.tasks.find((t) => t.id === resolved.task.id);
|
|
2214
|
+
if (!task) {
|
|
2215
|
+
msg = `Task not found: ${taskRef}`;
|
|
2216
|
+
return phase;
|
|
2217
|
+
}
|
|
2218
|
+
const ck = task.checklist ?? [];
|
|
2219
|
+
if (b === "checklist-add") {
|
|
2220
|
+
const item = addChecklistItem(ck, task.id, selector);
|
|
2221
|
+
ck.push(item);
|
|
2222
|
+
task.checklist = ck;
|
|
2223
|
+
msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
2224
|
+
}
|
|
2225
|
+
else if (b === "checklist-remove") {
|
|
2226
|
+
if (ck.length === 0) {
|
|
2227
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2228
|
+
return phase;
|
|
2229
|
+
}
|
|
2230
|
+
const removed = removeChecklistItem(ck, selector);
|
|
2231
|
+
if (!removed) {
|
|
2232
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2233
|
+
return phase;
|
|
2234
|
+
}
|
|
2235
|
+
msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
2236
|
+
}
|
|
2237
|
+
else {
|
|
2238
|
+
if (ck.length === 0) {
|
|
2239
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2240
|
+
return phase;
|
|
2241
|
+
}
|
|
2242
|
+
const target = toggleChecklistItem(ck, selector, checked);
|
|
2243
|
+
if (!target) {
|
|
2244
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2245
|
+
return phase;
|
|
2246
|
+
}
|
|
2247
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
2248
|
+
msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
2249
|
+
}
|
|
2250
|
+
task.updatedAt = nowISO();
|
|
2251
|
+
phase.updatedAt = nowISO();
|
|
2252
|
+
return phase;
|
|
2253
|
+
});
|
|
2254
|
+
await st.writeGenerated();
|
|
2255
|
+
ctx.ui.notify(`✅ ${msg}`, "info");
|
|
2085
2256
|
return;
|
|
2086
2257
|
}
|
|
2087
|
-
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
|
|
2258
|
+
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
|
|
2088
2259
|
return;
|
|
2089
2260
|
}
|
|
2090
2261
|
// ── discuss (legacy alias) ──
|
|
@@ -2140,7 +2311,9 @@ export default function planPiExtension(pi) {
|
|
|
2140
2311
|
return;
|
|
2141
2312
|
}
|
|
2142
2313
|
const handoffs = await st.listHandoffs();
|
|
2143
|
-
await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the
|
|
2314
|
+
await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the phase the user explicitly identifies as the one just worked on in this session, using the `handoff_write` tool (entity-scoped, stored on phase.handoff — .planner/HANDOFF.md is deprecated).\n\n" +
|
|
2315
|
+
"Pass a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring') — it becomes the H1 / first line in handoff lists. Generic titles like 'Handoff' or 'Canonical handoff' are REJECTED.\n\n" +
|
|
2316
|
+
"WARNING: Do not default to 'current in-progress phase' or a stale resume pointer. If the user just completed P058, write the handoff on P058 (or its continuation P061), not on P031 just because P031 is marked in-progress or has an existing handoff.\n\n" +
|
|
2144
2317
|
"The handoff MUST be a structured markdown document containing at minimum:\n" +
|
|
2145
2318
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2146
2319
|
"- `Reason:` why this handoff is being written\n" +
|
|
@@ -2164,7 +2337,7 @@ export default function planPiExtension(pi) {
|
|
|
2164
2337
|
ctx.ui.notify(r.error, "warning");
|
|
2165
2338
|
return;
|
|
2166
2339
|
}
|
|
2167
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2340
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2168
2341
|
ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
|
|
2169
2342
|
return;
|
|
2170
2343
|
}
|
|
@@ -2226,6 +2399,25 @@ export default function planPiExtension(pi) {
|
|
|
2226
2399
|
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2227
2400
|
return;
|
|
2228
2401
|
}
|
|
2402
|
+
if (a === "cleanup-orphans") {
|
|
2403
|
+
const found = await st.listOrphanPhases();
|
|
2404
|
+
if (found.length === 0) {
|
|
2405
|
+
ctx.ui.notify("No orphan phases found.", "info");
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
const lines = [
|
|
2409
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
|
|
2410
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2411
|
+
];
|
|
2412
|
+
const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
|
|
2413
|
+
if (!confirmed) {
|
|
2414
|
+
ctx.ui.notify("Cleanup aborted.", "warning");
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
const report = await st.cleanupOrphanPhases();
|
|
2418
|
+
ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2229
2421
|
// ── export ──
|
|
2230
2422
|
if (a === "export" || a === "export-full") {
|
|
2231
2423
|
const isFull = a === "export-full" || subArgs.includes("--full");
|
|
@@ -2595,7 +2787,7 @@ export default function planPiExtension(pi) {
|
|
|
2595
2787
|
pi.registerTool({
|
|
2596
2788
|
name: "plan_render",
|
|
2597
2789
|
label: "Plan Render",
|
|
2598
|
-
description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
|
|
2790
|
+
description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
|
|
2599
2791
|
parameters: Type.Object({}),
|
|
2600
2792
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2601
2793
|
const st = await requirePlan(ctx);
|
|
@@ -2608,7 +2800,7 @@ export default function planPiExtension(pi) {
|
|
|
2608
2800
|
pi.registerTool({
|
|
2609
2801
|
name: "plan_repair",
|
|
2610
2802
|
label: "Plan Repair",
|
|
2611
|
-
description: "Repair dangling feature→phase references and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found or after manual edits to .planner/.",
|
|
2803
|
+
description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug), and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found, tasks appear in the wrong phase, or after manual edits to .planner/.",
|
|
2612
2804
|
parameters: Type.Object({}),
|
|
2613
2805
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2614
2806
|
const st = await requirePlan(ctx);
|
|
@@ -2616,9 +2808,11 @@ export default function planPiExtension(pi) {
|
|
|
2616
2808
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2617
2809
|
const report = await st.repair();
|
|
2618
2810
|
const m = report.migrated;
|
|
2811
|
+
const c = report.containment;
|
|
2619
2812
|
const lines = [
|
|
2620
2813
|
`Repair complete.`,
|
|
2621
2814
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2815
|
+
`Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
|
|
2622
2816
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2623
2817
|
];
|
|
2624
2818
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2628,6 +2822,38 @@ export default function planPiExtension(pi) {
|
|
|
2628
2822
|
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2629
2823
|
},
|
|
2630
2824
|
});
|
|
2825
|
+
pi.registerTool({
|
|
2826
|
+
name: "plan_cleanup_orphan_phases",
|
|
2827
|
+
label: "Plan Cleanup Orphan Phases",
|
|
2828
|
+
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.",
|
|
2829
|
+
parameters: Type.Object({
|
|
2830
|
+
confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
|
|
2831
|
+
}),
|
|
2832
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2833
|
+
const st = await requirePlan(ctx);
|
|
2834
|
+
if (!st)
|
|
2835
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2836
|
+
const found = await st.listOrphanPhases();
|
|
2837
|
+
if (!params.confirm) {
|
|
2838
|
+
if (found.length === 0)
|
|
2839
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
|
|
2840
|
+
const lines = [
|
|
2841
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
2842
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2843
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
2844
|
+
];
|
|
2845
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
|
|
2846
|
+
}
|
|
2847
|
+
const report = await st.cleanupOrphanPhases();
|
|
2848
|
+
if (report.removed.length === 0)
|
|
2849
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
|
|
2850
|
+
const lines = [
|
|
2851
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
2852
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
2853
|
+
];
|
|
2854
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2855
|
+
},
|
|
2856
|
+
});
|
|
2631
2857
|
pi.registerTool({
|
|
2632
2858
|
name: "plan_get_handoff",
|
|
2633
2859
|
label: "Plan Get Handoff",
|
|
@@ -2647,9 +2873,10 @@ export default function planPiExtension(pi) {
|
|
|
2647
2873
|
pi.registerTool({
|
|
2648
2874
|
name: "plan_write_handoff",
|
|
2649
2875
|
label: "Plan Write Handoff",
|
|
2650
|
-
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. Write or refresh the canonical phase handoff. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made.",
|
|
2876
|
+
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. Write or refresh the canonical phase handoff. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made. IMPORTANT: write the handoff on the phase the user explicitly identifies as the one just worked on in this session; do not default to a stale 'current phase' pointer or an existing handoff on a different phase.",
|
|
2651
2877
|
parameters: Type.Object({
|
|
2652
2878
|
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2879
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Example: 'P049 — featureId validation: tests + adapter wiring'." })),
|
|
2653
2880
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
2654
2881
|
howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
|
|
2655
2882
|
extraSections: Type.Optional(Type.Array(Type.Object({
|
|
@@ -2662,6 +2889,7 @@ export default function planPiExtension(pi) {
|
|
|
2662
2889
|
if (!st)
|
|
2663
2890
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2664
2891
|
const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
|
|
2892
|
+
...(params.title?.trim() ? { title: params.title.trim() } : {}),
|
|
2665
2893
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2666
2894
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2667
2895
|
...(params.extraSections && params.extraSections.length > 0
|
|
@@ -2691,13 +2919,16 @@ export default function planPiExtension(pi) {
|
|
|
2691
2919
|
const r = await resolvePhaseForHandoff(st, undefined);
|
|
2692
2920
|
if (!r.ok)
|
|
2693
2921
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2694
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2922
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2695
2923
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — cleared handoff on ${r.compositeRef} (phase.handoff). Prefer handoff_clear.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2696
2924
|
},
|
|
2697
2925
|
});
|
|
2698
2926
|
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2699
2927
|
// UUID | title; omitted -> current in-progress phase (phase.status, or the
|
|
2700
2928
|
// phase containing an in-progress task). Returns the phase + its composite ref.
|
|
2929
|
+
// NOTE: callers must NOT treat the resolved phase as a directive; it is a
|
|
2930
|
+
// fallback pointer that may be stale if the agent worked on a different
|
|
2931
|
+
// workstream in this session.
|
|
2701
2932
|
async function resolvePhaseForHandoff(st, ref) {
|
|
2702
2933
|
const phases = await st.loadAllPhases();
|
|
2703
2934
|
const features = (await st.loadFeatures()).features;
|
|
@@ -2757,9 +2988,10 @@ export default function planPiExtension(pi) {
|
|
|
2757
2988
|
pi.registerTool({
|
|
2758
2989
|
name: "handoff_write",
|
|
2759
2990
|
label: "Handoff Write",
|
|
2760
|
-
description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content). phaseRef optional (default: current in-progress phase).
|
|
2991
|
+
description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). The title becomes the H1 and the first line shown in handoff lists; generic titles like 'Handoff' or 'Canonical handoff' are REJECTED. phaseRef optional (default: current in-progress phase). IMPORTANT: defaulting to 'current in-progress phase' is a fallback only; write the handoff on the phase the user explicitly identifies as the one just worked on in this session, and do not update an existing handoff on a different phase unless the user confirms.",
|
|
2761
2992
|
parameters: Type.Object({
|
|
2762
2993
|
phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
|
|
2994
|
+
title: Type.Optional(Type.String({ description: "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'." })),
|
|
2763
2995
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2764
2996
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2765
2997
|
}),
|
|
@@ -2767,9 +2999,32 @@ export default function planPiExtension(pi) {
|
|
|
2767
2999
|
const st = await requirePlan(ctx);
|
|
2768
3000
|
if (!st)
|
|
2769
3001
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2770
|
-
|
|
3002
|
+
let body = (params.markdown_content ?? params.content ?? "").trim();
|
|
2771
3003
|
if (!body)
|
|
2772
3004
|
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3005
|
+
const title = params.title?.trim();
|
|
3006
|
+
// Derive the current first non-empty line (H1) of the supplied body.
|
|
3007
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3008
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3009
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
3010
|
+
if (!title && isGeneric) {
|
|
3011
|
+
return {
|
|
3012
|
+
content: [{ type: "text", 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." }],
|
|
3013
|
+
details: { error: "generic title", firstHeadingText },
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3017
|
+
if (title) {
|
|
3018
|
+
const lines = body.split(/\r?\n/);
|
|
3019
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
3020
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
3021
|
+
lines[firstIdx] = `# ${title}`;
|
|
3022
|
+
body = lines.join("\n");
|
|
3023
|
+
}
|
|
3024
|
+
else {
|
|
3025
|
+
body = `# ${title}\n\n${body}`;
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
2773
3028
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2774
3029
|
if (!r.ok)
|
|
2775
3030
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
@@ -2789,8 +3044,8 @@ export default function planPiExtension(pi) {
|
|
|
2789
3044
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2790
3045
|
if (!r.ok)
|
|
2791
3046
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2792
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2793
|
-
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}
|
|
3047
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
3048
|
+
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2794
3049
|
},
|
|
2795
3050
|
});
|
|
2796
3051
|
pi.registerTool({
|
|
@@ -2826,38 +3081,44 @@ export default function planPiExtension(pi) {
|
|
|
2826
3081
|
pi.registerTool({
|
|
2827
3082
|
name: "feature_list",
|
|
2828
3083
|
label: "Feature List",
|
|
2829
|
-
description: "List
|
|
2830
|
-
parameters: Type.Object({}),
|
|
2831
|
-
async execute(_id,
|
|
3084
|
+
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
|
|
3085
|
+
parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
|
|
3086
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2832
3087
|
const st = await requirePlan(ctx);
|
|
2833
3088
|
if (!st)
|
|
2834
3089
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2835
|
-
const
|
|
3090
|
+
const all = (await st.loadFeatures()).features;
|
|
3091
|
+
const ref = params.featureRef?.trim();
|
|
3092
|
+
const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
|
|
3093
|
+
const features = all.filter(matchF);
|
|
2836
3094
|
const phases = await st.loadAllPhases();
|
|
2837
3095
|
const summary = features.map((f) => {
|
|
2838
3096
|
const fp = phases.filter((p) => p.featureId === f.id);
|
|
2839
|
-
return `- ${
|
|
3097
|
+
return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
2840
3098
|
}).join("\n");
|
|
2841
|
-
return { content: [{ type: "text", text: summary || "No features" }], details: {
|
|
3099
|
+
return { content: [{ type: "text", text: summary || "No features" }], details: {} };
|
|
2842
3100
|
},
|
|
2843
3101
|
});
|
|
2844
3102
|
pi.registerTool({
|
|
2845
3103
|
name: "feature_get",
|
|
2846
3104
|
label: "Feature Get",
|
|
2847
|
-
description: "
|
|
3105
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
2848
3106
|
parameters: Type.Object({
|
|
2849
|
-
featureId: Type.String({ description: "Feature
|
|
3107
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3108
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
2850
3109
|
}),
|
|
2851
3110
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2852
3111
|
const st = await requirePlan(ctx);
|
|
2853
3112
|
if (!st)
|
|
2854
3113
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2855
3114
|
const features = (await st.loadFeatures()).features;
|
|
2856
|
-
const
|
|
3115
|
+
const ref = params.featureId.trim();
|
|
3116
|
+
const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
|
|
2857
3117
|
if (!feature)
|
|
2858
|
-
return { content: [{ type: "text", text: `Feature not found: ${
|
|
3118
|
+
return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
|
|
2859
3119
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2860
|
-
|
|
3120
|
+
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
3121
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
|
|
2861
3122
|
},
|
|
2862
3123
|
});
|
|
2863
3124
|
pi.registerTool({
|
|
@@ -2880,7 +3141,7 @@ export default function planPiExtension(pi) {
|
|
|
2880
3141
|
const priority = await st.nextPriority("feature");
|
|
2881
3142
|
const feature = {
|
|
2882
3143
|
id: createFeatureId(),
|
|
2883
|
-
number:
|
|
3144
|
+
number: await st.allocFeatureNumber(),
|
|
2884
3145
|
shortId,
|
|
2885
3146
|
priority,
|
|
2886
3147
|
name: params.name,
|
|
@@ -2896,6 +3157,7 @@ export default function planPiExtension(pi) {
|
|
|
2896
3157
|
acceptedDecisions: [],
|
|
2897
3158
|
phaseIds: [],
|
|
2898
3159
|
dependsOn: [],
|
|
3160
|
+
statusLog: [],
|
|
2899
3161
|
createdAt: now,
|
|
2900
3162
|
updatedAt: now,
|
|
2901
3163
|
};
|
|
@@ -2906,7 +3168,52 @@ export default function planPiExtension(pi) {
|
|
|
2906
3168
|
return doc;
|
|
2907
3169
|
});
|
|
2908
3170
|
await st.writeGenerated();
|
|
2909
|
-
return { content: [{ type: "text", text:
|
|
3171
|
+
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3172
|
+
},
|
|
3173
|
+
});
|
|
3174
|
+
pi.registerTool({
|
|
3175
|
+
name: "feature_discuss",
|
|
3176
|
+
label: "Feature Discuss",
|
|
3177
|
+
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
3178
|
+
parameters: Type.Object({
|
|
3179
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3180
|
+
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
3181
|
+
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3182
|
+
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3183
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
3184
|
+
}),
|
|
3185
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3186
|
+
const st = await requirePlan(ctx);
|
|
3187
|
+
if (!st)
|
|
3188
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3189
|
+
const features = (await st.loadFeatures()).features;
|
|
3190
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3191
|
+
if (!resolvedFeature.ok)
|
|
3192
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3193
|
+
const featureId = resolvedFeature.feature.id;
|
|
3194
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3195
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3196
|
+
if (!feature)
|
|
3197
|
+
return doc;
|
|
3198
|
+
if (params.description !== undefined)
|
|
3199
|
+
feature.description = params.description;
|
|
3200
|
+
if (params.workDone !== undefined)
|
|
3201
|
+
feature.workDone = params.workDone;
|
|
3202
|
+
if (params.workRemaining !== undefined)
|
|
3203
|
+
feature.workRemaining = params.workRemaining;
|
|
3204
|
+
if (params.dependencies !== undefined)
|
|
3205
|
+
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3206
|
+
feature.discussedAt = nowISO();
|
|
3207
|
+
feature.contextReady = true;
|
|
3208
|
+
feature.contextReadyReason = "Updated through feature_discuss tool.";
|
|
3209
|
+
feature.updatedAt = nowISO();
|
|
3210
|
+
return doc;
|
|
3211
|
+
});
|
|
3212
|
+
const feature = updatedDoc.features.find((f) => f.id === featureId);
|
|
3213
|
+
if (!feature)
|
|
3214
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3215
|
+
await st.writeGenerated();
|
|
3216
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2910
3217
|
},
|
|
2911
3218
|
});
|
|
2912
3219
|
pi.registerTool({
|
|
@@ -2914,7 +3221,7 @@ export default function planPiExtension(pi) {
|
|
|
2914
3221
|
label: "Feature Update",
|
|
2915
3222
|
description: "Update one or more fields of a feature. Only provided fields are changed. IMPORTANT: feature status is derived from child phases/tasks, so do not update feature.status directly unless truly necessary.",
|
|
2916
3223
|
parameters: Type.Object({
|
|
2917
|
-
featureId: Type.String({ description: "Feature
|
|
3224
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2918
3225
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
2919
3226
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
2920
3227
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: feature status is derived from child phases/tasks." })),
|
|
@@ -2936,10 +3243,15 @@ export default function planPiExtension(pi) {
|
|
|
2936
3243
|
const st = await requirePlan(ctx);
|
|
2937
3244
|
if (!st)
|
|
2938
3245
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3246
|
+
const features = (await st.loadFeatures()).features;
|
|
3247
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3248
|
+
if (!resolvedFeature.ok)
|
|
3249
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3250
|
+
const featureId = resolvedFeature.feature.id;
|
|
2939
3251
|
let foundDoc;
|
|
2940
3252
|
try {
|
|
2941
3253
|
foundDoc = await st.updateFeatures((doc) => {
|
|
2942
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3254
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
2943
3255
|
if (!feature)
|
|
2944
3256
|
return doc; // not found: no-op, handled below
|
|
2945
3257
|
if (params.name !== undefined)
|
|
@@ -2977,11 +3289,11 @@ export default function planPiExtension(pi) {
|
|
|
2977
3289
|
catch (e) {
|
|
2978
3290
|
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
2979
3291
|
}
|
|
2980
|
-
const feature = foundDoc?.features.find((f) => f.id ===
|
|
3292
|
+
const feature = foundDoc?.features.find((f) => f.id === featureId);
|
|
2981
3293
|
if (!feature)
|
|
2982
3294
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2983
3295
|
await st.writeGenerated();
|
|
2984
|
-
return { content: [{ type: "text", text:
|
|
3296
|
+
return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2985
3297
|
},
|
|
2986
3298
|
});
|
|
2987
3299
|
pi.registerTool({
|
|
@@ -2989,28 +3301,30 @@ export default function planPiExtension(pi) {
|
|
|
2989
3301
|
label: "Feature Delete",
|
|
2990
3302
|
description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
|
|
2991
3303
|
parameters: Type.Object({
|
|
2992
|
-
featureId: Type.String({ description: "Feature
|
|
3304
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
|
|
2993
3305
|
cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
|
|
2994
3306
|
}),
|
|
2995
3307
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2996
3308
|
const st = await requirePlan(ctx);
|
|
2997
3309
|
if (!st)
|
|
2998
3310
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3311
|
+
const features = (await st.loadFeatures()).features;
|
|
3312
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3313
|
+
if (!resolvedFeature.ok)
|
|
3314
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3315
|
+
const featureId = resolvedFeature.feature.id;
|
|
2999
3316
|
let deleted = false;
|
|
3000
3317
|
await st.updateFeatures((doc) => {
|
|
3001
3318
|
const before = doc.features.length;
|
|
3002
|
-
doc.features = doc.features.filter((f) => f.id !==
|
|
3319
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3003
3320
|
if (doc.features.length !== before)
|
|
3004
3321
|
deleted = true;
|
|
3005
3322
|
return doc;
|
|
3006
3323
|
});
|
|
3007
|
-
if (!deleted) {
|
|
3008
|
-
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3009
|
-
}
|
|
3010
3324
|
let cascadeCount = 0;
|
|
3011
3325
|
if (params.cascade) {
|
|
3012
3326
|
const phases = await st.loadAllPhases();
|
|
3013
|
-
for (const phase of phases.filter((p) => p.featureId ===
|
|
3327
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
3014
3328
|
await st.deletePhase(phase.id);
|
|
3015
3329
|
cascadeCount += 1;
|
|
3016
3330
|
}
|
|
@@ -3023,40 +3337,57 @@ export default function planPiExtension(pi) {
|
|
|
3023
3337
|
pi.registerTool({
|
|
3024
3338
|
name: "phase_list",
|
|
3025
3339
|
label: "Phase List",
|
|
3026
|
-
description: "List
|
|
3340
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3027
3341
|
parameters: Type.Object({
|
|
3028
|
-
|
|
3342
|
+
featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
|
|
3343
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3029
3344
|
}),
|
|
3030
3345
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3031
3346
|
const st = await requirePlan(ctx);
|
|
3032
3347
|
if (!st)
|
|
3033
3348
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3349
|
+
const features = (await st.loadFeatures()).features;
|
|
3034
3350
|
let phases = await st.loadAllPhases();
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3351
|
+
const fref = params.featureRef?.trim();
|
|
3352
|
+
if (fref) {
|
|
3353
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3354
|
+
if (!f)
|
|
3355
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3356
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3357
|
+
}
|
|
3358
|
+
if (params.status)
|
|
3359
|
+
phases = phases.filter((p) => p.status === params.status);
|
|
3360
|
+
const summary = phases.map((p) => {
|
|
3361
|
+
const fNum = featureNumberOfPhase(p, features);
|
|
3362
|
+
const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
|
|
3363
|
+
return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
|
|
3364
|
+
}).join("\n");
|
|
3365
|
+
return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
|
|
3039
3366
|
},
|
|
3040
3367
|
});
|
|
3041
3368
|
pi.registerTool({
|
|
3042
3369
|
name: "phase_get",
|
|
3043
3370
|
label: "Phase Get",
|
|
3044
|
-
description: "
|
|
3371
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3045
3372
|
parameters: Type.Object({
|
|
3046
|
-
phaseId: Type.String({ description: "Phase
|
|
3373
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3374
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
3047
3375
|
}),
|
|
3048
3376
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3049
3377
|
const st = await requirePlan(ctx);
|
|
3050
3378
|
if (!st)
|
|
3051
3379
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
}
|
|
3056
|
-
catch {
|
|
3380
|
+
const features = (await st.loadFeatures()).features;
|
|
3381
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3382
|
+
if (!phase)
|
|
3057
3383
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3058
|
-
|
|
3059
|
-
|
|
3384
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3385
|
+
const reqCount = linkedRequirements.length;
|
|
3386
|
+
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"}` : ""})`;
|
|
3387
|
+
const requirementsBlock = reqCount > 0
|
|
3388
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3389
|
+
: "";
|
|
3390
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3060
3391
|
},
|
|
3061
3392
|
});
|
|
3062
3393
|
pi.registerTool({
|
|
@@ -3076,18 +3407,27 @@ export default function planPiExtension(pi) {
|
|
|
3076
3407
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3077
3408
|
if (!params.featureId?.trim())
|
|
3078
3409
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3410
|
+
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
3411
|
+
const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
|
|
3412
|
+
if (!resolvedFeature.ok)
|
|
3413
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3414
|
+
const featureId = resolvedFeature.feature.id;
|
|
3415
|
+
const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
|
|
3416
|
+
if (!featureValidation.ok)
|
|
3417
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3418
|
+
const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
|
|
3419
|
+
if (!existingFeature)
|
|
3420
|
+
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3079
3421
|
let phase;
|
|
3080
|
-
await withFeatureLock(
|
|
3081
|
-
const
|
|
3082
|
-
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3083
|
-
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
3422
|
+
await withFeatureLock(featureId, async () => {
|
|
3423
|
+
const number = await st.allocPhaseNumber();
|
|
3084
3424
|
const id = createPhaseId();
|
|
3085
3425
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3086
|
-
const priority = await st.nextPriority("phase",
|
|
3426
|
+
const priority = await st.nextPriority("phase", featureId);
|
|
3087
3427
|
const now = nowISO();
|
|
3088
3428
|
phase = {
|
|
3089
3429
|
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3090
|
-
featureId
|
|
3430
|
+
featureId,
|
|
3091
3431
|
status: params.status ?? "draft",
|
|
3092
3432
|
discussedAt: "",
|
|
3093
3433
|
contextReady: false,
|
|
@@ -3097,11 +3437,14 @@ export default function planPiExtension(pi) {
|
|
|
3097
3437
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3098
3438
|
createdAt: now, updatedAt: now,
|
|
3099
3439
|
handoff: "", handoffUpdatedAt: "",
|
|
3440
|
+
handoffReadAt: "",
|
|
3441
|
+
handoffHistory: [],
|
|
3442
|
+
statusLog: [],
|
|
3100
3443
|
};
|
|
3101
3444
|
await st.savePhase(phase);
|
|
3102
3445
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3103
3446
|
await st.updateFeatures((doc) => {
|
|
3104
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3447
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3105
3448
|
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3106
3449
|
feature.phaseIds.push(phase.id);
|
|
3107
3450
|
feature.updatedAt = now;
|
|
@@ -3112,7 +3455,8 @@ export default function planPiExtension(pi) {
|
|
|
3112
3455
|
});
|
|
3113
3456
|
if (!phase)
|
|
3114
3457
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
3115
|
-
|
|
3458
|
+
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
3459
|
+
return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3116
3460
|
},
|
|
3117
3461
|
});
|
|
3118
3462
|
pi.registerTool({
|
|
@@ -3120,7 +3464,7 @@ export default function planPiExtension(pi) {
|
|
|
3120
3464
|
label: "Phase Update",
|
|
3121
3465
|
description: "Update one or more fields of a phase. Only provided fields are changed. Supports re-linking to a feature via featureId. IMPORTANT: phase status is derived from task statuses, so do not update phase.status directly unless truly necessary.",
|
|
3122
3466
|
parameters: Type.Object({
|
|
3123
|
-
phaseId: Type.String({ description: "Phase
|
|
3467
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3124
3468
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3125
3469
|
status: Type.Optional(Type.String({ description: "New status: draft|discovery|planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: phase status is derived from task statuses." })),
|
|
3126
3470
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
@@ -3147,14 +3491,20 @@ export default function planPiExtension(pi) {
|
|
|
3147
3491
|
const st = await requirePlan(ctx);
|
|
3148
3492
|
if (!st)
|
|
3149
3493
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3494
|
+
const features = (await st.loadFeatures()).features;
|
|
3495
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3496
|
+
if (!resolvedPhase)
|
|
3497
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3498
|
+
const phaseId = resolvedPhase.id;
|
|
3150
3499
|
let phase;
|
|
3151
3500
|
try {
|
|
3152
|
-
phase = await st.loadPhase(
|
|
3501
|
+
phase = await st.loadPhase(phaseId);
|
|
3153
3502
|
}
|
|
3154
3503
|
catch {
|
|
3155
3504
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3156
3505
|
}
|
|
3157
3506
|
const prevFeatureId = phase.featureId;
|
|
3507
|
+
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3158
3508
|
if (params.title !== undefined) {
|
|
3159
3509
|
phase.title = params.title;
|
|
3160
3510
|
phase.slug = normalizeSlug(params.title);
|
|
@@ -3184,14 +3534,22 @@ export default function planPiExtension(pi) {
|
|
|
3184
3534
|
if (params.completionCriteria !== undefined)
|
|
3185
3535
|
phase.completionCriteria = params.completionCriteria;
|
|
3186
3536
|
if (params.featureId !== undefined) {
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3537
|
+
if (params.featureId === "") {
|
|
3538
|
+
nextFeatureIdForUpdate = undefined;
|
|
3539
|
+
}
|
|
3540
|
+
else {
|
|
3541
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3542
|
+
if (!resolvedFeature.ok)
|
|
3543
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3544
|
+
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3545
|
+
}
|
|
3546
|
+
phase.featureId = nextFeatureIdForUpdate;
|
|
3547
|
+
if (nextFeatureIdForUpdate !== prevFeatureId) {
|
|
3190
3548
|
await st.updateFeatures((doc) => {
|
|
3191
3549
|
for (const f of doc.features) {
|
|
3192
3550
|
if (f.id === prevFeatureId)
|
|
3193
3551
|
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3194
|
-
if (f.id ===
|
|
3552
|
+
if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
|
|
3195
3553
|
f.phaseIds.push(phase.id);
|
|
3196
3554
|
}
|
|
3197
3555
|
return doc;
|
|
@@ -3201,7 +3559,8 @@ export default function planPiExtension(pi) {
|
|
|
3201
3559
|
phase.updatedAt = nowISO();
|
|
3202
3560
|
await st.savePhase(phase);
|
|
3203
3561
|
await st.writeGenerated();
|
|
3204
|
-
|
|
3562
|
+
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
3563
|
+
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3205
3564
|
},
|
|
3206
3565
|
});
|
|
3207
3566
|
pi.registerTool({
|
|
@@ -3209,25 +3568,30 @@ export default function planPiExtension(pi) {
|
|
|
3209
3568
|
label: "Phase Delete",
|
|
3210
3569
|
description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
|
|
3211
3570
|
parameters: Type.Object({
|
|
3212
|
-
phaseId: Type.String({ description: "Phase
|
|
3571
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
|
|
3213
3572
|
}),
|
|
3214
3573
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3215
3574
|
const st = await requirePlan(ctx);
|
|
3216
3575
|
if (!st)
|
|
3217
3576
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3577
|
+
const features = (await st.loadFeatures()).features;
|
|
3578
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3579
|
+
if (!resolvedPhase)
|
|
3580
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3581
|
+
const phaseId = resolvedPhase.id;
|
|
3218
3582
|
let phase;
|
|
3219
3583
|
try {
|
|
3220
|
-
phase = await st.loadPhase(
|
|
3584
|
+
phase = await st.loadPhase(phaseId);
|
|
3221
3585
|
}
|
|
3222
3586
|
catch {
|
|
3223
3587
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3224
3588
|
}
|
|
3225
|
-
await st.deletePhase(
|
|
3589
|
+
await st.deletePhase(phaseId);
|
|
3226
3590
|
if (phase.featureId) {
|
|
3227
3591
|
await st.updateFeatures((doc) => {
|
|
3228
3592
|
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3229
3593
|
if (feature) {
|
|
3230
|
-
feature.phaseIds = feature.phaseIds.filter((pid) => pid !==
|
|
3594
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
3231
3595
|
feature.updatedAt = nowISO();
|
|
3232
3596
|
}
|
|
3233
3597
|
return doc;
|
|
@@ -3241,42 +3605,64 @@ export default function planPiExtension(pi) {
|
|
|
3241
3605
|
pi.registerTool({
|
|
3242
3606
|
name: "task_list",
|
|
3243
3607
|
label: "Task List",
|
|
3244
|
-
description: "List tasks
|
|
3608
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3245
3609
|
parameters: Type.Object({
|
|
3246
|
-
|
|
3610
|
+
featureRef: Type.Optional(Type.String({})),
|
|
3611
|
+
phaseRef: Type.Optional(Type.String({})),
|
|
3612
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3247
3613
|
}),
|
|
3248
3614
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3249
3615
|
const st = await requirePlan(ctx);
|
|
3250
3616
|
if (!st)
|
|
3251
3617
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3618
|
+
const features = (await st.loadFeatures()).features;
|
|
3619
|
+
let phases = await st.loadAllPhases();
|
|
3620
|
+
const fref = params.featureRef?.trim();
|
|
3621
|
+
if (fref) {
|
|
3622
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3623
|
+
if (!f)
|
|
3624
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3625
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3255
3626
|
}
|
|
3256
|
-
|
|
3257
|
-
|
|
3627
|
+
const pref = params.phaseRef?.trim();
|
|
3628
|
+
if (pref) {
|
|
3629
|
+
const p = findPhaseByRef(phases, features, pref);
|
|
3630
|
+
if (!p)
|
|
3631
|
+
return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
|
|
3632
|
+
phases = [p];
|
|
3258
3633
|
}
|
|
3259
|
-
const
|
|
3260
|
-
|
|
3634
|
+
const out = [];
|
|
3635
|
+
for (const phase of phases) {
|
|
3636
|
+
for (const task of phase.tasks) {
|
|
3637
|
+
if (params.status && task.status !== params.status)
|
|
3638
|
+
continue;
|
|
3639
|
+
out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
|
|
3261
3643
|
},
|
|
3262
3644
|
});
|
|
3263
3645
|
pi.registerTool({
|
|
3264
3646
|
name: "task_get",
|
|
3265
3647
|
label: "Task Get",
|
|
3266
|
-
description: "
|
|
3648
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
|
|
3267
3649
|
parameters: Type.Object({
|
|
3268
|
-
taskId: Type.String({ description: "Task
|
|
3650
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3651
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
|
|
3269
3652
|
}),
|
|
3270
3653
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3271
3654
|
const st = await requirePlan(ctx);
|
|
3272
3655
|
if (!st)
|
|
3273
3656
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3274
|
-
const
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3277
|
-
if (!task || !phase)
|
|
3657
|
+
const features = (await st.loadFeatures()).features;
|
|
3658
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3659
|
+
if (!found)
|
|
3278
3660
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3279
|
-
|
|
3661
|
+
const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
3662
|
+
if (!params.full)
|
|
3663
|
+
return { content: [{ type: "text", text: summary }], details: {} };
|
|
3664
|
+
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3665
|
+
return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
|
|
3280
3666
|
},
|
|
3281
3667
|
});
|
|
3282
3668
|
pi.registerTool({
|
|
@@ -3284,32 +3670,52 @@ export default function planPiExtension(pi) {
|
|
|
3284
3670
|
label: "Task Create",
|
|
3285
3671
|
description: "Add a task to a phase 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. Status defaults to planned.",
|
|
3286
3672
|
parameters: Type.Object({
|
|
3673
|
+
featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
|
|
3287
3674
|
phaseId: Type.String({ description: "Phase ID the task belongs to" }),
|
|
3288
3675
|
title: Type.String({ description: "Task title" }),
|
|
3289
3676
|
description: Type.String({ description: "REQUIRED — execution context: code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner. Prefix with 'design-only' for pre-implementation design tasks.", minLength: 50 }),
|
|
3290
3677
|
status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
|
|
3291
3678
|
shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
|
|
3679
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
|
|
3292
3680
|
}),
|
|
3293
3681
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3294
3682
|
const st = await requirePlan(ctx);
|
|
3295
3683
|
if (!st)
|
|
3296
3684
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3685
|
+
if (!params.featureId?.trim())
|
|
3686
|
+
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3687
|
+
const features = (await st.loadFeatures()).features;
|
|
3688
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
|
|
3689
|
+
if (!resolvedFeature.ok)
|
|
3690
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3691
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
3692
|
+
if (!featureValidation.ok)
|
|
3693
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3694
|
+
const feature = resolvedFeature.feature;
|
|
3695
|
+
const existingFeature = features.find((f) => f.id === feature.id);
|
|
3696
|
+
if (!existingFeature)
|
|
3697
|
+
return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
|
|
3698
|
+
// Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
|
|
3699
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3700
|
+
if (!resolvedPhase)
|
|
3302
3701
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3303
|
-
|
|
3702
|
+
if (resolvedPhase.featureId !== feature.id)
|
|
3703
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.` }], details: {} };
|
|
3704
|
+
const phaseId = resolvedPhase.id;
|
|
3705
|
+
const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
|
|
3706
|
+
if (!phaseValidation.ok)
|
|
3707
|
+
return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
|
|
3708
|
+
const existingPhase = await st.loadPhase(phaseId);
|
|
3709
|
+
if (!existingPhase)
|
|
3710
|
+
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3304
3711
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3305
3712
|
const taskId = createTaskId();
|
|
3306
3713
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3307
|
-
const priority = await st.nextPriority("task",
|
|
3714
|
+
const priority = await st.nextPriority("task", phaseId);
|
|
3308
3715
|
const now = nowISO();
|
|
3309
3716
|
const initialStatus = params.status ?? "planned";
|
|
3310
|
-
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3311
3717
|
const task = {
|
|
3312
|
-
id: taskId, phaseId
|
|
3718
|
+
id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
|
|
3313
3719
|
title: params.title,
|
|
3314
3720
|
status: initialStatus,
|
|
3315
3721
|
description: params.description ?? "",
|
|
@@ -3317,7 +3723,10 @@ export default function planPiExtension(pi) {
|
|
|
3317
3723
|
statusLog: [],
|
|
3318
3724
|
decisions: [],
|
|
3319
3725
|
acceptedDecisions: [],
|
|
3320
|
-
checklist:
|
|
3726
|
+
checklist: params.checklist
|
|
3727
|
+
? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
|
|
3728
|
+
: [],
|
|
3729
|
+
subtasks: [],
|
|
3321
3730
|
dependsOn: [],
|
|
3322
3731
|
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3323
3732
|
completedAt: initialStatus === "done" ? now : "",
|
|
@@ -3325,14 +3734,15 @@ export default function planPiExtension(pi) {
|
|
|
3325
3734
|
};
|
|
3326
3735
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3327
3736
|
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3328
|
-
await st.updatePhase(
|
|
3737
|
+
await st.updatePhase(phaseId, (phase) => {
|
|
3329
3738
|
phase.tasks.push(task);
|
|
3330
3739
|
phase.taskIds.push(taskId);
|
|
3331
3740
|
phase.updatedAt = now;
|
|
3332
3741
|
return phase;
|
|
3333
3742
|
});
|
|
3334
3743
|
await st.writeGenerated();
|
|
3335
|
-
|
|
3744
|
+
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
3745
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
3336
3746
|
},
|
|
3337
3747
|
});
|
|
3338
3748
|
pi.registerTool({
|
|
@@ -3340,7 +3750,7 @@ export default function planPiExtension(pi) {
|
|
|
3340
3750
|
label: "Task Update",
|
|
3341
3751
|
description: "Update one or more fields of a task. Only provided fields are changed. Do NOT use this tool to start or complete work; use task_start and task_complete for lifecycle transitions so startedAt/completedAt stay correct.",
|
|
3342
3752
|
parameters: Type.Object({
|
|
3343
|
-
taskId: Type.String({ description: "Task
|
|
3753
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3344
3754
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3345
3755
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
3346
3756
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
@@ -3363,15 +3773,16 @@ export default function planPiExtension(pi) {
|
|
|
3363
3773
|
if (!st)
|
|
3364
3774
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3365
3775
|
// Find the phase hosting this task first.
|
|
3366
|
-
const
|
|
3367
|
-
const
|
|
3368
|
-
if (!
|
|
3776
|
+
const features = (await st.loadFeatures()).features;
|
|
3777
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3778
|
+
if (!found)
|
|
3369
3779
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3780
|
+
const hostPhase = found.phase;
|
|
3370
3781
|
const now = nowISO();
|
|
3371
3782
|
let updatedTask;
|
|
3372
3783
|
try {
|
|
3373
3784
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3374
|
-
const task = phase.tasks.find((t) => t.id ===
|
|
3785
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3375
3786
|
if (!task)
|
|
3376
3787
|
return phase;
|
|
3377
3788
|
if (params.title !== undefined)
|
|
@@ -3387,7 +3798,7 @@ export default function planPiExtension(pi) {
|
|
|
3387
3798
|
if (params.acceptedDecisions !== undefined)
|
|
3388
3799
|
task.acceptedDecisions = params.acceptedDecisions;
|
|
3389
3800
|
if (params.checklist !== undefined) {
|
|
3390
|
-
task.checklist = params.checklist.map((itemTitle, index) => ({
|
|
3801
|
+
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
3391
3802
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
3392
3803
|
title: itemTitle,
|
|
3393
3804
|
checked: false,
|
|
@@ -3428,19 +3839,19 @@ export default function planPiExtension(pi) {
|
|
|
3428
3839
|
label: "Task Delete",
|
|
3429
3840
|
description: "Delete a task from its phase.",
|
|
3430
3841
|
parameters: Type.Object({
|
|
3431
|
-
taskId: Type.String({ description: "Task
|
|
3842
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
|
|
3432
3843
|
}),
|
|
3433
3844
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3434
3845
|
const st = await requirePlan(ctx);
|
|
3435
3846
|
if (!st)
|
|
3436
3847
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3437
|
-
const
|
|
3438
|
-
const
|
|
3439
|
-
if (!
|
|
3848
|
+
const features = (await st.loadFeatures()).features;
|
|
3849
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3850
|
+
if (!found)
|
|
3440
3851
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3441
|
-
await st.updatePhase(
|
|
3442
|
-
phase.tasks = phase.tasks.filter((t) => t.id !==
|
|
3443
|
-
phase.taskIds = phase.taskIds.filter((id) => id !==
|
|
3852
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3853
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
|
|
3854
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
|
|
3444
3855
|
phase.updatedAt = nowISO();
|
|
3445
3856
|
return phase;
|
|
3446
3857
|
});
|
|
@@ -3454,25 +3865,25 @@ export default function planPiExtension(pi) {
|
|
|
3454
3865
|
label: "Task Start",
|
|
3455
3866
|
description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
|
|
3456
3867
|
parameters: Type.Object({
|
|
3457
|
-
taskId: Type.String({ description: "Task
|
|
3868
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3458
3869
|
}),
|
|
3459
3870
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3460
3871
|
const st = await requirePlan(ctx);
|
|
3461
3872
|
if (!st)
|
|
3462
3873
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3463
|
-
const
|
|
3464
|
-
const
|
|
3465
|
-
|
|
3466
|
-
if (!task || !hostPhase)
|
|
3874
|
+
const features = (await st.loadFeatures()).features;
|
|
3875
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3876
|
+
if (!found)
|
|
3467
3877
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3878
|
+
const task = found.task;
|
|
3468
3879
|
if (task.status === "in-progress")
|
|
3469
3880
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3470
3881
|
if (task.status === "done")
|
|
3471
3882
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
3472
3883
|
const now = nowISO();
|
|
3473
3884
|
let startedTask;
|
|
3474
|
-
await st.updatePhase(
|
|
3475
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
3885
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3886
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3476
3887
|
if (!t)
|
|
3477
3888
|
return phase;
|
|
3478
3889
|
applyTaskLifecycleDates(t, "in-progress", now);
|
|
@@ -3484,7 +3895,9 @@ export default function planPiExtension(pi) {
|
|
|
3484
3895
|
await st.writeGenerated();
|
|
3485
3896
|
if (!startedTask)
|
|
3486
3897
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3487
|
-
|
|
3898
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
3899
|
+
const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
|
|
3900
|
+
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
|
|
3488
3901
|
},
|
|
3489
3902
|
});
|
|
3490
3903
|
pi.registerTool({
|
|
@@ -3492,7 +3905,7 @@ export default function planPiExtension(pi) {
|
|
|
3492
3905
|
label: "Task Complete",
|
|
3493
3906
|
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
3494
3907
|
parameters: Type.Object({
|
|
3495
|
-
taskId: Type.String({ description: "Task
|
|
3908
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
3496
3909
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
3497
3910
|
description_update: Type.Optional(Type.String({ description: "Post-hoc summary: commit hash(s), files touched, decisions made, updated code references with new line numbers. Keeps the planner alive and traceable." })),
|
|
3498
3911
|
}),
|
|
@@ -3500,11 +3913,11 @@ export default function planPiExtension(pi) {
|
|
|
3500
3913
|
const st = await requirePlan(ctx);
|
|
3501
3914
|
if (!st)
|
|
3502
3915
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3503
|
-
const
|
|
3504
|
-
const
|
|
3505
|
-
|
|
3506
|
-
if (!task || !hostPhase)
|
|
3916
|
+
const features = (await st.loadFeatures()).features;
|
|
3917
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3918
|
+
if (!found)
|
|
3507
3919
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3920
|
+
const task = found.task;
|
|
3508
3921
|
if (task.status === "done")
|
|
3509
3922
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3510
3923
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -3516,8 +3929,8 @@ export default function planPiExtension(pi) {
|
|
|
3516
3929
|
}
|
|
3517
3930
|
const now = nowISO();
|
|
3518
3931
|
let completedTask;
|
|
3519
|
-
await st.updatePhase(
|
|
3520
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
3932
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3933
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3521
3934
|
if (!t)
|
|
3522
3935
|
return phase;
|
|
3523
3936
|
applyTaskLifecycleDates(t, "done", now);
|
|
@@ -3536,6 +3949,125 @@ export default function planPiExtension(pi) {
|
|
|
3536
3949
|
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
|
|
3537
3950
|
},
|
|
3538
3951
|
});
|
|
3952
|
+
pi.registerTool({
|
|
3953
|
+
name: "task_checklist_toggle",
|
|
3954
|
+
label: "Task Checklist Toggle",
|
|
3955
|
+
description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), item id, or 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.",
|
|
3956
|
+
parameters: Type.Object({
|
|
3957
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3958
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
3959
|
+
checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
|
|
3960
|
+
}),
|
|
3961
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3962
|
+
const st = await requirePlan(ctx);
|
|
3963
|
+
if (!st)
|
|
3964
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3965
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
3966
|
+
if (!found)
|
|
3967
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3968
|
+
let result = "";
|
|
3969
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3970
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3971
|
+
if (!task) {
|
|
3972
|
+
result = `Task not found: ${params.taskId}`;
|
|
3973
|
+
return phase;
|
|
3974
|
+
}
|
|
3975
|
+
const ck = task.checklist ?? [];
|
|
3976
|
+
if (ck.length === 0) {
|
|
3977
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
3978
|
+
return phase;
|
|
3979
|
+
}
|
|
3980
|
+
const target = toggleChecklistItem(ck, params.item, params.checked);
|
|
3981
|
+
if (!target) {
|
|
3982
|
+
result = `No checklist item matching "${params.item}".`;
|
|
3983
|
+
return phase;
|
|
3984
|
+
}
|
|
3985
|
+
task.updatedAt = nowISO();
|
|
3986
|
+
phase.updatedAt = nowISO();
|
|
3987
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
3988
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
3989
|
+
return phase;
|
|
3990
|
+
});
|
|
3991
|
+
await st.writeGenerated();
|
|
3992
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
3993
|
+
},
|
|
3994
|
+
});
|
|
3995
|
+
pi.registerTool({
|
|
3996
|
+
name: "task_checklist_add",
|
|
3997
|
+
label: "Task Checklist Add",
|
|
3998
|
+
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 task_update.",
|
|
3999
|
+
parameters: Type.Object({
|
|
4000
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4001
|
+
title: Type.String({ description: "Checklist item text (a single step)" }),
|
|
4002
|
+
}),
|
|
4003
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4004
|
+
const st = await requirePlan(ctx);
|
|
4005
|
+
if (!st)
|
|
4006
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4007
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4008
|
+
if (!found)
|
|
4009
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4010
|
+
let result = "";
|
|
4011
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4012
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4013
|
+
if (!task) {
|
|
4014
|
+
result = `Task not found: ${params.taskId}`;
|
|
4015
|
+
return phase;
|
|
4016
|
+
}
|
|
4017
|
+
const ck = task.checklist ?? [];
|
|
4018
|
+
const item = addChecklistItem(ck, task.id, params.title);
|
|
4019
|
+
ck.push(item);
|
|
4020
|
+
task.checklist = ck;
|
|
4021
|
+
task.updatedAt = nowISO();
|
|
4022
|
+
phase.updatedAt = nowISO();
|
|
4023
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
4024
|
+
return phase;
|
|
4025
|
+
});
|
|
4026
|
+
await st.writeGenerated();
|
|
4027
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4028
|
+
},
|
|
4029
|
+
});
|
|
4030
|
+
pi.registerTool({
|
|
4031
|
+
name: "task_checklist_remove",
|
|
4032
|
+
label: "Task Checklist Remove",
|
|
4033
|
+
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 task_update.",
|
|
4034
|
+
parameters: Type.Object({
|
|
4035
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4036
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4037
|
+
}),
|
|
4038
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4039
|
+
const st = await requirePlan(ctx);
|
|
4040
|
+
if (!st)
|
|
4041
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4042
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4043
|
+
if (!found)
|
|
4044
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4045
|
+
let result = "";
|
|
4046
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4047
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4048
|
+
if (!task) {
|
|
4049
|
+
result = `Task not found: ${params.taskId}`;
|
|
4050
|
+
return phase;
|
|
4051
|
+
}
|
|
4052
|
+
const ck = task.checklist ?? [];
|
|
4053
|
+
if (ck.length === 0) {
|
|
4054
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4055
|
+
return phase;
|
|
4056
|
+
}
|
|
4057
|
+
const removed = removeChecklistItem(ck, params.item);
|
|
4058
|
+
if (!removed) {
|
|
4059
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4060
|
+
return phase;
|
|
4061
|
+
}
|
|
4062
|
+
task.updatedAt = nowISO();
|
|
4063
|
+
phase.updatedAt = nowISO();
|
|
4064
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
4065
|
+
return phase;
|
|
4066
|
+
});
|
|
4067
|
+
await st.writeGenerated();
|
|
4068
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4069
|
+
},
|
|
4070
|
+
});
|
|
3539
4071
|
// ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
|
|
3540
4072
|
// These let the agent manage the web dashboard directly, without relying
|
|
3541
4073
|
// on the /planner slash command (which is not intercepted when the planner
|
|
@@ -3632,6 +4164,7 @@ export default function planPiExtension(pi) {
|
|
|
3632
4164
|
// add seconds of latency on every turn if run unconditionally.
|
|
3633
4165
|
if (!plannerHeavyInitDone) {
|
|
3634
4166
|
await migrateToUuids(st);
|
|
4167
|
+
await migrateToGlobalSequence(st).catch(() => null);
|
|
3635
4168
|
await st.ensureShortIdsAndPriority().catch(() => null);
|
|
3636
4169
|
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3637
4170
|
await maybeHealStatuses(st, ctx);
|
|
@@ -3664,8 +4197,9 @@ export default function planPiExtension(pi) {
|
|
|
3664
4197
|
const profile = await st.loadCodebaseProfile();
|
|
3665
4198
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3666
4199
|
const activity = await st.loadActivityLog();
|
|
3667
|
-
const recentActivity = activity.entries.slice(-
|
|
4200
|
+
const recentActivity = activity.entries.slice(-3).reverse();
|
|
3668
4201
|
const handoffs = await st.listHandoffs();
|
|
4202
|
+
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
3669
4203
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3670
4204
|
const orderedPhases = [
|
|
3671
4205
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3678,21 +4212,9 @@ export default function planPiExtension(pi) {
|
|
|
3678
4212
|
...plan.phases.filter((phase) => !phase.featureId)
|
|
3679
4213
|
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3680
4214
|
];
|
|
3681
|
-
const
|
|
3682
|
-
.
|
|
3683
|
-
.
|
|
3684
|
-
const active = orderedPhases
|
|
3685
|
-
.filter((p) => p.status === "in-progress")
|
|
3686
|
-
.map((p) => `\`${p.id}\``)
|
|
3687
|
-
.join(", ");
|
|
3688
|
-
const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
|
|
3689
|
-
const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
|
|
3690
|
-
const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
|
|
3691
|
-
const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
|
|
3692
|
-
const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
3693
|
-
const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
|
|
3694
|
-
const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
|
|
3695
|
-
const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
|
|
4215
|
+
const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
|
|
4216
|
+
|| plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
|
|
4217
|
+
|| orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
3696
4218
|
const currentPhase = hasActiveWork
|
|
3697
4219
|
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3698
4220
|
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
@@ -3709,16 +4231,51 @@ export default function planPiExtension(pi) {
|
|
|
3709
4231
|
?? null)
|
|
3710
4232
|
: null;
|
|
3711
4233
|
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3712
|
-
?
|
|
4234
|
+
? featureById.get(currentPhase.featureId) ?? null
|
|
3713
4235
|
: hasActiveWork
|
|
3714
4236
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3715
4237
|
: null;
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
4238
|
+
const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
|
|
4239
|
+
const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
|
|
4240
|
+
const currentTaskRef = currentTask && currentPhase
|
|
4241
|
+
? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
|
|
4242
|
+
: "";
|
|
4243
|
+
const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
|
|
4244
|
+
const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
|
|
4245
|
+
for (const phase of plan.phases) {
|
|
4246
|
+
phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
|
|
4247
|
+
}
|
|
4248
|
+
const phaseCountsSummary = phaseStatusOrder
|
|
4249
|
+
.map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
|
|
4250
|
+
.join(" ");
|
|
4251
|
+
const activeTaskMap = new Map();
|
|
4252
|
+
for (const phase of orderedPhases) {
|
|
4253
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4254
|
+
for (const task of phase.tasks) {
|
|
4255
|
+
if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
|
|
4256
|
+
continue;
|
|
4257
|
+
activeTaskMap.set(task.id, { phase, task, featureNumber });
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
const inProgressTaskLines = [...activeTaskMap.values()]
|
|
4261
|
+
.sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
|
|
4262
|
+
.map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
|
|
4263
|
+
const otherActiveOrBlockedPhases = orderedPhases
|
|
4264
|
+
.filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
|
|
4265
|
+
.slice(0, 6)
|
|
4266
|
+
.map((phase) => {
|
|
4267
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4268
|
+
return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
|
|
4269
|
+
});
|
|
4270
|
+
const openQuestions = plan.phases
|
|
4271
|
+
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
4272
|
+
.slice(0, 8);
|
|
4273
|
+
const acceptedDecisions = [
|
|
4274
|
+
...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
|
|
4275
|
+
...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
|
|
4276
|
+
...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
|
|
4277
|
+
...plan.phases.flatMap((phase) => phase.tasks.flatMap((task) => task.acceptedDecisions.map((decision) => `[task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}] ${decision.title}`))),
|
|
4278
|
+
].slice(0, 20);
|
|
3722
4279
|
const webServer = server;
|
|
3723
4280
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3724
4281
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3731,15 +4288,6 @@ export default function planPiExtension(pi) {
|
|
|
3731
4288
|
catch { }
|
|
3732
4289
|
const webUrl = webLocalUrl;
|
|
3733
4290
|
const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
|
|
3734
|
-
const openQuestions = plan.phases
|
|
3735
|
-
.flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
|
|
3736
|
-
.slice(0, 12);
|
|
3737
|
-
const acceptedDecisions = [
|
|
3738
|
-
...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
|
|
3739
|
-
...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
|
|
3740
|
-
...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
|
|
3741
|
-
...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
|
|
3742
|
-
].slice(0, 20);
|
|
3743
4291
|
const pkgSummary = profile?.packageJson
|
|
3744
4292
|
? `name=${profile.packageJson.name ?? "?"} pm=${profile.packageJson.packageManager ?? "npm"} scripts=${Object.keys(profile.packageJson.scripts).join(",") || "none"} deps=${Object.keys(profile.packageJson.dependencies).length} devDeps=${Object.keys(profile.packageJson.devDependencies).length}`
|
|
3745
4293
|
: "(no package.json)";
|
|
@@ -3761,96 +4309,62 @@ export default function planPiExtension(pi) {
|
|
|
3761
4309
|
// Build/refresh the context block (slow path: cache miss).
|
|
3762
4310
|
const contextBlock = [
|
|
3763
4311
|
`[Plan Context — ${project.name}]`,
|
|
3764
|
-
// ── Web UI status (prominent — agent must surface URL in its summary) ──
|
|
3765
4312
|
webUrl ? `🌐 WEB UI RUNNING: ${webUrlFull}${webPort ? ` (port ${webPort})` : ""}. Include this address+port at the end of your resume summary.` : `Web UI: not running. Start it with '/planner web start'.`,
|
|
3766
4313
|
"",
|
|
3767
|
-
|
|
4314
|
+
"Operational rules:",
|
|
4315
|
+
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4316
|
+
"- BEFORE any code edit/write: task_start. AFTER finishing a task: task_complete.",
|
|
4317
|
+
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4318
|
+
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4319
|
+
"- Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
|
|
4320
|
+
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
3768
4321
|
"",
|
|
3769
|
-
"═══════════════════════════════════════════════════════════════",
|
|
3770
|
-
"MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
|
|
3771
|
-
"═══════════════════════════════════════════════════════════════",
|
|
3772
|
-
"1. HANDOFF HYGIENE: If one or more phases have a pending handoff (entity-scoped, phase.handoff), present the list, read the relevant one with handoff show <ref>, then CLEAR it with handoff clear <ref> once consumed. Handoffs are non-blocking context artifacts, not locks — they never block task_start.",
|
|
3773
|
-
"2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'. NOTE: Planner operations (plan_write_handoff / /planner handoff, task_start, task_complete, task_update, status reads) are NOT code edits. They are ALWAYS allowed regardless of task state — you may write/refresh the handoff even when no task is in-progress (e.g., all tasks done, or capturing state mid-flight). Never refuse to write a handoff because 'no task is open'; that is incorrect.",
|
|
3774
|
-
"3. IMMEDIATE SYNC: Update task status AT THE EXACT MOMENT of transition. Start = task_start NOW. Done = task_complete NOW. Blocked = task_update with motivation NOW. Never batch status updates.",
|
|
3775
|
-
"4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
|
|
3776
|
-
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3777
|
-
"6. ENTITY REFERENCES: When you mention a feature/phase/task in chat, use its composite ID (F00x / P00x / T00x, e.g. T003(P002/F001)) or its 5-char shortId (e.g. UUXD1) — NEVER a raw UUID like bd6ed366. Tool responses already show these refs (compositeRef, shortId); reuse them.",
|
|
3778
|
-
"═══════════════════════════════════════════════════════════════",
|
|
3779
|
-
"",
|
|
3780
|
-
// ── Project details ──
|
|
3781
4322
|
`Goal: ${project.goal || "(not set)"}`,
|
|
3782
4323
|
project.description ? `Description: ${project.description}` : "",
|
|
3783
4324
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
3784
4325
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
3785
4326
|
`Ambient: ${ambientSummary}`,
|
|
3786
4327
|
"",
|
|
3787
|
-
"Global rules
|
|
3788
|
-
project.globalRules.length ? project.globalRules.map((
|
|
3789
|
-
"",
|
|
3790
|
-
"Workflow rules:",
|
|
3791
|
-
` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
|
|
3792
|
-
` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
|
|
3793
|
-
` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
4328
|
+
"Global rules:",
|
|
4329
|
+
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
3794
4330
|
"",
|
|
4331
|
+
`Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
3795
4332
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
3796
4333
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
3797
4334
|
"",
|
|
3798
4335
|
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
3799
|
-
...acceptedDecisions.map((
|
|
4336
|
+
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
3800
4337
|
openQuestions.length ? "" : "",
|
|
3801
4338
|
openQuestions.length ? "Open questions:" : "",
|
|
3802
|
-
...openQuestions.map((
|
|
4339
|
+
...openQuestions.map((question) => `- ${question}`),
|
|
3803
4340
|
"",
|
|
3804
|
-
"Resume focus (
|
|
3805
|
-
|
|
3806
|
-
|
|
4341
|
+
"Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
|
|
4342
|
+
currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
|
|
4343
|
+
currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
|
|
4344
|
+
currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
|
|
4345
|
+
inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
|
|
3807
4346
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3808
|
-
|
|
3809
|
-
|
|
4347
|
+
handoffs.length === 0 && resume.nextSteps.length
|
|
4348
|
+
? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
|
|
4349
|
+
: "",
|
|
4350
|
+
"",
|
|
4351
|
+
"CRITICAL: The 'Resume focus' pointers above are persisted across sessions. They are NOT necessarily the phase you worked on right now. When asked to write a handoff, use the phase the user explicitly identifies as the one just completed in this session, not a stale pointer.",
|
|
3810
4352
|
"",
|
|
3811
|
-
|
|
3812
|
-
|
|
4353
|
+
"Phase overview:",
|
|
4354
|
+
` counts: ${phaseCountsSummary}`,
|
|
4355
|
+
currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
|
|
4356
|
+
otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
|
|
4357
|
+
...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
|
|
3813
4358
|
"",
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
phaseLines || " _none_",
|
|
3817
|
-
active ? `Active: ${active}` : "",
|
|
4359
|
+
recentActivity.length ? "Recent activity:" : "",
|
|
4360
|
+
...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
|
|
3818
4361
|
"",
|
|
3819
|
-
"
|
|
3820
|
-
|
|
3821
|
-
"
|
|
3822
|
-
"- Feature commands: planner feature list|add|show|update|delete.",
|
|
3823
|
-
"- Phase commands: planner phase add|show|discuss|update|delete.",
|
|
3824
|
-
"- Task commands: planner task add|show|discuss|update|delete|start|complete.",
|
|
3825
|
-
"- Handoff commands: planner handoff prepare|show|write|clear.",
|
|
3826
|
-
"- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
|
|
3827
|
-
"Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
|
|
3828
|
-
"Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
|
|
3829
|
-
`Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
|
|
3830
|
-
"If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
|
|
3831
|
-
"Ask for language only if BOTH preferences are unset.",
|
|
3832
|
-
"If one preference is set and the other is unset, infer the missing one from the saved value and persist it immediately with project_set_language_preferences without asking the user again.",
|
|
3833
|
-
"Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
|
|
3834
|
-
"If you are creating a planner from an existing plan or document, do NOT leave project title, project description, project goal, or top-level requirements empty when the source material provides them.",
|
|
3835
|
-
"When deriving a project title from an existing document, prefer a concise human project title; if the candidate title is long or compound, ask the user to confirm before using it.",
|
|
3836
|
-
"Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
|
|
4362
|
+
handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
|
|
4363
|
+
...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
|
|
4364
|
+
handoffs.length > 0 ? "Use handoff show <ref> for context. A pending handoff is a candidate to validate; do not default to it as the current focus unless it matches the phase you actually worked on in this session." : "",
|
|
3837
4365
|
"",
|
|
3838
|
-
|
|
3839
|
-
"
|
|
3840
|
-
"- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
|
|
3841
|
-
"- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
|
|
3842
|
-
"- Therefore, if not strictly necessary, do NOT change phase.status or feature.status directly. Move the task statuses instead and let rollup derive the parent statuses.",
|
|
3843
|
-
"- The extension blocks edit/write when no task is in-progress (bash stays free so git pull/build/test work). If you hit that block, either call task_start, OR ask the user to authorize a one-time bypass (they can run /planner bypass, or you can call plan_authorize_bypass) and then retry.",
|
|
3844
|
-
"- If you forget these task status calls, the derived rollup breaks: phase/feature can stay stale, the dashboard shows wrong active work, and resume focus is wrong.",
|
|
3845
|
-
"- Before your final answer after implementation work, either call task_complete if the task is finished, or explicitly tell the user the task remains in-progress.",
|
|
3846
|
-
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
3847
|
-
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
3848
|
-
handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
|
|
3849
|
-
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3850
|
-
handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
|
|
3851
|
-
handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
|
|
3852
|
-
handoffs.length > 0 ? "Once a handoff is fully consumed and work is safely resumed, clear it with handoff clear <ref> (or /planner handoff clear)." : "",
|
|
3853
|
-
"Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
|
|
4366
|
+
`Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
|
|
4367
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
|
|
3854
4368
|
].filter(Boolean).join("\n");
|
|
3855
4369
|
contextBlockCache = contextBlock;
|
|
3856
4370
|
contextBlockDirty = false;
|