@agent-plan/pi-adapter 0.2.19-next.20 → 0.2.19-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +392 -138
- package/package.json +3 -3
- package/web-ui-dist/assets/{index-DVspmkeP.js → index-B4qv5MwQ.js} +68 -68
- package/web-ui-dist/assets/index-CTAfas5X.css +1 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-Cxg9j9PL.css +0 -1
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock } from "@agent-plan/core";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, recommendNextTask } from "@agent-plan/core";
|
|
13
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";
|
|
@@ -131,9 +131,9 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
131
131
|
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
132
132
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
133
133
|
{ value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
|
|
134
|
-
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (
|
|
135
|
-
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (
|
|
136
|
-
{ value: "handoff clear", label: "handoff clear [P00x]", description: "
|
|
134
|
+
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
|
|
135
|
+
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
|
|
136
|
+
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
|
|
137
137
|
{ value: "web start", label: "web start", description: "Start the web UI" },
|
|
138
138
|
{ value: "web stop", label: "web stop", description: "Stop the web UI" },
|
|
139
139
|
{ value: "web status", label: "web status", description: "Show web UI status" },
|
|
@@ -144,6 +144,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
144
144
|
{ value: "load", label: "load", description: "Enable planner + start web UI (LAN) and show resume summary" },
|
|
145
145
|
{ value: "stop", label: "stop", description: "Disable planner and shut down the web UI" },
|
|
146
146
|
];
|
|
147
|
+
/** A completed nested command followed by a space is now receiving free-form
|
|
148
|
+
* arguments (entity refs, titles, etc.), not another command completion. */
|
|
149
|
+
function hasPlannerCommandArgument(prefix) {
|
|
150
|
+
const normalized = prefix.trimStart().toLowerCase();
|
|
151
|
+
return PLANNER_COMMAND_COMPLETIONS.some((item) => normalized.startsWith(`${item.value.toLowerCase()} `));
|
|
152
|
+
}
|
|
147
153
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
148
154
|
function nowISO() {
|
|
149
155
|
return new Date().toISOString();
|
|
@@ -201,7 +207,7 @@ async function maybeHealStatuses(st, ctx) {
|
|
|
201
207
|
function normalizeLanguagePref(value) {
|
|
202
208
|
return value?.trim() ?? "";
|
|
203
209
|
}
|
|
204
|
-
async function ensureProjectLanguagePreferences(st) {
|
|
210
|
+
async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
205
211
|
const project = await st.loadProject();
|
|
206
212
|
const contentLanguage = normalizeLanguagePref(project.contentLanguage);
|
|
207
213
|
const chatLanguage = normalizeLanguagePref(project.chatLanguage);
|
|
@@ -209,7 +215,8 @@ async function ensureProjectLanguagePreferences(st) {
|
|
|
209
215
|
if (project.contentLanguage !== contentLanguage || project.chatLanguage !== chatLanguage) {
|
|
210
216
|
project.contentLanguage = contentLanguage;
|
|
211
217
|
project.chatLanguage = chatLanguage;
|
|
212
|
-
|
|
218
|
+
if (persist)
|
|
219
|
+
await st.saveProject(project);
|
|
213
220
|
}
|
|
214
221
|
return project;
|
|
215
222
|
}
|
|
@@ -217,7 +224,8 @@ async function ensureProjectLanguagePreferences(st) {
|
|
|
217
224
|
const fallback = contentLanguage || chatLanguage;
|
|
218
225
|
project.contentLanguage = contentLanguage || fallback;
|
|
219
226
|
project.chatLanguage = chatLanguage || fallback;
|
|
220
|
-
|
|
227
|
+
if (persist)
|
|
228
|
+
await st.saveProject(project);
|
|
221
229
|
return project;
|
|
222
230
|
}
|
|
223
231
|
return project;
|
|
@@ -296,7 +304,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
296
304
|
const latestTaskUpdate = [...allTasks].sort((left, right) => right.task.updatedAt.localeCompare(left.task.updatedAt))[0] ?? null;
|
|
297
305
|
const latestPhaseUpdate = [...plan.phases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
298
306
|
const latestFeatureUpdate = [...plan.features.features].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
299
|
-
const currentPhase = plan.phases.find((phase) => phase.id ===
|
|
307
|
+
const currentPhase = (overrides?.phaseId ? plan.phases.find((phase) => phase.id === overrides.phaseId) : undefined)
|
|
308
|
+
?? plan.phases.find((phase) => phase.id === resume.currentPhaseId)
|
|
300
309
|
?? plan.phases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
301
310
|
?? plan.phases.find((phase) => phase.status === "in-progress")
|
|
302
311
|
?? nonTerminalTasks[0]?.phase
|
|
@@ -653,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
653
662
|
return;
|
|
654
663
|
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
655
664
|
const port = await pickProjectPort(ctx, requestedPort);
|
|
665
|
+
// A started server must be reflected in the next context block; the cache
|
|
666
|
+
// may hold a stale "Web UI: not running" block from a stop+prompt pair.
|
|
667
|
+
contextBlockDirty = true;
|
|
656
668
|
try {
|
|
657
669
|
server = await serve({
|
|
658
670
|
port,
|
|
@@ -665,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
665
677
|
});
|
|
666
678
|
}
|
|
667
679
|
catch (e) {
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
680
|
+
// Any listen failure at startup is potentially transient — EADDRINUSE from
|
|
681
|
+
// a racy port scan (another process grabbed the port between our check and
|
|
682
|
+
// bind), EMFILE/ENFILE under heavy load, EAGAIN, etc. Retry ONCE on a random
|
|
683
|
+
// free port (port 0) so a planner load never silently ends up with no server.
|
|
684
|
+
// serve() throws here only during startup binding/serving setup; if the retry
|
|
685
|
+
// also fails, surface the error to the user.
|
|
686
|
+
try {
|
|
687
|
+
server = await serve({
|
|
688
|
+
port: 0,
|
|
689
|
+
host,
|
|
690
|
+
planRoot: resolvePlanRoot(ctx.cwd),
|
|
691
|
+
staticDir: resolveStaticDir(),
|
|
692
|
+
quiet: true,
|
|
693
|
+
uiConfig: readUiConfig(ctx),
|
|
694
|
+
isBusy: () => planBusy,
|
|
695
|
+
});
|
|
696
|
+
ctx.ui.notify(`Planner web server: port ${port} unavailable (${e instanceof Error ? e.message : String(e)}), started on ${server?.url ?? "?"} instead.`, "info");
|
|
685
697
|
}
|
|
686
|
-
|
|
687
|
-
ctx.ui.notify(`Failed to start web server: ${
|
|
698
|
+
catch (e2) {
|
|
699
|
+
ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
|
|
688
700
|
}
|
|
689
701
|
}
|
|
690
702
|
if (server) {
|
|
@@ -717,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
717
729
|
catch { }
|
|
718
730
|
}
|
|
719
731
|
}
|
|
732
|
+
function disablePlannerSession() {
|
|
733
|
+
plannerSessionEnabled = false;
|
|
734
|
+
startupResumePromptPending = false;
|
|
735
|
+
startupResumeSummaryPending = false;
|
|
736
|
+
startupResumeSummaryText = "";
|
|
737
|
+
if (startupResumeSummaryTimer)
|
|
738
|
+
clearTimeout(startupResumeSummaryTimer);
|
|
739
|
+
startupResumeSummaryTimer = null;
|
|
740
|
+
contextBlockCache = "";
|
|
741
|
+
contextBlockDirty = true;
|
|
742
|
+
}
|
|
720
743
|
async function stopServer() {
|
|
721
744
|
if (server) {
|
|
722
745
|
await server.close();
|
|
723
746
|
server = null;
|
|
724
747
|
}
|
|
725
748
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
749
|
+
// The cached context block embeds the web UI address; after stop it must
|
|
750
|
+
// not keep claiming the dashboard is running (T241 — context invalidation).
|
|
751
|
+
contextBlockDirty = true;
|
|
726
752
|
}
|
|
727
753
|
// ─── Extension factory ──────────────────────────────────────────────────
|
|
728
754
|
export default function planPiExtension(pi) {
|
|
@@ -741,6 +767,9 @@ export default function planPiExtension(pi) {
|
|
|
741
767
|
}
|
|
742
768
|
const argPrefix = match[2];
|
|
743
769
|
const prefix = argPrefix === undefined ? match[1] : argPrefix;
|
|
770
|
+
if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
744
773
|
const normalized = (argPrefix ?? "").trimStart().toLowerCase();
|
|
745
774
|
const items = PLANNER_COMMAND_COMPLETIONS
|
|
746
775
|
.filter((item) => !normalized || item.value.startsWith(normalized))
|
|
@@ -965,8 +994,45 @@ export default function planPiExtension(pi) {
|
|
|
965
994
|
});
|
|
966
995
|
// ── Commands ───────────────────────────────────────────────────────
|
|
967
996
|
// ── Planner handler (reusable from multiple commands) ──────────
|
|
997
|
+
const plannerInteractionCancelled = Symbol("planner-interaction-cancelled");
|
|
998
|
+
/**
|
|
999
|
+
* Pi returns `undefined` when Escape closes an input/editor/select. Treat
|
|
1000
|
+
* that as a transaction-wide cancellation, not as an omitted optional
|
|
1001
|
+
* field: interactive planner commands must never keep prompting or write a
|
|
1002
|
+
* partial edit after Escape.
|
|
1003
|
+
*/
|
|
1004
|
+
function withCancellablePlannerUi(ctx) {
|
|
1005
|
+
const cancellableCtx = Object.create(ctx);
|
|
1006
|
+
const ui = new Proxy(ctx.ui, {
|
|
1007
|
+
get(target, property, receiver) {
|
|
1008
|
+
const member = Reflect.get(target, property, receiver);
|
|
1009
|
+
if (typeof member !== "function")
|
|
1010
|
+
return member;
|
|
1011
|
+
if (property === "input" || property === "editor" || property === "select") {
|
|
1012
|
+
return async (...args) => {
|
|
1013
|
+
const result = await member.apply(target, args);
|
|
1014
|
+
if (result === undefined || result === null)
|
|
1015
|
+
throw plannerInteractionCancelled;
|
|
1016
|
+
return result;
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return member.bind(target);
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
Object.defineProperty(cancellableCtx, "ui", { value: ui, enumerable: true });
|
|
1023
|
+
return cancellableCtx;
|
|
1024
|
+
}
|
|
968
1025
|
async function handlePlanner(args, ctx) {
|
|
969
|
-
|
|
1026
|
+
try {
|
|
1027
|
+
await withPlanBusy(() => runPlanner(args, withCancellablePlannerUi(ctx)));
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
if (error === plannerInteractionCancelled) {
|
|
1031
|
+
ctx.ui.notify("Cancelled — no changes saved.", "info");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
970
1036
|
}
|
|
971
1037
|
async function runPlanner(args, ctx) {
|
|
972
1038
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -1012,7 +1078,7 @@ export default function planPiExtension(pi) {
|
|
|
1012
1078
|
];
|
|
1013
1079
|
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
1080
|
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
|
|
1015
|
-
"Handoff actions: /planner handoff list | show
|
|
1081
|
+
"Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
|
|
1016
1082
|
if (!a) {
|
|
1017
1083
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
1018
1084
|
if (!action) {
|
|
@@ -1032,6 +1098,14 @@ export default function planPiExtension(pi) {
|
|
|
1032
1098
|
return;
|
|
1033
1099
|
}
|
|
1034
1100
|
const oldProject = await st.loadProject();
|
|
1101
|
+
// Collect the replacement metadata before deleting the existing plan;
|
|
1102
|
+
// Escape must leave the old project intact.
|
|
1103
|
+
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1104
|
+
if (!name?.trim()) {
|
|
1105
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
1035
1109
|
ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
|
|
1036
1110
|
if (server) {
|
|
1037
1111
|
ctx.ui.notify("Stopping active web server...", "info");
|
|
@@ -1039,12 +1113,6 @@ export default function planPiExtension(pi) {
|
|
|
1039
1113
|
server = null;
|
|
1040
1114
|
}
|
|
1041
1115
|
await rm(st.root, { recursive: true, force: true });
|
|
1042
|
-
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1043
|
-
if (!name?.trim()) {
|
|
1044
|
-
ctx.ui.notify("Aborted", "warning");
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
1048
1116
|
await st.init(name.trim());
|
|
1049
1117
|
const project = await st.loadProject();
|
|
1050
1118
|
project.description = description?.trim() ?? oldProject.description;
|
|
@@ -1195,23 +1263,6 @@ export default function planPiExtension(pi) {
|
|
|
1195
1263
|
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
1196
1264
|
return { ok: false, error: `Feature not found: ${raw}` };
|
|
1197
1265
|
}
|
|
1198
|
-
function findPhaseByRef(phases, ref) {
|
|
1199
|
-
const normalized = ref.trim().toLowerCase();
|
|
1200
|
-
if (!normalized)
|
|
1201
|
-
return null;
|
|
1202
|
-
const pMatch = normalized.match(/^p(\d+)$/);
|
|
1203
|
-
if (pMatch) {
|
|
1204
|
-
const n = parseInt(pMatch[1], 10);
|
|
1205
|
-
const byNum = phases.find((p) => p.number === n);
|
|
1206
|
-
if (byNum)
|
|
1207
|
-
return byNum;
|
|
1208
|
-
}
|
|
1209
|
-
return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
|
|
1210
|
-
?? phases.find((phase) => phase.id.toLowerCase() === normalized)
|
|
1211
|
-
?? phases.find((phase) => phase.title.toLowerCase() === normalized)
|
|
1212
|
-
?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
|
|
1213
|
-
?? null;
|
|
1214
|
-
}
|
|
1215
1266
|
function profile_packageManager(pkg, lockfile) {
|
|
1216
1267
|
if (pkg?.packageManager)
|
|
1217
1268
|
return pkg.packageManager;
|
|
@@ -1225,7 +1276,7 @@ export default function planPiExtension(pi) {
|
|
|
1225
1276
|
return "npm";
|
|
1226
1277
|
return "";
|
|
1227
1278
|
}
|
|
1228
|
-
async function scanCodebase() {
|
|
1279
|
+
async function scanCodebase(persist = true) {
|
|
1229
1280
|
const root = ctx.cwd;
|
|
1230
1281
|
const rootFiles = [];
|
|
1231
1282
|
const directories = [];
|
|
@@ -1307,7 +1358,8 @@ export default function planPiExtension(pi) {
|
|
|
1307
1358
|
tree,
|
|
1308
1359
|
ambient,
|
|
1309
1360
|
};
|
|
1310
|
-
|
|
1361
|
+
if (persist)
|
|
1362
|
+
await st.saveCodebaseProfile(profile);
|
|
1311
1363
|
return profile;
|
|
1312
1364
|
}
|
|
1313
1365
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1319,8 +1371,8 @@ export default function planPiExtension(pi) {
|
|
|
1319
1371
|
return;
|
|
1320
1372
|
}
|
|
1321
1373
|
if (b === "discuss") {
|
|
1322
|
-
const project = await ensureProjectLanguagePreferences(st);
|
|
1323
|
-
const profile = await scanCodebase();
|
|
1374
|
+
const project = await ensureProjectLanguagePreferences(st, false);
|
|
1375
|
+
const profile = await scanCodebase(false);
|
|
1324
1376
|
ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
|
|
1325
1377
|
ctx.ui.notify("Opening text editors for the initial project profile. These will serve as the foundation for the agent's deep discovery process.", "info");
|
|
1326
1378
|
const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
|
|
@@ -1347,6 +1399,7 @@ export default function planPiExtension(pi) {
|
|
|
1347
1399
|
: project.acceptedDecisions,
|
|
1348
1400
|
};
|
|
1349
1401
|
await st.saveProject(draftProject);
|
|
1402
|
+
await st.saveCodebaseProfile(profile);
|
|
1350
1403
|
await st.writeGenerated();
|
|
1351
1404
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
1352
1405
|
if (capturedPi) {
|
|
@@ -1638,6 +1691,10 @@ export default function planPiExtension(pi) {
|
|
|
1638
1691
|
ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
|
|
1639
1692
|
return;
|
|
1640
1693
|
}
|
|
1694
|
+
// This belongs to the creation transaction: Escape before the phase
|
|
1695
|
+
// is persisted cancels the whole command rather than creating a phase
|
|
1696
|
+
// and only then abandoning the follow-up question.
|
|
1697
|
+
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1641
1698
|
let phase;
|
|
1642
1699
|
await withFeatureLock(feature.id, async () => {
|
|
1643
1700
|
const phases = await st.loadAllPhases();
|
|
@@ -1671,7 +1728,6 @@ export default function planPiExtension(pi) {
|
|
|
1671
1728
|
if (!phase)
|
|
1672
1729
|
return;
|
|
1673
1730
|
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
1674
|
-
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1675
1731
|
if (isYes(startDiscuss)) {
|
|
1676
1732
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
1677
1733
|
}
|
|
@@ -1680,7 +1736,7 @@ export default function planPiExtension(pi) {
|
|
|
1680
1736
|
if (b === "discuss") {
|
|
1681
1737
|
let phase = null;
|
|
1682
1738
|
if (subArgs.trim()) {
|
|
1683
|
-
phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1739
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1684
1740
|
}
|
|
1685
1741
|
if (!phase) {
|
|
1686
1742
|
phase = await pickPhase();
|
|
@@ -1692,9 +1748,6 @@ export default function planPiExtension(pi) {
|
|
|
1692
1748
|
if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
|
|
1693
1749
|
ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
|
|
1694
1750
|
}
|
|
1695
|
-
phase.status = "discovery";
|
|
1696
|
-
phase.updatedAt = nowISO();
|
|
1697
|
-
await st.savePhase(phase);
|
|
1698
1751
|
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1699
1752
|
// ── Recap: give full context before planning tasks ──
|
|
1700
1753
|
const recapPlan = await st.loadAll();
|
|
@@ -1766,7 +1819,7 @@ export default function planPiExtension(pi) {
|
|
|
1766
1819
|
if (b === "show") {
|
|
1767
1820
|
let phase = null;
|
|
1768
1821
|
if (subArgs.trim()) {
|
|
1769
|
-
phase = await st.
|
|
1822
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1770
1823
|
}
|
|
1771
1824
|
if (!phase) {
|
|
1772
1825
|
phase = await pickPhase();
|
|
@@ -1792,7 +1845,7 @@ export default function planPiExtension(pi) {
|
|
|
1792
1845
|
if (b === "delete") {
|
|
1793
1846
|
let phase = null;
|
|
1794
1847
|
if (subArgs.trim()) {
|
|
1795
|
-
phase = await st.
|
|
1848
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1796
1849
|
}
|
|
1797
1850
|
if (!phase) {
|
|
1798
1851
|
phase = await pickPhase();
|
|
@@ -1814,7 +1867,7 @@ export default function planPiExtension(pi) {
|
|
|
1814
1867
|
if (b === "update") {
|
|
1815
1868
|
let phase = null;
|
|
1816
1869
|
if (subArgs.trim()) {
|
|
1817
|
-
phase = await st.
|
|
1870
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1818
1871
|
}
|
|
1819
1872
|
if (!phase) {
|
|
1820
1873
|
phase = await pickPhase();
|
|
@@ -1885,6 +1938,9 @@ export default function planPiExtension(pi) {
|
|
|
1885
1938
|
ctx.ui.notify("Aborted", "warning");
|
|
1886
1939
|
return;
|
|
1887
1940
|
}
|
|
1941
|
+
// Keep the optional follow-up inside the creation transaction so
|
|
1942
|
+
// Escape can still cancel before a task is persisted.
|
|
1943
|
+
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1888
1944
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1889
1945
|
const taskNum = await st.allocTaskNumber();
|
|
1890
1946
|
const taskId = createTaskId();
|
|
@@ -1910,7 +1966,6 @@ export default function planPiExtension(pi) {
|
|
|
1910
1966
|
await st.savePhase(phase);
|
|
1911
1967
|
await st.writeGenerated();
|
|
1912
1968
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
1913
|
-
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1914
1969
|
if (isYes(startDiscuss)) {
|
|
1915
1970
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
1916
1971
|
}
|
|
@@ -1993,7 +2048,7 @@ export default function planPiExtension(pi) {
|
|
|
1993
2048
|
if (b === "delete") {
|
|
1994
2049
|
let phase = null;
|
|
1995
2050
|
if (subArgs.trim()) {
|
|
1996
|
-
phase = await st.
|
|
2051
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1997
2052
|
}
|
|
1998
2053
|
if (!phase) {
|
|
1999
2054
|
phase = await pickPhase();
|
|
@@ -2027,7 +2082,7 @@ export default function planPiExtension(pi) {
|
|
|
2027
2082
|
if (b === "update") {
|
|
2028
2083
|
let phase = null;
|
|
2029
2084
|
if (subArgs.trim()) {
|
|
2030
|
-
phase = await st.
|
|
2085
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
2031
2086
|
}
|
|
2032
2087
|
if (!phase) {
|
|
2033
2088
|
phase = await pickPhase();
|
|
@@ -2069,6 +2124,7 @@ export default function planPiExtension(pi) {
|
|
|
2069
2124
|
task.updatedAt = now;
|
|
2070
2125
|
phase.updatedAt = now;
|
|
2071
2126
|
await st.savePhase(phase);
|
|
2127
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
2072
2128
|
await st.writeGenerated();
|
|
2073
2129
|
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");
|
|
2074
2130
|
return;
|
|
@@ -2101,17 +2157,9 @@ export default function planPiExtension(pi) {
|
|
|
2101
2157
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2102
2158
|
return;
|
|
2103
2159
|
}
|
|
2104
|
-
//
|
|
2105
|
-
//
|
|
2106
|
-
//
|
|
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");
|
|
2114
|
-
}
|
|
2160
|
+
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2161
|
+
// It is archived only when the phase completes (auto) or the user
|
|
2162
|
+
// explicitly clears it.
|
|
2115
2163
|
const now = nowISO();
|
|
2116
2164
|
// Record status change in the incremental statusLog.
|
|
2117
2165
|
const prevStatus = task.status;
|
|
@@ -2128,6 +2176,7 @@ export default function planPiExtension(pi) {
|
|
|
2128
2176
|
task.updatedAt = now;
|
|
2129
2177
|
phase.updatedAt = now;
|
|
2130
2178
|
await st.savePhase(phase);
|
|
2179
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
2131
2180
|
await st.writeGenerated();
|
|
2132
2181
|
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");
|
|
2133
2182
|
return;
|
|
@@ -2182,9 +2231,9 @@ export default function planPiExtension(pi) {
|
|
|
2182
2231
|
applyTaskLifecycleDates(task, "done", now);
|
|
2183
2232
|
task.updatedAt = now;
|
|
2184
2233
|
phase.updatedAt = now;
|
|
2185
|
-
await st.
|
|
2234
|
+
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2186
2235
|
await st.writeGenerated();
|
|
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");
|
|
2236
|
+
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}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
|
|
2188
2237
|
return;
|
|
2189
2238
|
}
|
|
2190
2239
|
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
@@ -2301,6 +2350,11 @@ export default function planPiExtension(pi) {
|
|
|
2301
2350
|
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2302
2351
|
return;
|
|
2303
2352
|
}
|
|
2353
|
+
const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
|
|
2354
|
+
if (!confirmed) {
|
|
2355
|
+
ctx.ui.notify("Handoff write cancelled.", "info");
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2304
2358
|
await st.setPhaseHandoff(r.phase.id, text);
|
|
2305
2359
|
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2306
2360
|
return;
|
|
@@ -2310,24 +2364,17 @@ export default function planPiExtension(pi) {
|
|
|
2310
2364
|
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2311
2365
|
return;
|
|
2312
2366
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
"
|
|
2316
|
-
"
|
|
2317
|
-
"The handoff MUST be a structured markdown document containing at minimum:\n" +
|
|
2367
|
+
await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
|
|
2368
|
+
"If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
|
|
2369
|
+
"Before calling `handoff_write`, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. Only after confirmation call `handoff_write` with that exact phaseRef. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
|
|
2370
|
+
"After confirmation, the handoff MUST contain at minimum:\n" +
|
|
2318
2371
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2319
2372
|
"- `Reason:` why this handoff is being written\n" +
|
|
2320
|
-
"- `## Current focus`:
|
|
2321
|
-
"- `## What was being done`:
|
|
2322
|
-
"- `## How to resume`: explicit
|
|
2323
|
-
"- `## Files touched
|
|
2324
|
-
"
|
|
2325
|
-
"- `## Next steps`: the immediate next steps.\n" +
|
|
2326
|
-
"- `## Recent decisions`: accepted decisions made this session, if any.\n" +
|
|
2327
|
-
"- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
|
|
2328
|
-
"The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
|
|
2329
|
-
(handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
|
|
2330
|
-
"Once written, confirm to me with a one-line summary.");
|
|
2373
|
+
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2374
|
+
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2375
|
+
"- `## How to resume`: explicit ordered steps\n" +
|
|
2376
|
+
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2377
|
+
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
|
|
2331
2378
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2332
2379
|
return;
|
|
2333
2380
|
}
|
|
@@ -2396,7 +2443,7 @@ export default function planPiExtension(pi) {
|
|
|
2396
2443
|
const m = report.migrated;
|
|
2397
2444
|
const dup = report.integrity.duplicatePhaseIds.length;
|
|
2398
2445
|
const dang = report.integrity.danglingPhaseIds.length;
|
|
2399
|
-
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2446
|
+
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}, archived ${report.handoffs.archived} stale handoff(s). Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2400
2447
|
return;
|
|
2401
2448
|
}
|
|
2402
2449
|
if (a === "cleanup-orphans") {
|
|
@@ -2471,6 +2518,10 @@ export default function planPiExtension(pi) {
|
|
|
2471
2518
|
startupResumeSummaryPending = false;
|
|
2472
2519
|
startupResumeSummaryTimer = null;
|
|
2473
2520
|
}, 60000);
|
|
2521
|
+
// Safety timeout must not keep the Node process alive on its own
|
|
2522
|
+
// (relevant for test hosts that load this entrypoint). Inside Pi the
|
|
2523
|
+
// process is long-lived, so unref is a no-op there.
|
|
2524
|
+
startupResumeSummaryTimer.unref?.();
|
|
2474
2525
|
// Build the recap and embed it in the trigger content. The trigger
|
|
2475
2526
|
// message is the channel that reliably reaches the agent on a triggered
|
|
2476
2527
|
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
@@ -2495,7 +2546,7 @@ export default function planPiExtension(pi) {
|
|
|
2495
2546
|
return;
|
|
2496
2547
|
}
|
|
2497
2548
|
if (a === "stop" || a === "disable") {
|
|
2498
|
-
|
|
2549
|
+
disablePlannerSession();
|
|
2499
2550
|
await stopServer().catch(() => { });
|
|
2500
2551
|
try {
|
|
2501
2552
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -2511,6 +2562,11 @@ export default function planPiExtension(pi) {
|
|
|
2511
2562
|
description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
|
|
2512
2563
|
getArgumentCompletions: (prefix) => {
|
|
2513
2564
|
const normalized = prefix.trim().toLowerCase();
|
|
2565
|
+
// Once a complete command path is followed by whitespace, the remaining
|
|
2566
|
+
// text is a free-form human ref/title. Returning the fallback menu here
|
|
2567
|
+
// lets Pi select `init` and replace input such as `feature update F001`.
|
|
2568
|
+
if (hasPlannerCommandArgument(prefix))
|
|
2569
|
+
return null;
|
|
2514
2570
|
const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
2515
2571
|
return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
|
|
2516
2572
|
},
|
|
@@ -2813,6 +2869,7 @@ export default function planPiExtension(pi) {
|
|
|
2813
2869
|
`Repair complete.`,
|
|
2814
2870
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2815
2871
|
`Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
|
|
2872
|
+
`Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
|
|
2816
2873
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2817
2874
|
];
|
|
2818
2875
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2873,8 +2930,10 @@ export default function planPiExtension(pi) {
|
|
|
2873
2930
|
pi.registerTool({
|
|
2874
2931
|
name: "plan_write_handoff",
|
|
2875
2932
|
label: "Plan Write Handoff",
|
|
2876
|
-
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write.
|
|
2933
|
+
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. phaseRef is required and must be the exact phase confirmed with the user; never infer a target from current status or resume pointers. Completed/canceled phases reject new handoffs.",
|
|
2877
2934
|
parameters: Type.Object({
|
|
2935
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
2936
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2878
2937
|
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2879
2938
|
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'." })),
|
|
2880
2939
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
@@ -2888,7 +2947,13 @@ export default function planPiExtension(pi) {
|
|
|
2888
2947
|
const st = await requirePlan(ctx);
|
|
2889
2948
|
if (!st)
|
|
2890
2949
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2950
|
+
const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2951
|
+
if (!resolved.ok)
|
|
2952
|
+
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${resolved.error}` }], details: { deprecated: true, error: resolved.error } };
|
|
2953
|
+
if (!params.confirmed)
|
|
2954
|
+
return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${resolved.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, confirmationRequired: true } };
|
|
2891
2955
|
const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
|
|
2956
|
+
phaseId: resolved.phase.id,
|
|
2892
2957
|
...(params.title?.trim() ? { title: params.title.trim() } : {}),
|
|
2893
2958
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2894
2959
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
@@ -2900,23 +2965,20 @@ export default function planPiExtension(pi) {
|
|
|
2900
2965
|
}
|
|
2901
2966
|
: {}),
|
|
2902
2967
|
});
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2906
|
-
await st.setPhaseHandoff(r.phase.id, markdown);
|
|
2907
|
-
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${r.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2968
|
+
await st.setPhaseHandoff(resolved.phase.id, markdown);
|
|
2969
|
+
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${resolved.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id } };
|
|
2908
2970
|
},
|
|
2909
2971
|
});
|
|
2910
2972
|
pi.registerTool({
|
|
2911
2973
|
name: "plan_delete_handoff",
|
|
2912
2974
|
label: "Plan Delete Handoff",
|
|
2913
|
-
description: "DEPRECATED — clears the entity-scoped handoff of
|
|
2914
|
-
parameters: Type.Object({}),
|
|
2915
|
-
async execute(_id,
|
|
2975
|
+
description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
|
|
2976
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
2977
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2916
2978
|
const st = await requirePlan(ctx);
|
|
2917
2979
|
if (!st)
|
|
2918
2980
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2919
|
-
const r = await resolvePhaseForHandoff(st,
|
|
2981
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2920
2982
|
if (!r.ok)
|
|
2921
2983
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2922
2984
|
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
@@ -2924,11 +2986,8 @@ export default function planPiExtension(pi) {
|
|
|
2924
2986
|
},
|
|
2925
2987
|
});
|
|
2926
2988
|
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2927
|
-
// UUID | title
|
|
2928
|
-
//
|
|
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.
|
|
2989
|
+
// UUID | title. A write target is always explicit: never guess from the
|
|
2990
|
+
// first in-progress phase or a stale resume pointer.
|
|
2932
2991
|
async function resolvePhaseForHandoff(st, ref) {
|
|
2933
2992
|
const phases = await st.loadAllPhases();
|
|
2934
2993
|
const features = (await st.loadFeatures()).features;
|
|
@@ -2936,16 +2995,12 @@ export default function planPiExtension(pi) {
|
|
|
2936
2995
|
if (ref && ref.trim()) {
|
|
2937
2996
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
2938
2997
|
}
|
|
2939
|
-
else {
|
|
2940
|
-
phase = phases.find((p) => p.status === "in-progress")
|
|
2941
|
-
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
2942
|
-
}
|
|
2943
2998
|
if (!phase) {
|
|
2944
2999
|
return {
|
|
2945
3000
|
ok: false,
|
|
2946
3001
|
error: ref && ref.trim()
|
|
2947
3002
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
2948
|
-
: "
|
|
3003
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
2949
3004
|
};
|
|
2950
3005
|
}
|
|
2951
3006
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -2970,8 +3025,8 @@ export default function planPiExtension(pi) {
|
|
|
2970
3025
|
pi.registerTool({
|
|
2971
3026
|
name: "handoff_show",
|
|
2972
3027
|
label: "Handoff Show",
|
|
2973
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title.
|
|
2974
|
-
parameters: Type.Object({ phaseRef: Type.
|
|
3028
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
|
|
3029
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
|
|
2975
3030
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2976
3031
|
const st = await requirePlan(ctx);
|
|
2977
3032
|
if (!st)
|
|
@@ -2988,10 +3043,11 @@ export default function planPiExtension(pi) {
|
|
|
2988
3043
|
pi.registerTool({
|
|
2989
3044
|
name: "handoff_write",
|
|
2990
3045
|
label: "Handoff Write",
|
|
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').
|
|
3046
|
+
description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). Never guess the phase from in-progress status or stale context. Completed/canceled phases reject new handoffs.",
|
|
2992
3047
|
parameters: Type.Object({
|
|
2993
|
-
phaseRef: Type.
|
|
3048
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
2994
3049
|
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'." })),
|
|
3050
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2995
3051
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2996
3052
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2997
3053
|
}),
|
|
@@ -3028,6 +3084,9 @@ export default function planPiExtension(pi) {
|
|
|
3028
3084
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3029
3085
|
if (!r.ok)
|
|
3030
3086
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3087
|
+
if (!params.confirmed) {
|
|
3088
|
+
return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
|
|
3089
|
+
}
|
|
3031
3090
|
await st.setPhaseHandoff(r.phase.id, body);
|
|
3032
3091
|
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3033
3092
|
},
|
|
@@ -3035,8 +3094,8 @@ export default function planPiExtension(pi) {
|
|
|
3035
3094
|
pi.registerTool({
|
|
3036
3095
|
name: "handoff_clear",
|
|
3037
3096
|
label: "Handoff Clear",
|
|
3038
|
-
description: "Clear the entity-scoped handoff of a phase (
|
|
3039
|
-
parameters: Type.Object({ phaseRef: Type.
|
|
3097
|
+
description: "Clear the entity-scoped handoff of a phase (archives it and keeps handoffUpdatedAt as an audit trail). phaseRef is required; never infer a phase automatically.",
|
|
3098
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3040
3099
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3041
3100
|
const st = await requirePlan(ctx);
|
|
3042
3101
|
if (!st)
|
|
@@ -3102,7 +3161,7 @@ export default function planPiExtension(pi) {
|
|
|
3102
3161
|
pi.registerTool({
|
|
3103
3162
|
name: "feature_get",
|
|
3104
3163
|
label: "Feature Get",
|
|
3105
|
-
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3164
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3106
3165
|
parameters: Type.Object({
|
|
3107
3166
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3108
3167
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
@@ -3113,12 +3172,17 @@ export default function planPiExtension(pi) {
|
|
|
3113
3172
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3114
3173
|
const features = (await st.loadFeatures()).features;
|
|
3115
3174
|
const ref = params.featureId.trim();
|
|
3116
|
-
const
|
|
3117
|
-
if (!
|
|
3118
|
-
return { content: [{ type: "text", text:
|
|
3175
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
3176
|
+
if (!resolvedFeature.ok)
|
|
3177
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3178
|
+
const feature = resolvedFeature.feature;
|
|
3119
3179
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3120
|
-
const
|
|
3121
|
-
|
|
3180
|
+
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3181
|
+
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
|
|
3182
|
+
const requirementsBlock = linkedRequirements.length > 0
|
|
3183
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3184
|
+
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3185
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3122
3186
|
},
|
|
3123
3187
|
});
|
|
3124
3188
|
pi.registerTool({
|
|
@@ -3248,6 +3312,27 @@ export default function planPiExtension(pi) {
|
|
|
3248
3312
|
if (!resolvedFeature.ok)
|
|
3249
3313
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3250
3314
|
const featureId = resolvedFeature.feature.id;
|
|
3315
|
+
const mutableFields = [
|
|
3316
|
+
"name",
|
|
3317
|
+
"description",
|
|
3318
|
+
"status",
|
|
3319
|
+
"startDate",
|
|
3320
|
+
"endDate",
|
|
3321
|
+
"workDone",
|
|
3322
|
+
"workRemaining",
|
|
3323
|
+
"priority",
|
|
3324
|
+
"acceptedDecisions",
|
|
3325
|
+
];
|
|
3326
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3327
|
+
if (receivedFields.length === 0) {
|
|
3328
|
+
return {
|
|
3329
|
+
content: [{
|
|
3330
|
+
type: "text",
|
|
3331
|
+
text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
|
|
3332
|
+
}],
|
|
3333
|
+
details: { updated: false, reason: "no-mutable-fields" },
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3251
3336
|
let foundDoc;
|
|
3252
3337
|
try {
|
|
3253
3338
|
foundDoc = await st.updateFeatures((doc) => {
|
|
@@ -3293,7 +3378,13 @@ export default function planPiExtension(pi) {
|
|
|
3293
3378
|
if (!feature)
|
|
3294
3379
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3295
3380
|
await st.writeGenerated();
|
|
3296
|
-
return {
|
|
3381
|
+
return {
|
|
3382
|
+
content: [{
|
|
3383
|
+
type: "text",
|
|
3384
|
+
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
3385
|
+
}],
|
|
3386
|
+
details: { ...feature, updated: true, updatedFields: receivedFields },
|
|
3387
|
+
};
|
|
3297
3388
|
},
|
|
3298
3389
|
});
|
|
3299
3390
|
pi.registerTool({
|
|
@@ -3563,6 +3654,77 @@ export default function planPiExtension(pi) {
|
|
|
3563
3654
|
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3564
3655
|
},
|
|
3565
3656
|
});
|
|
3657
|
+
pi.registerTool({
|
|
3658
|
+
name: "decision_record",
|
|
3659
|
+
label: "Decision Record",
|
|
3660
|
+
description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
|
|
3661
|
+
parameters: Type.Object({
|
|
3662
|
+
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
3663
|
+
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
3664
|
+
title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
|
|
3665
|
+
decision: Type.String({ minLength: 1, description: "The agreed decision" }),
|
|
3666
|
+
rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
|
|
3667
|
+
implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
|
|
3668
|
+
}),
|
|
3669
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3670
|
+
const st = await requirePlan(ctx);
|
|
3671
|
+
if (!st)
|
|
3672
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3673
|
+
const features = (await st.loadFeatures()).features;
|
|
3674
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3675
|
+
if (!resolvedFeature.ok)
|
|
3676
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3677
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
|
|
3678
|
+
if (!resolvedPhase)
|
|
3679
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3680
|
+
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
3681
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
3682
|
+
}
|
|
3683
|
+
const acceptedAt = nowISO();
|
|
3684
|
+
const acceptedDecision = {
|
|
3685
|
+
id: crypto.randomUUID(),
|
|
3686
|
+
title: params.title.trim(),
|
|
3687
|
+
decision: params.decision.trim(),
|
|
3688
|
+
rationale: params.rationale.trim(),
|
|
3689
|
+
implementationNotes: params.implementationNotes.trim(),
|
|
3690
|
+
acceptedAt,
|
|
3691
|
+
};
|
|
3692
|
+
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
3693
|
+
let featureWritten = false;
|
|
3694
|
+
try {
|
|
3695
|
+
await st.updateFeatures((doc) => {
|
|
3696
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3697
|
+
if (!feature)
|
|
3698
|
+
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
3699
|
+
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
3700
|
+
feature.updatedAt = acceptedAt;
|
|
3701
|
+
return doc;
|
|
3702
|
+
});
|
|
3703
|
+
featureWritten = true;
|
|
3704
|
+
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
3705
|
+
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
3706
|
+
phase.updatedAt = acceptedAt;
|
|
3707
|
+
return phase;
|
|
3708
|
+
});
|
|
3709
|
+
}
|
|
3710
|
+
catch (error) {
|
|
3711
|
+
// Best-effort compensation keeps a failed dual-write from reporting
|
|
3712
|
+
// success with a decision persisted only on the feature.
|
|
3713
|
+
if (featureWritten) {
|
|
3714
|
+
await st.updateFeatures((doc) => {
|
|
3715
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3716
|
+
if (feature)
|
|
3717
|
+
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
3718
|
+
return doc;
|
|
3719
|
+
}).catch(() => { });
|
|
3720
|
+
}
|
|
3721
|
+
throw error;
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3724
|
+
await st.writeGenerated();
|
|
3725
|
+
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
3726
|
+
},
|
|
3727
|
+
});
|
|
3566
3728
|
pi.registerTool({
|
|
3567
3729
|
name: "phase_delete",
|
|
3568
3730
|
label: "Phase Delete",
|
|
@@ -3780,6 +3942,19 @@ export default function planPiExtension(pi) {
|
|
|
3780
3942
|
const hostPhase = found.phase;
|
|
3781
3943
|
const now = nowISO();
|
|
3782
3944
|
let updatedTask;
|
|
3945
|
+
// Validate the motivation requirement for restrictive status transitions,
|
|
3946
|
+
// mirroring the MCP adapter and the /planner task update command
|
|
3947
|
+
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
3948
|
+
if (params.status !== undefined &&
|
|
3949
|
+
params.status !== found.task.status &&
|
|
3950
|
+
needsMotivation(found.task.status, params.status)) {
|
|
3951
|
+
if (!params.motivation || !params.motivation.trim()) {
|
|
3952
|
+
return {
|
|
3953
|
+
content: [{ type: "text", text: `Status transition "${found.task.status} → ${params.status}" requires a motivation. Provide the "motivation" parameter with a detailed explanation of why this change is needed.` }],
|
|
3954
|
+
details: {},
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3783
3958
|
try {
|
|
3784
3959
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3785
3960
|
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
@@ -3830,6 +4005,7 @@ export default function planPiExtension(pi) {
|
|
|
3830
4005
|
}
|
|
3831
4006
|
if (!updatedTask)
|
|
3832
4007
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4008
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
3833
4009
|
await st.writeGenerated();
|
|
3834
4010
|
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
|
|
3835
4011
|
},
|
|
@@ -3860,10 +4036,65 @@ export default function planPiExtension(pi) {
|
|
|
3860
4036
|
},
|
|
3861
4037
|
});
|
|
3862
4038
|
// ── task start / complete tools ──────────────────────────────────
|
|
4039
|
+
pi.registerTool({
|
|
4040
|
+
name: "task_recommend",
|
|
4041
|
+
label: "Task Recommend",
|
|
4042
|
+
description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
|
|
4043
|
+
parameters: Type.Object({}),
|
|
4044
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4045
|
+
const st = await requirePlan(ctx);
|
|
4046
|
+
if (!st)
|
|
4047
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4048
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4049
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4050
|
+
if (!result.candidate)
|
|
4051
|
+
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4052
|
+
const { candidate } = result;
|
|
4053
|
+
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
4054
|
+
return {
|
|
4055
|
+
content: [{ type: "text", text: `Recommended (${result.kind}): ${reference} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}` }],
|
|
4056
|
+
details: result,
|
|
4057
|
+
};
|
|
4058
|
+
},
|
|
4059
|
+
});
|
|
4060
|
+
pi.registerTool({
|
|
4061
|
+
name: "task_deviation",
|
|
4062
|
+
label: "Task Deviation",
|
|
4063
|
+
description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task but never starts, pauses, or blocks work; use normal task lifecycle tools for those transitions.",
|
|
4064
|
+
parameters: Type.Object({
|
|
4065
|
+
temporary_task: Type.String({ description: "Temporary task ref" }),
|
|
4066
|
+
resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
|
|
4067
|
+
reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
|
|
4068
|
+
}),
|
|
4069
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4070
|
+
const st = await requirePlan(ctx);
|
|
4071
|
+
if (!st)
|
|
4072
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4073
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4074
|
+
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4075
|
+
if (!temporary)
|
|
4076
|
+
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4077
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4078
|
+
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4079
|
+
if (!resume)
|
|
4080
|
+
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
4081
|
+
if (temporary.task.id === resume.task.id)
|
|
4082
|
+
return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
|
|
4083
|
+
if (temporary.task.status !== "planned")
|
|
4084
|
+
return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
|
|
4085
|
+
const timestamp = nowISO();
|
|
4086
|
+
const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason: params.reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
|
|
4087
|
+
await st.addWorkDeviation(record);
|
|
4088
|
+
await st.writeGenerated();
|
|
4089
|
+
const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
|
|
4090
|
+
const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
|
|
4091
|
+
return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
|
|
4092
|
+
},
|
|
4093
|
+
});
|
|
3863
4094
|
pi.registerTool({
|
|
3864
4095
|
name: "task_start",
|
|
3865
4096
|
label: "Task Start",
|
|
3866
|
-
description: "Set a task to in-progress.
|
|
4097
|
+
description: "Set a task to in-progress. BEFORE calling, read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true). Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Sets startedAt automatically and returns the ordered feature/phase requirements briefing.",
|
|
3867
4098
|
parameters: Type.Object({
|
|
3868
4099
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3869
4100
|
}),
|
|
@@ -3880,6 +4111,20 @@ export default function planPiExtension(pi) {
|
|
|
3880
4111
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3881
4112
|
if (task.status === "done")
|
|
3882
4113
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
4114
|
+
const project = await st.loadProject();
|
|
4115
|
+
const selection = recommendNextTask(features, await st.loadAllPhases(), project.workDeviations);
|
|
4116
|
+
if (!selection.candidate || selection.candidate.task.id !== task.id) {
|
|
4117
|
+
const recommended = selection.candidate ? ` Start ${selection.candidate.task.id} instead, or record a user-approved task_deviation first.` : "";
|
|
4118
|
+
return { content: [{ type: "text", text: `Task start denied: ${selection.reason}.${recommended}` }], details: selection };
|
|
4119
|
+
}
|
|
4120
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4121
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
4122
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
4123
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
4124
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
4125
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
4126
|
+
]);
|
|
4127
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
3883
4128
|
const now = nowISO();
|
|
3884
4129
|
let startedTask;
|
|
3885
4130
|
await st.updatePhase(found.phase.id, (phase) => {
|
|
@@ -3892,11 +4137,13 @@ export default function planPiExtension(pi) {
|
|
|
3892
4137
|
startedTask = t;
|
|
3893
4138
|
return phase;
|
|
3894
4139
|
});
|
|
4140
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
4141
|
+
if (selection.deviation?.temporaryTaskId === task.id && selection.deviation.state === "approved") {
|
|
4142
|
+
await st.setWorkDeviationState(selection.deviation.id, "active", now);
|
|
4143
|
+
}
|
|
3895
4144
|
await st.writeGenerated();
|
|
3896
4145
|
if (!startedTask)
|
|
3897
4146
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3898
|
-
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
3899
|
-
const phaseContext = buildPhaseContextBlock(found.phase, parentFeature);
|
|
3900
4147
|
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
|
|
3901
4148
|
},
|
|
3902
4149
|
});
|
|
@@ -3943,10 +4190,16 @@ export default function planPiExtension(pi) {
|
|
|
3943
4190
|
completedTask = t;
|
|
3944
4191
|
return phase;
|
|
3945
4192
|
});
|
|
4193
|
+
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
4194
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
4195
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
|
|
4196
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4197
|
+
if (completedDeviation)
|
|
4198
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
|
|
3946
4199
|
await st.writeGenerated();
|
|
3947
4200
|
if (!completedTask)
|
|
3948
4201
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3949
|
-
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
|
|
4202
|
+
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
|
|
3950
4203
|
},
|
|
3951
4204
|
});
|
|
3952
4205
|
pi.registerTool({
|
|
@@ -4142,7 +4395,7 @@ export default function planPiExtension(pi) {
|
|
|
4142
4395
|
description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
|
|
4143
4396
|
parameters: Type.Object({}),
|
|
4144
4397
|
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
4145
|
-
|
|
4398
|
+
disablePlannerSession();
|
|
4146
4399
|
await stopServer().catch(() => { });
|
|
4147
4400
|
try {
|
|
4148
4401
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -4313,10 +4566,11 @@ export default function planPiExtension(pi) {
|
|
|
4313
4566
|
"",
|
|
4314
4567
|
"Operational rules:",
|
|
4315
4568
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4316
|
-
"- BEFORE any code edit/write
|
|
4569
|
+
"- BEFORE work: read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true); only then call task_start before any code edit/write. AFTER finishing a task: task_complete.",
|
|
4570
|
+
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4317
4571
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4318
4572
|
"- 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).",
|
|
4573
|
+
"- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
|
|
4320
4574
|
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
4321
4575
|
"",
|
|
4322
4576
|
`Goal: ${project.goal || "(not set)"}`,
|