@agent-plan/pi-adapter 0.2.18 → 0.2.19-next.1
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 +344 -250
- package/package.json +3 -3
- package/web-ui-dist/assets/index-CZ27vQH6.js +211 -0
- package/web-ui-dist/assets/index-Jy8Yy6I4.css +1 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-Tma57h5W.js +0 -191
- package/web-ui-dist/assets/index-U-dx9Eua.css +0 -1
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation } from "@agent-plan/core";
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug } from "@agent-plan/core/naming";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
|
|
13
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
|
|
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";
|
|
@@ -101,10 +101,11 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
101
101
|
{ value: "task delete", label: "task delete", description: "Delete a task" },
|
|
102
102
|
{ value: "task start", label: "task start", description: "Mark a task in-progress" },
|
|
103
103
|
{ value: "task complete", label: "task complete", description: "Mark a task done" },
|
|
104
|
-
{ value: "handoff
|
|
105
|
-
{ value: "handoff
|
|
106
|
-
{ value: "handoff
|
|
107
|
-
{ value: "handoff
|
|
104
|
+
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
105
|
+
{ value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
|
|
106
|
+
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (default: in-progress phase)" },
|
|
107
|
+
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (default: in-progress phase)" },
|
|
108
|
+
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Clear a phase handoff (default: in-progress phase)" },
|
|
108
109
|
{ value: "web start", label: "web start", description: "Start the web UI" },
|
|
109
110
|
{ value: "web stop", label: "web stop", description: "Stop the web UI" },
|
|
110
111
|
{ value: "web status", label: "web status", description: "Show web UI status" },
|
|
@@ -157,11 +158,17 @@ function resetState() {
|
|
|
157
158
|
editedThisTurn = false;
|
|
158
159
|
taskCompleteReminderSaidThisTurn = false;
|
|
159
160
|
}
|
|
160
|
-
async function maybeHealStatuses(st) {
|
|
161
|
+
async function maybeHealStatuses(st, ctx) {
|
|
161
162
|
if (healedStatusRoots.has(st.root))
|
|
162
163
|
return;
|
|
163
|
-
await st.syncStatuses();
|
|
164
|
+
const cleared = await st.syncStatuses();
|
|
164
165
|
healedStatusRoots.add(st.root);
|
|
166
|
+
if (cleared.length > 0 && ctx?.ui?.notify) {
|
|
167
|
+
try {
|
|
168
|
+
ctx.ui.notify(`ℹ️ Handoff auto-cleared (phase completed): ${cleared.join(", ")}`, "info");
|
|
169
|
+
}
|
|
170
|
+
catch { }
|
|
171
|
+
}
|
|
165
172
|
}
|
|
166
173
|
function normalizeLanguagePref(value) {
|
|
167
174
|
return value?.trim() ?? "";
|
|
@@ -220,114 +227,13 @@ function phaseLabel(phase) {
|
|
|
220
227
|
function taskLabel(task) {
|
|
221
228
|
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
222
229
|
}
|
|
223
|
-
async function buildStartupResumeSummary(st) {
|
|
224
|
-
const [plan, resume, handoff] = await Promise.all([
|
|
225
|
-
st.loadAll(),
|
|
226
|
-
st.refreshResume(),
|
|
227
|
-
st.loadHandoff(),
|
|
228
|
-
]);
|
|
229
|
-
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
230
|
-
const orderedPhases = [
|
|
231
|
-
...plan.features.features.flatMap((feature) => {
|
|
232
|
-
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
233
|
-
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
234
|
-
const inferred = plan.phases
|
|
235
|
-
.filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
|
|
236
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
|
|
237
|
-
return [...linked, ...inferred];
|
|
238
|
-
}),
|
|
239
|
-
...plan.phases
|
|
240
|
-
.filter((phase) => !phase.featureId)
|
|
241
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
242
|
-
];
|
|
243
|
-
const allTasks = orderedPhases.flatMap((phase) => [...phase.tasks]
|
|
244
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
245
|
-
.map((task) => ({ phase, task })));
|
|
246
|
-
const totalFeatures = plan.features.features.length;
|
|
247
|
-
const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
|
|
248
|
-
const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
|
|
249
|
-
const totalPhases = plan.phases.length;
|
|
250
|
-
const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
|
|
251
|
-
const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
|
|
252
|
-
const totalTasks = allTasks.length;
|
|
253
|
-
const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
|
|
254
|
-
const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
|
|
255
|
-
const hasActiveWork = activeFeatures > 0 || activePhases > 0 || activeTasks > 0;
|
|
256
|
-
const currentPhase = hasActiveWork
|
|
257
|
-
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
258
|
-
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
259
|
-
?? orderedPhases.find((phase) => phase.status === "in-progress")
|
|
260
|
-
?? null
|
|
261
|
-
: null;
|
|
262
|
-
const currentTask = hasActiveWork
|
|
263
|
-
? ([...(currentPhase?.tasks ?? [])]
|
|
264
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
265
|
-
.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
266
|
-
?? [...(currentPhase?.tasks ?? [])]
|
|
267
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
268
|
-
.find((task) => task.status === "in-progress")
|
|
269
|
-
?? allTasks.find(({ task }) => task.status === "in-progress")?.task
|
|
270
|
-
?? null)
|
|
271
|
-
: null;
|
|
272
|
-
const currentFeature = hasActiveWork
|
|
273
|
-
? (currentPhase?.featureId
|
|
274
|
-
? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
|
|
275
|
-
: plan.features.features.find((feature) => feature.status === "in-progress") ?? null)
|
|
276
|
-
: null;
|
|
277
|
-
const nextActivity = hasActiveWork
|
|
278
|
-
? (resume.nextSteps[0]
|
|
279
|
-
?? (currentTask
|
|
280
|
-
? `riprendere il task ${currentTask.id} — ${taskLabel(currentTask)}`
|
|
281
|
-
: currentPhase
|
|
282
|
-
? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
|
|
283
|
-
: "riprendere il lavoro attivo corrente"))
|
|
284
|
-
: (handoff?.updatedAt
|
|
285
|
-
? "leggere l'handoff e verificare ordine, dipendenze e stato reale del piano prima di scegliere il prossimo task"
|
|
286
|
-
: "rivedere il piano e scegliere il prossimo task concreto");
|
|
287
|
-
const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
|
|
288
|
-
const italian = chatLanguage.includes("ital");
|
|
289
|
-
const localUrl = server?.localUrl ?? server?.url ?? "";
|
|
290
|
-
const lanUrl = server?.lanUrl ?? "";
|
|
291
|
-
const webUrl = lanUrl ? `${localUrl} (LAN: ${lanUrl})` : localUrl;
|
|
292
|
-
if (italian || !chatLanguage) {
|
|
293
|
-
return [
|
|
294
|
-
"### Summary di ripresa planner",
|
|
295
|
-
`- Progresso: ${doneFeatures}/${totalFeatures} feature completate (${activeFeatures} attive), ${donePhases}/${totalPhases} fasi completate (${activePhases} attive), ${doneTasks}/${totalTasks} task completati (${activeTasks} attivi).`,
|
|
296
|
-
currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
|
|
297
|
-
currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
|
|
298
|
-
currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
|
|
299
|
-
handoff?.updatedAt ? `- Handoff letto da .planner/HANDOFF.md (aggiornato ${handoff.updatedAt}).` : "- Nessun handoff strutturato disponibile.",
|
|
300
|
-
!hasActiveWork && handoff?.updatedAt ? "- Nota: l'handoff è un suggerimento della sessione precedente; con 0 task/fasi attivi va validato contro stato e dipendenze correnti prima di riprendere un task specifico." : "",
|
|
301
|
-
`- Prossima attività consigliata: ${nextActivity}.`,
|
|
302
|
-
"",
|
|
303
|
-
"Vuoi che riprendiamo da qui?",
|
|
304
|
-
"",
|
|
305
|
-
webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — avvia con /planner load",
|
|
306
|
-
].filter(Boolean).join("\n");
|
|
307
|
-
}
|
|
308
|
-
return [
|
|
309
|
-
"### Planner resume summary",
|
|
310
|
-
`- Progress: ${doneFeatures}/${totalFeatures} features done (${activeFeatures} active), ${donePhases}/${totalPhases} phases done (${activePhases} active), ${doneTasks}/${totalTasks} tasks done (${activeTasks} active).`,
|
|
311
|
-
currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
|
|
312
|
-
currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
|
|
313
|
-
currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
|
|
314
|
-
handoff?.updatedAt ? `- Handoff loaded from .planner/HANDOFF.md (updated ${handoff.updatedAt}).` : "- No structured handoff found.",
|
|
315
|
-
!hasActiveWork && handoff?.updatedAt ? "- Note: the handoff is only a previous-session hint; with 0 active tasks/phases it must be validated against current planner state and dependencies before resuming a specific task." : "",
|
|
316
|
-
`- Recommended next activity: ${nextActivity}.`,
|
|
317
|
-
"",
|
|
318
|
-
"Do you want to resume from here?",
|
|
319
|
-
"",
|
|
320
|
-
webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — start with /planner load",
|
|
321
|
-
].filter(Boolean).join("\n");
|
|
322
|
-
}
|
|
323
230
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
324
|
-
const [plan, resume, activity
|
|
231
|
+
const [plan, resume, activity] = await Promise.all([
|
|
325
232
|
st.loadAll(),
|
|
326
233
|
st.refreshResume(),
|
|
327
234
|
st.loadActivityLog(),
|
|
328
|
-
st.loadHandoff(),
|
|
329
235
|
]);
|
|
330
|
-
const createdAt =
|
|
236
|
+
const createdAt = nowISO();
|
|
331
237
|
const updatedAt = nowISO();
|
|
332
238
|
const allTasks = plan.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
333
239
|
const nonTerminalTasks = allTasks.filter(({ task }) => task.status !== "done" && task.status !== "canceled");
|
|
@@ -412,7 +318,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
412
318
|
: currentPhase
|
|
413
319
|
? `1. Open phase ${currentPhase.id} (${currentPhase.title}).`
|
|
414
320
|
: "1. Open the planner dashboard and inspect the latest feature/phase state.",
|
|
415
|
-
"2.
|
|
321
|
+
"2. Run handoff show <ref> (or /planner handoff show <ref>) to read the phase handoff and compare it with the latest planner data.",
|
|
416
322
|
currentTask && currentTask.status !== "in-progress"
|
|
417
323
|
? `3. Before implementation work, run /planner task start ${currentTask.id} (or call task_start).`
|
|
418
324
|
: "3. Confirm whether the current task is already in-progress before doing implementation work.",
|
|
@@ -420,11 +326,10 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
420
326
|
].join("\n");
|
|
421
327
|
const filesTouched = [
|
|
422
328
|
".planner/project.json",
|
|
423
|
-
".planner/features
|
|
329
|
+
".planner/features/",
|
|
424
330
|
currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
|
|
425
331
|
latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
|
|
426
332
|
".planner/resume.json",
|
|
427
|
-
".planner/HANDOFF.md",
|
|
428
333
|
".planner/generated/PLAN.md",
|
|
429
334
|
].filter(Boolean);
|
|
430
335
|
const recentActivityLines = recentActivity.length > 0
|
|
@@ -488,12 +393,19 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
488
393
|
...recentActivityLines,
|
|
489
394
|
"",
|
|
490
395
|
"## Reminder",
|
|
491
|
-
"- When work is fully resumed and this handoff is no longer needed,
|
|
396
|
+
"- When work is fully resumed and this handoff is no longer needed, clear the phase handoff with handoff clear <ref> (or /planner handoff clear).",
|
|
492
397
|
].join("\n");
|
|
493
398
|
}
|
|
494
399
|
async function writeProjectHandoff(st, reason, overrides) {
|
|
400
|
+
// Entity-scoped: write the auto-handoff on the current in-progress phase
|
|
401
|
+
// (not the deprecated .planner/HANDOFF.md file). If no in-progress phase,
|
|
402
|
+
// skip silently — the resume flow falls back to resume.json.
|
|
403
|
+
const phases = await st.loadAllPhases();
|
|
404
|
+
const phase = phases.find((p) => p.status === "in-progress") ?? null;
|
|
405
|
+
if (!phase)
|
|
406
|
+
return;
|
|
495
407
|
const markdown = await buildHandoffMarkdown(st, reason, overrides);
|
|
496
|
-
await st.
|
|
408
|
+
await st.setPhaseHandoff(phase.id, markdown);
|
|
497
409
|
}
|
|
498
410
|
function compactShortcut(spec) {
|
|
499
411
|
return {
|
|
@@ -613,13 +525,23 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
613
525
|
await st.saveProject(project);
|
|
614
526
|
return explicitPort;
|
|
615
527
|
}
|
|
616
|
-
if (
|
|
528
|
+
// Reuse the persisted port if it's still free (stable port across restarts,
|
|
529
|
+
// lets subagents notify the server via HTTP).
|
|
530
|
+
if (project.webPort > 0 && (await isPortAvailable(project.webPort))) {
|
|
617
531
|
return project.webPort;
|
|
618
532
|
}
|
|
533
|
+
// Persisted port busy (another planner instance on this project) or first run:
|
|
534
|
+
// scan for a free port. Persist ONLY on first run (canonical); when falling
|
|
535
|
+
// back from a busy persisted port, return transient so we don't hijack the
|
|
536
|
+
// primary session's canonical port (prevents port thrashing).
|
|
619
537
|
for (let port = 3030; port <= 3999; port += 1) {
|
|
538
|
+
if (port === project.webPort)
|
|
539
|
+
continue;
|
|
620
540
|
if (await isPortAvailable(port)) {
|
|
621
|
-
project.webPort
|
|
622
|
-
|
|
541
|
+
if (project.webPort <= 0) {
|
|
542
|
+
project.webPort = port;
|
|
543
|
+
await st.saveProject(project);
|
|
544
|
+
}
|
|
623
545
|
return port;
|
|
624
546
|
}
|
|
625
547
|
}
|
|
@@ -696,7 +618,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
696
618
|
uiConfig: readUiConfig(ctx),
|
|
697
619
|
isBusy: () => planBusy,
|
|
698
620
|
});
|
|
699
|
-
ctx.ui.notify(`Planner web server:
|
|
621
|
+
ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
|
|
700
622
|
}
|
|
701
623
|
catch (e2) {
|
|
702
624
|
ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
|
|
@@ -724,8 +646,13 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
724
646
|
const st = ensureStore(ctx);
|
|
725
647
|
const project = await st.loadProject().catch(() => null);
|
|
726
648
|
if (project) {
|
|
727
|
-
|
|
728
|
-
|
|
649
|
+
// Persist only the canonical port (first run, or realPort matches the
|
|
650
|
+
// existing canonical). Never persist a transient fallback port — that
|
|
651
|
+
// hijacks the primary session's port when two instances share a .planner.
|
|
652
|
+
if (project.webPort <= 0 || project.webPort === realPort) {
|
|
653
|
+
project.webPort = realPort;
|
|
654
|
+
await st.saveProject(project);
|
|
655
|
+
}
|
|
729
656
|
}
|
|
730
657
|
}
|
|
731
658
|
catch { }
|
|
@@ -802,11 +729,11 @@ export default function planPiExtension(pi) {
|
|
|
802
729
|
ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
|
|
803
730
|
}
|
|
804
731
|
plannerSessionEnabled = enablePlanner;
|
|
805
|
-
// Proactive
|
|
732
|
+
// Proactive review hint: surface pending phase handoffs (entity-scoped).
|
|
806
733
|
if (plannerSessionEnabled) {
|
|
807
|
-
const
|
|
808
|
-
if (
|
|
809
|
-
ctx.ui.notify(
|
|
734
|
+
const handoffs = await st.listHandoffs().catch(() => []);
|
|
735
|
+
if (handoffs.length > 0) {
|
|
736
|
+
ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
|
|
810
737
|
}
|
|
811
738
|
}
|
|
812
739
|
// If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
|
|
@@ -929,7 +856,7 @@ export default function planPiExtension(pi) {
|
|
|
929
856
|
const st = loadStore(ctx);
|
|
930
857
|
if (!(await st.exists().catch(() => false)))
|
|
931
858
|
return;
|
|
932
|
-
await maybeHealStatuses(st).catch(() => { });
|
|
859
|
+
await maybeHealStatuses(st, ctx).catch(() => { });
|
|
933
860
|
const guard = await getPlannerExecutionGuard(st).catch(() => null);
|
|
934
861
|
if (!guard || guard.totalTasks === 0)
|
|
935
862
|
return; // nothing to enforce yet
|
|
@@ -1018,7 +945,7 @@ export default function planPiExtension(pi) {
|
|
|
1018
945
|
];
|
|
1019
946
|
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1020
947
|
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
|
|
1021
|
-
"Handoff actions: /planner handoff
|
|
948
|
+
"Handoff actions: /planner handoff list | show [P00x] | write [P00x] | clear [P00x] | prepare";
|
|
1022
949
|
if (!a) {
|
|
1023
950
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
1024
951
|
if (!action) {
|
|
@@ -1101,40 +1028,33 @@ export default function planPiExtension(pi) {
|
|
|
1101
1028
|
// Helper: pick a feature interactively
|
|
1102
1029
|
// ═══════════════════════════════════════════════════════════════
|
|
1103
1030
|
async function pickFeature() {
|
|
1104
|
-
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
|
|
1031
|
+
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
|
|
1032
|
+
.slice()
|
|
1033
|
+
.sort((a, b) => a.number - b.number);
|
|
1105
1034
|
if (features.length === 0)
|
|
1106
1035
|
return null;
|
|
1107
|
-
const
|
|
1108
|
-
ctx.ui.
|
|
1109
|
-
|
|
1110
|
-
if (!pick?.trim())
|
|
1111
|
-
return null;
|
|
1112
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1113
|
-
if (isNaN(idx) || idx < 0 || idx >= features.length) {
|
|
1114
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1036
|
+
const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
|
|
1037
|
+
const chosen = await ctx.ui.select("Pick a feature", options);
|
|
1038
|
+
if (chosen == null)
|
|
1115
1039
|
return null;
|
|
1116
|
-
|
|
1117
|
-
return features[idx];
|
|
1040
|
+
const idx = options.indexOf(chosen);
|
|
1041
|
+
return idx >= 0 ? features[idx] : null;
|
|
1118
1042
|
}
|
|
1119
1043
|
// ═══════════════════════════════════════════════════════════════
|
|
1120
1044
|
// Helper: pick a phase interactively
|
|
1121
1045
|
// ═══════════════════════════════════════════════════════════════
|
|
1122
1046
|
async function pickPhase() {
|
|
1123
|
-
const phases = await st.loadAllPhases().catch(() => []);
|
|
1047
|
+
const phases = (await st.loadAllPhases().catch(() => [])).slice().sort((a, b) => a.number - b.number);
|
|
1124
1048
|
if (phases.length === 0) {
|
|
1125
1049
|
return null;
|
|
1126
1050
|
}
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
if (
|
|
1131
|
-
return null;
|
|
1132
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1133
|
-
if (isNaN(idx) || idx < 0 || idx >= phases.length) {
|
|
1134
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1051
|
+
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1052
|
+
const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
|
|
1053
|
+
const chosen = await ctx.ui.select("Pick a phase", options);
|
|
1054
|
+
if (chosen == null)
|
|
1135
1055
|
return null;
|
|
1136
|
-
|
|
1137
|
-
return phases[idx];
|
|
1056
|
+
const idx = options.indexOf(chosen);
|
|
1057
|
+
return idx >= 0 ? phases[idx] : null;
|
|
1138
1058
|
}
|
|
1139
1059
|
// ═══════════════════════════════════════════════════════════════
|
|
1140
1060
|
// Helper: pick a task interactively from a phase
|
|
@@ -1142,17 +1062,13 @@ export default function planPiExtension(pi) {
|
|
|
1142
1062
|
async function pickTask(phase) {
|
|
1143
1063
|
if (phase.tasks.length === 0)
|
|
1144
1064
|
return null;
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
const
|
|
1148
|
-
if (
|
|
1149
|
-
return null;
|
|
1150
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1151
|
-
if (isNaN(idx) || idx < 0 || idx >= phase.tasks.length) {
|
|
1152
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1065
|
+
const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
|
|
1066
|
+
const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
|
|
1067
|
+
const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
|
|
1068
|
+
if (chosen == null)
|
|
1153
1069
|
return null;
|
|
1154
|
-
|
|
1155
|
-
return
|
|
1070
|
+
const idx = options.indexOf(chosen);
|
|
1071
|
+
return idx >= 0 ? tasks[idx] : null;
|
|
1156
1072
|
}
|
|
1157
1073
|
function parseMultilineList(value) {
|
|
1158
1074
|
return (value ?? "")
|
|
@@ -1171,7 +1087,15 @@ export default function planPiExtension(pi) {
|
|
|
1171
1087
|
const normalized = ref.trim().toLowerCase();
|
|
1172
1088
|
if (!normalized)
|
|
1173
1089
|
return null;
|
|
1174
|
-
|
|
1090
|
+
const fMatch = normalized.match(/^f(\d+)$/);
|
|
1091
|
+
if (fMatch) {
|
|
1092
|
+
const n = parseInt(fMatch[1], 10);
|
|
1093
|
+
const byNum = features.find((f) => f.number === n);
|
|
1094
|
+
if (byNum)
|
|
1095
|
+
return byNum;
|
|
1096
|
+
}
|
|
1097
|
+
return features.find((feature) => feature.shortId && feature.shortId.toLowerCase() === normalized)
|
|
1098
|
+
?? features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
1175
1099
|
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
1176
1100
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1177
1101
|
?? null;
|
|
@@ -1180,7 +1104,15 @@ export default function planPiExtension(pi) {
|
|
|
1180
1104
|
const normalized = ref.trim().toLowerCase();
|
|
1181
1105
|
if (!normalized)
|
|
1182
1106
|
return null;
|
|
1183
|
-
|
|
1107
|
+
const pMatch = normalized.match(/^p(\d+)$/);
|
|
1108
|
+
if (pMatch) {
|
|
1109
|
+
const n = parseInt(pMatch[1], 10);
|
|
1110
|
+
const byNum = phases.find((p) => p.number === n);
|
|
1111
|
+
if (byNum)
|
|
1112
|
+
return byNum;
|
|
1113
|
+
}
|
|
1114
|
+
return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
|
|
1115
|
+
?? phases.find((phase) => phase.id.toLowerCase() === normalized)
|
|
1184
1116
|
?? phases.find((phase) => phase.title.toLowerCase() === normalized)
|
|
1185
1117
|
?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
|
|
1186
1118
|
?? null;
|
|
@@ -1403,9 +1335,13 @@ export default function planPiExtension(pi) {
|
|
|
1403
1335
|
}
|
|
1404
1336
|
const now = nowISO();
|
|
1405
1337
|
const featureNumber = (await st.loadFeatures()).features.length + 1;
|
|
1338
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
1339
|
+
const priority = await st.nextPriority("feature");
|
|
1406
1340
|
const feature = {
|
|
1407
1341
|
id: createFeatureId(),
|
|
1408
1342
|
number: featureNumber,
|
|
1343
|
+
shortId,
|
|
1344
|
+
priority,
|
|
1409
1345
|
name: nameInput.trim(),
|
|
1410
1346
|
description: description?.trim() ?? "",
|
|
1411
1347
|
status: status,
|
|
@@ -1588,11 +1524,14 @@ export default function planPiExtension(pi) {
|
|
|
1588
1524
|
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
1589
1525
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1590
1526
|
const id = createPhaseId();
|
|
1527
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
1528
|
+
const priority = await st.nextPriority("phase", feature.id);
|
|
1591
1529
|
phase = {
|
|
1592
|
-
id, number, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1530
|
+
id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1593
1531
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
1594
1532
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1595
1533
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1534
|
+
handoff: "", handoffUpdatedAt: "",
|
|
1596
1535
|
};
|
|
1597
1536
|
await st.savePhase(phase);
|
|
1598
1537
|
await st.updateFeatures((doc) => {
|
|
@@ -1825,9 +1764,11 @@ export default function planPiExtension(pi) {
|
|
|
1825
1764
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1826
1765
|
const taskNum = phase.tasks.length + 1;
|
|
1827
1766
|
const taskId = createTaskId();
|
|
1767
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
1768
|
+
const priority = await st.nextPriority("task", phase.id);
|
|
1828
1769
|
const now = nowISO();
|
|
1829
1770
|
const task = {
|
|
1830
|
-
id: taskId, phaseId: phase.id, number: taskNum, shortName,
|
|
1771
|
+
id: taskId, phaseId: phase.id, number: taskNum, shortId, priority, shortName,
|
|
1831
1772
|
title: title.trim(), status: "planned",
|
|
1832
1773
|
description: "",
|
|
1833
1774
|
notes: "",
|
|
@@ -1994,8 +1935,8 @@ export default function planPiExtension(pi) {
|
|
|
1994
1935
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
1995
1936
|
return;
|
|
1996
1937
|
}
|
|
1997
|
-
if (normalizedStatus === "in-progress" &&
|
|
1998
|
-
ctx.ui.notify("ℹ️
|
|
1938
|
+
if (normalizedStatus === "in-progress" && (await st.listHandoffs()).length > 0) {
|
|
1939
|
+
ctx.ui.notify("ℹ️ One or more phases have a pending handoff — if relevant to this task, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with the status change.", "info");
|
|
1999
1940
|
}
|
|
2000
1941
|
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2001
1942
|
}
|
|
@@ -2040,8 +1981,8 @@ export default function planPiExtension(pi) {
|
|
|
2040
1981
|
// block task_start — the handoff is a captured-context artifact, not a
|
|
2041
1982
|
// lock (a small modification after writing a handoff should start without
|
|
2042
1983
|
// forcing deletion, which would lose context).
|
|
2043
|
-
if (
|
|
2044
|
-
ctx.ui.notify("ℹ️
|
|
1984
|
+
if ((await st.listHandoffs()).length > 0) {
|
|
1985
|
+
ctx.ui.notify("ℹ️ One or more phases have a pending handoff — if relevant to the task you're starting, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with task start.", "info");
|
|
2045
1986
|
}
|
|
2046
1987
|
const now = nowISO();
|
|
2047
1988
|
// Record status change in the incremental statusLog.
|
|
@@ -2129,60 +2070,80 @@ export default function planPiExtension(pi) {
|
|
|
2129
2070
|
// ── handoff ──
|
|
2130
2071
|
if (a === "handoff") {
|
|
2131
2072
|
const action = b || "show";
|
|
2073
|
+
const phaseRef = parts[2]?.trim();
|
|
2132
2074
|
if (!(await st.exists())) {
|
|
2133
2075
|
ctx.ui.notify("No .planner/ found.", "warning");
|
|
2134
2076
|
return;
|
|
2135
2077
|
}
|
|
2078
|
+
if (action === "list") {
|
|
2079
|
+
const list = await st.listHandoffs();
|
|
2080
|
+
if (list.length === 0) {
|
|
2081
|
+
ctx.ui.notify("No phase handoffs set.", "info");
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
ctx.ui.notify(list.map((e) => `• ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`).join("\n"), "info");
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2136
2087
|
if (action === "show") {
|
|
2137
|
-
const
|
|
2138
|
-
if (!
|
|
2139
|
-
ctx.ui.notify(
|
|
2088
|
+
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2089
|
+
if (!r.ok) {
|
|
2090
|
+
ctx.ui.notify(r.error, "warning");
|
|
2140
2091
|
return;
|
|
2141
2092
|
}
|
|
2142
|
-
|
|
2093
|
+
const content = await st.getPhaseHandoff(r.phase.id);
|
|
2094
|
+
ctx.ui.notify(content.trim() ? `Handoff ${r.compositeRef}:\n${content}` : `No handoff set on ${r.compositeRef}.`, "info");
|
|
2143
2095
|
return;
|
|
2144
2096
|
}
|
|
2145
|
-
if (action === "
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
const handoff = await st.loadHandoff();
|
|
2152
|
-
await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it to `.planner/HANDOFF.md` using the `plan_write_handoff` tool.\n\n" +
|
|
2153
|
-
"The handoff MUST be a structured markdown document containing at minimum:\n" +
|
|
2154
|
-
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2155
|
-
"- `Reason:` why this handoff is being written\n" +
|
|
2156
|
-
"- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
|
|
2157
|
-
"- `## What was being done`: a concrete 3–10 line narrative of the work in progress, based on our conversation and the current task notes/description.\n" +
|
|
2158
|
-
"- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
|
|
2159
|
-
"- `## Files touched`: explicit file paths modified during this session.\n" +
|
|
2160
|
-
"- `## Blockers`: any open blockers, or `None recorded`.\n" +
|
|
2161
|
-
"- `## Next steps`: the immediate next steps.\n" +
|
|
2162
|
-
"- `## Recent decisions`: accepted decisions made this session, if any.\n" +
|
|
2163
|
-
"- `## Reminder`: note that when the work is fully resumed, the agent must delete `.planner/HANDOFF.md` using `plan_delete_handoff`.\n\n" +
|
|
2164
|
-
"Do NOT write the handoff to `.pi/`. The canonical path is `.planner/HANDOFF.md`. " +
|
|
2165
|
-
(handoff?.content ? "A previous handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
|
|
2166
|
-
"Once written, confirm to me with a one-line summary.");
|
|
2167
|
-
ctx.ui.notify("Instructing the agent to prepare .planner/HANDOFF.md…", "info");
|
|
2097
|
+
if (action === "write") {
|
|
2098
|
+
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2099
|
+
if (!r.ok) {
|
|
2100
|
+
ctx.ui.notify(r.error, "warning");
|
|
2168
2101
|
return;
|
|
2169
2102
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
});
|
|
2177
|
-
|
|
2103
|
+
const text = await ctx.ui.input(`Handoff text for ${r.compositeRef}`);
|
|
2104
|
+
if (!text?.trim()) {
|
|
2105
|
+
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
await st.setPhaseHandoff(r.phase.id, text);
|
|
2109
|
+
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (action === "prepare") {
|
|
2113
|
+
if (!capturedPi) {
|
|
2114
|
+
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const handoffs = await st.listHandoffs();
|
|
2118
|
+
await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it on the current in-progress phase using the `handoff_write` tool (entity-scoped, stored on phase.handoff — .planner/HANDOFF.md is deprecated).\n\n" +
|
|
2119
|
+
"The handoff MUST be a structured markdown document containing at minimum:\n" +
|
|
2120
|
+
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2121
|
+
"- `Reason:` why this handoff is being written\n" +
|
|
2122
|
+
"- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
|
|
2123
|
+
"- `## What was being done`: a concrete 3–10 line narrative of the work in progress, based on our conversation and the current task notes/description.\n" +
|
|
2124
|
+
"- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
|
|
2125
|
+
"- `## Files touched`: explicit file paths modified during this session.\n" +
|
|
2126
|
+
"- `## Blockers`: any open blockers, or `None recorded`.\n" +
|
|
2127
|
+
"- `## Next steps`: the immediate next steps.\n" +
|
|
2128
|
+
"- `## Recent decisions`: accepted decisions made this session, if any.\n" +
|
|
2129
|
+
"- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
|
|
2130
|
+
"The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
|
|
2131
|
+
(handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
|
|
2132
|
+
"Once written, confirm to me with a one-line summary.");
|
|
2133
|
+
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2178
2134
|
return;
|
|
2179
2135
|
}
|
|
2180
2136
|
if (action === "clear" || action === "delete") {
|
|
2181
|
-
await st
|
|
2182
|
-
|
|
2137
|
+
const r = await resolvePhaseForHandoff(st, phaseRef);
|
|
2138
|
+
if (!r.ok) {
|
|
2139
|
+
ctx.ui.notify(r.error, "warning");
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
await st.clearPhaseHandoff(r.phase.id);
|
|
2143
|
+
ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
|
|
2183
2144
|
return;
|
|
2184
2145
|
}
|
|
2185
|
-
ctx.ui.notify("handoff actions:
|
|
2146
|
+
ctx.ui.notify("handoff actions: list | show [phaseRef] | write [phaseRef] | clear [phaseRef] | prepare\nUse: /planner handoff list | show | write | clear | prepare", "info");
|
|
2186
2147
|
return;
|
|
2187
2148
|
}
|
|
2188
2149
|
// ── web ──
|
|
@@ -2293,9 +2254,23 @@ export default function planPiExtension(pi) {
|
|
|
2293
2254
|
startupResumeSummaryPending = false;
|
|
2294
2255
|
startupResumeSummaryTimer = null;
|
|
2295
2256
|
}, 60000);
|
|
2257
|
+
// Build the recap and embed it in the trigger content. The trigger
|
|
2258
|
+
// message is the channel that reliably reaches the agent on a triggered
|
|
2259
|
+
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
2260
|
+
// triggerTurn messages), so the recap data must live here. Uses the shared
|
|
2261
|
+
// core buildRecap so Pi and Claude Code/Codex present identical content.
|
|
2262
|
+
let recapText = "";
|
|
2263
|
+
try {
|
|
2264
|
+
const st = ensureStore(ctx);
|
|
2265
|
+
const srv = server;
|
|
2266
|
+
recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2267
|
+
}
|
|
2268
|
+
catch (e) {
|
|
2269
|
+
recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
2270
|
+
}
|
|
2296
2271
|
pi.sendMessage({
|
|
2297
2272
|
customType: "planner-resume-trigger",
|
|
2298
|
-
content: "planner recap",
|
|
2273
|
+
content: "[internal trigger — not a user command] Present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
|
|
2299
2274
|
display: false,
|
|
2300
2275
|
}, {
|
|
2301
2276
|
triggerTurn: true,
|
|
@@ -2338,7 +2313,7 @@ export default function planPiExtension(pi) {
|
|
|
2338
2313
|
const st = loadStore(ctx);
|
|
2339
2314
|
if (!(await st.exists()))
|
|
2340
2315
|
return null;
|
|
2341
|
-
await maybeHealStatuses(st);
|
|
2316
|
+
await maybeHealStatuses(st, ctx);
|
|
2342
2317
|
return st;
|
|
2343
2318
|
}
|
|
2344
2319
|
// ── plan lifecycle ──────────────────────────────────────────────────
|
|
@@ -2631,22 +2606,23 @@ export default function planPiExtension(pi) {
|
|
|
2631
2606
|
pi.registerTool({
|
|
2632
2607
|
name: "plan_get_handoff",
|
|
2633
2608
|
label: "Plan Get Handoff",
|
|
2634
|
-
description: "
|
|
2609
|
+
description: "DEPRECATED — redirects to the entity-scoped handoff (phase.handoff). Lists current phase handoffs. Prefer handoff_list / handoff_show.",
|
|
2635
2610
|
parameters: Type.Object({}),
|
|
2636
2611
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2637
2612
|
const st = await requirePlan(ctx);
|
|
2638
2613
|
if (!st)
|
|
2639
2614
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2640
|
-
const
|
|
2641
|
-
if (
|
|
2642
|
-
return { content: [{ type: "text", text: "
|
|
2643
|
-
|
|
2615
|
+
const list = await st.listHandoffs();
|
|
2616
|
+
if (list.length === 0)
|
|
2617
|
+
return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0 } };
|
|
2618
|
+
const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
|
|
2619
|
+
return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { deprecated: true, count: list.length, handoffs: list } };
|
|
2644
2620
|
},
|
|
2645
2621
|
});
|
|
2646
2622
|
pi.registerTool({
|
|
2647
2623
|
name: "plan_write_handoff",
|
|
2648
2624
|
label: "Plan Write Handoff",
|
|
2649
|
-
description: "
|
|
2625
|
+
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. Write or refresh the canonical phase handoff. Capture full design context, not just operational resume steps: pass extraSections with locked decisions, architecture (with file:line refs), mode/state flows, plugin/API contracts, data mappings, files touched, and known gaps so a resuming agent doesn't re-discover decisions already made.",
|
|
2650
2626
|
parameters: Type.Object({
|
|
2651
2627
|
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2652
2628
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
@@ -2660,7 +2636,7 @@ export default function planPiExtension(pi) {
|
|
|
2660
2636
|
const st = await requirePlan(ctx);
|
|
2661
2637
|
if (!st)
|
|
2662
2638
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2663
|
-
await
|
|
2639
|
+
const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
|
|
2664
2640
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2665
2641
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2666
2642
|
...(params.extraSections && params.extraSections.length > 0
|
|
@@ -2671,21 +2647,125 @@ export default function planPiExtension(pi) {
|
|
|
2671
2647
|
}
|
|
2672
2648
|
: {}),
|
|
2673
2649
|
});
|
|
2674
|
-
const
|
|
2675
|
-
|
|
2650
|
+
const r = await resolvePhaseForHandoff(st, undefined);
|
|
2651
|
+
if (!r.ok)
|
|
2652
|
+
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 } };
|
|
2653
|
+
await st.setPhaseHandoff(r.phase.id, markdown);
|
|
2654
|
+
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 } };
|
|
2676
2655
|
},
|
|
2677
2656
|
});
|
|
2678
2657
|
pi.registerTool({
|
|
2679
2658
|
name: "plan_delete_handoff",
|
|
2680
2659
|
label: "Plan Delete Handoff",
|
|
2681
|
-
description: "
|
|
2660
|
+
description: "DEPRECATED — clears the entity-scoped handoff of the current in-progress phase. Prefer handoff_clear.",
|
|
2682
2661
|
parameters: Type.Object({}),
|
|
2683
2662
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2684
2663
|
const st = await requirePlan(ctx);
|
|
2685
2664
|
if (!st)
|
|
2686
2665
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2687
|
-
await st
|
|
2688
|
-
|
|
2666
|
+
const r = await resolvePhaseForHandoff(st, undefined);
|
|
2667
|
+
if (!r.ok)
|
|
2668
|
+
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2669
|
+
await st.clearPhaseHandoff(r.phase.id);
|
|
2670
|
+
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — cleared handoff on ${r.compositeRef} (phase.handoff). Prefer handoff_clear.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2671
|
+
},
|
|
2672
|
+
});
|
|
2673
|
+
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2674
|
+
// UUID | title; omitted -> current in-progress phase (phase.status, or the
|
|
2675
|
+
// phase containing an in-progress task). Returns the phase + its composite ref.
|
|
2676
|
+
async function resolvePhaseForHandoff(st, ref) {
|
|
2677
|
+
const phases = await st.loadAllPhases();
|
|
2678
|
+
const features = (await st.loadFeatures()).features;
|
|
2679
|
+
let phase;
|
|
2680
|
+
if (ref && ref.trim()) {
|
|
2681
|
+
phase = findPhaseByRef(phases, features, ref.trim());
|
|
2682
|
+
}
|
|
2683
|
+
else {
|
|
2684
|
+
phase = phases.find((p) => p.status === "in-progress")
|
|
2685
|
+
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
2686
|
+
}
|
|
2687
|
+
if (!phase) {
|
|
2688
|
+
return {
|
|
2689
|
+
ok: false,
|
|
2690
|
+
error: ref && ref.trim()
|
|
2691
|
+
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
2692
|
+
: "No in-progress phase. Specify a phaseRef (P00x or P00x(F00x)).",
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
2696
|
+
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
2697
|
+
}
|
|
2698
|
+
pi.registerTool({
|
|
2699
|
+
name: "handoff_list",
|
|
2700
|
+
label: "Handoff List",
|
|
2701
|
+
description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
|
|
2702
|
+
parameters: Type.Object({}),
|
|
2703
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2704
|
+
const st = await requirePlan(ctx);
|
|
2705
|
+
if (!st)
|
|
2706
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2707
|
+
const list = await st.listHandoffs();
|
|
2708
|
+
if (list.length === 0)
|
|
2709
|
+
return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
|
|
2710
|
+
const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
|
|
2711
|
+
return { content: [{ type: "text", text: `Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { count: list.length, handoffs: list } };
|
|
2712
|
+
},
|
|
2713
|
+
});
|
|
2714
|
+
pi.registerTool({
|
|
2715
|
+
name: "handoff_show",
|
|
2716
|
+
label: "Handoff Show",
|
|
2717
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title. Omit to target the current in-progress phase.",
|
|
2718
|
+
parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref: P00x | P00x(F00x) | UUID | title. Default: current in-progress phase." })) }),
|
|
2719
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2720
|
+
const st = await requirePlan(ctx);
|
|
2721
|
+
if (!st)
|
|
2722
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2723
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2724
|
+
if (!r.ok)
|
|
2725
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2726
|
+
const content = await st.getPhaseHandoff(r.phase.id);
|
|
2727
|
+
if (!content.trim())
|
|
2728
|
+
return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, empty: true } };
|
|
2729
|
+
return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2730
|
+
},
|
|
2731
|
+
});
|
|
2732
|
+
pi.registerTool({
|
|
2733
|
+
name: "handoff_write",
|
|
2734
|
+
label: "Handoff Write",
|
|
2735
|
+
description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content). phaseRef optional (default: current in-progress phase). Captures design context for a resuming agent on that phase.",
|
|
2736
|
+
parameters: Type.Object({
|
|
2737
|
+
phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })),
|
|
2738
|
+
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2739
|
+
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2740
|
+
}),
|
|
2741
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2742
|
+
const st = await requirePlan(ctx);
|
|
2743
|
+
if (!st)
|
|
2744
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2745
|
+
const body = (params.markdown_content ?? params.content ?? "").trim();
|
|
2746
|
+
if (!body)
|
|
2747
|
+
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
2748
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2749
|
+
if (!r.ok)
|
|
2750
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2751
|
+
await st.setPhaseHandoff(r.phase.id, body);
|
|
2752
|
+
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2753
|
+
},
|
|
2754
|
+
});
|
|
2755
|
+
pi.registerTool({
|
|
2756
|
+
name: "handoff_clear",
|
|
2757
|
+
label: "Handoff Clear",
|
|
2758
|
+
description: "Clear the entity-scoped handoff of a phase (sets handoff to empty, keeps handoffUpdatedAt as an audit trail). phaseRef optional (default: current in-progress phase).",
|
|
2759
|
+
parameters: Type.Object({ phaseRef: Type.Optional(Type.String({ description: "Phase ref. Default: current in-progress phase." })) }),
|
|
2760
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2761
|
+
const st = await requirePlan(ctx);
|
|
2762
|
+
if (!st)
|
|
2763
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2764
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2765
|
+
if (!r.ok)
|
|
2766
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2767
|
+
await st.clearPhaseHandoff(r.phase.id);
|
|
2768
|
+
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}. (handoffUpdatedAt preserved as audit)` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2689
2769
|
},
|
|
2690
2770
|
});
|
|
2691
2771
|
pi.registerTool({
|
|
@@ -2771,9 +2851,13 @@ export default function planPiExtension(pi) {
|
|
|
2771
2851
|
const now = nowISO();
|
|
2772
2852
|
const status = params.status ?? "planned";
|
|
2773
2853
|
const currentFeatures = (await st.loadFeatures()).features;
|
|
2854
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
2855
|
+
const priority = await st.nextPriority("feature");
|
|
2774
2856
|
const feature = {
|
|
2775
2857
|
id: createFeatureId(),
|
|
2776
2858
|
number: currentFeatures.length + 1,
|
|
2859
|
+
shortId,
|
|
2860
|
+
priority,
|
|
2777
2861
|
name: params.name,
|
|
2778
2862
|
description: params.description ?? "",
|
|
2779
2863
|
status,
|
|
@@ -2813,6 +2897,7 @@ export default function planPiExtension(pi) {
|
|
|
2813
2897
|
endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
2814
2898
|
workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
|
|
2815
2899
|
workRemaining: Type.Optional(Type.String({ description: "Notes on remaining work" })),
|
|
2900
|
+
priority: Type.Optional(Type.Number({ description: "Display order within the project (lower = higher). Tiebreak by number then createdAt." })),
|
|
2816
2901
|
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
2817
2902
|
id: Type.String(),
|
|
2818
2903
|
title: Type.String(),
|
|
@@ -2844,6 +2929,8 @@ export default function planPiExtension(pi) {
|
|
|
2844
2929
|
feature.startDate = params.startDate;
|
|
2845
2930
|
if (params.endDate !== undefined)
|
|
2846
2931
|
feature.endDate = params.endDate;
|
|
2932
|
+
if (params.priority !== undefined)
|
|
2933
|
+
feature.priority = params.priority;
|
|
2847
2934
|
if (params.acceptedDecisions !== undefined)
|
|
2848
2935
|
feature.acceptedDecisions = params.acceptedDecisions;
|
|
2849
2936
|
if (params.status !== undefined) {
|
|
@@ -2970,9 +3057,11 @@ export default function planPiExtension(pi) {
|
|
|
2970
3057
|
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
2971
3058
|
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
2972
3059
|
const id = createPhaseId();
|
|
3060
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
3061
|
+
const priority = await st.nextPriority("phase", params.featureId);
|
|
2973
3062
|
const now = nowISO();
|
|
2974
3063
|
phase = {
|
|
2975
|
-
id, number, slug: normalizeSlug(params.title), title: params.title,
|
|
3064
|
+
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
2976
3065
|
featureId: params.featureId,
|
|
2977
3066
|
status: params.status ?? "draft",
|
|
2978
3067
|
discussedAt: "",
|
|
@@ -2982,6 +3071,7 @@ export default function planPiExtension(pi) {
|
|
|
2982
3071
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
2983
3072
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
2984
3073
|
createdAt: now, updatedAt: now,
|
|
3074
|
+
handoff: "", handoffUpdatedAt: "",
|
|
2985
3075
|
};
|
|
2986
3076
|
await st.savePhase(phase);
|
|
2987
3077
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
@@ -3011,6 +3101,7 @@ export default function planPiExtension(pi) {
|
|
|
3011
3101
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
3012
3102
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
3013
3103
|
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
3104
|
+
priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
|
|
3014
3105
|
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
|
|
3015
3106
|
nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
|
|
3016
3107
|
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
|
|
@@ -3049,6 +3140,8 @@ export default function planPiExtension(pi) {
|
|
|
3049
3140
|
phase.summary = params.summary;
|
|
3050
3141
|
if (params.description !== undefined)
|
|
3051
3142
|
phase.description = params.description;
|
|
3143
|
+
if (params.priority !== undefined)
|
|
3144
|
+
phase.priority = params.priority;
|
|
3052
3145
|
if (params.goals !== undefined)
|
|
3053
3146
|
phase.goals = params.goals;
|
|
3054
3147
|
if (params.nonGoals !== undefined)
|
|
@@ -3185,11 +3278,13 @@ export default function planPiExtension(pi) {
|
|
|
3185
3278
|
}
|
|
3186
3279
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3187
3280
|
const taskId = createTaskId();
|
|
3281
|
+
const shortId = createShortId(await st.assignedShortIds());
|
|
3282
|
+
const priority = await st.nextPriority("task", params.phaseId);
|
|
3188
3283
|
const now = nowISO();
|
|
3189
3284
|
const initialStatus = params.status ?? "planned";
|
|
3190
3285
|
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3191
3286
|
const task = {
|
|
3192
|
-
id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortName,
|
|
3287
|
+
id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortId, priority, shortName,
|
|
3193
3288
|
title: params.title,
|
|
3194
3289
|
status: initialStatus,
|
|
3195
3290
|
description: params.description ?? "",
|
|
@@ -3235,6 +3330,7 @@ export default function planPiExtension(pi) {
|
|
|
3235
3330
|
implementationNotes: Type.String(),
|
|
3236
3331
|
acceptedAt: Type.String(),
|
|
3237
3332
|
}), { description: "Replace accepted decisions list" })),
|
|
3333
|
+
priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
|
|
3238
3334
|
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
3239
3335
|
}),
|
|
3240
3336
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -3255,6 +3351,8 @@ export default function planPiExtension(pi) {
|
|
|
3255
3351
|
return phase;
|
|
3256
3352
|
if (params.title !== undefined)
|
|
3257
3353
|
task.title = params.title;
|
|
3354
|
+
if (params.priority !== undefined)
|
|
3355
|
+
task.priority = params.priority;
|
|
3258
3356
|
if (params.description !== undefined)
|
|
3259
3357
|
task.description = params.description;
|
|
3260
3358
|
if (params.notes !== undefined)
|
|
@@ -3470,16 +3568,15 @@ export default function planPiExtension(pi) {
|
|
|
3470
3568
|
if (!server) {
|
|
3471
3569
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
3472
3570
|
}
|
|
3571
|
+
const srv = server;
|
|
3473
3572
|
let recap = "";
|
|
3474
3573
|
try {
|
|
3475
|
-
recap = await
|
|
3574
|
+
recap = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
3476
3575
|
}
|
|
3477
3576
|
catch (e) {
|
|
3478
3577
|
recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
3479
3578
|
}
|
|
3480
|
-
|
|
3481
|
-
const webLine = srv ? `\n🌐 Web UI: ${srv.localUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""}` : "\n🌐 Web UI: not running";
|
|
3482
|
-
return { content: [{ type: "text", text: `${recap}${webLine}` }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
|
|
3579
|
+
return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
|
|
3483
3580
|
},
|
|
3484
3581
|
});
|
|
3485
3582
|
pi.registerTool({
|
|
@@ -3510,8 +3607,9 @@ export default function planPiExtension(pi) {
|
|
|
3510
3607
|
// add seconds of latency on every turn if run unconditionally.
|
|
3511
3608
|
if (!plannerHeavyInitDone) {
|
|
3512
3609
|
await migrateToUuids(st);
|
|
3610
|
+
await st.ensureShortIdsAndPriority().catch(() => null);
|
|
3513
3611
|
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3514
|
-
await maybeHealStatuses(st);
|
|
3612
|
+
await maybeHealStatuses(st, ctx);
|
|
3515
3613
|
await st.refreshResume();
|
|
3516
3614
|
plannerHeavyInitDone = true;
|
|
3517
3615
|
}
|
|
@@ -3542,7 +3640,7 @@ export default function planPiExtension(pi) {
|
|
|
3542
3640
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3543
3641
|
const activity = await st.loadActivityLog();
|
|
3544
3642
|
const recentActivity = activity.entries.slice(-8).reverse();
|
|
3545
|
-
const
|
|
3643
|
+
const handoffs = await st.listHandoffs();
|
|
3546
3644
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3547
3645
|
const orderedPhases = [
|
|
3548
3646
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3593,8 +3691,8 @@ export default function planPiExtension(pi) {
|
|
|
3593
3691
|
const nextActivity = hasActiveWork
|
|
3594
3692
|
? (resume.nextSteps[0]
|
|
3595
3693
|
?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
|
|
3596
|
-
: (
|
|
3597
|
-
? "Read the handoff,
|
|
3694
|
+
: (handoffs.length > 0
|
|
3695
|
+
? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task."
|
|
3598
3696
|
: "Review the current plan and choose the next task.");
|
|
3599
3697
|
const webServer = server;
|
|
3600
3698
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
@@ -3624,22 +3722,16 @@ export default function planPiExtension(pi) {
|
|
|
3624
3722
|
const ambientSummary = ambient
|
|
3625
3723
|
? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
|
|
3626
3724
|
: "(not scanned)";
|
|
3725
|
+
// NOTE: the recap DATA is delivered via the /planner load trigger message
|
|
3726
|
+
// content (buildRecap), not via this system-prompt protocol — before_agent_start's
|
|
3727
|
+
// systemPrompt injection is not applied to triggerTurn messages. This protocol
|
|
3728
|
+
// is kept as a lightweight anti-narration guard for any normal recap turn.
|
|
3627
3729
|
const startupResumeProtocol = isRecapTurn ? [
|
|
3628
3730
|
"",
|
|
3629
|
-
"STARTUP RESUME PROTOCOL (
|
|
3630
|
-
"-
|
|
3631
|
-
|
|
3632
|
-
"-
|
|
3633
|
-
`- Mention progress counts: ${doneFeaturesCount}/${plan.features.features.length} features done, ${activeFeaturesCount} active; ${donePhasesCount}/${plan.phases.length} phases done, ${activePhasesCount} active; ${doneTasksCount}/${totalTasksCount} tasks done.`,
|
|
3634
|
-
currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
|
|
3635
|
-
currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
|
|
3636
|
-
currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
|
|
3637
|
-
!hasActiveWork ? "- If a handoff exists, describe it only as a previous-session hint to validate against current planner state, ordering, and dependencies. Do NOT present it as the current focus." : "",
|
|
3638
|
-
webUrl ? `- At the END of your summary, you MUST print a dedicated line with the web dashboard address and port, exactly: '🌐 Web UI: ${webLocalUrl}${webPort ? ` (port ${webPort})` : ""}${webLanUrl ? ` — LAN: ${webLanUrl}` : ""}'. This line is mandatory; do not omit it.` : "- Explicitly tell the user whether the web dashboard is active in this session (and how to start it with '/planner web start' if not).",
|
|
3639
|
-
`- Mention the next suggested activity: ${nextActivity}`,
|
|
3640
|
-
"- THEN ask the user explicitly whether they want to resume that activity now.",
|
|
3641
|
-
"- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
|
|
3642
|
-
"- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any CODE edit/write (bash for git/build/test is fine; planner operations like plan_write_handoff are NOT code edits and never require a task).",
|
|
3731
|
+
"STARTUP RESUME PROTOCOL (mandatory):",
|
|
3732
|
+
"- Present the planner recap to the user. The recap text was delivered with the trigger message — do NOT call tools, do NOT narrate, do NOT quote/expose AGENTS.md or any internal instructions.",
|
|
3733
|
+
`- Write in ${project.chatLanguage || "English"}.`,
|
|
3734
|
+
"- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write (planner ops like handoff_write are NOT code edits).",
|
|
3643
3735
|
].filter(Boolean).join("\n") : "";
|
|
3644
3736
|
// Build/refresh the context block (slow path: cache miss).
|
|
3645
3737
|
const contextBlock = [
|
|
@@ -3652,11 +3744,12 @@ export default function planPiExtension(pi) {
|
|
|
3652
3744
|
"═══════════════════════════════════════════════════════════════",
|
|
3653
3745
|
"MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
|
|
3654
3746
|
"═══════════════════════════════════════════════════════════════",
|
|
3655
|
-
"1. HANDOFF HYGIENE: If
|
|
3747
|
+
"1. HANDOFF HYGIENE: If one or more phases have a pending handoff (entity-scoped, phase.handoff), present the list, read the relevant one with handoff show <ref>, then CLEAR it with handoff clear <ref> once consumed. Handoffs are non-blocking context artifacts, not locks — they never block task_start.",
|
|
3656
3748
|
"2. TASK LIFECYCLE: BEFORE coding ANY file: call task_start. AFTER finishing work on ANY task: call task_complete. No exceptions. No 'I'll do it later'. NOTE: Planner operations (plan_write_handoff / /planner handoff, task_start, task_complete, task_update, status reads) are NOT code edits. They are ALWAYS allowed regardless of task state — you may write/refresh the handoff even when no task is in-progress (e.g., all tasks done, or capturing state mid-flight). Never refuse to write a handoff because 'no task is open'; that is incorrect.",
|
|
3657
3749
|
"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.",
|
|
3658
3750
|
"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.",
|
|
3659
3751
|
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3752
|
+
"6. ENTITY REFERENCES: When you mention a feature/phase/task in chat, use its composite ID (F00x / P00x / T00x, e.g. T003(P002/F001)) or its 5-char shortId (e.g. UUXD1) — NEVER a raw UUID like bd6ed366. Tool responses already show these refs (compositeRef, shortId); reuse them.",
|
|
3660
3753
|
"═══════════════════════════════════════════════════════════════",
|
|
3661
3754
|
"",
|
|
3662
3755
|
// ── Project details ──
|
|
@@ -3727,11 +3820,12 @@ export default function planPiExtension(pi) {
|
|
|
3727
3820
|
"- 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.",
|
|
3728
3821
|
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
3729
3822
|
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3823
|
+
handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
|
|
3824
|
+
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3825
|
+
handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
|
|
3826
|
+
handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
|
|
3827
|
+
handoffs.length > 0 ? "Once a handoff is fully consumed and work is safely resumed, clear it with handoff clear <ref> (or /planner handoff clear)." : "",
|
|
3828
|
+
"Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
|
|
3735
3829
|
].filter(Boolean).join("\n");
|
|
3736
3830
|
contextBlockCache = contextBlock;
|
|
3737
3831
|
contextBlockDirty = false;
|