@agent-plan/pi-adapter 0.2.19-next.0 → 0.2.19-next.10
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 +285 -290
- package/package.json +3 -3
- package/web-ui-dist/assets/index--nGmEtA7.js +216 -0
- package/web-ui-dist/assets/index-Bfahk4d3.css +1 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-DmQC9Ttj.css +0 -1
- package/web-ui-dist/assets/index-O9MpLyc3.js +0 -211
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef } from "@agent-plan/core";
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, 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";
|
|
@@ -68,6 +68,29 @@ setWriteNotifyHook(() => {
|
|
|
68
68
|
let previousContextPercent = 0;
|
|
69
69
|
let autoHandoffTriggered = false;
|
|
70
70
|
let plannerSessionEnabled = false;
|
|
71
|
+
// In-process guard bypass (per session, NOT persisted/shared). The edit/write
|
|
72
|
+
// guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
|
|
73
|
+
// bypass is active the warning is silenced for the window. Lives in memory only:
|
|
74
|
+
// not on disk, not in git, not shared across agents/sessions on the same folder.
|
|
75
|
+
let guardBypassUntil = "";
|
|
76
|
+
function authorizeGuardBypass(durationMinutes = 15) {
|
|
77
|
+
const until = new Date(Date.now() + durationMinutes * 60_000).toISOString();
|
|
78
|
+
guardBypassUntil = until;
|
|
79
|
+
return until;
|
|
80
|
+
}
|
|
81
|
+
function clearGuardBypass() { guardBypassUntil = ""; }
|
|
82
|
+
function isGuardBypassed() {
|
|
83
|
+
if (!guardBypassUntil)
|
|
84
|
+
return false;
|
|
85
|
+
const until = Date.parse(guardBypassUntil);
|
|
86
|
+
if (!Number.isFinite(until))
|
|
87
|
+
return false;
|
|
88
|
+
if (until <= Date.now()) {
|
|
89
|
+
guardBypassUntil = "";
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
71
94
|
let startupResumePromptPending = false;
|
|
72
95
|
let startupResumeSummaryPending = false;
|
|
73
96
|
let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
|
|
@@ -227,106 +250,6 @@ function phaseLabel(phase) {
|
|
|
227
250
|
function taskLabel(task) {
|
|
228
251
|
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
229
252
|
}
|
|
230
|
-
async function buildStartupResumeSummary(st) {
|
|
231
|
-
const [plan, resume, handoffs] = await Promise.all([
|
|
232
|
-
st.loadAll(),
|
|
233
|
-
st.refreshResume(),
|
|
234
|
-
st.listHandoffs(),
|
|
235
|
-
]);
|
|
236
|
-
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
237
|
-
const orderedPhases = [
|
|
238
|
-
...plan.features.features.flatMap((feature) => {
|
|
239
|
-
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
240
|
-
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
241
|
-
const inferred = plan.phases
|
|
242
|
-
.filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
|
|
243
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
|
|
244
|
-
return [...linked, ...inferred];
|
|
245
|
-
}),
|
|
246
|
-
...plan.phases
|
|
247
|
-
.filter((phase) => !phase.featureId)
|
|
248
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
249
|
-
];
|
|
250
|
-
const allTasks = orderedPhases.flatMap((phase) => [...phase.tasks]
|
|
251
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
252
|
-
.map((task) => ({ phase, task })));
|
|
253
|
-
const totalFeatures = plan.features.features.length;
|
|
254
|
-
const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
|
|
255
|
-
const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
|
|
256
|
-
const totalPhases = plan.phases.length;
|
|
257
|
-
const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
|
|
258
|
-
const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
|
|
259
|
-
const totalTasks = allTasks.length;
|
|
260
|
-
const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
|
|
261
|
-
const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
|
|
262
|
-
const hasActiveWork = activeFeatures > 0 || activePhases > 0 || activeTasks > 0;
|
|
263
|
-
const currentPhase = hasActiveWork
|
|
264
|
-
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
265
|
-
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
266
|
-
?? orderedPhases.find((phase) => phase.status === "in-progress")
|
|
267
|
-
?? null
|
|
268
|
-
: null;
|
|
269
|
-
const currentTask = hasActiveWork
|
|
270
|
-
? ([...(currentPhase?.tasks ?? [])]
|
|
271
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
272
|
-
.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
273
|
-
?? [...(currentPhase?.tasks ?? [])]
|
|
274
|
-
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
275
|
-
.find((task) => task.status === "in-progress")
|
|
276
|
-
?? allTasks.find(({ task }) => task.status === "in-progress")?.task
|
|
277
|
-
?? null)
|
|
278
|
-
: null;
|
|
279
|
-
const currentFeature = hasActiveWork
|
|
280
|
-
? (currentPhase?.featureId
|
|
281
|
-
? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
|
|
282
|
-
: plan.features.features.find((feature) => feature.status === "in-progress") ?? null)
|
|
283
|
-
: null;
|
|
284
|
-
const nextActivity = hasActiveWork
|
|
285
|
-
? (resume.nextSteps[0]
|
|
286
|
-
?? (currentTask
|
|
287
|
-
? `riprendere il task ${currentTask.id} — ${taskLabel(currentTask)}`
|
|
288
|
-
: currentPhase
|
|
289
|
-
? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
|
|
290
|
-
: "riprendere il lavoro attivo corrente"))
|
|
291
|
-
: (handoffs.length > 0
|
|
292
|
-
? "leggere l'handoff di fase più recente (handoff show <ref>) e verificarne ordine e stato rispetto al piano prima di scegliere il prossimo task"
|
|
293
|
-
: "rivedere il piano e scegliere il prossimo task concreto");
|
|
294
|
-
const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
|
|
295
|
-
const italian = chatLanguage.includes("ital");
|
|
296
|
-
const localUrl = server?.localUrl ?? server?.url ?? "";
|
|
297
|
-
const lanUrl = server?.lanUrl ?? "";
|
|
298
|
-
const webUrl = lanUrl ? `${localUrl} (LAN: ${lanUrl})` : localUrl;
|
|
299
|
-
if (italian || !chatLanguage) {
|
|
300
|
-
return [
|
|
301
|
-
"### Summary di ripresa planner",
|
|
302
|
-
`- Progresso: ${doneFeatures}/${totalFeatures} feature completate (${activeFeatures} attive), ${donePhases}/${totalPhases} fasi completate (${activePhases} attive), ${doneTasks}/${totalTasks} task completati (${activeTasks} attivi).`,
|
|
303
|
-
currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
|
|
304
|
-
currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
|
|
305
|
-
currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
|
|
306
|
-
handoffs.length > 0 ? `- Handoff di fase pendenti (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- Nessun handoff di fase pendente.",
|
|
307
|
-
!hasActiveWork && handoffs.length > 0 ? "- Nota: l'handoff di fase è un suggerimento della sessione precedente; con 0 task/fasi attivi va validato contro stato e dipendenze correnti prima di riprendere un task specifico." : "",
|
|
308
|
-
`- Prossima attività consigliata: ${nextActivity}.`,
|
|
309
|
-
"",
|
|
310
|
-
"Vuoi che riprendiamo da qui?",
|
|
311
|
-
"",
|
|
312
|
-
webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — avvia con /planner load",
|
|
313
|
-
].filter(Boolean).join("\n");
|
|
314
|
-
}
|
|
315
|
-
return [
|
|
316
|
-
"### Planner resume summary",
|
|
317
|
-
`- Progress: ${doneFeatures}/${totalFeatures} features done (${activeFeatures} active), ${donePhases}/${totalPhases} phases done (${activePhases} active), ${doneTasks}/${totalTasks} tasks done (${activeTasks} active).`,
|
|
318
|
-
currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
|
|
319
|
-
currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
|
|
320
|
-
currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
|
|
321
|
-
handoffs.length > 0 ? `- Pending phase handoffs (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- No pending phase handoffs.",
|
|
322
|
-
!hasActiveWork && handoffs.length > 0 ? "- Note: the phase 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." : "",
|
|
323
|
-
`- Recommended next activity: ${nextActivity}.`,
|
|
324
|
-
"",
|
|
325
|
-
"Do you want to resume from here?",
|
|
326
|
-
"",
|
|
327
|
-
webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — start with /planner load",
|
|
328
|
-
].filter(Boolean).join("\n");
|
|
329
|
-
}
|
|
330
253
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
331
254
|
const [plan, resume, activity] = await Promise.all([
|
|
332
255
|
st.loadAll(),
|
|
@@ -493,7 +416,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
493
416
|
...recentActivityLines,
|
|
494
417
|
"",
|
|
495
418
|
"## Reminder",
|
|
496
|
-
"-
|
|
419
|
+
"- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/handoff-archive/) or the phase completes. No need to clear it manually.",
|
|
497
420
|
].join("\n");
|
|
498
421
|
}
|
|
499
422
|
async function writeProjectHandoff(st, reason, overrides) {
|
|
@@ -589,6 +512,9 @@ function statusIcon(status) {
|
|
|
589
512
|
};
|
|
590
513
|
return icons[status] ?? "❓";
|
|
591
514
|
}
|
|
515
|
+
function pad(n) {
|
|
516
|
+
return String(n).padStart(3, "0");
|
|
517
|
+
}
|
|
592
518
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
593
519
|
const previousStatus = task.status;
|
|
594
520
|
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
@@ -625,13 +551,23 @@ async function pickProjectPort(ctx, explicitPort) {
|
|
|
625
551
|
await st.saveProject(project);
|
|
626
552
|
return explicitPort;
|
|
627
553
|
}
|
|
628
|
-
if (
|
|
554
|
+
// Reuse the persisted port if it's still free (stable port across restarts,
|
|
555
|
+
// lets subagents notify the server via HTTP).
|
|
556
|
+
if (project.webPort > 0 && (await isPortAvailable(project.webPort))) {
|
|
629
557
|
return project.webPort;
|
|
630
558
|
}
|
|
559
|
+
// Persisted port busy (another planner instance on this project) or first run:
|
|
560
|
+
// scan for a free port. Persist ONLY on first run (canonical); when falling
|
|
561
|
+
// back from a busy persisted port, return transient so we don't hijack the
|
|
562
|
+
// primary session's canonical port (prevents port thrashing).
|
|
631
563
|
for (let port = 3030; port <= 3999; port += 1) {
|
|
564
|
+
if (port === project.webPort)
|
|
565
|
+
continue;
|
|
632
566
|
if (await isPortAvailable(port)) {
|
|
633
|
-
project.webPort
|
|
634
|
-
|
|
567
|
+
if (project.webPort <= 0) {
|
|
568
|
+
project.webPort = port;
|
|
569
|
+
await st.saveProject(project);
|
|
570
|
+
}
|
|
635
571
|
return port;
|
|
636
572
|
}
|
|
637
573
|
}
|
|
@@ -708,7 +644,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
708
644
|
uiConfig: readUiConfig(ctx),
|
|
709
645
|
isBusy: () => planBusy,
|
|
710
646
|
});
|
|
711
|
-
ctx.ui.notify(`Planner web server:
|
|
647
|
+
ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
|
|
712
648
|
}
|
|
713
649
|
catch (e2) {
|
|
714
650
|
ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
|
|
@@ -736,8 +672,13 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
736
672
|
const st = ensureStore(ctx);
|
|
737
673
|
const project = await st.loadProject().catch(() => null);
|
|
738
674
|
if (project) {
|
|
739
|
-
|
|
740
|
-
|
|
675
|
+
// Persist only the canonical port (first run, or realPort matches the
|
|
676
|
+
// existing canonical). Never persist a transient fallback port — that
|
|
677
|
+
// hijacks the primary session's port when two instances share a .planner.
|
|
678
|
+
if (project.webPort <= 0 || project.webPort === realPort) {
|
|
679
|
+
project.webPort = realPort;
|
|
680
|
+
await st.saveProject(project);
|
|
681
|
+
}
|
|
741
682
|
}
|
|
742
683
|
}
|
|
743
684
|
catch { }
|
|
@@ -819,6 +760,10 @@ export default function planPiExtension(pi) {
|
|
|
819
760
|
const handoffs = await st.listHandoffs().catch(() => []);
|
|
820
761
|
if (handoffs.length > 0) {
|
|
821
762
|
ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
|
|
763
|
+
// Mark each handoff as read (recap presented). Do NOT clear — content is
|
|
764
|
+
// kept until a task in the phase starts (auto-archived) or phase completes.
|
|
765
|
+
for (const h of handoffs)
|
|
766
|
+
await st.markHandoffRead(h.phaseId).catch(() => { });
|
|
822
767
|
}
|
|
823
768
|
}
|
|
824
769
|
// If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
|
|
@@ -947,6 +892,8 @@ export default function planPiExtension(pi) {
|
|
|
947
892
|
return; // nothing to enforce yet
|
|
948
893
|
if (guard.inProgressTaskIds.length > 0)
|
|
949
894
|
return; // a task is open → we're good
|
|
895
|
+
if (isGuardBypassed())
|
|
896
|
+
return; // user authorized proceeding without a task
|
|
950
897
|
const focusHint = guard.focusTaskId
|
|
951
898
|
? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
|
|
952
899
|
: `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
|
|
@@ -1113,40 +1060,33 @@ export default function planPiExtension(pi) {
|
|
|
1113
1060
|
// Helper: pick a feature interactively
|
|
1114
1061
|
// ═══════════════════════════════════════════════════════════════
|
|
1115
1062
|
async function pickFeature() {
|
|
1116
|
-
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
|
|
1063
|
+
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
|
|
1064
|
+
.slice()
|
|
1065
|
+
.sort((a, b) => a.number - b.number);
|
|
1117
1066
|
if (features.length === 0)
|
|
1118
1067
|
return null;
|
|
1119
|
-
const
|
|
1120
|
-
ctx.ui.
|
|
1121
|
-
|
|
1122
|
-
if (!pick?.trim())
|
|
1123
|
-
return null;
|
|
1124
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1125
|
-
if (isNaN(idx) || idx < 0 || idx >= features.length) {
|
|
1126
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1068
|
+
const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
|
|
1069
|
+
const chosen = await ctx.ui.select("Pick a feature", options);
|
|
1070
|
+
if (chosen == null)
|
|
1127
1071
|
return null;
|
|
1128
|
-
|
|
1129
|
-
return features[idx];
|
|
1072
|
+
const idx = options.indexOf(chosen);
|
|
1073
|
+
return idx >= 0 ? features[idx] : null;
|
|
1130
1074
|
}
|
|
1131
1075
|
// ═══════════════════════════════════════════════════════════════
|
|
1132
1076
|
// Helper: pick a phase interactively
|
|
1133
1077
|
// ═══════════════════════════════════════════════════════════════
|
|
1134
1078
|
async function pickPhase() {
|
|
1135
|
-
const phases = await st.loadAllPhases().catch(() => []);
|
|
1079
|
+
const phases = (await st.loadAllPhases().catch(() => [])).slice().sort((a, b) => a.number - b.number);
|
|
1136
1080
|
if (phases.length === 0) {
|
|
1137
1081
|
return null;
|
|
1138
1082
|
}
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
const
|
|
1142
|
-
if (
|
|
1143
|
-
return null;
|
|
1144
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1145
|
-
if (isNaN(idx) || idx < 0 || idx >= phases.length) {
|
|
1146
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1083
|
+
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1084
|
+
const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
|
|
1085
|
+
const chosen = await ctx.ui.select("Pick a phase", options);
|
|
1086
|
+
if (chosen == null)
|
|
1147
1087
|
return null;
|
|
1148
|
-
|
|
1149
|
-
return phases[idx];
|
|
1088
|
+
const idx = options.indexOf(chosen);
|
|
1089
|
+
return idx >= 0 ? phases[idx] : null;
|
|
1150
1090
|
}
|
|
1151
1091
|
// ═══════════════════════════════════════════════════════════════
|
|
1152
1092
|
// Helper: pick a task interactively from a phase
|
|
@@ -1154,17 +1094,13 @@ export default function planPiExtension(pi) {
|
|
|
1154
1094
|
async function pickTask(phase) {
|
|
1155
1095
|
if (phase.tasks.length === 0)
|
|
1156
1096
|
return null;
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
const
|
|
1160
|
-
if (
|
|
1161
|
-
return null;
|
|
1162
|
-
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1163
|
-
if (isNaN(idx) || idx < 0 || idx >= phase.tasks.length) {
|
|
1164
|
-
ctx.ui.notify("Invalid number", "error");
|
|
1097
|
+
const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
|
|
1098
|
+
const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
|
|
1099
|
+
const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
|
|
1100
|
+
if (chosen == null)
|
|
1165
1101
|
return null;
|
|
1166
|
-
|
|
1167
|
-
return
|
|
1102
|
+
const idx = options.indexOf(chosen);
|
|
1103
|
+
return idx >= 0 ? tasks[idx] : null;
|
|
1168
1104
|
}
|
|
1169
1105
|
function parseMultilineList(value) {
|
|
1170
1106
|
return (value ?? "")
|
|
@@ -1183,7 +1119,15 @@ export default function planPiExtension(pi) {
|
|
|
1183
1119
|
const normalized = ref.trim().toLowerCase();
|
|
1184
1120
|
if (!normalized)
|
|
1185
1121
|
return null;
|
|
1186
|
-
|
|
1122
|
+
const fMatch = normalized.match(/^f(\d+)$/);
|
|
1123
|
+
if (fMatch) {
|
|
1124
|
+
const n = parseInt(fMatch[1], 10);
|
|
1125
|
+
const byNum = features.find((f) => f.number === n);
|
|
1126
|
+
if (byNum)
|
|
1127
|
+
return byNum;
|
|
1128
|
+
}
|
|
1129
|
+
return features.find((feature) => feature.shortId && feature.shortId.toLowerCase() === normalized)
|
|
1130
|
+
?? features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
1187
1131
|
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
1188
1132
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1189
1133
|
?? null;
|
|
@@ -1192,24 +1136,19 @@ export default function planPiExtension(pi) {
|
|
|
1192
1136
|
const normalized = ref.trim().toLowerCase();
|
|
1193
1137
|
if (!normalized)
|
|
1194
1138
|
return null;
|
|
1195
|
-
|
|
1139
|
+
const pMatch = normalized.match(/^p(\d+)$/);
|
|
1140
|
+
if (pMatch) {
|
|
1141
|
+
const n = parseInt(pMatch[1], 10);
|
|
1142
|
+
const byNum = phases.find((p) => p.number === n);
|
|
1143
|
+
if (byNum)
|
|
1144
|
+
return byNum;
|
|
1145
|
+
}
|
|
1146
|
+
return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
|
|
1147
|
+
?? phases.find((phase) => phase.id.toLowerCase() === normalized)
|
|
1196
1148
|
?? phases.find((phase) => phase.title.toLowerCase() === normalized)
|
|
1197
1149
|
?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
|
|
1198
1150
|
?? null;
|
|
1199
1151
|
}
|
|
1200
|
-
function findTaskByRef(phases, ref) {
|
|
1201
|
-
const normalized = ref.trim().toLowerCase();
|
|
1202
|
-
if (!normalized)
|
|
1203
|
-
return null;
|
|
1204
|
-
for (const phase of phases) {
|
|
1205
|
-
const task = phase.tasks.find((entryTask) => entryTask.id.toLowerCase() === normalized
|
|
1206
|
-
|| entryTask.title.toLowerCase() === normalized
|
|
1207
|
-
|| entryTask.title.toLowerCase().includes(normalized));
|
|
1208
|
-
if (task)
|
|
1209
|
-
return { phase, task };
|
|
1210
|
-
}
|
|
1211
|
-
return null;
|
|
1212
|
-
}
|
|
1213
1152
|
function profile_packageManager(pkg, lockfile) {
|
|
1214
1153
|
if (pkg?.packageManager)
|
|
1215
1154
|
return pkg.packageManager;
|
|
@@ -1414,7 +1353,7 @@ export default function planPiExtension(pi) {
|
|
|
1414
1353
|
return;
|
|
1415
1354
|
}
|
|
1416
1355
|
const now = nowISO();
|
|
1417
|
-
const featureNumber =
|
|
1356
|
+
const featureNumber = await st.allocFeatureNumber();
|
|
1418
1357
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1419
1358
|
const priority = await st.nextPriority("feature");
|
|
1420
1359
|
const feature = {
|
|
@@ -1443,7 +1382,7 @@ export default function planPiExtension(pi) {
|
|
|
1443
1382
|
return doc;
|
|
1444
1383
|
});
|
|
1445
1384
|
await st.writeGenerated();
|
|
1446
|
-
ctx.ui.notify(`Feature created: ${feature.
|
|
1385
|
+
ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1447
1386
|
return;
|
|
1448
1387
|
}
|
|
1449
1388
|
if (b === "show") {
|
|
@@ -1524,7 +1463,7 @@ export default function planPiExtension(pi) {
|
|
|
1524
1463
|
});
|
|
1525
1464
|
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1526
1465
|
await st.writeGenerated();
|
|
1527
|
-
ctx.ui.notify(`Feature updated: ${feature.
|
|
1466
|
+
ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
|
|
1528
1467
|
return;
|
|
1529
1468
|
}
|
|
1530
1469
|
if (b === "delete") {
|
|
@@ -1601,7 +1540,7 @@ export default function planPiExtension(pi) {
|
|
|
1601
1540
|
await withFeatureLock(feature.id, async () => {
|
|
1602
1541
|
const phases = await st.loadAllPhases();
|
|
1603
1542
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1604
|
-
const number =
|
|
1543
|
+
const number = await st.allocPhaseNumber();
|
|
1605
1544
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1606
1545
|
const id = createPhaseId();
|
|
1607
1546
|
const shortId = createShortId(await st.assignedShortIds());
|
|
@@ -1612,6 +1551,8 @@ export default function planPiExtension(pi) {
|
|
|
1612
1551
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1613
1552
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1614
1553
|
handoff: "", handoffUpdatedAt: "",
|
|
1554
|
+
handoffReadAt: "",
|
|
1555
|
+
handoffHistory: [],
|
|
1615
1556
|
};
|
|
1616
1557
|
await st.savePhase(phase);
|
|
1617
1558
|
await st.updateFeatures((doc) => {
|
|
@@ -1626,7 +1567,7 @@ export default function planPiExtension(pi) {
|
|
|
1626
1567
|
});
|
|
1627
1568
|
if (!phase)
|
|
1628
1569
|
return;
|
|
1629
|
-
ctx.ui.notify(`Phase created: ${phase.
|
|
1570
|
+
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
1630
1571
|
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1631
1572
|
if (isYes(startDiscuss)) {
|
|
1632
1573
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
@@ -1651,7 +1592,7 @@ export default function planPiExtension(pi) {
|
|
|
1651
1592
|
phase.status = "discovery";
|
|
1652
1593
|
phase.updatedAt = nowISO();
|
|
1653
1594
|
await st.savePhase(phase);
|
|
1654
|
-
ctx.ui.notify(`Discovery for ${phase.
|
|
1595
|
+
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1655
1596
|
// ── Recap: give full context before planning tasks ──
|
|
1656
1597
|
const recapPlan = await st.loadAll();
|
|
1657
1598
|
const recapProject = recapPlan.project;
|
|
@@ -1716,7 +1657,7 @@ export default function planPiExtension(pi) {
|
|
|
1716
1657
|
phase.updatedAt = nowISO();
|
|
1717
1658
|
await st.savePhase(phase);
|
|
1718
1659
|
await st.writeGenerated();
|
|
1719
|
-
ctx.ui.notify(`Phase ${phase.
|
|
1660
|
+
ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1720
1661
|
return;
|
|
1721
1662
|
}
|
|
1722
1663
|
if (b === "show") {
|
|
@@ -1764,7 +1705,7 @@ export default function planPiExtension(pi) {
|
|
|
1764
1705
|
}
|
|
1765
1706
|
await st.deletePhase(phase.id);
|
|
1766
1707
|
await st.writeGenerated();
|
|
1767
|
-
ctx.ui.notify(`Phase deleted: ${phase.
|
|
1708
|
+
ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1768
1709
|
return;
|
|
1769
1710
|
}
|
|
1770
1711
|
if (b === "update") {
|
|
@@ -1816,7 +1757,7 @@ export default function planPiExtension(pi) {
|
|
|
1816
1757
|
phase.updatedAt = nowISO();
|
|
1817
1758
|
await st.savePhase(phase);
|
|
1818
1759
|
await st.writeGenerated();
|
|
1819
|
-
ctx.ui.notify(`Phase updated: ${phase.
|
|
1760
|
+
ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1820
1761
|
return;
|
|
1821
1762
|
}
|
|
1822
1763
|
ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
|
|
@@ -1842,7 +1783,7 @@ export default function planPiExtension(pi) {
|
|
|
1842
1783
|
return;
|
|
1843
1784
|
}
|
|
1844
1785
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1845
|
-
const taskNum =
|
|
1786
|
+
const taskNum = await st.allocTaskNumber();
|
|
1846
1787
|
const taskId = createTaskId();
|
|
1847
1788
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1848
1789
|
const priority = await st.nextPriority("task", phase.id);
|
|
@@ -1874,7 +1815,7 @@ export default function planPiExtension(pi) {
|
|
|
1874
1815
|
}
|
|
1875
1816
|
if (b === "discuss") {
|
|
1876
1817
|
const phases = await st.loadAllPhases();
|
|
1877
|
-
let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
|
|
1818
|
+
let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
|
|
1878
1819
|
if (!resolved) {
|
|
1879
1820
|
const phase = await pickPhase();
|
|
1880
1821
|
if (!phase) {
|
|
@@ -1908,14 +1849,14 @@ export default function planPiExtension(pi) {
|
|
|
1908
1849
|
phase.updatedAt = nowISO();
|
|
1909
1850
|
await st.savePhase(phase);
|
|
1910
1851
|
await st.writeGenerated();
|
|
1911
|
-
ctx.ui.notify(`Task ${task.
|
|
1852
|
+
ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
|
|
1912
1853
|
return;
|
|
1913
1854
|
}
|
|
1914
1855
|
if (b === "show") {
|
|
1915
1856
|
let phase = null;
|
|
1916
1857
|
let task = null;
|
|
1917
1858
|
if (subArgs.trim()) {
|
|
1918
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1859
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
1919
1860
|
phase = resolved?.phase ?? null;
|
|
1920
1861
|
task = resolved?.task ?? null;
|
|
1921
1862
|
}
|
|
@@ -1977,7 +1918,7 @@ export default function planPiExtension(pi) {
|
|
|
1977
1918
|
phase.updatedAt = nowISO();
|
|
1978
1919
|
await st.savePhase(phase);
|
|
1979
1920
|
await st.writeGenerated();
|
|
1980
|
-
ctx.ui.notify(`Task deleted: ${task.
|
|
1921
|
+
ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
1981
1922
|
return;
|
|
1982
1923
|
}
|
|
1983
1924
|
if (b === "update") {
|
|
@@ -2026,14 +1967,14 @@ export default function planPiExtension(pi) {
|
|
|
2026
1967
|
phase.updatedAt = now;
|
|
2027
1968
|
await st.savePhase(phase);
|
|
2028
1969
|
await st.writeGenerated();
|
|
2029
|
-
ctx.ui.notify(`Task updated: ${task.
|
|
1970
|
+
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");
|
|
2030
1971
|
return;
|
|
2031
1972
|
}
|
|
2032
1973
|
if (b === "start") {
|
|
2033
1974
|
let phase = null;
|
|
2034
1975
|
let task = null;
|
|
2035
1976
|
if (subArgs.trim()) {
|
|
2036
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1977
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
2037
1978
|
phase = resolved?.phase ?? null;
|
|
2038
1979
|
task = resolved?.task ?? null;
|
|
2039
1980
|
}
|
|
@@ -2057,12 +1998,16 @@ export default function planPiExtension(pi) {
|
|
|
2057
1998
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2058
1999
|
return;
|
|
2059
2000
|
}
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
ctx.ui.notify("
|
|
2001
|
+
// Auto-archive the handoff on THIS task's phase (reason: task-started) —
|
|
2002
|
+
// the agent is now actively working, so the captured context is consumed.
|
|
2003
|
+
// Other phases' handoffs are left untouched (informational, non-blocking).
|
|
2004
|
+
if ((phase.handoff ?? "") !== "") {
|
|
2005
|
+
await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
|
|
2006
|
+
ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/handoff-archive/.", "info");
|
|
2007
|
+
}
|
|
2008
|
+
const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
|
|
2009
|
+
if (_otherHandoffs.length > 0) {
|
|
2010
|
+
ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
|
|
2066
2011
|
}
|
|
2067
2012
|
const now = nowISO();
|
|
2068
2013
|
// Record status change in the incremental statusLog.
|
|
@@ -2081,14 +2026,14 @@ export default function planPiExtension(pi) {
|
|
|
2081
2026
|
phase.updatedAt = now;
|
|
2082
2027
|
await st.savePhase(phase);
|
|
2083
2028
|
await st.writeGenerated();
|
|
2084
|
-
ctx.ui.notify(`✅ Task started: ${task.
|
|
2029
|
+
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");
|
|
2085
2030
|
return;
|
|
2086
2031
|
}
|
|
2087
2032
|
if (b === "complete") {
|
|
2088
2033
|
let phase = null;
|
|
2089
2034
|
let task = null;
|
|
2090
2035
|
if (subArgs.trim()) {
|
|
2091
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2036
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
2092
2037
|
phase = resolved?.phase ?? null;
|
|
2093
2038
|
task = resolved?.task ?? null;
|
|
2094
2039
|
}
|
|
@@ -2136,7 +2081,7 @@ export default function planPiExtension(pi) {
|
|
|
2136
2081
|
phase.updatedAt = now;
|
|
2137
2082
|
await st.savePhase(phase);
|
|
2138
2083
|
await st.writeGenerated();
|
|
2139
|
-
ctx.ui.notify(`✅ Task completed: ${task.
|
|
2084
|
+
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");
|
|
2140
2085
|
return;
|
|
2141
2086
|
}
|
|
2142
2087
|
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
|
|
@@ -2219,7 +2164,7 @@ export default function planPiExtension(pi) {
|
|
|
2219
2164
|
ctx.ui.notify(r.error, "warning");
|
|
2220
2165
|
return;
|
|
2221
2166
|
}
|
|
2222
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2167
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2223
2168
|
ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
|
|
2224
2169
|
return;
|
|
2225
2170
|
}
|
|
@@ -2301,12 +2246,12 @@ export default function planPiExtension(pi) {
|
|
|
2301
2246
|
try {
|
|
2302
2247
|
if (a === "bypass") {
|
|
2303
2248
|
const mins = parseInt(subArgs.trim(), 10);
|
|
2304
|
-
const until =
|
|
2305
|
-
ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write
|
|
2249
|
+
const until = authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
|
|
2250
|
+
ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write advisory warnings silenced for that window (per-session, not shared).`, "info");
|
|
2306
2251
|
}
|
|
2307
2252
|
else {
|
|
2308
|
-
|
|
2309
|
-
ctx.ui.notify("Guard bypass revoked. edit/write
|
|
2253
|
+
clearGuardBypass();
|
|
2254
|
+
ctx.ui.notify("Guard bypass revoked. edit/write advisory warnings re-enabled.", "info");
|
|
2310
2255
|
}
|
|
2311
2256
|
}
|
|
2312
2257
|
catch (e) {
|
|
@@ -2334,9 +2279,23 @@ export default function planPiExtension(pi) {
|
|
|
2334
2279
|
startupResumeSummaryPending = false;
|
|
2335
2280
|
startupResumeSummaryTimer = null;
|
|
2336
2281
|
}, 60000);
|
|
2282
|
+
// Build the recap and embed it in the trigger content. The trigger
|
|
2283
|
+
// message is the channel that reliably reaches the agent on a triggered
|
|
2284
|
+
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
2285
|
+
// triggerTurn messages), so the recap data must live here. Uses the shared
|
|
2286
|
+
// core buildRecap so Pi and Claude Code/Codex present identical content.
|
|
2287
|
+
let recapText = "";
|
|
2288
|
+
try {
|
|
2289
|
+
const st = ensureStore(ctx);
|
|
2290
|
+
const srv = server;
|
|
2291
|
+
recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
2292
|
+
}
|
|
2293
|
+
catch (e) {
|
|
2294
|
+
recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
2295
|
+
}
|
|
2337
2296
|
pi.sendMessage({
|
|
2338
2297
|
customType: "planner-resume-trigger",
|
|
2339
|
-
content: "planner recap",
|
|
2298
|
+
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 ---",
|
|
2340
2299
|
display: false,
|
|
2341
2300
|
}, {
|
|
2342
2301
|
triggerTurn: true,
|
|
@@ -2649,7 +2608,7 @@ export default function planPiExtension(pi) {
|
|
|
2649
2608
|
pi.registerTool({
|
|
2650
2609
|
name: "plan_repair",
|
|
2651
2610
|
label: "Plan Repair",
|
|
2652
|
-
description: "Repair dangling feature→phase references and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found or after manual edits to .planner/.",
|
|
2611
|
+
description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug), and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found, tasks appear in the wrong phase, or after manual edits to .planner/.",
|
|
2653
2612
|
parameters: Type.Object({}),
|
|
2654
2613
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2655
2614
|
const st = await requirePlan(ctx);
|
|
@@ -2657,9 +2616,11 @@ export default function planPiExtension(pi) {
|
|
|
2657
2616
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2658
2617
|
const report = await st.repair();
|
|
2659
2618
|
const m = report.migrated;
|
|
2619
|
+
const c = report.containment;
|
|
2660
2620
|
const lines = [
|
|
2661
2621
|
`Repair complete.`,
|
|
2662
2622
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2623
|
+
`Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
|
|
2663
2624
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2664
2625
|
];
|
|
2665
2626
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2732,7 +2693,7 @@ export default function planPiExtension(pi) {
|
|
|
2732
2693
|
const r = await resolvePhaseForHandoff(st, undefined);
|
|
2733
2694
|
if (!r.ok)
|
|
2734
2695
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2735
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2696
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2736
2697
|
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 } };
|
|
2737
2698
|
},
|
|
2738
2699
|
});
|
|
@@ -2830,36 +2791,36 @@ export default function planPiExtension(pi) {
|
|
|
2830
2791
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2831
2792
|
if (!r.ok)
|
|
2832
2793
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2833
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2834
|
-
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}
|
|
2794
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2795
|
+
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2835
2796
|
},
|
|
2836
2797
|
});
|
|
2837
2798
|
pi.registerTool({
|
|
2838
2799
|
name: "plan_authorize_bypass",
|
|
2839
2800
|
label: "Plan Authorize Guard Bypass",
|
|
2840
|
-
description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write
|
|
2801
|
+
description: "Authorize a temporary guard bypass (default 15 minutes) so the edit/write advisory guard stops warning when no task is in-progress. Per-session in-memory: not persisted, not shared across agents. Use ONLY after the user explicitly authorizes proceeding without a task.",
|
|
2841
2802
|
parameters: Type.Object({
|
|
2842
2803
|
durationMinutes: Type.Optional(Type.Number({ description: "Bypass window in minutes. Default 15." })),
|
|
2843
2804
|
}),
|
|
2844
2805
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2845
|
-
const
|
|
2846
|
-
if (!
|
|
2806
|
+
const ok = await requirePlan(ctx);
|
|
2807
|
+
if (!ok)
|
|
2847
2808
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2848
2809
|
const mins = params.durationMinutes ?? 15;
|
|
2849
|
-
const until =
|
|
2850
|
-
return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. edit/write
|
|
2810
|
+
const until = authorizeGuardBypass(mins);
|
|
2811
|
+
return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. Advisory edit/write warnings silenced for ${mins} minutes (per-session, not shared).` }], details: { until } };
|
|
2851
2812
|
},
|
|
2852
2813
|
});
|
|
2853
2814
|
pi.registerTool({
|
|
2854
2815
|
name: "plan_clear_bypass",
|
|
2855
2816
|
label: "Plan Clear Guard Bypass",
|
|
2856
|
-
description: "Revoke
|
|
2817
|
+
description: "Revoke the per-session guard bypass so the edit/write advisory warnings resume.",
|
|
2857
2818
|
parameters: Type.Object({}),
|
|
2858
2819
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2859
|
-
const
|
|
2860
|
-
if (!
|
|
2820
|
+
const ok = await requirePlan(ctx);
|
|
2821
|
+
if (!ok)
|
|
2861
2822
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2862
|
-
|
|
2823
|
+
clearGuardBypass();
|
|
2863
2824
|
return { content: [{ type: "text", text: "Guard bypass revoked." }], details: { cleared: true } };
|
|
2864
2825
|
},
|
|
2865
2826
|
});
|
|
@@ -2867,38 +2828,44 @@ export default function planPiExtension(pi) {
|
|
|
2867
2828
|
pi.registerTool({
|
|
2868
2829
|
name: "feature_list",
|
|
2869
2830
|
label: "Feature List",
|
|
2870
|
-
description: "List
|
|
2871
|
-
parameters: Type.Object({}),
|
|
2872
|
-
async execute(_id,
|
|
2831
|
+
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
|
|
2832
|
+
parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
|
|
2833
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2873
2834
|
const st = await requirePlan(ctx);
|
|
2874
2835
|
if (!st)
|
|
2875
2836
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2876
|
-
const
|
|
2837
|
+
const all = (await st.loadFeatures()).features;
|
|
2838
|
+
const ref = params.featureRef?.trim();
|
|
2839
|
+
const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
|
|
2840
|
+
const features = all.filter(matchF);
|
|
2877
2841
|
const phases = await st.loadAllPhases();
|
|
2878
2842
|
const summary = features.map((f) => {
|
|
2879
2843
|
const fp = phases.filter((p) => p.featureId === f.id);
|
|
2880
|
-
return `- ${
|
|
2844
|
+
return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
2881
2845
|
}).join("\n");
|
|
2882
|
-
return { content: [{ type: "text", text: summary || "No features" }], details: {
|
|
2846
|
+
return { content: [{ type: "text", text: summary || "No features" }], details: {} };
|
|
2883
2847
|
},
|
|
2884
2848
|
});
|
|
2885
2849
|
pi.registerTool({
|
|
2886
2850
|
name: "feature_get",
|
|
2887
2851
|
label: "Feature Get",
|
|
2888
|
-
description: "
|
|
2852
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
2889
2853
|
parameters: Type.Object({
|
|
2890
|
-
featureId: Type.String({ description: "Feature
|
|
2854
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2855
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
2891
2856
|
}),
|
|
2892
2857
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2893
2858
|
const st = await requirePlan(ctx);
|
|
2894
2859
|
if (!st)
|
|
2895
2860
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2896
2861
|
const features = (await st.loadFeatures()).features;
|
|
2897
|
-
const
|
|
2862
|
+
const ref = params.featureId.trim();
|
|
2863
|
+
const feature = features.find((f) => formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase());
|
|
2898
2864
|
if (!feature)
|
|
2899
|
-
return { content: [{ type: "text", text: `Feature not found: ${
|
|
2865
|
+
return { content: [{ type: "text", text: `Feature not found: ${ref}` }], details: {} };
|
|
2900
2866
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2901
|
-
|
|
2867
|
+
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases)`;
|
|
2868
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}` : summary }], details: {} };
|
|
2902
2869
|
},
|
|
2903
2870
|
});
|
|
2904
2871
|
pi.registerTool({
|
|
@@ -2921,7 +2888,7 @@ export default function planPiExtension(pi) {
|
|
|
2921
2888
|
const priority = await st.nextPriority("feature");
|
|
2922
2889
|
const feature = {
|
|
2923
2890
|
id: createFeatureId(),
|
|
2924
|
-
number:
|
|
2891
|
+
number: await st.allocFeatureNumber(),
|
|
2925
2892
|
shortId,
|
|
2926
2893
|
priority,
|
|
2927
2894
|
name: params.name,
|
|
@@ -2947,7 +2914,7 @@ export default function planPiExtension(pi) {
|
|
|
2947
2914
|
return doc;
|
|
2948
2915
|
});
|
|
2949
2916
|
await st.writeGenerated();
|
|
2950
|
-
return { content: [{ type: "text", text:
|
|
2917
|
+
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2951
2918
|
},
|
|
2952
2919
|
});
|
|
2953
2920
|
pi.registerTool({
|
|
@@ -2955,7 +2922,7 @@ export default function planPiExtension(pi) {
|
|
|
2955
2922
|
label: "Feature Update",
|
|
2956
2923
|
description: "Update one or more fields of a feature. Only provided fields are changed. IMPORTANT: feature status is derived from child phases/tasks, so do not update feature.status directly unless truly necessary.",
|
|
2957
2924
|
parameters: Type.Object({
|
|
2958
|
-
featureId: Type.String({ description: "Feature
|
|
2925
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2959
2926
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
2960
2927
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
2961
2928
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: feature status is derived from child phases/tasks." })),
|
|
@@ -3022,7 +2989,7 @@ export default function planPiExtension(pi) {
|
|
|
3022
2989
|
if (!feature)
|
|
3023
2990
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3024
2991
|
await st.writeGenerated();
|
|
3025
|
-
return { content: [{ type: "text", text:
|
|
2992
|
+
return { content: [{ type: "text", text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3026
2993
|
},
|
|
3027
2994
|
});
|
|
3028
2995
|
pi.registerTool({
|
|
@@ -3030,7 +2997,7 @@ export default function planPiExtension(pi) {
|
|
|
3030
2997
|
label: "Feature Delete",
|
|
3031
2998
|
description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
|
|
3032
2999
|
parameters: Type.Object({
|
|
3033
|
-
featureId: Type.String({ description: "Feature
|
|
3000
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
|
|
3034
3001
|
cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
|
|
3035
3002
|
}),
|
|
3036
3003
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -3064,40 +3031,52 @@ export default function planPiExtension(pi) {
|
|
|
3064
3031
|
pi.registerTool({
|
|
3065
3032
|
name: "phase_list",
|
|
3066
3033
|
label: "Phase List",
|
|
3067
|
-
description: "List
|
|
3034
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3068
3035
|
parameters: Type.Object({
|
|
3069
|
-
|
|
3036
|
+
featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
|
|
3037
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3070
3038
|
}),
|
|
3071
3039
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3072
3040
|
const st = await requirePlan(ctx);
|
|
3073
3041
|
if (!st)
|
|
3074
3042
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3043
|
+
const features = (await st.loadFeatures()).features;
|
|
3075
3044
|
let phases = await st.loadAllPhases();
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3045
|
+
const fref = params.featureRef?.trim();
|
|
3046
|
+
if (fref) {
|
|
3047
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3048
|
+
if (!f)
|
|
3049
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3050
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3051
|
+
}
|
|
3052
|
+
if (params.status)
|
|
3053
|
+
phases = phases.filter((p) => p.status === params.status);
|
|
3054
|
+
const summary = phases.map((p) => {
|
|
3055
|
+
const fNum = featureNumberOfPhase(p, features);
|
|
3056
|
+
const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
|
|
3057
|
+
return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
|
|
3058
|
+
}).join("\n");
|
|
3059
|
+
return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
|
|
3080
3060
|
},
|
|
3081
3061
|
});
|
|
3082
3062
|
pi.registerTool({
|
|
3083
3063
|
name: "phase_get",
|
|
3084
3064
|
label: "Phase Get",
|
|
3085
|
-
description: "
|
|
3065
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3086
3066
|
parameters: Type.Object({
|
|
3087
|
-
phaseId: Type.String({ description: "Phase
|
|
3067
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3068
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
3088
3069
|
}),
|
|
3089
3070
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3090
3071
|
const st = await requirePlan(ctx);
|
|
3091
3072
|
if (!st)
|
|
3092
3073
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
}
|
|
3097
|
-
catch {
|
|
3074
|
+
const features = (await st.loadFeatures()).features;
|
|
3075
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3076
|
+
if (!phase)
|
|
3098
3077
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3099
|
-
}
|
|
3100
|
-
return { content: [{ type: "text", text:
|
|
3078
|
+
const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks)`;
|
|
3079
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}` : summary }], details: {} };
|
|
3101
3080
|
},
|
|
3102
3081
|
});
|
|
3103
3082
|
pi.registerTool({
|
|
@@ -3121,7 +3100,7 @@ export default function planPiExtension(pi) {
|
|
|
3121
3100
|
await withFeatureLock(params.featureId, async () => {
|
|
3122
3101
|
const phases = await st.loadAllPhases();
|
|
3123
3102
|
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3124
|
-
const number =
|
|
3103
|
+
const number = await st.allocPhaseNumber();
|
|
3125
3104
|
const id = createPhaseId();
|
|
3126
3105
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3127
3106
|
const priority = await st.nextPriority("phase", params.featureId);
|
|
@@ -3138,6 +3117,8 @@ export default function planPiExtension(pi) {
|
|
|
3138
3117
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3139
3118
|
createdAt: now, updatedAt: now,
|
|
3140
3119
|
handoff: "", handoffUpdatedAt: "",
|
|
3120
|
+
handoffReadAt: "",
|
|
3121
|
+
handoffHistory: [],
|
|
3141
3122
|
};
|
|
3142
3123
|
await st.savePhase(phase);
|
|
3143
3124
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
@@ -3153,7 +3134,8 @@ export default function planPiExtension(pi) {
|
|
|
3153
3134
|
});
|
|
3154
3135
|
if (!phase)
|
|
3155
3136
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
3156
|
-
|
|
3137
|
+
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
3138
|
+
return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3157
3139
|
},
|
|
3158
3140
|
});
|
|
3159
3141
|
pi.registerTool({
|
|
@@ -3161,7 +3143,7 @@ export default function planPiExtension(pi) {
|
|
|
3161
3143
|
label: "Phase Update",
|
|
3162
3144
|
description: "Update one or more fields of a phase. Only provided fields are changed. Supports re-linking to a feature via featureId. IMPORTANT: phase status is derived from task statuses, so do not update phase.status directly unless truly necessary.",
|
|
3163
3145
|
parameters: Type.Object({
|
|
3164
|
-
phaseId: Type.String({ description: "Phase
|
|
3146
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3165
3147
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3166
3148
|
status: Type.Optional(Type.String({ description: "New status: draft|discovery|planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: phase status is derived from task statuses." })),
|
|
3167
3149
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
@@ -3242,7 +3224,8 @@ export default function planPiExtension(pi) {
|
|
|
3242
3224
|
phase.updatedAt = nowISO();
|
|
3243
3225
|
await st.savePhase(phase);
|
|
3244
3226
|
await st.writeGenerated();
|
|
3245
|
-
|
|
3227
|
+
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
3228
|
+
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3246
3229
|
},
|
|
3247
3230
|
});
|
|
3248
3231
|
pi.registerTool({
|
|
@@ -3250,7 +3233,7 @@ export default function planPiExtension(pi) {
|
|
|
3250
3233
|
label: "Phase Delete",
|
|
3251
3234
|
description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
|
|
3252
3235
|
parameters: Type.Object({
|
|
3253
|
-
phaseId: Type.String({ description: "Phase
|
|
3236
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
|
|
3254
3237
|
}),
|
|
3255
3238
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3256
3239
|
const st = await requirePlan(ctx);
|
|
@@ -3282,42 +3265,64 @@ export default function planPiExtension(pi) {
|
|
|
3282
3265
|
pi.registerTool({
|
|
3283
3266
|
name: "task_list",
|
|
3284
3267
|
label: "Task List",
|
|
3285
|
-
description: "List tasks
|
|
3268
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3286
3269
|
parameters: Type.Object({
|
|
3287
|
-
|
|
3270
|
+
featureRef: Type.Optional(Type.String({})),
|
|
3271
|
+
phaseRef: Type.Optional(Type.String({})),
|
|
3272
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3288
3273
|
}),
|
|
3289
3274
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3290
3275
|
const st = await requirePlan(ctx);
|
|
3291
3276
|
if (!st)
|
|
3292
3277
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3278
|
+
const features = (await st.loadFeatures()).features;
|
|
3279
|
+
let phases = await st.loadAllPhases();
|
|
3280
|
+
const fref = params.featureRef?.trim();
|
|
3281
|
+
if (fref) {
|
|
3282
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3283
|
+
if (!f)
|
|
3284
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3285
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3286
|
+
}
|
|
3287
|
+
const pref = params.phaseRef?.trim();
|
|
3288
|
+
if (pref) {
|
|
3289
|
+
const p = findPhaseByRef(phases, features, pref);
|
|
3290
|
+
if (!p)
|
|
3291
|
+
return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
|
|
3292
|
+
phases = [p];
|
|
3293
|
+
}
|
|
3294
|
+
const out = [];
|
|
3295
|
+
for (const phase of phases) {
|
|
3296
|
+
for (const task of phase.tasks) {
|
|
3297
|
+
if (params.status && task.status !== params.status)
|
|
3298
|
+
continue;
|
|
3299
|
+
out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
|
|
3300
|
+
}
|
|
3299
3301
|
}
|
|
3300
|
-
|
|
3301
|
-
return { content: [{ type: "text", text: summary || "No tasks" }], details: { tasks: phase.tasks } };
|
|
3302
|
+
return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
|
|
3302
3303
|
},
|
|
3303
3304
|
});
|
|
3304
3305
|
pi.registerTool({
|
|
3305
3306
|
name: "task_get",
|
|
3306
3307
|
label: "Task Get",
|
|
3307
|
-
description: "
|
|
3308
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
|
|
3308
3309
|
parameters: Type.Object({
|
|
3309
|
-
taskId: Type.String({ description: "Task
|
|
3310
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3311
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
|
|
3310
3312
|
}),
|
|
3311
3313
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3312
3314
|
const st = await requirePlan(ctx);
|
|
3313
3315
|
if (!st)
|
|
3314
3316
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3315
|
-
const
|
|
3316
|
-
const
|
|
3317
|
-
|
|
3318
|
-
if (!task || !phase)
|
|
3317
|
+
const features = (await st.loadFeatures()).features;
|
|
3318
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3319
|
+
if (!found)
|
|
3319
3320
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3320
|
-
|
|
3321
|
+
const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
3322
|
+
if (!params.full)
|
|
3323
|
+
return { content: [{ type: "text", text: summary }], details: {} };
|
|
3324
|
+
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3325
|
+
return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
|
|
3321
3326
|
},
|
|
3322
3327
|
});
|
|
3323
3328
|
pi.registerTool({
|
|
@@ -3350,7 +3355,7 @@ export default function planPiExtension(pi) {
|
|
|
3350
3355
|
const initialStatus = params.status ?? "planned";
|
|
3351
3356
|
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3352
3357
|
const task = {
|
|
3353
|
-
id: taskId, phaseId: params.phaseId, number:
|
|
3358
|
+
id: taskId, phaseId: params.phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
|
|
3354
3359
|
title: params.title,
|
|
3355
3360
|
status: initialStatus,
|
|
3356
3361
|
description: params.description ?? "",
|
|
@@ -3381,7 +3386,7 @@ export default function planPiExtension(pi) {
|
|
|
3381
3386
|
label: "Task Update",
|
|
3382
3387
|
description: "Update one or more fields of a task. Only provided fields are changed. Do NOT use this tool to start or complete work; use task_start and task_complete for lifecycle transitions so startedAt/completedAt stay correct.",
|
|
3383
3388
|
parameters: Type.Object({
|
|
3384
|
-
taskId: Type.String({ description: "Task
|
|
3389
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3385
3390
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3386
3391
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
3387
3392
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
@@ -3469,7 +3474,7 @@ export default function planPiExtension(pi) {
|
|
|
3469
3474
|
label: "Task Delete",
|
|
3470
3475
|
description: "Delete a task from its phase.",
|
|
3471
3476
|
parameters: Type.Object({
|
|
3472
|
-
taskId: Type.String({ description: "Task
|
|
3477
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
|
|
3473
3478
|
}),
|
|
3474
3479
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3475
3480
|
const st = await requirePlan(ctx);
|
|
@@ -3495,7 +3500,7 @@ export default function planPiExtension(pi) {
|
|
|
3495
3500
|
label: "Task Start",
|
|
3496
3501
|
description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
|
|
3497
3502
|
parameters: Type.Object({
|
|
3498
|
-
taskId: Type.String({ description: "Task
|
|
3503
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3499
3504
|
}),
|
|
3500
3505
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3501
3506
|
const st = await requirePlan(ctx);
|
|
@@ -3533,7 +3538,7 @@ export default function planPiExtension(pi) {
|
|
|
3533
3538
|
label: "Task Complete",
|
|
3534
3539
|
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
3535
3540
|
parameters: Type.Object({
|
|
3536
|
-
taskId: Type.String({ description: "Task
|
|
3541
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
3537
3542
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
3538
3543
|
description_update: Type.Optional(Type.String({ description: "Post-hoc summary: commit hash(s), files touched, decisions made, updated code references with new line numbers. Keeps the planner alive and traceable." })),
|
|
3539
3544
|
}),
|
|
@@ -3634,16 +3639,15 @@ export default function planPiExtension(pi) {
|
|
|
3634
3639
|
if (!server) {
|
|
3635
3640
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
3636
3641
|
}
|
|
3642
|
+
const srv = server;
|
|
3637
3643
|
let recap = "";
|
|
3638
3644
|
try {
|
|
3639
|
-
recap = await
|
|
3645
|
+
recap = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
|
|
3640
3646
|
}
|
|
3641
3647
|
catch (e) {
|
|
3642
3648
|
recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
|
|
3643
3649
|
}
|
|
3644
|
-
|
|
3645
|
-
const webLine = srv ? `\n🌐 Web UI: ${srv.localUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""}` : "\n🌐 Web UI: not running";
|
|
3646
|
-
return { content: [{ type: "text", text: `${recap}${webLine}` }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
|
|
3650
|
+
return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
|
|
3647
3651
|
},
|
|
3648
3652
|
});
|
|
3649
3653
|
pi.registerTool({
|
|
@@ -3674,6 +3678,7 @@ export default function planPiExtension(pi) {
|
|
|
3674
3678
|
// add seconds of latency on every turn if run unconditionally.
|
|
3675
3679
|
if (!plannerHeavyInitDone) {
|
|
3676
3680
|
await migrateToUuids(st);
|
|
3681
|
+
await migrateToGlobalSequence(st).catch(() => null);
|
|
3677
3682
|
await st.ensureShortIdsAndPriority().catch(() => null);
|
|
3678
3683
|
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3679
3684
|
await maybeHealStatuses(st, ctx);
|
|
@@ -3755,12 +3760,12 @@ export default function planPiExtension(pi) {
|
|
|
3755
3760
|
: hasActiveWork
|
|
3756
3761
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3757
3762
|
: null;
|
|
3758
|
-
const nextActivity =
|
|
3759
|
-
? (resume.nextSteps
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
: "Review the current plan and choose the next task."
|
|
3763
|
+
const nextActivity = handoffs.length > 0
|
|
3764
|
+
? "Read the most recent phase handoff (handoff show <ref>), validate it against current plan ordering and dependencies, then pick the next task. (Legacy resume.json nextSteps may be stale — the handoff is authoritative.)"
|
|
3765
|
+
: hasActiveWork
|
|
3766
|
+
? (resume.nextSteps[0]
|
|
3767
|
+
?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
|
|
3768
|
+
: "Review the current plan and choose the next task.";
|
|
3764
3769
|
const webServer = server;
|
|
3765
3770
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3766
3771
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3789,27 +3794,16 @@ export default function planPiExtension(pi) {
|
|
|
3789
3794
|
const ambientSummary = ambient
|
|
3790
3795
|
? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
|
|
3791
3796
|
: "(not scanned)";
|
|
3797
|
+
// NOTE: the recap DATA is delivered via the /planner load trigger message
|
|
3798
|
+
// content (buildRecap), not via this system-prompt protocol — before_agent_start's
|
|
3799
|
+
// systemPrompt injection is not applied to triggerTurn messages. This protocol
|
|
3800
|
+
// is kept as a lightweight anti-narration guard for any normal recap turn.
|
|
3792
3801
|
const startupResumeProtocol = isRecapTurn ? [
|
|
3793
3802
|
"",
|
|
3794
|
-
"STARTUP RESUME PROTOCOL (
|
|
3795
|
-
"-
|
|
3796
|
-
|
|
3797
|
-
"-
|
|
3798
|
-
`- Mention progress counts: ${doneFeaturesCount}/${plan.features.features.length} features done, ${activeFeaturesCount} active; ${donePhasesCount}/${plan.phases.length} phases done, ${activePhasesCount} active; ${doneTasksCount}/${totalTasksCount} tasks done.`,
|
|
3799
|
-
currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
|
|
3800
|
-
currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
|
|
3801
|
-
currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
|
|
3802
|
-
handoffs.length > 0 ? `- PHASE HANDOFFS (${handoffs.length} pending):` : "",
|
|
3803
|
-
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3804
|
-
handoffs.length > 0 ? "- Present this numbered list to the user. Recommend the most recent (or the one aligned with the current resume focus)." : "",
|
|
3805
|
-
handoffs.length > 0 ? "- Ask the user to confirm which one to resume. Then run handoff show <ref> (or /planner handoff show <ref>) to read it." : "",
|
|
3806
|
-
handoffs.length > 0 ? "- After reading and confirming the resume, CLEAR that handoff with handoff clear <ref> (or /planner handoff clear) BEFORE starting implementation work — a resumed handoff must not go stale." : "",
|
|
3807
|
-
handoffs.length === 0 ? "- No pending phase handoffs: fall back to the resume.json focus (current in-progress task/phase) above." : "",
|
|
3808
|
-
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).",
|
|
3809
|
-
`- Mention the next suggested activity: ${nextActivity}`,
|
|
3810
|
-
"- THEN ask the user explicitly whether they want to resume that activity now.",
|
|
3811
|
-
"- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
|
|
3812
|
-
"- 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).",
|
|
3803
|
+
"STARTUP RESUME PROTOCOL (mandatory):",
|
|
3804
|
+
"- 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.",
|
|
3805
|
+
`- Write in ${project.chatLanguage || "English"}.`,
|
|
3806
|
+
"- 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).",
|
|
3813
3807
|
].filter(Boolean).join("\n") : "";
|
|
3814
3808
|
// Build/refresh the context block (slow path: cache miss).
|
|
3815
3809
|
const contextBlock = [
|
|
@@ -3822,11 +3816,12 @@ export default function planPiExtension(pi) {
|
|
|
3822
3816
|
"═══════════════════════════════════════════════════════════════",
|
|
3823
3817
|
"MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
|
|
3824
3818
|
"═══════════════════════════════════════════════════════════════",
|
|
3825
|
-
"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
|
|
3819
|
+
"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> for context. Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — no manual clear needed. They are non-blocking context artifacts, not locks — they never block task_start.",
|
|
3826
3820
|
"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.",
|
|
3827
3821
|
"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.",
|
|
3828
3822
|
"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.",
|
|
3829
3823
|
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3824
|
+
"6. ENTITY REFERENCES & FAST LOOKUP: Feature/Phase/Task numbers are GLOBALLY unique (one P003, one T007 in the whole plan) — bare P00x/T00x is a valid unambiguous ref. PREFER the 5-char shortId (e.g. UUXD1) or composite F00x/P00x/T00x in chat; NEVER a raw UUID. To FIND an entity: call feature_list / phase_list / task_list (compact, ~2K tokens for the whole plan) — do NOT read .planner/*.json files or plan_get full=true (~32K tokens) to locate an entity. To ACT: pass the ref directly to the action tool (task_start/complete/update/phase_update accept F00x/P00x/T00x, bare P00x/T00x, shortId, UUID, title). To read a description: call task_get / phase_get / feature_get with full=true for that ONE entity. Tool responses already show shortId + compositeRef; reuse them.",
|
|
3830
3825
|
"═══════════════════════════════════════════════════════════════",
|
|
3831
3826
|
"",
|
|
3832
3827
|
// ── Project details ──
|
|
@@ -3857,7 +3852,7 @@ export default function planPiExtension(pi) {
|
|
|
3857
3852
|
resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
|
|
3858
3853
|
resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
|
|
3859
3854
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3860
|
-
resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}` : " next steps: (none)",
|
|
3855
|
+
resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}${handoffs.length > 0 ? " (⚠️ may be stale — handoff is authoritative)" : resume.nextStepsUpdatedAt ? ` (last updated ${resume.nextStepsUpdatedAt}, may be stale)` : ""}` : " next steps: (none)",
|
|
3861
3856
|
resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
|
|
3862
3857
|
"",
|
|
3863
3858
|
recentActivity.length ? "Recent activity (most recent first):" : "",
|
|
@@ -3901,7 +3896,7 @@ export default function planPiExtension(pi) {
|
|
|
3901
3896
|
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3902
3897
|
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." : "",
|
|
3903
3898
|
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." : "",
|
|
3904
|
-
handoffs.length > 0 ? "
|
|
3899
|
+
handoffs.length > 0 ? "Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — read with handoff show <ref> for context; no manual clear needed." : "",
|
|
3905
3900
|
"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",
|
|
3906
3901
|
].filter(Boolean).join("\n");
|
|
3907
3902
|
contextBlockCache = contextBlock;
|