@agent-plan/pi-adapter 0.2.19-next.15 → 0.2.19-next.17
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 +382 -164
- package/package.json +3 -3
- package/web-ui-dist/assets/index-DG2XF_Sy.css +1 -0
- package/web-ui-dist/assets/index-DS7Qrq0d.js +293 -0
- package/web-ui-dist/index.html +3 -2
- package/web-ui-dist/assets/index-DNjqMsl7.js +0 -263
- package/web-ui-dist/assets/index-zZFIH6DD.css +0 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem } from "@agent-plan/core";
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
|
|
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" },
|
|
@@ -253,6 +255,34 @@ function phaseLabel(phase) {
|
|
|
253
255
|
function taskLabel(task) {
|
|
254
256
|
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
255
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
|
+
}
|
|
256
286
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
257
287
|
const [plan, resume, activity] = await Promise.all([
|
|
258
288
|
st.loadAll(),
|
|
@@ -750,9 +780,10 @@ export default function planPiExtension(pi) {
|
|
|
750
780
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
751
781
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
752
782
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
753
|
-
// until the safety-net timeout).
|
|
754
|
-
//
|
|
755
|
-
//
|
|
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).
|
|
756
787
|
let enablePlanner = false;
|
|
757
788
|
if (exists) {
|
|
758
789
|
ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
|
|
@@ -945,6 +976,7 @@ export default function planPiExtension(pi) {
|
|
|
945
976
|
"init",
|
|
946
977
|
"show",
|
|
947
978
|
"repair",
|
|
979
|
+
"cleanup-orphans",
|
|
948
980
|
"project discuss",
|
|
949
981
|
"project language",
|
|
950
982
|
"feature list",
|
|
@@ -978,8 +1010,8 @@ export default function planPiExtension(pi) {
|
|
|
978
1010
|
"load",
|
|
979
1011
|
"stop",
|
|
980
1012
|
];
|
|
981
|
-
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
982
|
-
"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" +
|
|
983
1015
|
"Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
|
|
984
1016
|
if (!a) {
|
|
985
1017
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
@@ -1135,6 +1167,34 @@ export default function planPiExtension(pi) {
|
|
|
1135
1167
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1136
1168
|
?? null;
|
|
1137
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
|
+
}
|
|
1138
1198
|
function findPhaseByRef(phases, ref) {
|
|
1139
1199
|
const normalized = ref.trim().toLowerCase();
|
|
1140
1200
|
if (!normalized)
|
|
@@ -1324,7 +1384,7 @@ export default function planPiExtension(pi) {
|
|
|
1324
1384
|
// ═══════════════════════════════════════════════════════════════
|
|
1325
1385
|
if (a === "feature") {
|
|
1326
1386
|
if (!b) {
|
|
1327
|
-
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");
|
|
1328
1388
|
return;
|
|
1329
1389
|
}
|
|
1330
1390
|
if (b === "list") {
|
|
@@ -1415,6 +1475,44 @@ export default function planPiExtension(pi) {
|
|
|
1415
1475
|
].filter(Boolean).join("\n"), "info");
|
|
1416
1476
|
return;
|
|
1417
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
|
+
}
|
|
1418
1516
|
if (b === "update") {
|
|
1419
1517
|
const featuresDoc = await st.loadFeatures();
|
|
1420
1518
|
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
@@ -1513,7 +1611,7 @@ export default function planPiExtension(pi) {
|
|
|
1513
1611
|
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1514
1612
|
return;
|
|
1515
1613
|
}
|
|
1516
|
-
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");
|
|
1517
1615
|
return;
|
|
1518
1616
|
}
|
|
1519
1617
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -2299,6 +2397,25 @@ export default function planPiExtension(pi) {
|
|
|
2299
2397
|
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2300
2398
|
return;
|
|
2301
2399
|
}
|
|
2400
|
+
if (a === "cleanup-orphans") {
|
|
2401
|
+
const found = await st.listOrphanPhases();
|
|
2402
|
+
if (found.length === 0) {
|
|
2403
|
+
ctx.ui.notify("No orphan phases found.", "info");
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const lines = [
|
|
2407
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
|
|
2408
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2409
|
+
];
|
|
2410
|
+
const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
|
|
2411
|
+
if (!confirmed) {
|
|
2412
|
+
ctx.ui.notify("Cleanup aborted.", "warning");
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
const report = await st.cleanupOrphanPhases();
|
|
2416
|
+
ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2302
2419
|
// ── export ──
|
|
2303
2420
|
if (a === "export" || a === "export-full") {
|
|
2304
2421
|
const isFull = a === "export-full" || subArgs.includes("--full");
|
|
@@ -2703,6 +2820,38 @@ export default function planPiExtension(pi) {
|
|
|
2703
2820
|
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2704
2821
|
},
|
|
2705
2822
|
});
|
|
2823
|
+
pi.registerTool({
|
|
2824
|
+
name: "plan_cleanup_orphan_phases",
|
|
2825
|
+
label: "Plan Cleanup Orphan Phases",
|
|
2826
|
+
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.",
|
|
2827
|
+
parameters: Type.Object({
|
|
2828
|
+
confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
|
|
2829
|
+
}),
|
|
2830
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2831
|
+
const st = await requirePlan(ctx);
|
|
2832
|
+
if (!st)
|
|
2833
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2834
|
+
const found = await st.listOrphanPhases();
|
|
2835
|
+
if (!params.confirm) {
|
|
2836
|
+
if (found.length === 0)
|
|
2837
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
|
|
2838
|
+
const lines = [
|
|
2839
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
2840
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2841
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
2842
|
+
];
|
|
2843
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
|
|
2844
|
+
}
|
|
2845
|
+
const report = await st.cleanupOrphanPhases();
|
|
2846
|
+
if (report.removed.length === 0)
|
|
2847
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
|
|
2848
|
+
const lines = [
|
|
2849
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
2850
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
2851
|
+
];
|
|
2852
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2853
|
+
},
|
|
2854
|
+
});
|
|
2706
2855
|
pi.registerTool({
|
|
2707
2856
|
name: "plan_get_handoff",
|
|
2708
2857
|
label: "Plan Get Handoff",
|
|
@@ -2991,6 +3140,51 @@ export default function planPiExtension(pi) {
|
|
|
2991
3140
|
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2992
3141
|
},
|
|
2993
3142
|
});
|
|
3143
|
+
pi.registerTool({
|
|
3144
|
+
name: "feature_discuss",
|
|
3145
|
+
label: "Feature Discuss",
|
|
3146
|
+
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
3147
|
+
parameters: Type.Object({
|
|
3148
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3149
|
+
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
3150
|
+
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3151
|
+
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3152
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
3153
|
+
}),
|
|
3154
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3155
|
+
const st = await requirePlan(ctx);
|
|
3156
|
+
if (!st)
|
|
3157
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3158
|
+
const features = (await st.loadFeatures()).features;
|
|
3159
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3160
|
+
if (!resolvedFeature.ok)
|
|
3161
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3162
|
+
const featureId = resolvedFeature.feature.id;
|
|
3163
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3164
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3165
|
+
if (!feature)
|
|
3166
|
+
return doc;
|
|
3167
|
+
if (params.description !== undefined)
|
|
3168
|
+
feature.description = params.description;
|
|
3169
|
+
if (params.workDone !== undefined)
|
|
3170
|
+
feature.workDone = params.workDone;
|
|
3171
|
+
if (params.workRemaining !== undefined)
|
|
3172
|
+
feature.workRemaining = params.workRemaining;
|
|
3173
|
+
if (params.dependencies !== undefined)
|
|
3174
|
+
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3175
|
+
feature.discussedAt = nowISO();
|
|
3176
|
+
feature.contextReady = true;
|
|
3177
|
+
feature.contextReadyReason = "Updated through feature_discuss tool.";
|
|
3178
|
+
feature.updatedAt = nowISO();
|
|
3179
|
+
return doc;
|
|
3180
|
+
});
|
|
3181
|
+
const feature = updatedDoc.features.find((f) => f.id === featureId);
|
|
3182
|
+
if (!feature)
|
|
3183
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3184
|
+
await st.writeGenerated();
|
|
3185
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3186
|
+
},
|
|
3187
|
+
});
|
|
2994
3188
|
pi.registerTool({
|
|
2995
3189
|
name: "feature_update",
|
|
2996
3190
|
label: "Feature Update",
|
|
@@ -3018,10 +3212,15 @@ export default function planPiExtension(pi) {
|
|
|
3018
3212
|
const st = await requirePlan(ctx);
|
|
3019
3213
|
if (!st)
|
|
3020
3214
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3215
|
+
const features = (await st.loadFeatures()).features;
|
|
3216
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3217
|
+
if (!resolvedFeature.ok)
|
|
3218
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3219
|
+
const featureId = resolvedFeature.feature.id;
|
|
3021
3220
|
let foundDoc;
|
|
3022
3221
|
try {
|
|
3023
3222
|
foundDoc = await st.updateFeatures((doc) => {
|
|
3024
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3223
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3025
3224
|
if (!feature)
|
|
3026
3225
|
return doc; // not found: no-op, handled below
|
|
3027
3226
|
if (params.name !== undefined)
|
|
@@ -3059,7 +3258,7 @@ export default function planPiExtension(pi) {
|
|
|
3059
3258
|
catch (e) {
|
|
3060
3259
|
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
3061
3260
|
}
|
|
3062
|
-
const feature = foundDoc?.features.find((f) => f.id ===
|
|
3261
|
+
const feature = foundDoc?.features.find((f) => f.id === featureId);
|
|
3063
3262
|
if (!feature)
|
|
3064
3263
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3065
3264
|
await st.writeGenerated();
|
|
@@ -3078,21 +3277,23 @@ export default function planPiExtension(pi) {
|
|
|
3078
3277
|
const st = await requirePlan(ctx);
|
|
3079
3278
|
if (!st)
|
|
3080
3279
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3280
|
+
const features = (await st.loadFeatures()).features;
|
|
3281
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3282
|
+
if (!resolvedFeature.ok)
|
|
3283
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3284
|
+
const featureId = resolvedFeature.feature.id;
|
|
3081
3285
|
let deleted = false;
|
|
3082
3286
|
await st.updateFeatures((doc) => {
|
|
3083
3287
|
const before = doc.features.length;
|
|
3084
|
-
doc.features = doc.features.filter((f) => f.id !==
|
|
3288
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3085
3289
|
if (doc.features.length !== before)
|
|
3086
3290
|
deleted = true;
|
|
3087
3291
|
return doc;
|
|
3088
3292
|
});
|
|
3089
|
-
if (!deleted) {
|
|
3090
|
-
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3091
|
-
}
|
|
3092
3293
|
let cascadeCount = 0;
|
|
3093
3294
|
if (params.cascade) {
|
|
3094
3295
|
const phases = await st.loadAllPhases();
|
|
3095
|
-
for (const phase of phases.filter((p) => p.featureId ===
|
|
3296
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
3096
3297
|
await st.deletePhase(phase.id);
|
|
3097
3298
|
cascadeCount += 1;
|
|
3098
3299
|
}
|
|
@@ -3136,7 +3337,7 @@ export default function planPiExtension(pi) {
|
|
|
3136
3337
|
pi.registerTool({
|
|
3137
3338
|
name: "phase_get",
|
|
3138
3339
|
label: "Phase Get",
|
|
3139
|
-
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3340
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3140
3341
|
parameters: Type.Object({
|
|
3141
3342
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3142
3343
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
@@ -3149,8 +3350,13 @@ export default function planPiExtension(pi) {
|
|
|
3149
3350
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3150
3351
|
if (!phase)
|
|
3151
3352
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3152
|
-
const
|
|
3153
|
-
|
|
3353
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3354
|
+
const reqCount = linkedRequirements.length;
|
|
3355
|
+
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"}` : ""})`;
|
|
3356
|
+
const requirementsBlock = reqCount > 0
|
|
3357
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3358
|
+
: "";
|
|
3359
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3154
3360
|
},
|
|
3155
3361
|
});
|
|
3156
3362
|
pi.registerTool({
|
|
@@ -3170,18 +3376,27 @@ export default function planPiExtension(pi) {
|
|
|
3170
3376
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3171
3377
|
if (!params.featureId?.trim())
|
|
3172
3378
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3379
|
+
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
3380
|
+
const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
|
|
3381
|
+
if (!resolvedFeature.ok)
|
|
3382
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3383
|
+
const featureId = resolvedFeature.feature.id;
|
|
3384
|
+
const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
|
|
3385
|
+
if (!featureValidation.ok)
|
|
3386
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3387
|
+
const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
|
|
3388
|
+
if (!existingFeature)
|
|
3389
|
+
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3173
3390
|
let phase;
|
|
3174
|
-
await withFeatureLock(
|
|
3175
|
-
const phases = await st.loadAllPhases();
|
|
3176
|
-
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3391
|
+
await withFeatureLock(featureId, async () => {
|
|
3177
3392
|
const number = await st.allocPhaseNumber();
|
|
3178
3393
|
const id = createPhaseId();
|
|
3179
3394
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3180
|
-
const priority = await st.nextPriority("phase",
|
|
3395
|
+
const priority = await st.nextPriority("phase", featureId);
|
|
3181
3396
|
const now = nowISO();
|
|
3182
3397
|
phase = {
|
|
3183
3398
|
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3184
|
-
featureId
|
|
3399
|
+
featureId,
|
|
3185
3400
|
status: params.status ?? "draft",
|
|
3186
3401
|
discussedAt: "",
|
|
3187
3402
|
contextReady: false,
|
|
@@ -3198,7 +3413,7 @@ export default function planPiExtension(pi) {
|
|
|
3198
3413
|
await st.savePhase(phase);
|
|
3199
3414
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3200
3415
|
await st.updateFeatures((doc) => {
|
|
3201
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3416
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3202
3417
|
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3203
3418
|
feature.phaseIds.push(phase.id);
|
|
3204
3419
|
feature.updatedAt = now;
|
|
@@ -3245,14 +3460,20 @@ export default function planPiExtension(pi) {
|
|
|
3245
3460
|
const st = await requirePlan(ctx);
|
|
3246
3461
|
if (!st)
|
|
3247
3462
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3463
|
+
const features = (await st.loadFeatures()).features;
|
|
3464
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3465
|
+
if (!resolvedPhase)
|
|
3466
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3467
|
+
const phaseId = resolvedPhase.id;
|
|
3248
3468
|
let phase;
|
|
3249
3469
|
try {
|
|
3250
|
-
phase = await st.loadPhase(
|
|
3470
|
+
phase = await st.loadPhase(phaseId);
|
|
3251
3471
|
}
|
|
3252
3472
|
catch {
|
|
3253
3473
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3254
3474
|
}
|
|
3255
3475
|
const prevFeatureId = phase.featureId;
|
|
3476
|
+
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3256
3477
|
if (params.title !== undefined) {
|
|
3257
3478
|
phase.title = params.title;
|
|
3258
3479
|
phase.slug = normalizeSlug(params.title);
|
|
@@ -3282,14 +3503,22 @@ export default function planPiExtension(pi) {
|
|
|
3282
3503
|
if (params.completionCriteria !== undefined)
|
|
3283
3504
|
phase.completionCriteria = params.completionCriteria;
|
|
3284
3505
|
if (params.featureId !== undefined) {
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3506
|
+
if (params.featureId === "") {
|
|
3507
|
+
nextFeatureIdForUpdate = undefined;
|
|
3508
|
+
}
|
|
3509
|
+
else {
|
|
3510
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3511
|
+
if (!resolvedFeature.ok)
|
|
3512
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3513
|
+
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3514
|
+
}
|
|
3515
|
+
phase.featureId = nextFeatureIdForUpdate;
|
|
3516
|
+
if (nextFeatureIdForUpdate !== prevFeatureId) {
|
|
3288
3517
|
await st.updateFeatures((doc) => {
|
|
3289
3518
|
for (const f of doc.features) {
|
|
3290
3519
|
if (f.id === prevFeatureId)
|
|
3291
3520
|
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3292
|
-
if (f.id ===
|
|
3521
|
+
if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
|
|
3293
3522
|
f.phaseIds.push(phase.id);
|
|
3294
3523
|
}
|
|
3295
3524
|
return doc;
|
|
@@ -3314,19 +3543,24 @@ export default function planPiExtension(pi) {
|
|
|
3314
3543
|
const st = await requirePlan(ctx);
|
|
3315
3544
|
if (!st)
|
|
3316
3545
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3546
|
+
const features = (await st.loadFeatures()).features;
|
|
3547
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3548
|
+
if (!resolvedPhase)
|
|
3549
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3550
|
+
const phaseId = resolvedPhase.id;
|
|
3317
3551
|
let phase;
|
|
3318
3552
|
try {
|
|
3319
|
-
phase = await st.loadPhase(
|
|
3553
|
+
phase = await st.loadPhase(phaseId);
|
|
3320
3554
|
}
|
|
3321
3555
|
catch {
|
|
3322
3556
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3323
3557
|
}
|
|
3324
|
-
await st.deletePhase(
|
|
3558
|
+
await st.deletePhase(phaseId);
|
|
3325
3559
|
if (phase.featureId) {
|
|
3326
3560
|
await st.updateFeatures((doc) => {
|
|
3327
3561
|
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3328
3562
|
if (feature) {
|
|
3329
|
-
feature.phaseIds = feature.phaseIds.filter((pid) => pid !==
|
|
3563
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
3330
3564
|
feature.updatedAt = nowISO();
|
|
3331
3565
|
}
|
|
3332
3566
|
return doc;
|
|
@@ -3416,22 +3650,26 @@ export default function planPiExtension(pi) {
|
|
|
3416
3650
|
const st = await requirePlan(ctx);
|
|
3417
3651
|
if (!st)
|
|
3418
3652
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3419
|
-
//
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
catch {
|
|
3653
|
+
// Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
|
|
3654
|
+
const features = (await st.loadFeatures()).features;
|
|
3655
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3656
|
+
if (!resolvedPhase)
|
|
3424
3657
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3425
|
-
|
|
3658
|
+
const phaseId = resolvedPhase.id;
|
|
3659
|
+
const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
|
|
3660
|
+
if (!phaseValidation.ok)
|
|
3661
|
+
return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
|
|
3662
|
+
const existingPhase = await st.loadPhase(phaseId);
|
|
3663
|
+
if (!existingPhase)
|
|
3664
|
+
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3426
3665
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3427
3666
|
const taskId = createTaskId();
|
|
3428
3667
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3429
|
-
const priority = await st.nextPriority("task",
|
|
3668
|
+
const priority = await st.nextPriority("task", phaseId);
|
|
3430
3669
|
const now = nowISO();
|
|
3431
3670
|
const initialStatus = params.status ?? "planned";
|
|
3432
|
-
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3433
3671
|
const task = {
|
|
3434
|
-
id: taskId, phaseId
|
|
3672
|
+
id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
|
|
3435
3673
|
title: params.title,
|
|
3436
3674
|
status: initialStatus,
|
|
3437
3675
|
description: params.description ?? "",
|
|
@@ -3450,7 +3688,7 @@ export default function planPiExtension(pi) {
|
|
|
3450
3688
|
};
|
|
3451
3689
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3452
3690
|
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3453
|
-
await st.updatePhase(
|
|
3691
|
+
await st.updatePhase(phaseId, (phase) => {
|
|
3454
3692
|
phase.tasks.push(task);
|
|
3455
3693
|
phase.taskIds.push(taskId);
|
|
3456
3694
|
phase.updatedAt = now;
|
|
@@ -3488,15 +3726,16 @@ export default function planPiExtension(pi) {
|
|
|
3488
3726
|
if (!st)
|
|
3489
3727
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3490
3728
|
// Find the phase hosting this task first.
|
|
3491
|
-
const
|
|
3492
|
-
const
|
|
3493
|
-
if (!
|
|
3729
|
+
const features = (await st.loadFeatures()).features;
|
|
3730
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3731
|
+
if (!found)
|
|
3494
3732
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3733
|
+
const hostPhase = found.phase;
|
|
3495
3734
|
const now = nowISO();
|
|
3496
3735
|
let updatedTask;
|
|
3497
3736
|
try {
|
|
3498
3737
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3499
|
-
const task = phase.tasks.find((t) => t.id ===
|
|
3738
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3500
3739
|
if (!task)
|
|
3501
3740
|
return phase;
|
|
3502
3741
|
if (params.title !== undefined)
|
|
@@ -3559,13 +3798,13 @@ export default function planPiExtension(pi) {
|
|
|
3559
3798
|
const st = await requirePlan(ctx);
|
|
3560
3799
|
if (!st)
|
|
3561
3800
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3562
|
-
const
|
|
3563
|
-
const
|
|
3564
|
-
if (!
|
|
3801
|
+
const features = (await st.loadFeatures()).features;
|
|
3802
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3803
|
+
if (!found)
|
|
3565
3804
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3566
|
-
await st.updatePhase(
|
|
3567
|
-
phase.tasks = phase.tasks.filter((t) => t.id !==
|
|
3568
|
-
phase.taskIds = phase.taskIds.filter((id) => id !==
|
|
3805
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3806
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
|
|
3807
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
|
|
3569
3808
|
phase.updatedAt = nowISO();
|
|
3570
3809
|
return phase;
|
|
3571
3810
|
});
|
|
@@ -3585,19 +3824,19 @@ export default function planPiExtension(pi) {
|
|
|
3585
3824
|
const st = await requirePlan(ctx);
|
|
3586
3825
|
if (!st)
|
|
3587
3826
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3588
|
-
const
|
|
3589
|
-
const
|
|
3590
|
-
|
|
3591
|
-
if (!task || !hostPhase)
|
|
3827
|
+
const features = (await st.loadFeatures()).features;
|
|
3828
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3829
|
+
if (!found)
|
|
3592
3830
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3831
|
+
const task = found.task;
|
|
3593
3832
|
if (task.status === "in-progress")
|
|
3594
3833
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3595
3834
|
if (task.status === "done")
|
|
3596
3835
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
3597
3836
|
const now = nowISO();
|
|
3598
3837
|
let startedTask;
|
|
3599
|
-
await st.updatePhase(
|
|
3600
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
3838
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3839
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3601
3840
|
if (!t)
|
|
3602
3841
|
return phase;
|
|
3603
3842
|
applyTaskLifecycleDates(t, "in-progress", now);
|
|
@@ -3625,11 +3864,11 @@ export default function planPiExtension(pi) {
|
|
|
3625
3864
|
const st = await requirePlan(ctx);
|
|
3626
3865
|
if (!st)
|
|
3627
3866
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3628
|
-
const
|
|
3629
|
-
const
|
|
3630
|
-
|
|
3631
|
-
if (!task || !hostPhase)
|
|
3867
|
+
const features = (await st.loadFeatures()).features;
|
|
3868
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3869
|
+
if (!found)
|
|
3632
3870
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3871
|
+
const task = found.task;
|
|
3633
3872
|
if (task.status === "done")
|
|
3634
3873
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3635
3874
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -3641,8 +3880,8 @@ export default function planPiExtension(pi) {
|
|
|
3641
3880
|
}
|
|
3642
3881
|
const now = nowISO();
|
|
3643
3882
|
let completedTask;
|
|
3644
|
-
await st.updatePhase(
|
|
3645
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
3883
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
3884
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3646
3885
|
if (!t)
|
|
3647
3886
|
return phase;
|
|
3648
3887
|
applyTaskLifecycleDates(t, "done", now);
|
|
@@ -3909,8 +4148,9 @@ export default function planPiExtension(pi) {
|
|
|
3909
4148
|
const profile = await st.loadCodebaseProfile();
|
|
3910
4149
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3911
4150
|
const activity = await st.loadActivityLog();
|
|
3912
|
-
const recentActivity = activity.entries.slice(-
|
|
4151
|
+
const recentActivity = activity.entries.slice(-3).reverse();
|
|
3913
4152
|
const handoffs = await st.listHandoffs();
|
|
4153
|
+
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
3914
4154
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3915
4155
|
const orderedPhases = [
|
|
3916
4156
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3923,21 +4163,9 @@ export default function planPiExtension(pi) {
|
|
|
3923
4163
|
...plan.phases.filter((phase) => !phase.featureId)
|
|
3924
4164
|
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3925
4165
|
];
|
|
3926
|
-
const
|
|
3927
|
-
.
|
|
3928
|
-
.
|
|
3929
|
-
const active = orderedPhases
|
|
3930
|
-
.filter((p) => p.status === "in-progress")
|
|
3931
|
-
.map((p) => `\`${p.id}\``)
|
|
3932
|
-
.join(", ");
|
|
3933
|
-
const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
|
|
3934
|
-
const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
|
|
3935
|
-
const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
|
|
3936
|
-
const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
|
|
3937
|
-
const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
3938
|
-
const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
|
|
3939
|
-
const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
|
|
3940
|
-
const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
|
|
4166
|
+
const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
|
|
4167
|
+
|| plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
|
|
4168
|
+
|| orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
3941
4169
|
const currentPhase = hasActiveWork
|
|
3942
4170
|
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3943
4171
|
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
@@ -3954,16 +4182,51 @@ export default function planPiExtension(pi) {
|
|
|
3954
4182
|
?? null)
|
|
3955
4183
|
: null;
|
|
3956
4184
|
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3957
|
-
?
|
|
4185
|
+
? featureById.get(currentPhase.featureId) ?? null
|
|
3958
4186
|
: hasActiveWork
|
|
3959
4187
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3960
4188
|
: null;
|
|
3961
|
-
const
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
4189
|
+
const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
|
|
4190
|
+
const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
|
|
4191
|
+
const currentTaskRef = currentTask && currentPhase
|
|
4192
|
+
? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
|
|
4193
|
+
: "";
|
|
4194
|
+
const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
|
|
4195
|
+
const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
|
|
4196
|
+
for (const phase of plan.phases) {
|
|
4197
|
+
phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
|
|
4198
|
+
}
|
|
4199
|
+
const phaseCountsSummary = phaseStatusOrder
|
|
4200
|
+
.map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
|
|
4201
|
+
.join(" ");
|
|
4202
|
+
const activeTaskMap = new Map();
|
|
4203
|
+
for (const phase of orderedPhases) {
|
|
4204
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4205
|
+
for (const task of phase.tasks) {
|
|
4206
|
+
if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
|
|
4207
|
+
continue;
|
|
4208
|
+
activeTaskMap.set(task.id, { phase, task, featureNumber });
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
const inProgressTaskLines = [...activeTaskMap.values()]
|
|
4212
|
+
.sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
|
|
4213
|
+
.map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
|
|
4214
|
+
const otherActiveOrBlockedPhases = orderedPhases
|
|
4215
|
+
.filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
|
|
4216
|
+
.slice(0, 6)
|
|
4217
|
+
.map((phase) => {
|
|
4218
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4219
|
+
return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
|
|
4220
|
+
});
|
|
4221
|
+
const openQuestions = plan.phases
|
|
4222
|
+
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
4223
|
+
.slice(0, 8);
|
|
4224
|
+
const acceptedDecisions = [
|
|
4225
|
+
...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
|
|
4226
|
+
...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
|
|
4227
|
+
...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
|
|
4228
|
+
...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}`))),
|
|
4229
|
+
].slice(0, 20);
|
|
3967
4230
|
const webServer = server;
|
|
3968
4231
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3969
4232
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3976,15 +4239,6 @@ export default function planPiExtension(pi) {
|
|
|
3976
4239
|
catch { }
|
|
3977
4240
|
const webUrl = webLocalUrl;
|
|
3978
4241
|
const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
|
|
3979
|
-
const openQuestions = plan.phases
|
|
3980
|
-
.flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
|
|
3981
|
-
.slice(0, 12);
|
|
3982
|
-
const acceptedDecisions = [
|
|
3983
|
-
...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
|
|
3984
|
-
...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
|
|
3985
|
-
...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
|
|
3986
|
-
...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
|
|
3987
|
-
].slice(0, 20);
|
|
3988
4242
|
const pkgSummary = profile?.packageJson
|
|
3989
4243
|
? `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}`
|
|
3990
4244
|
: "(no package.json)";
|
|
@@ -4006,96 +4260,60 @@ export default function planPiExtension(pi) {
|
|
|
4006
4260
|
// Build/refresh the context block (slow path: cache miss).
|
|
4007
4261
|
const contextBlock = [
|
|
4008
4262
|
`[Plan Context — ${project.name}]`,
|
|
4009
|
-
// ── Web UI status (prominent — agent must surface URL in its summary) ──
|
|
4010
4263
|
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'.`,
|
|
4011
4264
|
"",
|
|
4012
|
-
|
|
4013
|
-
"",
|
|
4014
|
-
"
|
|
4015
|
-
"
|
|
4016
|
-
"
|
|
4017
|
-
"
|
|
4018
|
-
"
|
|
4019
|
-
"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.",
|
|
4020
|
-
"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.",
|
|
4021
|
-
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
4022
|
-
"6. ENTITY REFERENCES & FAST LOOKUP: Feature/Phase/Task numbers are GLOBALLY unique (one P003, one T007 in the whole plan) — bare P00x/T00x is a valid unambiguous ref. PREFER the 5-char shortId (e.g. UUXD1) or composite F00x/P00x/T00x in chat; NEVER a raw UUID. To FIND an entity: call feature_list / phase_list / task_list (compact, ~2K tokens for the whole plan) — do NOT read .planner/*.json files or plan_get full=true (~32K tokens) to locate an entity. To ACT: pass the ref directly to the action tool (task_start/complete/update/phase_update accept F00x/P00x/T00x, bare P00x/T00x, shortId, UUID, title). To read a description: call task_get / phase_get / feature_get with full=true for that ONE entity. Tool responses already show shortId + compositeRef; reuse them.",
|
|
4023
|
-
"═══════════════════════════════════════════════════════════════",
|
|
4265
|
+
"Operational rules:",
|
|
4266
|
+
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4267
|
+
"- BEFORE any code edit/write: task_start. AFTER finishing a task: task_complete.",
|
|
4268
|
+
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4269
|
+
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4270
|
+
"- Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
|
|
4271
|
+
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
4024
4272
|
"",
|
|
4025
|
-
// ── Project details ──
|
|
4026
4273
|
`Goal: ${project.goal || "(not set)"}`,
|
|
4027
4274
|
project.description ? `Description: ${project.description}` : "",
|
|
4028
4275
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
4029
4276
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
4030
4277
|
`Ambient: ${ambientSummary}`,
|
|
4031
4278
|
"",
|
|
4032
|
-
"Global rules
|
|
4033
|
-
project.globalRules.length ? project.globalRules.map((
|
|
4034
|
-
"",
|
|
4035
|
-
"Workflow rules:",
|
|
4036
|
-
` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
|
|
4037
|
-
` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
|
|
4038
|
-
` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
4279
|
+
"Global rules:",
|
|
4280
|
+
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
4039
4281
|
"",
|
|
4282
|
+
`Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
4040
4283
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
4041
4284
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
4042
4285
|
"",
|
|
4043
4286
|
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
4044
|
-
...acceptedDecisions.map((
|
|
4287
|
+
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
4045
4288
|
openQuestions.length ? "" : "",
|
|
4046
4289
|
openQuestions.length ? "Open questions:" : "",
|
|
4047
|
-
...openQuestions.map((
|
|
4290
|
+
...openQuestions.map((question) => `- ${question}`),
|
|
4048
4291
|
"",
|
|
4049
|
-
"Resume focus
|
|
4050
|
-
|
|
4051
|
-
|
|
4292
|
+
"Resume focus:",
|
|
4293
|
+
currentFeatureRef ? ` current feature: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature: (none)",
|
|
4294
|
+
currentPhaseRef ? ` current phase: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase: (none)",
|
|
4295
|
+
currentTaskRef ? ` current task: ${currentTaskRef} — ${currentTask?.title}` : " current task: (none)",
|
|
4296
|
+
inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
|
|
4052
4297
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
recentActivity.length ? "Recent activity (most recent first):" : "",
|
|
4057
|
-
...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
|
|
4298
|
+
handoffs.length === 0 && resume.nextSteps.length
|
|
4299
|
+
? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
|
|
4300
|
+
: "",
|
|
4058
4301
|
"",
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4302
|
+
"Phase overview:",
|
|
4303
|
+
` counts: ${phaseCountsSummary}`,
|
|
4304
|
+
currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
|
|
4305
|
+
otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
|
|
4306
|
+
...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
|
|
4063
4307
|
"",
|
|
4064
|
-
"
|
|
4065
|
-
|
|
4066
|
-
"- Use `/planner` with no args to open the navigable planner menu.",
|
|
4067
|
-
"- Feature commands: planner feature list|add|show|update|delete.",
|
|
4068
|
-
"- Phase commands: planner phase add|show|discuss|update|delete.",
|
|
4069
|
-
"- Task commands: planner task add|show|discuss|update|delete|start|complete.",
|
|
4070
|
-
"- Handoff commands: planner handoff prepare|show|write|clear.",
|
|
4071
|
-
"- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
|
|
4072
|
-
"Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
|
|
4073
|
-
"Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
|
|
4074
|
-
`Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
|
|
4075
|
-
"If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
|
|
4076
|
-
"Ask for language only if BOTH preferences are unset.",
|
|
4077
|
-
"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.",
|
|
4078
|
-
"Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
|
|
4079
|
-
"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.",
|
|
4080
|
-
"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.",
|
|
4081
|
-
"Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
|
|
4308
|
+
recentActivity.length ? "Recent activity:" : "",
|
|
4309
|
+
...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
|
|
4082
4310
|
"",
|
|
4083
|
-
"TASK STATUS RULES (strict — do not skip):",
|
|
4084
|
-
"- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
|
|
4085
|
-
"- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
|
|
4086
|
-
"- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
|
|
4087
|
-
"- 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.",
|
|
4088
|
-
"- 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.",
|
|
4089
|
-
"- 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.",
|
|
4090
|
-
"- 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.",
|
|
4091
|
-
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
4092
|
-
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
4093
4311
|
handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
|
|
4094
|
-
...handoffs.map((
|
|
4095
|
-
handoffs.length > 0 ? "
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
"
|
|
4312
|
+
...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
|
|
4313
|
+
handoffs.length > 0 ? "Use handoff show <ref>; if no task is active, treat the handoff target as a candidate to validate, not the current focus." : "",
|
|
4314
|
+
"",
|
|
4315
|
+
`Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
|
|
4316
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
|
|
4099
4317
|
].filter(Boolean).join("\n");
|
|
4100
4318
|
contextBlockCache = contextBlock;
|
|
4101
4319
|
contextBlockDirty = false;
|