@agent-plan/pi-adapter 0.2.2
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/LICENSE +21 -0
- package/README.md +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3732 -0
- package/package.json +43 -0
- package/web-ui-dist/assets/index-AkkZ1Syc.css +1 -0
- package/web-ui-dist/assets/index-BXrUndZJ.js +191 -0
- package/web-ui-dist/index.html +13 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Plan — Pi adapter extension.
|
|
3
|
+
*
|
|
4
|
+
* Integrates the Agent Plan Platform with Pi as an orchestrator:
|
|
5
|
+
* - initialize .planner/ if missing
|
|
6
|
+
* - create & discuss phases
|
|
7
|
+
* - manage tasks and subtasks
|
|
8
|
+
* - start/stop local web server
|
|
9
|
+
* - inject context into prompts
|
|
10
|
+
*/
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation } from "@agent-plan/core";
|
|
13
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, normalizeSlug } from "@agent-plan/core/naming";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { writeFile, rm } from "node:fs/promises";
|
|
18
|
+
import { createServer } from "node:net";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { serve } from "@agent-plan/server/serve";
|
|
21
|
+
// ─── State (module-level) ───────────────────────────────────────────────
|
|
22
|
+
const PI_CONFIG_DIR_NAME = ".pi";
|
|
23
|
+
const PLAN_DIR_NAME = ".planner";
|
|
24
|
+
let capturedPi = null;
|
|
25
|
+
let store = null;
|
|
26
|
+
let server = null;
|
|
27
|
+
let lastKnownWebPort = null;
|
|
28
|
+
// True while the agent is mutating .planner/ files. The web server returns 503-busy
|
|
29
|
+
// during this window so the UI doesn't render inconsistent data.
|
|
30
|
+
let planBusy = false;
|
|
31
|
+
function setPlanBusy(value) { planBusy = value; }
|
|
32
|
+
function withPlanBusy(fn) {
|
|
33
|
+
return Promise.resolve()
|
|
34
|
+
.then(() => { planBusy = true; })
|
|
35
|
+
.then(() => fn())
|
|
36
|
+
.finally(() => { planBusy = false; });
|
|
37
|
+
}
|
|
38
|
+
// Helper to notify a running server in another process via HTTP.
|
|
39
|
+
async function notifyExternalServer() {
|
|
40
|
+
try {
|
|
41
|
+
const port = lastKnownWebPort;
|
|
42
|
+
if (!port)
|
|
43
|
+
return;
|
|
44
|
+
await fetch(`http://127.0.0.1:${port}/internal/notify`, { method: "POST" }).catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
// Wire the plan-core write hook to our planBusy flag so every atomic write
|
|
49
|
+
// (from any tool, command, or the server) marks the plan busy for the web UI.
|
|
50
|
+
setWriteBusyHook((busy) => { planBusy = busy; });
|
|
51
|
+
// After every successful atomic write, broadcast a live-update event to the
|
|
52
|
+
// web UI via the running server's WebSocket hub. This makes the dashboard
|
|
53
|
+
// refresh in real time even when mutations happen through adapter tools
|
|
54
|
+
// (feature_create/task_create/...) rather than HTTP routes, without relying
|
|
55
|
+
// solely on the filesystem watcher (which can miss atomic renames).
|
|
56
|
+
setWriteNotifyHook(() => {
|
|
57
|
+
contextBlockDirty = true; // invalidate cached context so next turn rebuilds
|
|
58
|
+
try {
|
|
59
|
+
if (server?.hub) {
|
|
60
|
+
server.hub.broadcast({ type: "plan-rendered", data: {} });
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
void notifyExternalServer();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
});
|
|
68
|
+
let previousContextPercent = 0;
|
|
69
|
+
let autoHandoffTriggered = false;
|
|
70
|
+
let plannerSessionEnabled = false;
|
|
71
|
+
let startupResumePromptPending = false;
|
|
72
|
+
let startupResumeSummaryPending = false;
|
|
73
|
+
let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
|
|
74
|
+
let startupResumeSummaryText = "";
|
|
75
|
+
let contextBlockCache = ""; // cached before_agent_start context; rebuilt only when plan changes
|
|
76
|
+
let contextBlockDirty = true; // build on first turn; invalidated by write notify hook
|
|
77
|
+
let editedThisTurn = false; // tracks edit/write activity for the task_complete reminder
|
|
78
|
+
let taskCompleteReminderSaidThisTurn = false;
|
|
79
|
+
const healedStatusRoots = new Set();
|
|
80
|
+
const PLANNER_COMMAND_COMPLETIONS = [
|
|
81
|
+
{ value: "init", label: "init", description: "Initialize planner in this project" },
|
|
82
|
+
{ value: "show", label: "show", description: "Show planner overview" },
|
|
83
|
+
{ value: "repair", label: "repair", description: "Repair planner integrity" },
|
|
84
|
+
{ value: "project discuss", label: "project discuss", description: "Run project discovery" },
|
|
85
|
+
{ value: "project language", label: "project language", description: "Set persistent language preferences" },
|
|
86
|
+
{ value: "feature list", label: "feature list", description: "List features" },
|
|
87
|
+
{ value: "feature add", label: "feature add", description: "Create a feature" },
|
|
88
|
+
{ value: "feature show", label: "feature show", description: "Show a feature" },
|
|
89
|
+
{ value: "feature update", label: "feature update", description: "Update a feature" },
|
|
90
|
+
{ value: "feature delete", label: "feature delete", description: "Delete a feature" },
|
|
91
|
+
{ value: "phase add", label: "phase add", description: "Add a phase" },
|
|
92
|
+
{ value: "phase show", label: "phase show", description: "Show a phase" },
|
|
93
|
+
{ value: "phase discuss", label: "phase discuss", description: "Discuss a phase" },
|
|
94
|
+
{ value: "phase update", label: "phase update", description: "Update a phase" },
|
|
95
|
+
{ value: "phase delete", label: "phase delete", description: "Delete a phase" },
|
|
96
|
+
{ value: "task add", label: "task add", description: "Add a task" },
|
|
97
|
+
{ value: "task show", label: "task show", description: "Show a task" },
|
|
98
|
+
{ value: "task discuss", label: "task discuss", description: "Discuss a task" },
|
|
99
|
+
{ value: "task update", label: "task update", description: "Update a task" },
|
|
100
|
+
{ value: "task delete", label: "task delete", description: "Delete a task" },
|
|
101
|
+
{ value: "task start", label: "task start", description: "Mark a task in-progress" },
|
|
102
|
+
{ value: "task complete", label: "task complete", description: "Mark a task done" },
|
|
103
|
+
{ value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to create/update the handoff" },
|
|
104
|
+
{ value: "handoff show", label: "handoff show", description: "Show the current handoff" },
|
|
105
|
+
{ value: "handoff write", label: "handoff write", description: "Write handoff directly from planner data" },
|
|
106
|
+
{ value: "handoff clear", label: "handoff clear", description: "Delete the current handoff" },
|
|
107
|
+
{ value: "web start", label: "web start", description: "Start the web UI" },
|
|
108
|
+
{ value: "web stop", label: "web stop", description: "Stop the web UI" },
|
|
109
|
+
{ value: "web status", label: "web status", description: "Show web UI status" },
|
|
110
|
+
{ value: "export", label: "export", description: "Export plan summary as Markdown" },
|
|
111
|
+
{ value: "export-full", label: "export-full", description: "Export full detailed plan as Markdown" },
|
|
112
|
+
{ value: "bypass", label: "bypass", description: "Authorize edit/write without a task in-progress (15 min)" },
|
|
113
|
+
{ value: "clear-bypass", label: "clear-bypass", description: "Revoke the guard bypass" },
|
|
114
|
+
{ value: "load", label: "load", description: "Re-enable planner and start web UI" },
|
|
115
|
+
{ value: "disable", label: "disable", description: "Reset planner preferences and disable for this session" },
|
|
116
|
+
];
|
|
117
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
118
|
+
function nowISO() {
|
|
119
|
+
return new Date().toISOString();
|
|
120
|
+
}
|
|
121
|
+
function manifestPathFor(root) {
|
|
122
|
+
return join(root, "manifest.json");
|
|
123
|
+
}
|
|
124
|
+
function createRequirementId() {
|
|
125
|
+
return crypto.randomUUID();
|
|
126
|
+
}
|
|
127
|
+
function planRootForCwd(cwd) {
|
|
128
|
+
return join(cwd, PLAN_DIR_NAME);
|
|
129
|
+
}
|
|
130
|
+
function rootHasPlan(root) {
|
|
131
|
+
return existsSync(manifestPathFor(root));
|
|
132
|
+
}
|
|
133
|
+
function resolvePlanRoot(cwd) {
|
|
134
|
+
const plannerRoot = planRootForCwd(cwd);
|
|
135
|
+
if (rootHasPlan(plannerRoot))
|
|
136
|
+
return plannerRoot;
|
|
137
|
+
return plannerRoot;
|
|
138
|
+
}
|
|
139
|
+
function ensureStore(ctx) {
|
|
140
|
+
const root = resolvePlanRoot(ctx.cwd);
|
|
141
|
+
if (!store || store.root !== root) {
|
|
142
|
+
store = new PlanStore(root);
|
|
143
|
+
store.enableAutoSync(true);
|
|
144
|
+
}
|
|
145
|
+
return store;
|
|
146
|
+
}
|
|
147
|
+
function resetState() {
|
|
148
|
+
store = null;
|
|
149
|
+
plannerSessionEnabled = false;
|
|
150
|
+
startupResumePromptPending = false;
|
|
151
|
+
startupResumeSummaryPending = false;
|
|
152
|
+
startupResumeSummaryText = "";
|
|
153
|
+
plannerHeavyInitDone = false;
|
|
154
|
+
contextBlockCache = "";
|
|
155
|
+
contextBlockDirty = true;
|
|
156
|
+
editedThisTurn = false;
|
|
157
|
+
taskCompleteReminderSaidThisTurn = false;
|
|
158
|
+
}
|
|
159
|
+
async function maybeHealStatuses(st) {
|
|
160
|
+
if (healedStatusRoots.has(st.root))
|
|
161
|
+
return;
|
|
162
|
+
await st.syncStatuses();
|
|
163
|
+
healedStatusRoots.add(st.root);
|
|
164
|
+
}
|
|
165
|
+
function normalizeLanguagePref(value) {
|
|
166
|
+
return value?.trim() ?? "";
|
|
167
|
+
}
|
|
168
|
+
async function ensureProjectLanguagePreferences(st) {
|
|
169
|
+
const project = await st.loadProject();
|
|
170
|
+
const contentLanguage = normalizeLanguagePref(project.contentLanguage);
|
|
171
|
+
const chatLanguage = normalizeLanguagePref(project.chatLanguage);
|
|
172
|
+
if (contentLanguage && chatLanguage) {
|
|
173
|
+
if (project.contentLanguage !== contentLanguage || project.chatLanguage !== chatLanguage) {
|
|
174
|
+
project.contentLanguage = contentLanguage;
|
|
175
|
+
project.chatLanguage = chatLanguage;
|
|
176
|
+
await st.saveProject(project);
|
|
177
|
+
}
|
|
178
|
+
return project;
|
|
179
|
+
}
|
|
180
|
+
if (contentLanguage || chatLanguage) {
|
|
181
|
+
const fallback = contentLanguage || chatLanguage;
|
|
182
|
+
project.contentLanguage = contentLanguage || fallback;
|
|
183
|
+
project.chatLanguage = chatLanguage || fallback;
|
|
184
|
+
await st.saveProject(project);
|
|
185
|
+
return project;
|
|
186
|
+
}
|
|
187
|
+
return project;
|
|
188
|
+
}
|
|
189
|
+
async function getPlannerExecutionGuard(st) {
|
|
190
|
+
const [workspace, resume] = await Promise.all([st.loadAll(), st.loadResume()]);
|
|
191
|
+
const allTasks = workspace.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
192
|
+
const inProgress = allTasks.filter(({ task }) => task.status === "in-progress");
|
|
193
|
+
const totalTasks = allTasks.length;
|
|
194
|
+
const focusFromResume = resume?.inProgressTaskIds?.[0]
|
|
195
|
+
? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
|
|
196
|
+
: undefined;
|
|
197
|
+
const focusFromCurrentPhase = resume?.currentPhaseId
|
|
198
|
+
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled")
|
|
199
|
+
: undefined;
|
|
200
|
+
const fallbackFocus = allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked")
|
|
201
|
+
?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled");
|
|
202
|
+
const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
|
|
203
|
+
return {
|
|
204
|
+
totalTasks,
|
|
205
|
+
inProgressTaskIds: inProgress.map(({ task }) => task.id),
|
|
206
|
+
focusTaskId: focus?.task.id ?? "",
|
|
207
|
+
focusTaskTitle: focus?.task.title ?? "",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function formatSequence(value) {
|
|
211
|
+
return String(value && value > 0 ? value : 0).padStart(3, "0");
|
|
212
|
+
}
|
|
213
|
+
function featureLabel(feature) {
|
|
214
|
+
return `F${formatSequence(feature.number)} — ${feature.name}`;
|
|
215
|
+
}
|
|
216
|
+
function phaseLabel(phase) {
|
|
217
|
+
return `P${formatSequence(phase.number)} — ${phase.title}`;
|
|
218
|
+
}
|
|
219
|
+
function taskLabel(task) {
|
|
220
|
+
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
221
|
+
}
|
|
222
|
+
async function buildStartupResumeSummary(st) {
|
|
223
|
+
const [plan, resume, handoff] = await Promise.all([
|
|
224
|
+
st.loadAll(),
|
|
225
|
+
st.refreshResume(),
|
|
226
|
+
st.loadHandoff(),
|
|
227
|
+
]);
|
|
228
|
+
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
229
|
+
const orderedPhases = [
|
|
230
|
+
...plan.features.features.flatMap((feature) => {
|
|
231
|
+
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
232
|
+
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
233
|
+
const inferred = plan.phases
|
|
234
|
+
.filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
|
|
235
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
|
|
236
|
+
return [...linked, ...inferred];
|
|
237
|
+
}),
|
|
238
|
+
...plan.phases
|
|
239
|
+
.filter((phase) => !phase.featureId)
|
|
240
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
241
|
+
];
|
|
242
|
+
const allTasks = orderedPhases.flatMap((phase) => [...phase.tasks]
|
|
243
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
244
|
+
.map((task) => ({ phase, task })));
|
|
245
|
+
const totalFeatures = plan.features.features.length;
|
|
246
|
+
const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
|
|
247
|
+
const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
|
|
248
|
+
const totalPhases = plan.phases.length;
|
|
249
|
+
const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
|
|
250
|
+
const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
|
|
251
|
+
const totalTasks = allTasks.length;
|
|
252
|
+
const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
|
|
253
|
+
const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
|
|
254
|
+
const hasActiveWork = activeFeatures > 0 || activePhases > 0 || activeTasks > 0;
|
|
255
|
+
const currentPhase = hasActiveWork
|
|
256
|
+
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
257
|
+
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
258
|
+
?? orderedPhases.find((phase) => phase.status === "in-progress")
|
|
259
|
+
?? null
|
|
260
|
+
: null;
|
|
261
|
+
const currentTask = hasActiveWork
|
|
262
|
+
? ([...(currentPhase?.tasks ?? [])]
|
|
263
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
264
|
+
.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
265
|
+
?? [...(currentPhase?.tasks ?? [])]
|
|
266
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
267
|
+
.find((task) => task.status === "in-progress")
|
|
268
|
+
?? allTasks.find(({ task }) => task.status === "in-progress")?.task
|
|
269
|
+
?? null)
|
|
270
|
+
: null;
|
|
271
|
+
const currentFeature = hasActiveWork
|
|
272
|
+
? (currentPhase?.featureId
|
|
273
|
+
? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
|
|
274
|
+
: plan.features.features.find((feature) => feature.status === "in-progress") ?? null)
|
|
275
|
+
: null;
|
|
276
|
+
const nextActivity = hasActiveWork
|
|
277
|
+
? (resume.nextSteps[0]
|
|
278
|
+
?? (currentTask
|
|
279
|
+
? `riprendere il task ${currentTask.id} — ${taskLabel(currentTask)}`
|
|
280
|
+
: currentPhase
|
|
281
|
+
? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
|
|
282
|
+
: "riprendere il lavoro attivo corrente"))
|
|
283
|
+
: (handoff?.updatedAt
|
|
284
|
+
? "leggere l'handoff e verificare ordine, dipendenze e stato reale del piano prima di scegliere il prossimo task"
|
|
285
|
+
: "rivedere il piano e scegliere il prossimo task concreto");
|
|
286
|
+
const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
|
|
287
|
+
const italian = chatLanguage.includes("ital");
|
|
288
|
+
const localUrl = server?.localUrl ?? server?.url ?? "";
|
|
289
|
+
const lanUrl = server?.lanUrl ?? "";
|
|
290
|
+
const webUrl = lanUrl ? `${localUrl} (LAN: ${lanUrl})` : localUrl;
|
|
291
|
+
if (italian || !chatLanguage) {
|
|
292
|
+
return [
|
|
293
|
+
"### Summary di ripresa planner",
|
|
294
|
+
`- Progresso: ${doneFeatures}/${totalFeatures} feature completate (${activeFeatures} attive), ${donePhases}/${totalPhases} fasi completate (${activePhases} attive), ${doneTasks}/${totalTasks} task completati (${activeTasks} attivi).`,
|
|
295
|
+
currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
|
|
296
|
+
currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
|
|
297
|
+
currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
|
|
298
|
+
handoff?.updatedAt ? `- Handoff letto da .planner/HANDOFF.md (aggiornato ${handoff.updatedAt}).` : "- Nessun handoff strutturato disponibile.",
|
|
299
|
+
!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." : "",
|
|
300
|
+
webUrl ? `- Dashboard web: ${webUrl}` : "- Dashboard web: non attiva in questa sessione.",
|
|
301
|
+
`- Prossima attività consigliata: ${nextActivity}.`,
|
|
302
|
+
"",
|
|
303
|
+
"Vuoi che riprendiamo da qui?",
|
|
304
|
+
].filter(Boolean).join("\n");
|
|
305
|
+
}
|
|
306
|
+
return [
|
|
307
|
+
"### Planner resume summary",
|
|
308
|
+
`- Progress: ${doneFeatures}/${totalFeatures} features done (${activeFeatures} active), ${donePhases}/${totalPhases} phases done (${activePhases} active), ${doneTasks}/${totalTasks} tasks done (${activeTasks} active).`,
|
|
309
|
+
currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
|
|
310
|
+
currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
|
|
311
|
+
currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
|
|
312
|
+
handoff?.updatedAt ? `- Handoff loaded from .planner/HANDOFF.md (updated ${handoff.updatedAt}).` : "- No structured handoff found.",
|
|
313
|
+
!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." : "",
|
|
314
|
+
webUrl ? `- Web dashboard: ${webUrl}` : "- Web dashboard: not active in this session.",
|
|
315
|
+
`- Recommended next activity: ${nextActivity}.`,
|
|
316
|
+
"",
|
|
317
|
+
"Do you want to resume from here?",
|
|
318
|
+
].filter(Boolean).join("\n");
|
|
319
|
+
}
|
|
320
|
+
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
321
|
+
const [plan, resume, activity, existingHandoff] = await Promise.all([
|
|
322
|
+
st.loadAll(),
|
|
323
|
+
st.refreshResume(),
|
|
324
|
+
st.loadActivityLog(),
|
|
325
|
+
st.loadHandoff(),
|
|
326
|
+
]);
|
|
327
|
+
const createdAt = existingHandoff?.createdAt || nowISO();
|
|
328
|
+
const updatedAt = nowISO();
|
|
329
|
+
const allTasks = plan.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
330
|
+
const nonTerminalTasks = allTasks.filter(({ task }) => task.status !== "done" && task.status !== "canceled");
|
|
331
|
+
const latestTaskUpdate = [...allTasks].sort((left, right) => right.task.updatedAt.localeCompare(left.task.updatedAt))[0] ?? null;
|
|
332
|
+
const latestPhaseUpdate = [...plan.phases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
333
|
+
const latestFeatureUpdate = [...plan.features.features].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
334
|
+
const currentPhase = plan.phases.find((phase) => phase.id === resume.currentPhaseId)
|
|
335
|
+
?? plan.phases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
336
|
+
?? plan.phases.find((phase) => phase.status === "in-progress")
|
|
337
|
+
?? nonTerminalTasks[0]?.phase
|
|
338
|
+
?? latestPhaseUpdate
|
|
339
|
+
?? null;
|
|
340
|
+
const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
341
|
+
?? currentPhase?.tasks.find((task) => task.status === "in-progress")
|
|
342
|
+
?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
|
|
343
|
+
?? latestTaskUpdate?.task
|
|
344
|
+
?? null;
|
|
345
|
+
const currentFeature = currentPhase?.featureId
|
|
346
|
+
? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
|
|
347
|
+
: latestFeatureUpdate ?? null;
|
|
348
|
+
const recentActivity = activity.entries.slice(-8).reverse();
|
|
349
|
+
const defaultBootstrapStep = "Run /planner project discuss to bootstrap discovery";
|
|
350
|
+
const hasStructuredWork = plan.features.features.length > 0 || plan.phases.length > 0 || allTasks.length > 0;
|
|
351
|
+
const normalizedResumeNextSteps = (resume.nextSteps ?? []).filter((step) => !(hasStructuredWork && step === defaultBootstrapStep));
|
|
352
|
+
const totalFeatures = plan.features.features.length;
|
|
353
|
+
const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
|
|
354
|
+
const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
|
|
355
|
+
const totalPhases = plan.phases.length;
|
|
356
|
+
const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
|
|
357
|
+
const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
|
|
358
|
+
const totalTasks = allTasks.length;
|
|
359
|
+
const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
|
|
360
|
+
const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
|
|
361
|
+
const inferredRecentChanges = [
|
|
362
|
+
latestFeatureUpdate ? `Latest feature update: ${latestFeatureUpdate.name} (${latestFeatureUpdate.status}) at ${latestFeatureUpdate.updatedAt}` : "",
|
|
363
|
+
latestPhaseUpdate ? `Latest phase update: ${latestPhaseUpdate.title} (${latestPhaseUpdate.status}) at ${latestPhaseUpdate.updatedAt}` : "",
|
|
364
|
+
latestTaskUpdate ? `Latest task update: ${latestTaskUpdate.task.title} (${latestTaskUpdate.task.status}) at ${latestTaskUpdate.task.updatedAt}` : "",
|
|
365
|
+
].filter(Boolean);
|
|
366
|
+
const whatWasBeingDone = (overrides?.whatWasBeingDone
|
|
367
|
+
?? currentTask?.notes
|
|
368
|
+
?? currentTask?.description
|
|
369
|
+
?? currentPhase?.notes
|
|
370
|
+
?? resume.notes
|
|
371
|
+
?? [
|
|
372
|
+
currentFeature ? `Work appears to have been centered on feature ${currentFeature.id} — ${currentFeature.name} (${currentFeature.status}).` : "",
|
|
373
|
+
currentPhase ? `The most relevant phase is ${currentPhase.id} — ${currentPhase.title} (${currentPhase.status}).` : "",
|
|
374
|
+
currentTask ? `The most relevant task is ${currentTask.id} — ${currentTask.title} (${currentTask.status}).` : "",
|
|
375
|
+
inferredRecentChanges.length > 0 ? `Recent planner changes:\n${inferredRecentChanges.map((line) => `- ${line}`).join("\n")}` : "",
|
|
376
|
+
].filter(Boolean).join("\n\n")) || "No additional execution notes were captured.";
|
|
377
|
+
const inferredNextSteps = overrides?.nextSteps
|
|
378
|
+
?? normalizedResumeNextSteps.length > 0
|
|
379
|
+
? normalizedResumeNextSteps
|
|
380
|
+
: currentTask && currentTask.status === "in-progress"
|
|
381
|
+
? [
|
|
382
|
+
`Resume task ${currentTask.id} — ${currentTask.title}.`,
|
|
383
|
+
`Review the task details and continue implementation in phase ${currentPhase?.id ?? currentTask.phaseId}.`,
|
|
384
|
+
"When the work is complete, call task_complete so the derived phase/feature statuses stay correct.",
|
|
385
|
+
]
|
|
386
|
+
: currentTask
|
|
387
|
+
? [
|
|
388
|
+
`Start task ${currentTask.id} — ${currentTask.title} with /planner task start (or task_start) before doing implementation work.`,
|
|
389
|
+
`Then continue work in phase ${currentPhase?.id ?? currentTask.phaseId}.`,
|
|
390
|
+
]
|
|
391
|
+
: currentPhase && currentPhase.status !== "done"
|
|
392
|
+
? [
|
|
393
|
+
`Review phase ${currentPhase.id} — ${currentPhase.title}.`,
|
|
394
|
+
"Pick the next actionable task in that phase and start it with /planner task start before editing code.",
|
|
395
|
+
]
|
|
396
|
+
: plan.phases.find((phase) => phase.status === "planned" || phase.status === "draft" || phase.status === "discovery")
|
|
397
|
+
? [
|
|
398
|
+
`Review the next non-complete phase: ${plan.phases.find((phase) => phase.status === "planned" || phase.status === "draft" || phase.status === "discovery").id}.`,
|
|
399
|
+
"Create or start the next task in that phase.",
|
|
400
|
+
]
|
|
401
|
+
: hasStructuredWork
|
|
402
|
+
? ["Review the latest updated feature/phase/task and choose the next concrete task to start."]
|
|
403
|
+
: [defaultBootstrapStep];
|
|
404
|
+
const blockers = overrides?.blockers ?? resume.blockers;
|
|
405
|
+
const howToResume = overrides?.howToResume
|
|
406
|
+
?? [
|
|
407
|
+
currentTask
|
|
408
|
+
? `1. Open task ${currentTask.id} (${currentTask.title}).`
|
|
409
|
+
: currentPhase
|
|
410
|
+
? `1. Open phase ${currentPhase.id} (${currentPhase.title}).`
|
|
411
|
+
: "1. Open the planner dashboard and inspect the latest feature/phase state.",
|
|
412
|
+
"2. Read `.planner/HANDOFF.md` and compare it with the latest planner data.",
|
|
413
|
+
currentTask && currentTask.status !== "in-progress"
|
|
414
|
+
? `3. Before implementation work, run /planner task start ${currentTask.id} (or call task_start).`
|
|
415
|
+
: "3. Confirm whether the current task is already in-progress before doing implementation work.",
|
|
416
|
+
`4. Continue with the next activity: ${inferredNextSteps[0] ?? "choose the next concrete task"}`,
|
|
417
|
+
].join("\n");
|
|
418
|
+
const filesTouched = [
|
|
419
|
+
".planner/project.json",
|
|
420
|
+
".planner/features.json",
|
|
421
|
+
currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
|
|
422
|
+
latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
|
|
423
|
+
".planner/resume.json",
|
|
424
|
+
".planner/HANDOFF.md",
|
|
425
|
+
".planner/generated/PLAN.md",
|
|
426
|
+
].filter(Boolean);
|
|
427
|
+
const recentActivityLines = recentActivity.length > 0
|
|
428
|
+
? recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`)
|
|
429
|
+
: inferredRecentChanges.length > 0
|
|
430
|
+
? inferredRecentChanges.map((entry) => `- ${entry}`)
|
|
431
|
+
: ["- No recent activity recorded"];
|
|
432
|
+
return [
|
|
433
|
+
"# Handoff",
|
|
434
|
+
"",
|
|
435
|
+
`Created at: ${createdAt}`,
|
|
436
|
+
`Updated at: ${updatedAt}`,
|
|
437
|
+
`Reason: ${reason}`,
|
|
438
|
+
"",
|
|
439
|
+
"## Progress snapshot",
|
|
440
|
+
`- Features: ${doneFeatures}/${totalFeatures} done, ${activeFeatures} active`,
|
|
441
|
+
`- Phases: ${donePhases}/${totalPhases} done, ${activePhases} active/discovery`,
|
|
442
|
+
`- Tasks: ${doneTasks}/${totalTasks} done, ${activeTasks} active`,
|
|
443
|
+
"",
|
|
444
|
+
"## Current focus",
|
|
445
|
+
currentFeature ? `- Feature: \`${currentFeature.id}\` — ${currentFeature.name} (${currentFeature.status})` : "- Feature: (none)",
|
|
446
|
+
currentPhase ? `- Phase: \`${currentPhase.id}\` — ${currentPhase.title} (${currentPhase.status})` : "- Phase: (none)",
|
|
447
|
+
currentTask ? `- Task: \`${currentTask.id}\` — ${currentTask.title} (${currentTask.status})` : "- Task: (none)",
|
|
448
|
+
"",
|
|
449
|
+
"## What was being done",
|
|
450
|
+
whatWasBeingDone || "No additional execution notes were captured.",
|
|
451
|
+
"",
|
|
452
|
+
"## How to resume",
|
|
453
|
+
howToResume,
|
|
454
|
+
"",
|
|
455
|
+
"## Files to inspect first",
|
|
456
|
+
...filesTouched.map((entry) => `- ${entry}`),
|
|
457
|
+
"",
|
|
458
|
+
"## Blockers",
|
|
459
|
+
...(blockers.length > 0 ? blockers.map((entry) => `- ${entry}`) : ["- None recorded"]),
|
|
460
|
+
"",
|
|
461
|
+
"## Next steps",
|
|
462
|
+
...(inferredNextSteps.length > 0 ? inferredNextSteps.map((entry) => `- ${entry}`) : ["- None recorded"]),
|
|
463
|
+
"",
|
|
464
|
+
"## Recent activity",
|
|
465
|
+
...recentActivityLines,
|
|
466
|
+
"",
|
|
467
|
+
"## Reminder",
|
|
468
|
+
"- When work is fully resumed and this handoff is no longer needed, delete `.planner/HANDOFF.md`.",
|
|
469
|
+
].join("\n");
|
|
470
|
+
}
|
|
471
|
+
async function writeProjectHandoff(st, reason, overrides) {
|
|
472
|
+
const markdown = await buildHandoffMarkdown(st, reason, overrides);
|
|
473
|
+
await st.saveHandoff(markdown);
|
|
474
|
+
}
|
|
475
|
+
function compactShortcut(spec) {
|
|
476
|
+
return {
|
|
477
|
+
key: spec.key,
|
|
478
|
+
...(spec.primary ? { primary: true } : {}),
|
|
479
|
+
...(spec.meta ? { meta: true } : {}),
|
|
480
|
+
...(spec.ctrl ? { ctrl: true } : {}),
|
|
481
|
+
...(spec.shift ? { shift: true } : {}),
|
|
482
|
+
...(spec.alt ? { alt: true } : {}),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function parseShortcut(value) {
|
|
486
|
+
if (typeof value === "object" && value && "key" in value) {
|
|
487
|
+
const input = value;
|
|
488
|
+
if (typeof input.key !== "string" || !input.key)
|
|
489
|
+
return undefined;
|
|
490
|
+
return compactShortcut({
|
|
491
|
+
key: input.key,
|
|
492
|
+
primary: input.primary || false,
|
|
493
|
+
meta: input.meta || false,
|
|
494
|
+
ctrl: input.ctrl || false,
|
|
495
|
+
shift: input.shift || false,
|
|
496
|
+
alt: input.alt || false,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (typeof value !== "string")
|
|
500
|
+
return undefined;
|
|
501
|
+
const parts = value.split("+").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
502
|
+
if (parts.length === 0)
|
|
503
|
+
return undefined;
|
|
504
|
+
const key = parts[parts.length - 1] ?? "";
|
|
505
|
+
return compactShortcut({
|
|
506
|
+
key: key === "enter" ? "Enter" : key.length === 1 ? key : key,
|
|
507
|
+
meta: parts.includes("meta") || parts.includes("cmd"),
|
|
508
|
+
ctrl: parts.includes("ctrl"),
|
|
509
|
+
shift: parts.includes("shift"),
|
|
510
|
+
alt: parts.includes("alt") || parts.includes("option"),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function readShortcutSettingsFile(path) {
|
|
514
|
+
if (!existsSync(path))
|
|
515
|
+
return undefined;
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
518
|
+
const shortcuts = parsed.agentPlan?.shortcuts;
|
|
519
|
+
if (!shortcuts)
|
|
520
|
+
return undefined;
|
|
521
|
+
const resolved = {
|
|
522
|
+
create: parseShortcut(shortcuts.create),
|
|
523
|
+
edit: parseShortcut(shortcuts.edit),
|
|
524
|
+
delete: parseShortcut(shortcuts.delete),
|
|
525
|
+
submit: parseShortcut(shortcuts.submit),
|
|
526
|
+
};
|
|
527
|
+
return {
|
|
528
|
+
shortcuts: {
|
|
529
|
+
...(resolved.create ? { create: resolved.create } : {}),
|
|
530
|
+
...(resolved.edit ? { edit: resolved.edit } : {}),
|
|
531
|
+
...(resolved.delete ? { delete: resolved.delete } : {}),
|
|
532
|
+
...(resolved.submit ? { submit: resolved.submit } : {}),
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function readUiConfig(ctx) {
|
|
541
|
+
const globalSettings = readShortcutSettingsFile(join(homedir(), ".pi", "agent", "settings.json"));
|
|
542
|
+
const projectSettings = readShortcutSettingsFile(join(ctx.cwd, PI_CONFIG_DIR_NAME, "settings.json"));
|
|
543
|
+
return {
|
|
544
|
+
shortcuts: {
|
|
545
|
+
...globalSettings?.shortcuts,
|
|
546
|
+
...projectSettings?.shortcuts,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function statusIcon(status) {
|
|
551
|
+
const icons = {
|
|
552
|
+
draft: "📄", discovery: "🔍", planned: "📋",
|
|
553
|
+
"in-progress": "🚧", done: "✅", blocked: "🚫", canceled: "❌",
|
|
554
|
+
};
|
|
555
|
+
return icons[status] ?? "❓";
|
|
556
|
+
}
|
|
557
|
+
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
558
|
+
const previousStatus = task.status;
|
|
559
|
+
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
560
|
+
task.startedAt = now;
|
|
561
|
+
}
|
|
562
|
+
if (nextStatus === "done") {
|
|
563
|
+
if (!task.startedAt)
|
|
564
|
+
task.startedAt = now;
|
|
565
|
+
task.completedAt = now;
|
|
566
|
+
}
|
|
567
|
+
else if (previousStatus === "done") {
|
|
568
|
+
task.completedAt = "";
|
|
569
|
+
}
|
|
570
|
+
task.status = nextStatus;
|
|
571
|
+
}
|
|
572
|
+
async function isPortAvailable(port) {
|
|
573
|
+
return await new Promise((resolve) => {
|
|
574
|
+
const probe = createServer();
|
|
575
|
+
probe.once("error", () => resolve(false));
|
|
576
|
+
probe.once("listening", () => {
|
|
577
|
+
probe.close(() => resolve(true));
|
|
578
|
+
});
|
|
579
|
+
probe.listen(port, "127.0.0.1");
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
async function pickProjectPort(ctx, explicitPort) {
|
|
583
|
+
const st = ensureStore(ctx);
|
|
584
|
+
const project = await st.loadProject();
|
|
585
|
+
if (explicitPort && explicitPort > 0) {
|
|
586
|
+
if (!(await isPortAvailable(explicitPort)) && project.webPort !== explicitPort) {
|
|
587
|
+
throw new Error(`Port ${explicitPort} is already in use`);
|
|
588
|
+
}
|
|
589
|
+
project.webPort = explicitPort;
|
|
590
|
+
await st.saveProject(project);
|
|
591
|
+
return explicitPort;
|
|
592
|
+
}
|
|
593
|
+
if (project.webPort > 0) {
|
|
594
|
+
return project.webPort;
|
|
595
|
+
}
|
|
596
|
+
for (let port = 3030; port <= 3999; port += 1) {
|
|
597
|
+
if (await isPortAvailable(port)) {
|
|
598
|
+
project.webPort = port;
|
|
599
|
+
await st.saveProject(project);
|
|
600
|
+
return port;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
throw new Error("No free port found in range 3030-3999");
|
|
604
|
+
}
|
|
605
|
+
// ─── Server lifecycle (uses capturedPi) ─────────────────────────────────
|
|
606
|
+
function resolveStaticDir() {
|
|
607
|
+
try {
|
|
608
|
+
const adapterFile = fileURLToPath(import.meta.url);
|
|
609
|
+
const adapterDir = dirname(adapterFile);
|
|
610
|
+
// When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
|
|
611
|
+
const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
|
|
612
|
+
if (existsSync(pkgWebUi))
|
|
613
|
+
return pkgWebUi;
|
|
614
|
+
// Fallback: monorepo dev — packages/plan-web-ui/dist/
|
|
615
|
+
return join(adapterDir, "..", "..", "plan-web-ui", "dist");
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async function maybeStartWeb(ctx) {
|
|
622
|
+
if (server) {
|
|
623
|
+
plannerSessionEnabled = true;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const ans = await ctx.ui.input("Planner active for this session. Start the web UI now? (y/N)");
|
|
627
|
+
if (ans && /^(y|yes|s|si|sì)$/i.test(ans.trim())) {
|
|
628
|
+
ctx.ui.notify("Starting web server…", "info");
|
|
629
|
+
try {
|
|
630
|
+
await startServer(ctx);
|
|
631
|
+
ctx.ui.notify(`Web UI ready. Open: ${server?.url ?? "?"}`, "info");
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
ctx.ui.notify(`Failed to start web server: ${String(err)}`, "error");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function normalizeVisibility(input) {
|
|
639
|
+
const v = (input ?? "").trim().toLowerCase();
|
|
640
|
+
if (!v)
|
|
641
|
+
return undefined;
|
|
642
|
+
if (v === "lan" || v === "network" || v === "0.0.0.0")
|
|
643
|
+
return "lan";
|
|
644
|
+
if (v === "local" || v === "localhost" || v === "127.0.0.1")
|
|
645
|
+
return "local";
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
async function promptWebVisibility(ctx) {
|
|
649
|
+
try {
|
|
650
|
+
const ans = await ctx.ui.input("Web UI visibility: (local) only this machine / (lan) visible on the local network? [local/lan]");
|
|
651
|
+
const v = normalizeVisibility(ans);
|
|
652
|
+
if (v)
|
|
653
|
+
return v;
|
|
654
|
+
}
|
|
655
|
+
catch { }
|
|
656
|
+
return "local";
|
|
657
|
+
}
|
|
658
|
+
async function startServer(ctx, requestedPort, visibility = "local") {
|
|
659
|
+
plannerSessionEnabled = true;
|
|
660
|
+
if (server)
|
|
661
|
+
return;
|
|
662
|
+
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
663
|
+
const port = await pickProjectPort(ctx, requestedPort);
|
|
664
|
+
try {
|
|
665
|
+
server = await serve({
|
|
666
|
+
port,
|
|
667
|
+
host,
|
|
668
|
+
planRoot: resolvePlanRoot(ctx.cwd),
|
|
669
|
+
staticDir: resolveStaticDir(),
|
|
670
|
+
quiet: true,
|
|
671
|
+
uiConfig: readUiConfig(ctx),
|
|
672
|
+
isBusy: () => planBusy,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
catch (e) {
|
|
676
|
+
// EADDRINUSE or other listen failure: retry on a random free port.
|
|
677
|
+
if (e instanceof Error && /EADDRINUSE|EACCES|EADDRNOTAVAIL/.test(e.message)) {
|
|
678
|
+
try {
|
|
679
|
+
server = await serve({
|
|
680
|
+
port: 0,
|
|
681
|
+
host,
|
|
682
|
+
planRoot: resolvePlanRoot(ctx.cwd),
|
|
683
|
+
staticDir: resolveStaticDir(),
|
|
684
|
+
quiet: true,
|
|
685
|
+
uiConfig: readUiConfig(ctx),
|
|
686
|
+
isBusy: () => planBusy,
|
|
687
|
+
});
|
|
688
|
+
ctx.ui.notify(`Planner web server: requested port ${port} busy, started on ${server?.url ?? "?"} instead.`, "info");
|
|
689
|
+
}
|
|
690
|
+
catch (e2) {
|
|
691
|
+
ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
ctx.ui.notify(`Failed to start web server: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (server) {
|
|
699
|
+
// Extract the REAL listening port from server.url (especially after a
|
|
700
|
+
// retry on port 0, where the `port` variable still holds the requested 0).
|
|
701
|
+
let realPort = port;
|
|
702
|
+
try {
|
|
703
|
+
const p = Number(new URL(server.url).port);
|
|
704
|
+
if (p)
|
|
705
|
+
realPort = p;
|
|
706
|
+
}
|
|
707
|
+
catch { }
|
|
708
|
+
capturedPi?.appendEntry("plan-web-state", { running: true, port: realPort, mode: server.mode });
|
|
709
|
+
lastKnownWebPort = realPort;
|
|
710
|
+
// Persist the actual listening port in project data so other processes
|
|
711
|
+
// (e.g. subagents) can notify the server via HTTP.
|
|
712
|
+
try {
|
|
713
|
+
const st = ensureStore(ctx);
|
|
714
|
+
const project = await st.loadProject().catch(() => null);
|
|
715
|
+
if (project) {
|
|
716
|
+
project.webPort = realPort;
|
|
717
|
+
await st.saveProject(project);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch { }
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function stopServer() {
|
|
724
|
+
if (server) {
|
|
725
|
+
await server.close();
|
|
726
|
+
server = null;
|
|
727
|
+
}
|
|
728
|
+
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
729
|
+
}
|
|
730
|
+
// ─── Extension factory ──────────────────────────────────────────────────
|
|
731
|
+
export default function planPiExtension(pi) {
|
|
732
|
+
capturedPi = pi;
|
|
733
|
+
// ── Restore on session start/reload ─────────────────────────────────
|
|
734
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
735
|
+
try {
|
|
736
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
737
|
+
triggerCharacters: ["/", " "],
|
|
738
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
739
|
+
const line = lines[cursorLine] ?? "";
|
|
740
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
741
|
+
const match = beforeCursor.match(/(?:^|\s)(\/planner)(?:\s+(.*))?$/);
|
|
742
|
+
if (!match) {
|
|
743
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
744
|
+
}
|
|
745
|
+
const argPrefix = match[2];
|
|
746
|
+
const prefix = argPrefix === undefined ? match[1] : argPrefix;
|
|
747
|
+
const normalized = (argPrefix ?? "").trimStart().toLowerCase();
|
|
748
|
+
const items = PLANNER_COMMAND_COMPLETIONS
|
|
749
|
+
.filter((item) => !normalized || item.value.startsWith(normalized))
|
|
750
|
+
.map((item) => ({
|
|
751
|
+
...item,
|
|
752
|
+
value: argPrefix === undefined ? `/planner ${item.value}` : item.value,
|
|
753
|
+
}));
|
|
754
|
+
return {
|
|
755
|
+
prefix,
|
|
756
|
+
items: items.length > 0 ? items : PLANNER_COMMAND_COMPLETIONS,
|
|
757
|
+
};
|
|
758
|
+
},
|
|
759
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
760
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
761
|
+
},
|
|
762
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
763
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
764
|
+
},
|
|
765
|
+
}));
|
|
766
|
+
resetState();
|
|
767
|
+
autoHandoffTriggered = false;
|
|
768
|
+
previousContextPercent = 0;
|
|
769
|
+
const st = ensureStore(ctx);
|
|
770
|
+
st.enableAutoSync(true);
|
|
771
|
+
// Read any persisted web port (for reuse), but do NOT auto-start here.
|
|
772
|
+
// The two-step gating below decides whether to start the server.
|
|
773
|
+
let preferredPort;
|
|
774
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
775
|
+
if (entry.type === "custom" && entry.customType === "plan-web-state") {
|
|
776
|
+
const data = entry.data;
|
|
777
|
+
if (data?.port)
|
|
778
|
+
preferredPort = data.port;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const exists = await st.exists().catch(() => false);
|
|
782
|
+
let project = exists ? await st.loadProject().catch(() => null) : null;
|
|
783
|
+
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
784
|
+
let enablePlanner = false;
|
|
785
|
+
if (exists) {
|
|
786
|
+
if (project?.plannerAutoEnable) {
|
|
787
|
+
enablePlanner = true; // 'always' persisted — no prompt
|
|
788
|
+
}
|
|
789
|
+
else if (project?.plannerNeverAsk) {
|
|
790
|
+
enablePlanner = false; // 'never' persisted — no prompt, no activation
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
try {
|
|
794
|
+
const ans = await ctx.ui.input("Planner detected in this project. Enable the planner extension? (y)es / (n)o / (a)lways / n(e)ver)");
|
|
795
|
+
const normalized = ans?.trim().toLowerCase() ?? "";
|
|
796
|
+
if (["a", "always", "sempre"].includes(normalized)) {
|
|
797
|
+
enablePlanner = true;
|
|
798
|
+
if (project) {
|
|
799
|
+
project.plannerAutoEnable = true;
|
|
800
|
+
project.plannerNeverAsk = false;
|
|
801
|
+
await st.saveProject(project);
|
|
802
|
+
}
|
|
803
|
+
ctx.ui.notify("Saved: planner will auto-enable for this project.", "info");
|
|
804
|
+
}
|
|
805
|
+
else if (["e", "never", "mai"].includes(normalized)) {
|
|
806
|
+
enablePlanner = false;
|
|
807
|
+
if (project) {
|
|
808
|
+
project.plannerNeverAsk = true;
|
|
809
|
+
project.plannerAutoEnable = false;
|
|
810
|
+
await st.saveProject(project);
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
814
|
+
}
|
|
815
|
+
catch { }
|
|
816
|
+
ctx.ui.notify("Saved: planner will not ask again for this project. Use '/planner load' to re-enable manually.", "info");
|
|
817
|
+
}
|
|
818
|
+
else if (/^(y|yes|s|si|sì)$/i.test(normalized)) {
|
|
819
|
+
enablePlanner = true; // this session only
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
// 'n' / 'no' / empty — this session only, NOT persisted
|
|
823
|
+
enablePlanner = false;
|
|
824
|
+
try {
|
|
825
|
+
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
826
|
+
}
|
|
827
|
+
catch { }
|
|
828
|
+
ctx.ui.notify("Planner disabled for this session. Use '/planner load' to enable manually.", "info");
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
enablePlanner = false;
|
|
833
|
+
try {
|
|
834
|
+
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
835
|
+
}
|
|
836
|
+
catch { }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
plannerSessionEnabled = enablePlanner;
|
|
841
|
+
// Proactive check for leftover handoff files.
|
|
842
|
+
if (plannerSessionEnabled) {
|
|
843
|
+
const handoffPath = join(st.root, "HANDOFF.md");
|
|
844
|
+
if (existsSync(handoffPath)) {
|
|
845
|
+
ctx.ui.notify("🚨 HANDOFF DETECTED: Read and delete .planner/HANDOFF.md immediately to avoid state conflicts.", "warning");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
|
|
849
|
+
if (exists && !enablePlanner) {
|
|
850
|
+
startupResumePromptPending = false;
|
|
851
|
+
startupResumeSummaryPending = false;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
// ── Step 2: Web UI gating (only if planner enabled) ─────────────
|
|
855
|
+
startupResumePromptPending = exists && plannerSessionEnabled;
|
|
856
|
+
startupResumeSummaryPending = exists && plannerSessionEnabled;
|
|
857
|
+
if (exists && plannerSessionEnabled) {
|
|
858
|
+
// Migration first.
|
|
859
|
+
try {
|
|
860
|
+
await migrateToUuids(st);
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
ctx.ui.notify(`Migration failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
864
|
+
}
|
|
865
|
+
await st.ensureStructureOrdering().catch(() => { });
|
|
866
|
+
// Reconcile derived statuses once on startup: backfills any drift from
|
|
867
|
+
// manual edits, pre-fix data, or tools that bypassed the rollup.
|
|
868
|
+
await st.syncStatuses().catch(() => { });
|
|
869
|
+
// Decide whether to start the web UI based on persisted prefs or prompt.
|
|
870
|
+
let startWeb = false;
|
|
871
|
+
if (project?.plannerAutoStartWeb) {
|
|
872
|
+
startWeb = true; // 'always' persisted
|
|
873
|
+
}
|
|
874
|
+
else if (project?.plannerNeverStartWeb) {
|
|
875
|
+
startWeb = false; // 'never' persisted
|
|
876
|
+
}
|
|
877
|
+
else if (server === null) {
|
|
878
|
+
try {
|
|
879
|
+
const ans = await ctx.ui.input("Start the planner web UI? (y)es / (n)o / (a)lways / n(e)ver)");
|
|
880
|
+
const normalized = ans?.trim().toLowerCase() ?? "";
|
|
881
|
+
if (["a", "always", "sempre"].includes(normalized)) {
|
|
882
|
+
startWeb = true;
|
|
883
|
+
if (project) {
|
|
884
|
+
project.plannerAutoStartWeb = true;
|
|
885
|
+
project.plannerNeverStartWeb = false;
|
|
886
|
+
await st.saveProject(project);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
else if (["e", "never", "mai"].includes(normalized)) {
|
|
890
|
+
startWeb = false;
|
|
891
|
+
if (project) {
|
|
892
|
+
project.plannerNeverStartWeb = true;
|
|
893
|
+
project.plannerAutoStartWeb = false;
|
|
894
|
+
await st.saveProject(project);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else if (/^(y|yes|s|si|sì)$/i.test(normalized)) {
|
|
898
|
+
startWeb = true;
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
startWeb = false; // 'n' / 'no' / empty — this session only
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
startWeb = false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (startWeb && server === null) {
|
|
909
|
+
const visibility = await promptWebVisibility(ctx);
|
|
910
|
+
await startServer(ctx, preferredPort, visibility).catch(() => { });
|
|
911
|
+
const srv = server;
|
|
912
|
+
if (srv) {
|
|
913
|
+
const urls = srv.lanUrl ? `${srv.localUrl} (LAN: ${srv.lanUrl})` : srv.url;
|
|
914
|
+
ctx.ui.notify(`Web UI started at ${urls}`, "info");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const url = server?.url ?? "Starting server...";
|
|
918
|
+
ctx.ui.notify(`Planner enabled. Dashboard: ${url}\nAnalyzing project state and preparing resume summary...`, "info");
|
|
919
|
+
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
920
|
+
await maybeHealStatuses(st).catch(() => { });
|
|
921
|
+
// Background cleanup of orphan .bak/.tmp.* files (non-blocking)
|
|
922
|
+
st.cleanupOrphanBackups().catch(() => { });
|
|
923
|
+
const handoff = await st.loadHandoff().catch(() => null);
|
|
924
|
+
if (handoff?.content) {
|
|
925
|
+
ctx.ui.notify(`Handoff detected at .planner/HANDOFF.md (updated ${handoff.updatedAt}). It will be loaded automatically when the agent starts.`, "info");
|
|
926
|
+
}
|
|
927
|
+
pi.sendMessage({
|
|
928
|
+
customType: "planner-resume-trigger",
|
|
929
|
+
content: "Emit the mandatory planner startup resume summary now.",
|
|
930
|
+
display: false,
|
|
931
|
+
}, {
|
|
932
|
+
triggerTurn: true,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch (e) {
|
|
937
|
+
// Defensive: never let session_start throw crash Pi silently.
|
|
938
|
+
try {
|
|
939
|
+
ctx.ui.notify(`Planner session_start error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
940
|
+
}
|
|
941
|
+
catch { }
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
pi.on("session_before_switch", async (event, ctx) => {
|
|
945
|
+
try {
|
|
946
|
+
const st = ensureStore(ctx);
|
|
947
|
+
if (await st.exists().catch(() => false)) {
|
|
948
|
+
await writeProjectHandoff(st, `before session ${event.reason}`).catch(() => { });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch { }
|
|
952
|
+
});
|
|
953
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
954
|
+
try {
|
|
955
|
+
const st = ensureStore(ctx);
|
|
956
|
+
if (await st.exists().catch(() => false)) {
|
|
957
|
+
await writeProjectHandoff(st, "before compact").catch(() => { });
|
|
958
|
+
autoHandoffTriggered = true;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
catch { }
|
|
962
|
+
});
|
|
963
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
964
|
+
try {
|
|
965
|
+
const st = store;
|
|
966
|
+
if (st && await st.exists().catch(() => false)) {
|
|
967
|
+
await writeProjectHandoff(st, `session shutdown (${event.reason})`).catch(() => { });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch { }
|
|
971
|
+
await stopServer().catch(() => { });
|
|
972
|
+
resetState();
|
|
973
|
+
capturedPi = null;
|
|
974
|
+
});
|
|
975
|
+
pi.on("message_end", async (event, _ctx) => {
|
|
976
|
+
if (!plannerSessionEnabled)
|
|
977
|
+
return;
|
|
978
|
+
if (!startupResumeSummaryPending)
|
|
979
|
+
return;
|
|
980
|
+
if (event.message.role !== "assistant")
|
|
981
|
+
return;
|
|
982
|
+
const hasVisibleText = event.message.content.some((item) => item.type === "text" && Boolean(item.text?.trim()));
|
|
983
|
+
if (!hasVisibleText)
|
|
984
|
+
return;
|
|
985
|
+
startupResumeSummaryPending = false;
|
|
986
|
+
const summaryText = startupResumeSummaryText.trim();
|
|
987
|
+
startupResumeSummaryText = "";
|
|
988
|
+
if (!summaryText)
|
|
989
|
+
return;
|
|
990
|
+
return {
|
|
991
|
+
message: {
|
|
992
|
+
...event.message,
|
|
993
|
+
content: [{ type: "text", text: summaryText }],
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
998
|
+
if (!plannerSessionEnabled)
|
|
999
|
+
return;
|
|
1000
|
+
if (event.toolName === "task_update") {
|
|
1001
|
+
const nextStatus = event.input?.status;
|
|
1002
|
+
const motivation = event.input?.motivation;
|
|
1003
|
+
if (nextStatus) {
|
|
1004
|
+
const st = ensureStore(ctx);
|
|
1005
|
+
const tasks = await st.loadAllPhases().then(phases => phases.flatMap(p => p.tasks));
|
|
1006
|
+
const task = tasks.find(t => t.id === event.input.taskId);
|
|
1007
|
+
if (task) {
|
|
1008
|
+
// Allow legal reopen: done → in-progress via task_update.
|
|
1009
|
+
if (nextStatus === "in-progress" && task.status === "done") {
|
|
1010
|
+
// Legal reopen — no motivation needed.
|
|
1011
|
+
}
|
|
1012
|
+
else if (needsMotivation(task.status, nextStatus)) {
|
|
1013
|
+
if (!motivation || !motivation.trim()) {
|
|
1014
|
+
return {
|
|
1015
|
+
block: true,
|
|
1016
|
+
reason: `Status transition "${task.status} → ${nextStatus}" requires a motivation. Add a "motivation" parameter with a detailed explanation of why this change is needed.`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// Guard the code-writing tools (edit/write). bash stays free so that
|
|
1024
|
+
// git pull, build, test, ls, etc. always work.
|
|
1025
|
+
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
1026
|
+
return;
|
|
1027
|
+
const st = loadStore(ctx);
|
|
1028
|
+
if (!(await st.exists().catch(() => false)))
|
|
1029
|
+
return;
|
|
1030
|
+
await maybeHealStatuses(st).catch(() => { });
|
|
1031
|
+
const guard = await getPlannerExecutionGuard(st).catch(() => null);
|
|
1032
|
+
if (!guard || guard.totalTasks === 0)
|
|
1033
|
+
return; // nothing to enforce yet
|
|
1034
|
+
if (guard.inProgressTaskIds.length > 0)
|
|
1035
|
+
return; // a task is open → we're good
|
|
1036
|
+
const focusHint = guard.focusTaskId
|
|
1037
|
+
? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
|
|
1038
|
+
: `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
|
|
1039
|
+
ctx.ui.notify(`⚠️ NO ACTIVE TASK: You are editing files without an in-progress task. Remember to update the plan to maintain dashboard integrity. ${focusHint}`, "warning");
|
|
1040
|
+
return; // Allow the tool to proceed
|
|
1041
|
+
});
|
|
1042
|
+
// Reset per-turn flags at the start of each turn.
|
|
1043
|
+
pi.on("turn_start", async () => {
|
|
1044
|
+
editedThisTurn = false;
|
|
1045
|
+
taskCompleteReminderSaidThisTurn = false;
|
|
1046
|
+
});
|
|
1047
|
+
// After edit/write succeeds, track activity and (once per turn, when a task
|
|
1048
|
+
// is in-progress) remind the agent to complete the task when the work is done.
|
|
1049
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
1050
|
+
if (!plannerSessionEnabled)
|
|
1051
|
+
return;
|
|
1052
|
+
if (event.toolName !== "edit" && event.toolName !== "write")
|
|
1053
|
+
return;
|
|
1054
|
+
editedThisTurn = true;
|
|
1055
|
+
if (taskCompleteReminderSaidThisTurn)
|
|
1056
|
+
return;
|
|
1057
|
+
try {
|
|
1058
|
+
const st = loadStore(ctx);
|
|
1059
|
+
if (!(await st.exists().catch(() => false)))
|
|
1060
|
+
return;
|
|
1061
|
+
const guard = await getPlannerExecutionGuard(st).catch(() => null);
|
|
1062
|
+
if (guard && guard.inProgressTaskIds.length > 0) {
|
|
1063
|
+
taskCompleteReminderSaidThisTurn = true;
|
|
1064
|
+
return {
|
|
1065
|
+
content: [{ type: "text", text: "Reminder: when this implementation work is finished, call task_complete (or /planner task complete) so the task status moves to done and phase/feature rollups stay correct." }],
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
catch { }
|
|
1070
|
+
});
|
|
1071
|
+
// ── Commands ───────────────────────────────────────────────────────
|
|
1072
|
+
// ── Planner handler (reusable from multiple commands) ──────────
|
|
1073
|
+
async function handlePlanner(args, ctx) {
|
|
1074
|
+
await withPlanBusy(() => runPlanner(args, ctx));
|
|
1075
|
+
}
|
|
1076
|
+
async function runPlanner(args, ctx) {
|
|
1077
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
1078
|
+
const [a, b, ...rest] = parts;
|
|
1079
|
+
const subArgs = rest.join(" ");
|
|
1080
|
+
const PLANNER_MENU_ACTIONS = [
|
|
1081
|
+
"init",
|
|
1082
|
+
"show",
|
|
1083
|
+
"repair",
|
|
1084
|
+
"project discuss",
|
|
1085
|
+
"project language",
|
|
1086
|
+
"feature list",
|
|
1087
|
+
"feature add",
|
|
1088
|
+
"feature show",
|
|
1089
|
+
"feature update",
|
|
1090
|
+
"feature delete",
|
|
1091
|
+
"phase add",
|
|
1092
|
+
"phase show",
|
|
1093
|
+
"phase discuss",
|
|
1094
|
+
"phase update",
|
|
1095
|
+
"phase delete",
|
|
1096
|
+
"task add",
|
|
1097
|
+
"task show",
|
|
1098
|
+
"task discuss",
|
|
1099
|
+
"task update",
|
|
1100
|
+
"task delete",
|
|
1101
|
+
"task start",
|
|
1102
|
+
"task complete",
|
|
1103
|
+
"handoff prepare",
|
|
1104
|
+
"handoff show",
|
|
1105
|
+
"handoff write",
|
|
1106
|
+
"handoff clear",
|
|
1107
|
+
"web start",
|
|
1108
|
+
"web stop",
|
|
1109
|
+
"web status",
|
|
1110
|
+
"export",
|
|
1111
|
+
"export-full",
|
|
1112
|
+
"bypass",
|
|
1113
|
+
"clear-bypass",
|
|
1114
|
+
"load",
|
|
1115
|
+
"disable",
|
|
1116
|
+
];
|
|
1117
|
+
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, disable\n" +
|
|
1118
|
+
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner export-full\n" +
|
|
1119
|
+
"Handoff actions: /planner handoff prepare | show | write | clear";
|
|
1120
|
+
if (!a) {
|
|
1121
|
+
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
1122
|
+
if (!action) {
|
|
1123
|
+
ctx.ui.notify(SUB_HELP, "info");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
await runPlanner(action, ctx);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const st = ensureStore(ctx);
|
|
1130
|
+
// ── init ──
|
|
1131
|
+
if (a === "init") {
|
|
1132
|
+
if (await st.exists()) {
|
|
1133
|
+
const confirm = await ctx.ui.input("Project already exists. Do you want to reset and reinitialize? (y/N)");
|
|
1134
|
+
if (!isYes(confirm)) {
|
|
1135
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const oldProject = await st.loadProject();
|
|
1139
|
+
ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
|
|
1140
|
+
if (server) {
|
|
1141
|
+
ctx.ui.notify("Stopping active web server...", "info");
|
|
1142
|
+
await server.close();
|
|
1143
|
+
server = null;
|
|
1144
|
+
}
|
|
1145
|
+
await rm(st.root, { recursive: true, force: true });
|
|
1146
|
+
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1147
|
+
if (!name?.trim()) {
|
|
1148
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
1152
|
+
await st.init(name.trim());
|
|
1153
|
+
const project = await st.loadProject();
|
|
1154
|
+
project.description = description?.trim() ?? oldProject.description;
|
|
1155
|
+
await st.saveProject(project);
|
|
1156
|
+
await st.writeGenerated();
|
|
1157
|
+
await scanCodebase();
|
|
1158
|
+
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discuss…`, "info");
|
|
1159
|
+
await maybeStartWeb(ctx);
|
|
1160
|
+
await handlePlanner("project discuss", ctx);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const name = await ctx.ui.input("Project title");
|
|
1164
|
+
if (!name?.trim()) {
|
|
1165
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const description = await ctx.ui.input("Short project description");
|
|
1169
|
+
await st.init(name.trim());
|
|
1170
|
+
const project = await st.loadProject();
|
|
1171
|
+
project.description = description?.trim() ?? "";
|
|
1172
|
+
await st.saveProject(project);
|
|
1173
|
+
await st.writeGenerated();
|
|
1174
|
+
await scanCodebase();
|
|
1175
|
+
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discuss…`, "info");
|
|
1176
|
+
await maybeStartWeb(ctx);
|
|
1177
|
+
await handlePlanner("project discuss", ctx);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
// Guard: most subcommands need .planner/
|
|
1181
|
+
if (!(await st.exists())) {
|
|
1182
|
+
ctx.ui.notify("No .planner/ — start with /planner init", "warning");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// ── show ──
|
|
1186
|
+
if (a === "show") {
|
|
1187
|
+
const plan = await st.loadAll();
|
|
1188
|
+
ctx.ui.notify([
|
|
1189
|
+
`📋 **${plan.project.name}**`,
|
|
1190
|
+
` Description: ${plan.project.description || "*not set*"}`,
|
|
1191
|
+
` Goal: ${plan.project.goal || "*not set*"}`,
|
|
1192
|
+
` Phases: ${plan.phases.length}`,
|
|
1193
|
+
` Requirements: ${plan.requirements.requirements.length}`,
|
|
1194
|
+
` Updated: ${plan.manifest.updatedAt}`,
|
|
1195
|
+
].join("\n"), "info");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1199
|
+
// Helper: pick a feature interactively
|
|
1200
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1201
|
+
async function pickFeature() {
|
|
1202
|
+
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1203
|
+
if (features.length === 0)
|
|
1204
|
+
return null;
|
|
1205
|
+
const list = features.map((f, i) => ` ${i + 1}. ${statusIcon(f.status)} ${f.name} (${f.id})`).join("\n");
|
|
1206
|
+
ctx.ui.notify(`Pick a feature:\n${list}`, "info");
|
|
1207
|
+
const pick = await ctx.ui.input("Enter feature number");
|
|
1208
|
+
if (!pick?.trim())
|
|
1209
|
+
return null;
|
|
1210
|
+
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1211
|
+
if (isNaN(idx) || idx < 0 || idx >= features.length) {
|
|
1212
|
+
ctx.ui.notify("Invalid number", "error");
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
return features[idx];
|
|
1216
|
+
}
|
|
1217
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1218
|
+
// Helper: pick a phase interactively
|
|
1219
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1220
|
+
async function pickPhase() {
|
|
1221
|
+
const phases = await st.loadAllPhases().catch(() => []);
|
|
1222
|
+
if (phases.length === 0) {
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
const list = phases.map((p, i) => ` ${i + 1}. ${statusIcon(p.status)} ${p.title} (${p.id})`).join("\n");
|
|
1226
|
+
ctx.ui.notify(`Pick a phase:\n${list}`, "info");
|
|
1227
|
+
const pick = await ctx.ui.input("Enter phase number");
|
|
1228
|
+
if (!pick?.trim())
|
|
1229
|
+
return null;
|
|
1230
|
+
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1231
|
+
if (isNaN(idx) || idx < 0 || idx >= phases.length) {
|
|
1232
|
+
ctx.ui.notify("Invalid number", "error");
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
return phases[idx];
|
|
1236
|
+
}
|
|
1237
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1238
|
+
// Helper: pick a task interactively from a phase
|
|
1239
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1240
|
+
async function pickTask(phase) {
|
|
1241
|
+
if (phase.tasks.length === 0)
|
|
1242
|
+
return null;
|
|
1243
|
+
const list = phase.tasks.map((t, i) => ` ${i + 1}. ${statusIcon(t.status)} ${t.title} (${t.id})`).join("\n");
|
|
1244
|
+
ctx.ui.notify(`Pick a task from "${phase.title}":\n${list}`, "info");
|
|
1245
|
+
const pick = await ctx.ui.input("Enter task number");
|
|
1246
|
+
if (!pick?.trim())
|
|
1247
|
+
return null;
|
|
1248
|
+
const idx = parseInt(pick.trim(), 10) - 1;
|
|
1249
|
+
if (isNaN(idx) || idx < 0 || idx >= phase.tasks.length) {
|
|
1250
|
+
ctx.ui.notify("Invalid number", "error");
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
return phase.tasks[idx];
|
|
1254
|
+
}
|
|
1255
|
+
function parseMultilineList(value) {
|
|
1256
|
+
return (value ?? "")
|
|
1257
|
+
.split("\n")
|
|
1258
|
+
.map((line) => line.trim())
|
|
1259
|
+
.map((line) => line.replace(/^[-*•]\s*/, "")) // strip bullet points if the user types them
|
|
1260
|
+
.filter(Boolean);
|
|
1261
|
+
}
|
|
1262
|
+
function splitCsv(value) {
|
|
1263
|
+
return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
1264
|
+
}
|
|
1265
|
+
function isYes(value) {
|
|
1266
|
+
return ["y", "yes", "si", "sì"].includes((value ?? "").trim().toLowerCase());
|
|
1267
|
+
}
|
|
1268
|
+
function findFeatureByRef(features, ref) {
|
|
1269
|
+
const normalized = ref.trim().toLowerCase();
|
|
1270
|
+
if (!normalized)
|
|
1271
|
+
return null;
|
|
1272
|
+
return features.find((feature) => feature.id.toLowerCase() === normalized)
|
|
1273
|
+
?? features.find((feature) => feature.name.toLowerCase() === normalized)
|
|
1274
|
+
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1275
|
+
?? null;
|
|
1276
|
+
}
|
|
1277
|
+
function findPhaseByRef(phases, ref) {
|
|
1278
|
+
const normalized = ref.trim().toLowerCase();
|
|
1279
|
+
if (!normalized)
|
|
1280
|
+
return null;
|
|
1281
|
+
return phases.find((phase) => phase.id.toLowerCase() === normalized)
|
|
1282
|
+
?? phases.find((phase) => phase.title.toLowerCase() === normalized)
|
|
1283
|
+
?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
|
|
1284
|
+
?? null;
|
|
1285
|
+
}
|
|
1286
|
+
function findTaskByRef(phases, ref) {
|
|
1287
|
+
const normalized = ref.trim().toLowerCase();
|
|
1288
|
+
if (!normalized)
|
|
1289
|
+
return null;
|
|
1290
|
+
for (const phase of phases) {
|
|
1291
|
+
const task = phase.tasks.find((entryTask) => entryTask.id.toLowerCase() === normalized
|
|
1292
|
+
|| entryTask.title.toLowerCase() === normalized
|
|
1293
|
+
|| entryTask.title.toLowerCase().includes(normalized));
|
|
1294
|
+
if (task)
|
|
1295
|
+
return { phase, task };
|
|
1296
|
+
}
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
function profile_packageManager(pkg, lockfile) {
|
|
1300
|
+
if (pkg?.packageManager)
|
|
1301
|
+
return pkg.packageManager;
|
|
1302
|
+
if (lockfile === "pnpm-lock.yaml")
|
|
1303
|
+
return "pnpm";
|
|
1304
|
+
if (lockfile === "yarn.lock")
|
|
1305
|
+
return "yarn";
|
|
1306
|
+
if (lockfile === "bun.lockb")
|
|
1307
|
+
return "bun";
|
|
1308
|
+
if (lockfile === "package-lock.json")
|
|
1309
|
+
return "npm";
|
|
1310
|
+
return "";
|
|
1311
|
+
}
|
|
1312
|
+
async function scanCodebase() {
|
|
1313
|
+
const root = ctx.cwd;
|
|
1314
|
+
const rootFiles = [];
|
|
1315
|
+
const directories = [];
|
|
1316
|
+
const tree = [];
|
|
1317
|
+
// Top-level entries (skip noise)
|
|
1318
|
+
const skip = new Set([".git", "node_modules", ".planner", ".plan", "dist", "build", ".next", ".cache", ".turbo", "coverage"]);
|
|
1319
|
+
let entries = [];
|
|
1320
|
+
try {
|
|
1321
|
+
entries = readdirSync(root).sort();
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
entries = [];
|
|
1325
|
+
}
|
|
1326
|
+
for (const name of entries) {
|
|
1327
|
+
if (skip.has(name) || name.startsWith("."))
|
|
1328
|
+
continue;
|
|
1329
|
+
const full = join(root, name);
|
|
1330
|
+
try {
|
|
1331
|
+
const st = statSync(full);
|
|
1332
|
+
if (st.isDirectory()) {
|
|
1333
|
+
directories.push(`${name}/`);
|
|
1334
|
+
tree.push(`${name}/`);
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
rootFiles.push({ path: name, kind: "file" });
|
|
1338
|
+
tree.push(name);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
catch { /* ignore */ }
|
|
1342
|
+
}
|
|
1343
|
+
// package.json summary
|
|
1344
|
+
let packageJson = null;
|
|
1345
|
+
try {
|
|
1346
|
+
const raw = readFileSync(join(root, "package.json"), "utf-8");
|
|
1347
|
+
const pkg = JSON.parse(raw);
|
|
1348
|
+
packageJson = {
|
|
1349
|
+
name: pkg.name,
|
|
1350
|
+
packageManager: pkg.packageManager,
|
|
1351
|
+
scripts: pkg.scripts ?? {},
|
|
1352
|
+
dependencies: pkg.dependencies ?? {},
|
|
1353
|
+
devDependencies: pkg.devDependencies ?? {},
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
catch { /* no package.json */ }
|
|
1357
|
+
// AGENTS.md / README excerpts (first ~4KB)
|
|
1358
|
+
const readExcerpt = (file) => {
|
|
1359
|
+
try {
|
|
1360
|
+
return readFileSync(join(root, file), "utf-8").slice(0, 4096);
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
return "";
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const agentsMd = readExcerpt("AGENTS.md") || readExcerpt("CLAUDE.md");
|
|
1367
|
+
const readme = readExcerpt("README.md");
|
|
1368
|
+
// Ambient facts: node version, package manager, lockfile, key scripts
|
|
1369
|
+
const lockfile = ["pnpm-lock.yaml", "yarn.lock", "package-lock.json", "bun.lockb"].find((f) => existsSync(join(root, f))) ?? "";
|
|
1370
|
+
const pm = profile_packageManager(packageJson, lockfile);
|
|
1371
|
+
const allScripts = packageJson?.scripts ?? {};
|
|
1372
|
+
const keyScriptKeys = ["build", "dev", "start", "test", "lint", "typecheck", "tsc", "format"];
|
|
1373
|
+
const keyScripts = {};
|
|
1374
|
+
for (const k of keyScriptKeys)
|
|
1375
|
+
if (allScripts[k])
|
|
1376
|
+
keyScripts[k] = allScripts[k];
|
|
1377
|
+
const ambient = {
|
|
1378
|
+
nodeVersion: process.versions.node ?? "",
|
|
1379
|
+
packageManager: pm,
|
|
1380
|
+
lockfile,
|
|
1381
|
+
scripts: keyScripts,
|
|
1382
|
+
};
|
|
1383
|
+
const profile = {
|
|
1384
|
+
scannedAt: nowISO(),
|
|
1385
|
+
rootPath: root,
|
|
1386
|
+
rootFiles,
|
|
1387
|
+
directories,
|
|
1388
|
+
packageJson,
|
|
1389
|
+
agentsMd,
|
|
1390
|
+
readme,
|
|
1391
|
+
tree,
|
|
1392
|
+
ambient,
|
|
1393
|
+
};
|
|
1394
|
+
await st.saveCodebaseProfile(profile);
|
|
1395
|
+
return profile;
|
|
1396
|
+
}
|
|
1397
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1398
|
+
// project <sub>
|
|
1399
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1400
|
+
if (a === "project") {
|
|
1401
|
+
if (!b) {
|
|
1402
|
+
ctx.ui.notify("project actions: discuss | language", "info");
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
if (b === "discuss") {
|
|
1406
|
+
const project = await ensureProjectLanguagePreferences(st);
|
|
1407
|
+
const profile = await scanCodebase();
|
|
1408
|
+
ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
|
|
1409
|
+
ctx.ui.notify("Opening text editors for the initial project profile. These will serve as the foundation for the agent's deep discovery process.", "info");
|
|
1410
|
+
const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
|
|
1411
|
+
const techRaw = await ctx.ui.editor("Technologies & Tools: What frameworks, languages, or external utilities are used? (Type one per line)", [...project.technologies, ...project.tools].join("\n") || "");
|
|
1412
|
+
const rulesRaw = await ctx.ui.editor("Project rules: Standards, non-negotiable guidelines, or architectural preferences. (Type one per line)", project.globalRules.join("\n") || "");
|
|
1413
|
+
const decisionsRaw = await ctx.ui.editor("Key decisions / notes (Type one per line)", project.decisions.join("\n") || "");
|
|
1414
|
+
const parsedDecisionLines = parseMultilineList(decisionsRaw);
|
|
1415
|
+
const draftProject = {
|
|
1416
|
+
...project,
|
|
1417
|
+
goal: goal?.trim() ? goal.trim() : project.goal,
|
|
1418
|
+
technologies: parseMultilineList(techRaw).filter((t) => !project.tools.includes(t)),
|
|
1419
|
+
tools: parseMultilineList(techRaw).filter((t) => !project.technologies.includes(t)),
|
|
1420
|
+
globalRules: parseMultilineList(rulesRaw),
|
|
1421
|
+
decisions: parsedDecisionLines,
|
|
1422
|
+
acceptedDecisions: parsedDecisionLines.length > 0
|
|
1423
|
+
? parsedDecisionLines.map((entry, index) => ({
|
|
1424
|
+
id: `project-decision-${index + 1}`,
|
|
1425
|
+
title: entry,
|
|
1426
|
+
decision: entry,
|
|
1427
|
+
rationale: "Accepted during planner project discuss.",
|
|
1428
|
+
implementationNotes: "",
|
|
1429
|
+
acceptedAt: nowISO(),
|
|
1430
|
+
}))
|
|
1431
|
+
: project.acceptedDecisions,
|
|
1432
|
+
};
|
|
1433
|
+
await st.saveProject(draftProject);
|
|
1434
|
+
await st.writeGenerated();
|
|
1435
|
+
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
1436
|
+
if (capturedPi) {
|
|
1437
|
+
await capturedPi.sendUserMessage("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
|
|
1438
|
+
"Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
|
|
1439
|
+
"Your mission is to produce a professional-grade specification. " +
|
|
1440
|
+
`\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
|
|
1441
|
+
"If these preferences are already set, follow them and do NOT ask again. " +
|
|
1442
|
+
"Ask for language only if BOTH preferences are unset. If one is set and the other is unset, infer the missing one from the saved value and persist it with project_set_language_preferences without asking.\n\n" +
|
|
1443
|
+
"DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
|
|
1444
|
+
"1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
|
|
1445
|
+
"2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
|
|
1446
|
+
"3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
|
|
1447
|
+
"4. We will iterate until you have a complete, detailed picture.\n\n" +
|
|
1448
|
+
"Once you are 100% certain and we agree the context is complete, you will then generate the exhaustive professional specification (Features, Phases, Tasks) including deep logic, code examples, and technical references as previously required.");
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (b === "language") {
|
|
1453
|
+
const project = await st.loadProject();
|
|
1454
|
+
const contentLanguage = await ctx.ui.input(`Plan content language [${project.contentLanguage || "English"}]`);
|
|
1455
|
+
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "Italian"}]`);
|
|
1456
|
+
project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
|
|
1457
|
+
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "Italian";
|
|
1458
|
+
await st.saveProject(project);
|
|
1459
|
+
await st.writeGenerated();
|
|
1460
|
+
ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language`, "warning");
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1467
|
+
// feature <sub>
|
|
1468
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1469
|
+
if (a === "feature") {
|
|
1470
|
+
if (!b) {
|
|
1471
|
+
ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (b === "list") {
|
|
1475
|
+
const features = (await st.loadFeatures()).features;
|
|
1476
|
+
if (features.length === 0) {
|
|
1477
|
+
ctx.ui.notify("No features", "info");
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const phases = await st.loadAllPhases();
|
|
1481
|
+
ctx.ui.notify(features.map((feature) => {
|
|
1482
|
+
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
1483
|
+
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
1484
|
+
return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${featurePhases.length} phases, ${taskCount} tasks`;
|
|
1485
|
+
}).join("\n"), "info");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (b === "add") {
|
|
1489
|
+
const nameInput = subArgs.trim() || await ctx.ui.input("Feature name");
|
|
1490
|
+
if (!nameInput?.trim()) {
|
|
1491
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const description = await ctx.ui.editor("Feature description (optional)", "");
|
|
1495
|
+
const statusInput = await ctx.ui.input("Status [planned] (planned|in-progress|done|blocked|canceled|rejected|deferred|waiting)");
|
|
1496
|
+
const validStatuses = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
1497
|
+
const status = statusInput?.trim() || "planned";
|
|
1498
|
+
if (!validStatuses.includes(status)) {
|
|
1499
|
+
ctx.ui.notify(`Invalid status. Use: ${validStatuses.join(", ")}`, "error");
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const now = nowISO();
|
|
1503
|
+
const featureNumber = (await st.loadFeatures()).features.length + 1;
|
|
1504
|
+
const feature = {
|
|
1505
|
+
id: createFeatureId(),
|
|
1506
|
+
number: featureNumber,
|
|
1507
|
+
name: nameInput.trim(),
|
|
1508
|
+
description: description?.trim() ?? "",
|
|
1509
|
+
status: status,
|
|
1510
|
+
discussedAt: "",
|
|
1511
|
+
contextReady: false,
|
|
1512
|
+
contextReadyReason: "",
|
|
1513
|
+
startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
|
|
1514
|
+
endDate: status === "done" ? new Date().toISOString().slice(0, 10) : "",
|
|
1515
|
+
workDone: "",
|
|
1516
|
+
workRemaining: "",
|
|
1517
|
+
acceptedDecisions: [],
|
|
1518
|
+
phaseIds: [],
|
|
1519
|
+
dependsOn: [],
|
|
1520
|
+
createdAt: now,
|
|
1521
|
+
updatedAt: now,
|
|
1522
|
+
};
|
|
1523
|
+
await st.updateFeatures((doc) => {
|
|
1524
|
+
doc.features.push(feature);
|
|
1525
|
+
return doc;
|
|
1526
|
+
});
|
|
1527
|
+
await st.writeGenerated();
|
|
1528
|
+
ctx.ui.notify(`Feature created: ${feature.id} — ${feature.name}`, "info");
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (b === "show") {
|
|
1532
|
+
const features = (await st.loadFeatures()).features;
|
|
1533
|
+
let feature = subArgs.trim() ? findFeatureByRef(features, subArgs.trim()) : null;
|
|
1534
|
+
if (!feature) {
|
|
1535
|
+
feature = await pickFeature();
|
|
1536
|
+
if (!feature) {
|
|
1537
|
+
ctx.ui.notify("No features available", "warning");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
1542
|
+
const taskCount = phases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
1543
|
+
ctx.ui.notify([
|
|
1544
|
+
`🌟 **${feature.name}**`,
|
|
1545
|
+
` ID: ${feature.id}`,
|
|
1546
|
+
` Status: ${feature.status}`,
|
|
1547
|
+
` Phases: ${phases.length}`,
|
|
1548
|
+
` Tasks: ${taskCount}`,
|
|
1549
|
+
` Window: ${feature.startDate || "not set"} → ${feature.endDate || "not set"}`,
|
|
1550
|
+
feature.description ? "" : "",
|
|
1551
|
+
feature.description || "",
|
|
1552
|
+
feature.workDone ? `Work done: ${feature.workDone}` : "",
|
|
1553
|
+
feature.workRemaining ? `Work remaining: ${feature.workRemaining}` : "",
|
|
1554
|
+
].filter(Boolean).join("\n"), "info");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (b === "update") {
|
|
1558
|
+
const featuresDoc = await st.loadFeatures();
|
|
1559
|
+
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
1560
|
+
if (!feature) {
|
|
1561
|
+
feature = await pickFeature();
|
|
1562
|
+
if (!feature) {
|
|
1563
|
+
ctx.ui.notify("No features available", "warning");
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const title = await ctx.ui.input(`Name [${feature.name}]`);
|
|
1568
|
+
const statusInput = await ctx.ui.input(`Status [${feature.status}] (planned|in-progress|done|blocked|canceled|rejected|deferred|waiting)`);
|
|
1569
|
+
const description = await ctx.ui.editor("Description [leave unchanged by submitting current text]", feature.description || "");
|
|
1570
|
+
const workDone = await ctx.ui.editor("Work done [leave unchanged by submitting current text]", feature.workDone || "");
|
|
1571
|
+
const workRemaining = await ctx.ui.editor("Work remaining [leave unchanged by submitting current text]", feature.workRemaining || "");
|
|
1572
|
+
const startDate = await ctx.ui.input(`Start date YYYY-MM-DD [${feature.startDate || ""}]`);
|
|
1573
|
+
const endDate = await ctx.ui.input(`End date YYYY-MM-DD [${feature.endDate || ""}]`);
|
|
1574
|
+
const validStatuses = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
1575
|
+
if (statusInput?.trim() && !validStatuses.includes(statusInput.trim())) {
|
|
1576
|
+
ctx.ui.notify(`Invalid status. Use: ${validStatuses.join(", ")}`, "error");
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const featureId = feature.id;
|
|
1580
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
1581
|
+
const target = doc.features.find((entry) => entry.id === featureId);
|
|
1582
|
+
if (!target)
|
|
1583
|
+
return doc;
|
|
1584
|
+
if (title?.trim())
|
|
1585
|
+
target.name = title.trim();
|
|
1586
|
+
if (statusInput?.trim()) {
|
|
1587
|
+
const nextStatus = statusInput.trim();
|
|
1588
|
+
if (nextStatus === "in-progress" && !target.startDate)
|
|
1589
|
+
target.startDate = new Date().toISOString().slice(0, 10);
|
|
1590
|
+
if (nextStatus === "done" && !target.endDate)
|
|
1591
|
+
target.endDate = new Date().toISOString().slice(0, 10);
|
|
1592
|
+
target.status = nextStatus;
|
|
1593
|
+
}
|
|
1594
|
+
if (description !== undefined)
|
|
1595
|
+
target.description = description.trim();
|
|
1596
|
+
if (workDone !== undefined)
|
|
1597
|
+
target.workDone = workDone.trim();
|
|
1598
|
+
if (workRemaining !== undefined)
|
|
1599
|
+
target.workRemaining = workRemaining.trim();
|
|
1600
|
+
if (startDate !== undefined)
|
|
1601
|
+
target.startDate = startDate.trim();
|
|
1602
|
+
if (endDate !== undefined)
|
|
1603
|
+
target.endDate = endDate.trim();
|
|
1604
|
+
target.updatedAt = nowISO();
|
|
1605
|
+
return doc;
|
|
1606
|
+
});
|
|
1607
|
+
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1608
|
+
await st.writeGenerated();
|
|
1609
|
+
ctx.ui.notify(`Feature updated: ${feature.id} — ${feature.name} (${feature.status})`, "info");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (b === "delete") {
|
|
1613
|
+
const features = (await st.loadFeatures()).features;
|
|
1614
|
+
let feature = subArgs.trim() ? findFeatureByRef(features, subArgs.trim()) : null;
|
|
1615
|
+
if (!feature) {
|
|
1616
|
+
feature = await pickFeature();
|
|
1617
|
+
if (!feature) {
|
|
1618
|
+
ctx.ui.notify("No features available", "warning");
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const phases = (await st.loadAllPhases()).filter((phase) => phase.featureId === feature.id);
|
|
1623
|
+
const cascade = phases.length > 0
|
|
1624
|
+
? await ctx.ui.confirm(`Delete ${phases.length} phase(s) inside "${feature.name}" too? If no, phases remain but are unlinked.`, "Delete phases too")
|
|
1625
|
+
: false;
|
|
1626
|
+
const confirm = await ctx.ui.input(`Confirm delete "${feature.name}" (${feature.id})? Type yes:`);
|
|
1627
|
+
if (confirm?.trim() !== "yes") {
|
|
1628
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const featureId = feature.id;
|
|
1632
|
+
await st.updateFeatures((doc) => {
|
|
1633
|
+
doc.features = doc.features.filter((entry) => entry.id !== featureId);
|
|
1634
|
+
return doc;
|
|
1635
|
+
});
|
|
1636
|
+
let affectedPhases = 0;
|
|
1637
|
+
if (cascade) {
|
|
1638
|
+
for (const phase of phases) {
|
|
1639
|
+
await st.deletePhase(phase.id);
|
|
1640
|
+
affectedPhases += 1;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
for (const phase of phases) {
|
|
1645
|
+
phase.featureId = undefined;
|
|
1646
|
+
phase.updatedAt = nowISO();
|
|
1647
|
+
await st.savePhase(phase);
|
|
1648
|
+
affectedPhases += 1;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
await st.writeGenerated();
|
|
1652
|
+
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1659
|
+
// phase <sub>
|
|
1660
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1661
|
+
if (a === "phase") {
|
|
1662
|
+
if (!b) {
|
|
1663
|
+
ctx.ui.notify("phase actions: add <title> | show [id] | discuss [id|name] | delete | update", "info");
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (b === "add") {
|
|
1667
|
+
const title = subArgs.trim();
|
|
1668
|
+
if (!title) {
|
|
1669
|
+
ctx.ui.notify("Usage: /planner phase add <title>", "warning");
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const features = (await st.loadFeatures()).features;
|
|
1673
|
+
if (features.length === 0) {
|
|
1674
|
+
ctx.ui.notify("Create a feature first: a phase must belong to a feature. Use /planner feature add.", "warning");
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const feature = await pickFeature();
|
|
1678
|
+
if (!feature) {
|
|
1679
|
+
ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
let phase;
|
|
1683
|
+
await withFeatureLock(feature.id, async () => {
|
|
1684
|
+
const phases = await st.loadAllPhases();
|
|
1685
|
+
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1686
|
+
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
1687
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1688
|
+
const id = createPhaseId();
|
|
1689
|
+
phase = {
|
|
1690
|
+
id, number, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1691
|
+
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
1692
|
+
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1693
|
+
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1694
|
+
};
|
|
1695
|
+
await st.savePhase(phase);
|
|
1696
|
+
await st.updateFeatures((doc) => {
|
|
1697
|
+
const target = doc.features.find((f) => f.id === feature.id);
|
|
1698
|
+
if (target && !target.phaseIds.includes(phase.id)) {
|
|
1699
|
+
target.phaseIds.push(phase.id);
|
|
1700
|
+
target.updatedAt = nowISO();
|
|
1701
|
+
}
|
|
1702
|
+
return doc;
|
|
1703
|
+
});
|
|
1704
|
+
await st.writeGenerated();
|
|
1705
|
+
});
|
|
1706
|
+
if (!phase)
|
|
1707
|
+
return;
|
|
1708
|
+
ctx.ui.notify(`Phase created: ${phase.id} — ${title} (feature ${feature.name})`, "info");
|
|
1709
|
+
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1710
|
+
if (isYes(startDiscuss)) {
|
|
1711
|
+
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
1712
|
+
}
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (b === "discuss") {
|
|
1716
|
+
let phase = null;
|
|
1717
|
+
if (subArgs.trim()) {
|
|
1718
|
+
phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1719
|
+
}
|
|
1720
|
+
if (!phase) {
|
|
1721
|
+
phase = await pickPhase();
|
|
1722
|
+
if (!phase) {
|
|
1723
|
+
ctx.ui.notify("No phases to discuss. Create one first.", "warning");
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
|
|
1728
|
+
ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
|
|
1729
|
+
}
|
|
1730
|
+
phase.status = "discovery";
|
|
1731
|
+
phase.updatedAt = nowISO();
|
|
1732
|
+
await st.savePhase(phase);
|
|
1733
|
+
ctx.ui.notify(`Discovery for ${phase.id}…`, "info");
|
|
1734
|
+
// ── Recap: give full context before planning tasks ──
|
|
1735
|
+
const recapPlan = await st.loadAll();
|
|
1736
|
+
const recapProject = recapPlan.project;
|
|
1737
|
+
const recapLines = [
|
|
1738
|
+
`📋 RECAP — ${recapProject.name}`,
|
|
1739
|
+
`Goal: ${recapProject.goal || "(not set)"}`,
|
|
1740
|
+
`Stack: ${[...recapProject.technologies, ...recapProject.tools].join(", ") || "(not set)"}`,
|
|
1741
|
+
`Global rules: ${recapProject.globalRules.join(" | ") || "(none)"}`,
|
|
1742
|
+
`Phases: ${recapPlan.phases.map((p) => `${p.id}(${p.status})`).join(", ") || "(none)"}`,
|
|
1743
|
+
"",
|
|
1744
|
+
`Now discussing phase: ${phase.id} — ${phase.title}`,
|
|
1745
|
+
` current goals: ${phase.goals.join(" | ") || "(none)"}`,
|
|
1746
|
+
` dependencies: ${phase.dependencies.join(", ") || "(none)"}`,
|
|
1747
|
+
` risks: ${phase.risks.join(", ") || "(none)"}`,
|
|
1748
|
+
` completion criteria: ${phase.completionCriteria.join(", ") || "(none)"}`,
|
|
1749
|
+
` tasks: ${phase.tasks.length}`,
|
|
1750
|
+
"",
|
|
1751
|
+
"⚠️ Have requirements or dependencies changed since last time?",
|
|
1752
|
+
" Tell me now before we (re)plan the tasks. If nothing changed, continue with the questions below.",
|
|
1753
|
+
];
|
|
1754
|
+
ctx.ui.notify(recapLines.join("\n"), "info");
|
|
1755
|
+
const changeNote = await ctx.ui.input("Anything changed? (free text, or leave empty to keep current plan)");
|
|
1756
|
+
if (changeNote?.trim()) {
|
|
1757
|
+
phase.notes = `${phase.notes ? phase.notes + "\n" : ""}[discuss ${nowISO()}] ${changeNote.trim()}`;
|
|
1758
|
+
await st.savePhase(phase);
|
|
1759
|
+
if (capturedPi) {
|
|
1760
|
+
await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
|
|
1761
|
+
"Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
|
|
1762
|
+
"Do NOT invoke GSD. Stay in Agent Plan.");
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
const answers = {};
|
|
1766
|
+
const questions = [
|
|
1767
|
+
{ key: "goal", prompt: `Main goal of "${phase.title}"?` },
|
|
1768
|
+
{ key: "summary", prompt: `Short summary [${phase.summary || ""}]` },
|
|
1769
|
+
{ key: "scope", prompt: "What is in scope for this phase?" },
|
|
1770
|
+
{ key: "non_scope", prompt: "What is out of scope for this phase?" },
|
|
1771
|
+
{ key: "deps", prompt: "What does this phase depend on? (comma-separated)" },
|
|
1772
|
+
{ key: "risks", prompt: "Main risks? (comma-separated)" },
|
|
1773
|
+
{ key: "completion", prompt: "What defines completion? (comma-separated)" },
|
|
1774
|
+
];
|
|
1775
|
+
for (const q of questions) {
|
|
1776
|
+
const ans = await ctx.ui.input(q.prompt);
|
|
1777
|
+
if (ans?.trim())
|
|
1778
|
+
answers[q.key] = ans.trim();
|
|
1779
|
+
}
|
|
1780
|
+
if (answers.goal)
|
|
1781
|
+
phase.goals = [answers.goal];
|
|
1782
|
+
if (answers.summary)
|
|
1783
|
+
phase.summary = answers.summary;
|
|
1784
|
+
if (answers.scope)
|
|
1785
|
+
phase.description = answers.scope;
|
|
1786
|
+
if (answers.non_scope)
|
|
1787
|
+
phase.nonGoals = [answers.non_scope];
|
|
1788
|
+
if (answers.deps)
|
|
1789
|
+
phase.dependencies = splitCsv(answers.deps);
|
|
1790
|
+
if (answers.risks)
|
|
1791
|
+
phase.risks = splitCsv(answers.risks);
|
|
1792
|
+
if (answers.completion)
|
|
1793
|
+
phase.completionCriteria = splitCsv(answers.completion);
|
|
1794
|
+
phase.status = "planned";
|
|
1795
|
+
phase.updatedAt = nowISO();
|
|
1796
|
+
await st.savePhase(phase);
|
|
1797
|
+
await st.writeGenerated();
|
|
1798
|
+
ctx.ui.notify(`Phase ${phase.id} is now **planned**.`, "info");
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (b === "show") {
|
|
1802
|
+
let phase = null;
|
|
1803
|
+
if (subArgs.trim()) {
|
|
1804
|
+
phase = await st.loadPhase(subArgs.trim()).catch(() => null);
|
|
1805
|
+
}
|
|
1806
|
+
if (!phase) {
|
|
1807
|
+
phase = await pickPhase();
|
|
1808
|
+
if (!phase) {
|
|
1809
|
+
ctx.ui.notify("No phases available", "warning");
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
ctx.ui.notify([
|
|
1814
|
+
`🧩 **${phase.title}**`,
|
|
1815
|
+
` ID: ${phase.id}`,
|
|
1816
|
+
` Status: ${phase.status}`,
|
|
1817
|
+
` Feature: ${phase.featureId ?? "none"}`,
|
|
1818
|
+
` Tasks: ${phase.tasks.length}`,
|
|
1819
|
+
` Goals: ${phase.goals.length}`,
|
|
1820
|
+
` Dependencies: ${phase.dependencies.length}`,
|
|
1821
|
+
` Completion criteria: ${phase.completionCriteria.length}`,
|
|
1822
|
+
phase.summary || "",
|
|
1823
|
+
phase.description || "",
|
|
1824
|
+
].filter(Boolean).join("\n"), "info");
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
if (b === "delete") {
|
|
1828
|
+
let phase = null;
|
|
1829
|
+
if (subArgs.trim()) {
|
|
1830
|
+
phase = await st.loadPhase(subArgs.trim()).catch(() => null);
|
|
1831
|
+
}
|
|
1832
|
+
if (!phase) {
|
|
1833
|
+
phase = await pickPhase();
|
|
1834
|
+
if (!phase) {
|
|
1835
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
const confirm = await ctx.ui.input(`Confirm delete "${phase.title}" (${phase.id})? Type yes:`);
|
|
1840
|
+
if (confirm?.trim() !== "yes") {
|
|
1841
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
await st.deletePhase(phase.id);
|
|
1845
|
+
await st.writeGenerated();
|
|
1846
|
+
ctx.ui.notify(`Phase deleted: ${phase.id}`, "info");
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (b === "update") {
|
|
1850
|
+
let phase = null;
|
|
1851
|
+
if (subArgs.trim()) {
|
|
1852
|
+
phase = await st.loadPhase(subArgs.trim()).catch(() => null);
|
|
1853
|
+
}
|
|
1854
|
+
if (!phase) {
|
|
1855
|
+
phase = await pickPhase();
|
|
1856
|
+
if (!phase) {
|
|
1857
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
const title = await ctx.ui.input(`Title [${phase.title}]`);
|
|
1862
|
+
const statusInput = await ctx.ui.input(`Status [${phase.status}] (draft|discovery|planned|in-progress|done|blocked|canceled)`);
|
|
1863
|
+
const summary = await ctx.ui.input(`Summary [${phase.summary || ""}]`);
|
|
1864
|
+
const desc = await ctx.ui.input(`Description [leave empty to keep]`);
|
|
1865
|
+
const goals = await ctx.ui.input(`Goals (comma-separated) [${phase.goals.join(", ") || ""}]`);
|
|
1866
|
+
const nonGoals = await ctx.ui.input(`Non-goals (comma-separated) [${phase.nonGoals.join(", ") || ""}]`);
|
|
1867
|
+
const deps = await ctx.ui.input(`Dependencies (comma-separated) [${phase.dependencies.join(", ") || ""}]`);
|
|
1868
|
+
const risks = await ctx.ui.input(`Risks (comma-separated) [${phase.risks.join(", ") || ""}]`);
|
|
1869
|
+
const completion = await ctx.ui.input(`Completion criteria (comma-separated) [${phase.completionCriteria.join(", ") || ""}]`);
|
|
1870
|
+
// Update only changed fields
|
|
1871
|
+
if (title?.trim())
|
|
1872
|
+
phase.title = title.trim();
|
|
1873
|
+
if (statusInput?.trim()) {
|
|
1874
|
+
const valid = ["draft", "discovery", "planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
1875
|
+
if (!valid.includes(statusInput.trim())) {
|
|
1876
|
+
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
phase.status = statusInput.trim();
|
|
1880
|
+
}
|
|
1881
|
+
if (summary !== undefined)
|
|
1882
|
+
phase.summary = summary;
|
|
1883
|
+
if (desc?.trim())
|
|
1884
|
+
phase.description = desc.trim();
|
|
1885
|
+
if (goals?.trim())
|
|
1886
|
+
phase.goals = goals.split(",").map((g) => g.trim()).filter(Boolean);
|
|
1887
|
+
if (nonGoals?.trim())
|
|
1888
|
+
phase.nonGoals = nonGoals.split(",").map((g) => g.trim()).filter(Boolean);
|
|
1889
|
+
if (deps?.trim())
|
|
1890
|
+
phase.dependencies = deps.split(",").map((g) => g.trim()).filter(Boolean);
|
|
1891
|
+
if (risks?.trim())
|
|
1892
|
+
phase.risks = risks.split(",").map((g) => g.trim()).filter(Boolean);
|
|
1893
|
+
if (completion?.trim())
|
|
1894
|
+
phase.completionCriteria = completion.split(",").map((g) => g.trim()).filter(Boolean);
|
|
1895
|
+
phase.updatedAt = nowISO();
|
|
1896
|
+
await st.savePhase(phase);
|
|
1897
|
+
await st.writeGenerated();
|
|
1898
|
+
ctx.ui.notify(`Phase updated: ${phase.id} — ${phase.title} (${phase.status})`, "info");
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1905
|
+
// task <sub>
|
|
1906
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1907
|
+
if (a === "task") {
|
|
1908
|
+
if (!b) {
|
|
1909
|
+
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
if (b === "add") {
|
|
1913
|
+
const phase = await pickPhase();
|
|
1914
|
+
if (!phase) {
|
|
1915
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
const title = await ctx.ui.input(`Task title (for "${phase.title}")`);
|
|
1919
|
+
if (!title?.trim()) {
|
|
1920
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const shortName = title.trim().toLowerCase()
|
|
1924
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
|
|
1925
|
+
const taskNum = phase.tasks.length + 1;
|
|
1926
|
+
const taskId = createTaskId();
|
|
1927
|
+
const now = nowISO();
|
|
1928
|
+
const task = {
|
|
1929
|
+
id: taskId, phaseId: phase.id, number: taskNum, shortName,
|
|
1930
|
+
title: title.trim(), status: "planned",
|
|
1931
|
+
description: "",
|
|
1932
|
+
notes: "",
|
|
1933
|
+
statusLog: [],
|
|
1934
|
+
decisions: [],
|
|
1935
|
+
acceptedDecisions: [],
|
|
1936
|
+
checklist: [], subtasks: [],
|
|
1937
|
+
dependsOn: [],
|
|
1938
|
+
startedAt: "", completedAt: "",
|
|
1939
|
+
createdAt: now, updatedAt: now,
|
|
1940
|
+
};
|
|
1941
|
+
phase.tasks.push(task);
|
|
1942
|
+
phase.taskIds.push(taskId);
|
|
1943
|
+
phase.updatedAt = nowISO();
|
|
1944
|
+
await st.savePhase(phase);
|
|
1945
|
+
await st.writeGenerated();
|
|
1946
|
+
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
1947
|
+
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1948
|
+
if (isYes(startDiscuss)) {
|
|
1949
|
+
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
1950
|
+
}
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (b === "discuss") {
|
|
1954
|
+
const phases = await st.loadAllPhases();
|
|
1955
|
+
let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
|
|
1956
|
+
if (!resolved) {
|
|
1957
|
+
const phase = await pickPhase();
|
|
1958
|
+
if (!phase) {
|
|
1959
|
+
ctx.ui.notify("No phases available", "warning");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (phase.tasks.length === 0) {
|
|
1963
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
const task = await pickTask(phase);
|
|
1967
|
+
if (!task) {
|
|
1968
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
resolved = { phase, task };
|
|
1972
|
+
}
|
|
1973
|
+
const { phase, task } = resolved;
|
|
1974
|
+
const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
|
|
1975
|
+
const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
|
|
1976
|
+
if (description?.trim())
|
|
1977
|
+
task.description = description.trim();
|
|
1978
|
+
if (checklistSeed?.trim()) {
|
|
1979
|
+
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
1980
|
+
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
1981
|
+
title: itemTitle,
|
|
1982
|
+
checked: false,
|
|
1983
|
+
}));
|
|
1984
|
+
}
|
|
1985
|
+
task.updatedAt = nowISO();
|
|
1986
|
+
phase.updatedAt = nowISO();
|
|
1987
|
+
await st.savePhase(phase);
|
|
1988
|
+
await st.writeGenerated();
|
|
1989
|
+
ctx.ui.notify(`Task ${task.id} discussed and updated.`, "info");
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (b === "show") {
|
|
1993
|
+
let phase = null;
|
|
1994
|
+
let task = null;
|
|
1995
|
+
if (subArgs.trim()) {
|
|
1996
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1997
|
+
phase = resolved?.phase ?? null;
|
|
1998
|
+
task = resolved?.task ?? null;
|
|
1999
|
+
}
|
|
2000
|
+
if (!task) {
|
|
2001
|
+
phase = await pickPhase();
|
|
2002
|
+
if (!phase) {
|
|
2003
|
+
ctx.ui.notify("No phases available", "warning");
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (phase.tasks.length === 0) {
|
|
2007
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
task = await pickTask(phase);
|
|
2011
|
+
if (!task) {
|
|
2012
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
ctx.ui.notify([
|
|
2017
|
+
`📝 **${task.title}**`,
|
|
2018
|
+
` ID: ${task.id}`,
|
|
2019
|
+
` Status: ${task.status}`,
|
|
2020
|
+
` Phase: ${phase?.title ?? task.phaseId}`,
|
|
2021
|
+
` Checklist items: ${task.checklist.length}`,
|
|
2022
|
+
` Subtasks: ${task.subtasks.length}`,
|
|
2023
|
+
task.description || "",
|
|
2024
|
+
].filter(Boolean).join("\n"), "info");
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (b === "delete") {
|
|
2028
|
+
let phase = null;
|
|
2029
|
+
if (subArgs.trim()) {
|
|
2030
|
+
phase = await st.loadPhase(subArgs.trim()).catch(() => null);
|
|
2031
|
+
}
|
|
2032
|
+
if (!phase) {
|
|
2033
|
+
phase = await pickPhase();
|
|
2034
|
+
if (!phase) {
|
|
2035
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
if (phase.tasks.length === 0) {
|
|
2040
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const task = await pickTask(phase);
|
|
2044
|
+
if (!task) {
|
|
2045
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
const confirm = await ctx.ui.input(`Confirm delete "${task.title}" (${task.id})? Type yes:`);
|
|
2049
|
+
if (confirm?.trim() !== "yes") {
|
|
2050
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== task.id);
|
|
2054
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== task.id);
|
|
2055
|
+
phase.updatedAt = nowISO();
|
|
2056
|
+
await st.savePhase(phase);
|
|
2057
|
+
await st.writeGenerated();
|
|
2058
|
+
ctx.ui.notify(`Task deleted: ${task.id}`, "info");
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (b === "update") {
|
|
2062
|
+
let phase = null;
|
|
2063
|
+
if (subArgs.trim()) {
|
|
2064
|
+
phase = await st.loadPhase(subArgs.trim()).catch(() => null);
|
|
2065
|
+
}
|
|
2066
|
+
if (!phase) {
|
|
2067
|
+
phase = await pickPhase();
|
|
2068
|
+
if (!phase) {
|
|
2069
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (phase.tasks.length === 0) {
|
|
2074
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const task = await pickTask(phase);
|
|
2078
|
+
if (!task) {
|
|
2079
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const title = await ctx.ui.input(`Title [${task.title}]`);
|
|
2083
|
+
const statusInput = await ctx.ui.input(`Status [${task.status}] (planned|in-progress|done|blocked|canceled)`);
|
|
2084
|
+
const desc = await ctx.ui.input(`Description [leave empty to keep]`);
|
|
2085
|
+
// Update only changed fields
|
|
2086
|
+
if (title?.trim())
|
|
2087
|
+
task.title = title.trim();
|
|
2088
|
+
const now = nowISO();
|
|
2089
|
+
if (statusInput?.trim()) {
|
|
2090
|
+
const normalizedStatus = statusInput.trim();
|
|
2091
|
+
const valid = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
|
|
2092
|
+
if (!valid.includes(normalizedStatus)) {
|
|
2093
|
+
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (normalizedStatus === "in-progress" && existsSync(join(st.root, "HANDOFF.md"))) {
|
|
2097
|
+
ctx.ui.notify("🚨 HYGIENE VIOLATION: A pending handoff file exists at .planner/HANDOFF.md. You MUST read and delete it before you can move a task to in-progress.", "error");
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2101
|
+
}
|
|
2102
|
+
if (desc?.trim())
|
|
2103
|
+
task.description = desc.trim();
|
|
2104
|
+
task.updatedAt = now;
|
|
2105
|
+
phase.updatedAt = now;
|
|
2106
|
+
await st.savePhase(phase);
|
|
2107
|
+
await st.writeGenerated();
|
|
2108
|
+
ctx.ui.notify(`Task updated: ${task.id} — ${task.title} (${task.status})`, "info");
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (b === "start") {
|
|
2112
|
+
let phase = null;
|
|
2113
|
+
let task = null;
|
|
2114
|
+
if (subArgs.trim()) {
|
|
2115
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2116
|
+
phase = resolved?.phase ?? null;
|
|
2117
|
+
task = resolved?.task ?? null;
|
|
2118
|
+
}
|
|
2119
|
+
if (!task) {
|
|
2120
|
+
phase = await pickPhase();
|
|
2121
|
+
if (!phase) {
|
|
2122
|
+
ctx.ui.notify("No phases available", "warning");
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
if (phase.tasks.length === 0) {
|
|
2126
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
task = await pickTask(phase);
|
|
2130
|
+
if (!task) {
|
|
2131
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
if (task.status === "in-progress") {
|
|
2136
|
+
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
// Hygiene Gate: block starting work if a pending handoff exists.
|
|
2140
|
+
if (existsSync(join(st.root, "HANDOFF.md"))) {
|
|
2141
|
+
ctx.ui.notify("🚨 HYGIENE VIOLATION: A pending handoff file exists at .planner/HANDOFF.md. You MUST read and delete it before you can officially start a task.", "error");
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
const now = nowISO();
|
|
2145
|
+
// Record status change in the incremental statusLog.
|
|
2146
|
+
const prevStatus = task.status;
|
|
2147
|
+
const entry = {
|
|
2148
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
|
|
2149
|
+
date: now,
|
|
2150
|
+
fromStatus: task.status,
|
|
2151
|
+
toStatus: "in-progress",
|
|
2152
|
+
title: task.status === "done" ? "Reopened" : `→ in-progress`,
|
|
2153
|
+
description: task.status === "done" ? "Task reopened from done status." : "",
|
|
2154
|
+
};
|
|
2155
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2156
|
+
applyTaskLifecycleDates(task, "in-progress", now);
|
|
2157
|
+
task.updatedAt = now;
|
|
2158
|
+
phase.updatedAt = now;
|
|
2159
|
+
await st.savePhase(phase);
|
|
2160
|
+
await st.writeGenerated();
|
|
2161
|
+
ctx.ui.notify(`✅ Task started: ${task.id} — ${task.title} (in-progress)`, "info");
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
if (b === "complete") {
|
|
2165
|
+
let phase = null;
|
|
2166
|
+
let task = null;
|
|
2167
|
+
if (subArgs.trim()) {
|
|
2168
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2169
|
+
phase = resolved?.phase ?? null;
|
|
2170
|
+
task = resolved?.task ?? null;
|
|
2171
|
+
}
|
|
2172
|
+
if (!task) {
|
|
2173
|
+
phase = await pickPhase();
|
|
2174
|
+
if (!phase) {
|
|
2175
|
+
ctx.ui.notify("No phases available", "warning");
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (phase.tasks.length === 0) {
|
|
2179
|
+
ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
task = await pickTask(phase);
|
|
2183
|
+
if (!task) {
|
|
2184
|
+
ctx.ui.notify("Aborted", "warning");
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
if (task.status === "done") {
|
|
2189
|
+
ctx.ui.notify(`Task "${task.title}" is already done.`, "info");
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
2193
|
+
if (unchecked.length > 0) {
|
|
2194
|
+
const confirm = await ctx.ui.input(`⚠️ ${unchecked.length} checklist item(s) not done. Complete anyway? (y/N)`);
|
|
2195
|
+
if (!isYes(confirm)) {
|
|
2196
|
+
ctx.ui.notify("Completion cancelled — resolve checklist items first.", "info");
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
const now = nowISO();
|
|
2201
|
+
// Record status change in the incremental statusLog.
|
|
2202
|
+
const entry = {
|
|
2203
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-done`),
|
|
2204
|
+
date: now,
|
|
2205
|
+
fromStatus: task.status,
|
|
2206
|
+
toStatus: "done",
|
|
2207
|
+
title: `→ done`,
|
|
2208
|
+
description: "",
|
|
2209
|
+
};
|
|
2210
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2211
|
+
applyTaskLifecycleDates(task, "done", now);
|
|
2212
|
+
task.updatedAt = now;
|
|
2213
|
+
phase.updatedAt = now;
|
|
2214
|
+
await st.savePhase(phase);
|
|
2215
|
+
await st.writeGenerated();
|
|
2216
|
+
ctx.ui.notify(`✅ Task completed: ${task.id} — ${task.title} (done)`, "info");
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
// ── discuss (legacy alias) ──
|
|
2223
|
+
if (a === "discuss") {
|
|
2224
|
+
await handlePlanner(`phase discuss ${[b, ...rest].filter(Boolean).join(" ")}`.trim(), ctx);
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
// ── handoff ──
|
|
2228
|
+
if (a === "handoff") {
|
|
2229
|
+
const action = b || "show";
|
|
2230
|
+
if (!(await st.exists())) {
|
|
2231
|
+
ctx.ui.notify("No .planner/ found.", "warning");
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (action === "show") {
|
|
2235
|
+
const handoff = await st.loadHandoff();
|
|
2236
|
+
if (!handoff) {
|
|
2237
|
+
ctx.ui.notify("No .planner/HANDOFF.md present.", "info");
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
ctx.ui.notify(handoff.content, "info");
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
if (action === "prepare" || action === "write") {
|
|
2244
|
+
if (action === "prepare") {
|
|
2245
|
+
if (!capturedPi) {
|
|
2246
|
+
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const handoff = await st.loadHandoff();
|
|
2250
|
+
await capturedPi.sendUserMessage("Prepare the canonical session handoff now and write it to `.planner/HANDOFF.md` using the `plan_write_handoff` tool.\n\n" +
|
|
2251
|
+
"The handoff MUST be a structured markdown document containing at minimum:\n" +
|
|
2252
|
+
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2253
|
+
"- `Reason:` why this handoff is being written\n" +
|
|
2254
|
+
"- `## Current focus`: the current feature (id + name + status), phase (id + title + status) and task (id + title + status). Derive them from the plan data.\n" +
|
|
2255
|
+
"- `## 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" +
|
|
2256
|
+
"- `## How to resume`: explicit, ordered steps to pick the work back up (next command / tool / file to open / build to run).\n" +
|
|
2257
|
+
"- `## Files touched`: explicit file paths modified during this session.\n" +
|
|
2258
|
+
"- `## Blockers`: any open blockers, or `None recorded`.\n" +
|
|
2259
|
+
"- `## Next steps`: the immediate next steps.\n" +
|
|
2260
|
+
"- `## Recent decisions`: accepted decisions made this session, if any.\n" +
|
|
2261
|
+
"- `## Reminder`: note that when the work is fully resumed, the agent must delete `.planner/HANDOFF.md` using `plan_delete_handoff`.\n\n" +
|
|
2262
|
+
"Do NOT write the handoff to `.pi/`. The canonical path is `.planner/HANDOFF.md`. " +
|
|
2263
|
+
(handoff?.content ? "A previous handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
|
|
2264
|
+
"Once written, confirm to me with a one-line summary.");
|
|
2265
|
+
ctx.ui.notify("Instructing the agent to prepare .planner/HANDOFF.md…", "info");
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
// action === "write": auto-generate from plan data without agent narrative.
|
|
2269
|
+
const whatWasBeingDone = await ctx.ui.input("What was being done? (optional override)");
|
|
2270
|
+
const howToResume = await ctx.ui.input("How should the next agent resume? (optional override)");
|
|
2271
|
+
await writeProjectHandoff(st, "manual on-demand handoff", {
|
|
2272
|
+
...(whatWasBeingDone?.trim() ? { whatWasBeingDone: whatWasBeingDone.trim() } : {}),
|
|
2273
|
+
...(howToResume?.trim() ? { howToResume: howToResume.trim() } : {}),
|
|
2274
|
+
});
|
|
2275
|
+
ctx.ui.notify("Wrote .planner/HANDOFF.md", "info");
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (action === "clear" || action === "delete") {
|
|
2279
|
+
await st.deleteHandoff();
|
|
2280
|
+
ctx.ui.notify("Deleted .planner/HANDOFF.md", "info");
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
ctx.ui.notify("handoff actions: prepare | show | write | clear\nUse: /planner handoff prepare | show | write | clear", "info");
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
// ── web ──
|
|
2287
|
+
if (a === "web") {
|
|
2288
|
+
const action = b || "status";
|
|
2289
|
+
const requestedPort = parts[2]?.trim() ? parseInt(parts[2], 10) : undefined;
|
|
2290
|
+
if (parts[2]?.trim() && (!Number.isFinite(requestedPort) || (requestedPort ?? 0) <= 0)) {
|
|
2291
|
+
ctx.ui.notify(`Invalid port "${parts[2]}"`, "error");
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
switch (action) {
|
|
2295
|
+
case "start": {
|
|
2296
|
+
if (server) {
|
|
2297
|
+
ctx.ui.notify(`Already running at ${server.lanUrl ? server.localUrl + " (LAN: " + server.lanUrl + ")" : server.url}`, "info");
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const visibilityArg = normalizeVisibility(parts[2]);
|
|
2301
|
+
const portArg = parts[2]?.trim() && Number.isFinite(parseInt(parts[2], 10)) ? parseInt(parts[2], 10) : undefined;
|
|
2302
|
+
const visibility = visibilityArg ?? await promptWebVisibility(ctx);
|
|
2303
|
+
ctx.ui.notify(`Starting web server (${visibility})${portArg ? ` on port ${portArg}` : ""} …`, "info");
|
|
2304
|
+
await startServer(ctx, portArg, visibility);
|
|
2305
|
+
const srv = server;
|
|
2306
|
+
if (srv)
|
|
2307
|
+
ctx.ui.notify(srv.lanUrl ? `Web UI ready. Local: ${srv.localUrl} — LAN: ${srv.lanUrl}` : `Web UI ready. Open: ${srv.url}`, "info");
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
case "stop":
|
|
2311
|
+
if (!server) {
|
|
2312
|
+
ctx.ui.notify("Not running", "info");
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
await stopServer();
|
|
2316
|
+
ctx.ui.notify("Server stopped", "info");
|
|
2317
|
+
break;
|
|
2318
|
+
default: {
|
|
2319
|
+
const srv = server;
|
|
2320
|
+
if (!srv) {
|
|
2321
|
+
ctx.ui.notify("Not running", "info");
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const lines = [`Web UI running (${srv.mode}).`, ` local: ${srv.localUrl}`];
|
|
2325
|
+
if (srv.lanUrl)
|
|
2326
|
+
lines.push(` lan: ${srv.lanUrl}`);
|
|
2327
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
// ── maintenance/session actions ──
|
|
2333
|
+
if (a === "repair") {
|
|
2334
|
+
const report = await st.repair();
|
|
2335
|
+
const m = report.migrated;
|
|
2336
|
+
const dup = report.integrity.duplicatePhaseIds.length;
|
|
2337
|
+
const dang = report.integrity.danglingPhaseIds.length;
|
|
2338
|
+
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
// ── export ──
|
|
2342
|
+
if (a === "export" || a === "export-full") {
|
|
2343
|
+
const isFull = a === "export-full" || subArgs.includes("--full");
|
|
2344
|
+
try {
|
|
2345
|
+
const plan = await st.loadAll();
|
|
2346
|
+
const markdown = new ExportService().exportToMarkdown(plan, isFull);
|
|
2347
|
+
const filePath = join(st.root, "EXPORT.md");
|
|
2348
|
+
await writeFile(filePath, markdown, "utf-8");
|
|
2349
|
+
ctx.ui.notify(`Export generated: ${filePath}\n\n${markdown.slice(0, 500)}${markdown.length > 500 ? "..." : ""}`, "info");
|
|
2350
|
+
}
|
|
2351
|
+
catch (e) {
|
|
2352
|
+
ctx.ui.notify(`Export failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
2353
|
+
}
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
// ── bypass / clear-bypass ──
|
|
2357
|
+
if (a === "bypass" || a === "clear-bypass") {
|
|
2358
|
+
try {
|
|
2359
|
+
if (a === "bypass") {
|
|
2360
|
+
const mins = parseInt(subArgs.trim(), 10);
|
|
2361
|
+
const until = await st.authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
|
|
2362
|
+
ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write will work without a task in-progress for that window.`, "info");
|
|
2363
|
+
}
|
|
2364
|
+
else {
|
|
2365
|
+
await st.clearGuardBypass();
|
|
2366
|
+
ctx.ui.notify("Guard bypass revoked. edit/write now requires a task in-progress again.", "info");
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
catch (e) {
|
|
2370
|
+
ctx.ui.notify(`Bypass action failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
2371
|
+
}
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (a === "load") {
|
|
2375
|
+
const project = await st.loadProject().catch(() => null);
|
|
2376
|
+
if (project) {
|
|
2377
|
+
let changed = false;
|
|
2378
|
+
if (project.plannerNeverAsk) {
|
|
2379
|
+
project.plannerNeverAsk = false;
|
|
2380
|
+
changed = true;
|
|
2381
|
+
}
|
|
2382
|
+
if (project.plannerNeverStartWeb) {
|
|
2383
|
+
project.plannerNeverStartWeb = false;
|
|
2384
|
+
changed = true;
|
|
2385
|
+
}
|
|
2386
|
+
if (changed) {
|
|
2387
|
+
await st.saveProject(project);
|
|
2388
|
+
ctx.ui.notify("Cleared 'never' flags. Planner enabled for this session.", "info");
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
plannerSessionEnabled = true;
|
|
2392
|
+
if (!server) {
|
|
2393
|
+
ctx.ui.notify("Starting web server …", "info");
|
|
2394
|
+
await startServer(ctx);
|
|
2395
|
+
}
|
|
2396
|
+
ctx.ui.notify(`Planner loaded. Web UI: ${server?.url ?? "(not started)"}`, "info");
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
if (a === "disable") {
|
|
2400
|
+
const project = await st.loadProject().catch(() => null);
|
|
2401
|
+
if (project) {
|
|
2402
|
+
project.plannerAutoEnable = false;
|
|
2403
|
+
project.plannerNeverAsk = false;
|
|
2404
|
+
project.plannerAutoStartWeb = false;
|
|
2405
|
+
project.plannerNeverStartWeb = false;
|
|
2406
|
+
await st.saveProject(project);
|
|
2407
|
+
}
|
|
2408
|
+
plannerSessionEnabled = false;
|
|
2409
|
+
await stopServer().catch(() => { });
|
|
2410
|
+
try {
|
|
2411
|
+
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
2412
|
+
}
|
|
2413
|
+
catch { }
|
|
2414
|
+
ctx.ui.notify("Planner preferences reset to 'ask'. Disabled for this session. Next restart will prompt again.", "info");
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
ctx.ui.notify(`Unknown "${a}". ${SUB_HELP}`, "warning");
|
|
2418
|
+
}
|
|
2419
|
+
// ── Single /planner command (hierarchical with spaces) ──────────
|
|
2420
|
+
pi.registerCommand("planner", {
|
|
2421
|
+
description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
|
|
2422
|
+
getArgumentCompletions: (prefix) => {
|
|
2423
|
+
const normalized = prefix.trim().toLowerCase();
|
|
2424
|
+
const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
2425
|
+
return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
|
|
2426
|
+
},
|
|
2427
|
+
handler: async (args, ctx) => handlePlanner(args, ctx),
|
|
2428
|
+
});
|
|
2429
|
+
// ── Custom Tools (non-interactive, usable by the LLM agent) ──────
|
|
2430
|
+
//
|
|
2431
|
+
// The grouped /planner command is interactive (pickers, inputs) and meant
|
|
2432
|
+
// for humans in the TUI. The agent (LLM) can only call tools, so the full
|
|
2433
|
+
// CRUD surface is exposed here as tools that operate directly on the store.
|
|
2434
|
+
function loadStore(ctx) {
|
|
2435
|
+
const s = new PlanStore(resolvePlanRoot(ctx.cwd));
|
|
2436
|
+
s.enableAutoSync(true);
|
|
2437
|
+
return s;
|
|
2438
|
+
}
|
|
2439
|
+
async function requirePlan(ctx) {
|
|
2440
|
+
const st = loadStore(ctx);
|
|
2441
|
+
if (!(await st.exists()))
|
|
2442
|
+
return null;
|
|
2443
|
+
await maybeHealStatuses(st);
|
|
2444
|
+
return st;
|
|
2445
|
+
}
|
|
2446
|
+
// ── plan lifecycle ──────────────────────────────────────────────────
|
|
2447
|
+
pi.registerTool({
|
|
2448
|
+
name: "project_set_language_preferences",
|
|
2449
|
+
label: "Project Set Language Preferences",
|
|
2450
|
+
description: "Persist preferred languages for plan content and chat in the current planner project. Use this once after the user decides, so you do not have to ask again in later sessions.",
|
|
2451
|
+
parameters: Type.Object({
|
|
2452
|
+
contentLanguage: Type.Optional(Type.String({ description: "Preferred language for plan content (rules, decisions, descriptions, summaries)" })),
|
|
2453
|
+
chatLanguage: Type.Optional(Type.String({ description: "Preferred language for assistant-user chat in this project" })),
|
|
2454
|
+
}),
|
|
2455
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2456
|
+
const st = await requirePlan(ctx);
|
|
2457
|
+
if (!st)
|
|
2458
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2459
|
+
if (params.contentLanguage === undefined && params.chatLanguage === undefined) {
|
|
2460
|
+
return { content: [{ type: "text", text: "Nothing to update. Provide contentLanguage and/or chatLanguage." }], details: {} };
|
|
2461
|
+
}
|
|
2462
|
+
const project = await st.loadProject();
|
|
2463
|
+
if (params.contentLanguage !== undefined)
|
|
2464
|
+
project.contentLanguage = params.contentLanguage.trim();
|
|
2465
|
+
if (params.chatLanguage !== undefined)
|
|
2466
|
+
project.chatLanguage = params.chatLanguage.trim();
|
|
2467
|
+
const normalizedContent = normalizeLanguagePref(project.contentLanguage);
|
|
2468
|
+
const normalizedChat = normalizeLanguagePref(project.chatLanguage);
|
|
2469
|
+
if (normalizedContent || normalizedChat) {
|
|
2470
|
+
const fallback = normalizedContent || normalizedChat;
|
|
2471
|
+
project.contentLanguage = normalizedContent || fallback;
|
|
2472
|
+
project.chatLanguage = normalizedChat || fallback;
|
|
2473
|
+
}
|
|
2474
|
+
await st.saveProject(project);
|
|
2475
|
+
await st.writeGenerated();
|
|
2476
|
+
return {
|
|
2477
|
+
content: [{ type: "text", text: `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}` }],
|
|
2478
|
+
details: { contentLanguage: project.contentLanguage, chatLanguage: project.chatLanguage },
|
|
2479
|
+
};
|
|
2480
|
+
},
|
|
2481
|
+
});
|
|
2482
|
+
pi.registerTool({
|
|
2483
|
+
name: "plan_init",
|
|
2484
|
+
label: "Plan Init",
|
|
2485
|
+
description: "Initialize a new project plan (.planner/) in the current cwd. Use once at the start of a project. When deriving the planner from an existing document or plan, use a concise human project title and ask the user to confirm it if your candidate title is long, compound, or looks like a phase heading.",
|
|
2486
|
+
parameters: Type.Object({
|
|
2487
|
+
projectName: Type.String({ description: "Concise project name/title" }),
|
|
2488
|
+
description: Type.Optional(Type.String({ description: "Short project description" })),
|
|
2489
|
+
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
2490
|
+
requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial top-level requirements to seed requirements.json" })),
|
|
2491
|
+
}),
|
|
2492
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2493
|
+
const st = loadStore(ctx);
|
|
2494
|
+
if (await st.exists()) {
|
|
2495
|
+
return { content: [{ type: "text", text: ".planner/ already exists" }], details: {} };
|
|
2496
|
+
}
|
|
2497
|
+
let projectName = params.projectName.trim();
|
|
2498
|
+
if (projectName.length > 60 || projectName.includes("—") || projectName.includes("->")) {
|
|
2499
|
+
const confirmed = await ctx.ui.confirm(`The project name "${projectName}" seems long or compound. Is this the final concise title you want for the .planner/ root?`, "Yes, use this title");
|
|
2500
|
+
if (!confirmed) {
|
|
2501
|
+
const newTitle = await ctx.ui.input("Please enter a more concise project title:");
|
|
2502
|
+
if (newTitle)
|
|
2503
|
+
projectName = newTitle.trim();
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
await st.init(projectName);
|
|
2507
|
+
const project = await st.loadProject();
|
|
2508
|
+
if (params.description !== undefined)
|
|
2509
|
+
project.description = params.description.trim();
|
|
2510
|
+
if (params.goal !== undefined)
|
|
2511
|
+
project.goal = params.goal.trim();
|
|
2512
|
+
await st.saveProject(project);
|
|
2513
|
+
const requirementTitles = (params.requirements ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
2514
|
+
if (requirementTitles.length > 0) {
|
|
2515
|
+
const now = nowISO();
|
|
2516
|
+
await st.saveRequirements({
|
|
2517
|
+
requirements: requirementTitles.map((title, index) => ({
|
|
2518
|
+
id: createRequirementId(),
|
|
2519
|
+
title,
|
|
2520
|
+
description: "",
|
|
2521
|
+
status: "planned",
|
|
2522
|
+
macroTasks: [],
|
|
2523
|
+
linkedPhaseIds: [],
|
|
2524
|
+
createdAt: now,
|
|
2525
|
+
updatedAt: now,
|
|
2526
|
+
})),
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
await st.writeGenerated();
|
|
2530
|
+
return {
|
|
2531
|
+
content: [{ type: "text", text: `.planner/ initialized for "${projectName}"` }],
|
|
2532
|
+
details: { projectName, description: project.description, goal: project.goal, requirementsCount: requirementTitles.length },
|
|
2533
|
+
};
|
|
2534
|
+
},
|
|
2535
|
+
});
|
|
2536
|
+
pi.registerTool({
|
|
2537
|
+
name: "project_update",
|
|
2538
|
+
label: "Project Update",
|
|
2539
|
+
description: "Update project-level metadata such as title, description, goal, scope, technologies, and rules. Use this after importing or refining an existing plan so the planner root is not left empty.",
|
|
2540
|
+
parameters: Type.Object({
|
|
2541
|
+
name: Type.Optional(Type.String({ description: "Project title" })),
|
|
2542
|
+
description: Type.Optional(Type.String({ description: "Short project description" })),
|
|
2543
|
+
goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
|
|
2544
|
+
scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
|
|
2545
|
+
outOfScope: Type.Optional(Type.Array(Type.String(), { description: "Replace out-of-scope items" })),
|
|
2546
|
+
technologies: Type.Optional(Type.Array(Type.String(), { description: "Replace technologies list" })),
|
|
2547
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Replace tools list" })),
|
|
2548
|
+
globalRules: Type.Optional(Type.Array(Type.String(), { description: "Replace global rules" })),
|
|
2549
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace project decisions" })),
|
|
2550
|
+
}),
|
|
2551
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2552
|
+
const st = await requirePlan(ctx);
|
|
2553
|
+
if (!st)
|
|
2554
|
+
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
2555
|
+
const project = await st.loadProject();
|
|
2556
|
+
if (params.name !== undefined)
|
|
2557
|
+
project.name = params.name.trim();
|
|
2558
|
+
if (params.description !== undefined)
|
|
2559
|
+
project.description = params.description.trim();
|
|
2560
|
+
if (params.goal !== undefined)
|
|
2561
|
+
project.goal = params.goal.trim();
|
|
2562
|
+
if (params.scope !== undefined)
|
|
2563
|
+
project.scope = params.scope.map((entry) => entry.trim()).filter(Boolean);
|
|
2564
|
+
if (params.outOfScope !== undefined)
|
|
2565
|
+
project.outOfScope = params.outOfScope.map((entry) => entry.trim()).filter(Boolean);
|
|
2566
|
+
if (params.technologies !== undefined)
|
|
2567
|
+
project.technologies = params.technologies.map((entry) => entry.trim()).filter(Boolean);
|
|
2568
|
+
if (params.tools !== undefined)
|
|
2569
|
+
project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
|
|
2570
|
+
if (params.globalRules !== undefined)
|
|
2571
|
+
project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
|
|
2572
|
+
if (params.decisions !== undefined)
|
|
2573
|
+
project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
|
|
2574
|
+
await st.saveProject(project);
|
|
2575
|
+
await st.writeGenerated();
|
|
2576
|
+
return { content: [{ type: "text", text: `Project updated: ${project.name}` }], details: project };
|
|
2577
|
+
},
|
|
2578
|
+
});
|
|
2579
|
+
pi.registerTool({
|
|
2580
|
+
name: "requirement_list",
|
|
2581
|
+
label: "Requirement List",
|
|
2582
|
+
description: "List all top-level requirements in requirements.json.",
|
|
2583
|
+
parameters: Type.Object({}),
|
|
2584
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2585
|
+
const st = await requirePlan(ctx);
|
|
2586
|
+
if (!st)
|
|
2587
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2588
|
+
const requirements = await st.loadRequirements();
|
|
2589
|
+
return {
|
|
2590
|
+
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2591
|
+
details: requirements,
|
|
2592
|
+
};
|
|
2593
|
+
},
|
|
2594
|
+
});
|
|
2595
|
+
pi.registerTool({
|
|
2596
|
+
name: "requirement_create",
|
|
2597
|
+
label: "Requirement Create",
|
|
2598
|
+
description: "Create a new top-level requirement in requirements.json.",
|
|
2599
|
+
parameters: Type.Object({
|
|
2600
|
+
title: Type.String({ description: "Requirement title" }),
|
|
2601
|
+
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
2602
|
+
status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
|
|
2603
|
+
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
|
|
2604
|
+
}),
|
|
2605
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2606
|
+
const st = await requirePlan(ctx);
|
|
2607
|
+
if (!st)
|
|
2608
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2609
|
+
const requirements = await st.loadRequirements();
|
|
2610
|
+
const now = nowISO();
|
|
2611
|
+
const requirement = {
|
|
2612
|
+
id: createRequirementId(),
|
|
2613
|
+
title: params.title.trim(),
|
|
2614
|
+
description: params.description?.trim() ?? "",
|
|
2615
|
+
status: params.status?.trim() ?? "planned",
|
|
2616
|
+
macroTasks: [],
|
|
2617
|
+
linkedPhaseIds: (params.linkedPhaseIds ?? []).map((entry) => entry.trim()).filter(Boolean),
|
|
2618
|
+
createdAt: now,
|
|
2619
|
+
updatedAt: now,
|
|
2620
|
+
};
|
|
2621
|
+
requirements.requirements.push(requirement);
|
|
2622
|
+
await st.saveRequirements(requirements);
|
|
2623
|
+
await st.writeGenerated();
|
|
2624
|
+
return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: requirement };
|
|
2625
|
+
},
|
|
2626
|
+
});
|
|
2627
|
+
pi.registerTool({
|
|
2628
|
+
name: "requirement_update",
|
|
2629
|
+
label: "Requirement Update",
|
|
2630
|
+
description: "Update an existing top-level requirement.",
|
|
2631
|
+
parameters: Type.Object({
|
|
2632
|
+
requirementId: Type.String({ description: "Requirement ID" }),
|
|
2633
|
+
title: Type.Optional(Type.String({ description: "Requirement title" })),
|
|
2634
|
+
description: Type.Optional(Type.String({ description: "Requirement description" })),
|
|
2635
|
+
status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
|
|
2636
|
+
linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
|
|
2637
|
+
}),
|
|
2638
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2639
|
+
const st = await requirePlan(ctx);
|
|
2640
|
+
if (!st)
|
|
2641
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2642
|
+
const requirements = await st.loadRequirements();
|
|
2643
|
+
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
2644
|
+
if (!requirement)
|
|
2645
|
+
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2646
|
+
if (params.title !== undefined)
|
|
2647
|
+
requirement.title = params.title.trim();
|
|
2648
|
+
if (params.description !== undefined)
|
|
2649
|
+
requirement.description = params.description.trim();
|
|
2650
|
+
if (params.status !== undefined)
|
|
2651
|
+
requirement.status = params.status.trim();
|
|
2652
|
+
if (params.linkedPhaseIds !== undefined)
|
|
2653
|
+
requirement.linkedPhaseIds = params.linkedPhaseIds.map((entry) => entry.trim()).filter(Boolean);
|
|
2654
|
+
requirement.updatedAt = nowISO();
|
|
2655
|
+
await st.saveRequirements(requirements);
|
|
2656
|
+
await st.writeGenerated();
|
|
2657
|
+
return { content: [{ type: "text", text: `Requirement updated: ${requirement.id}` }], details: requirement };
|
|
2658
|
+
},
|
|
2659
|
+
});
|
|
2660
|
+
pi.registerTool({
|
|
2661
|
+
name: "requirement_delete",
|
|
2662
|
+
label: "Requirement Delete",
|
|
2663
|
+
description: "Delete a top-level requirement from requirements.json.",
|
|
2664
|
+
parameters: Type.Object({
|
|
2665
|
+
requirementId: Type.String({ description: "Requirement ID" }),
|
|
2666
|
+
}),
|
|
2667
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2668
|
+
const st = await requirePlan(ctx);
|
|
2669
|
+
if (!st)
|
|
2670
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2671
|
+
const requirements = await st.loadRequirements();
|
|
2672
|
+
const next = requirements.requirements.filter((entry) => entry.id !== params.requirementId);
|
|
2673
|
+
if (next.length === requirements.requirements.length) {
|
|
2674
|
+
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2675
|
+
}
|
|
2676
|
+
await st.saveRequirements({ requirements: next });
|
|
2677
|
+
await st.writeGenerated();
|
|
2678
|
+
return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: params.requirementId } };
|
|
2679
|
+
},
|
|
2680
|
+
});
|
|
2681
|
+
pi.registerTool({
|
|
2682
|
+
name: "plan_get",
|
|
2683
|
+
label: "Plan Get",
|
|
2684
|
+
description: "Read the full plan: manifest, project, features, phases (with their tasks), and requirements. Call this first to understand current state before planning work.",
|
|
2685
|
+
parameters: Type.Object({}),
|
|
2686
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2687
|
+
const st = await requirePlan(ctx);
|
|
2688
|
+
if (!st)
|
|
2689
|
+
return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
|
|
2690
|
+
const plan = await st.loadAll();
|
|
2691
|
+
return {
|
|
2692
|
+
content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements` }],
|
|
2693
|
+
details: plan,
|
|
2694
|
+
};
|
|
2695
|
+
},
|
|
2696
|
+
});
|
|
2697
|
+
pi.registerTool({
|
|
2698
|
+
name: "plan_render",
|
|
2699
|
+
label: "Plan Render",
|
|
2700
|
+
description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
|
|
2701
|
+
parameters: Type.Object({}),
|
|
2702
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2703
|
+
const st = await requirePlan(ctx);
|
|
2704
|
+
if (!st)
|
|
2705
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2706
|
+
const files = await st.writeGenerated();
|
|
2707
|
+
return { content: [{ type: "text", text: `Regenerated ${files.length} files` }], details: { files } };
|
|
2708
|
+
},
|
|
2709
|
+
});
|
|
2710
|
+
pi.registerTool({
|
|
2711
|
+
name: "plan_repair",
|
|
2712
|
+
label: "Plan Repair",
|
|
2713
|
+
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/.",
|
|
2714
|
+
parameters: Type.Object({}),
|
|
2715
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2716
|
+
const st = await requirePlan(ctx);
|
|
2717
|
+
if (!st)
|
|
2718
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2719
|
+
const report = await st.repair();
|
|
2720
|
+
const m = report.migrated;
|
|
2721
|
+
const lines = [
|
|
2722
|
+
`Repair complete.`,
|
|
2723
|
+
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2724
|
+
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2725
|
+
];
|
|
2726
|
+
if (report.integrity.danglingPhaseIds.length)
|
|
2727
|
+
lines.push("Dangling: " + report.integrity.danglingPhaseIds.join(", "));
|
|
2728
|
+
if (report.integrity.duplicatePhaseIds.length)
|
|
2729
|
+
lines.push("Duplicates: " + report.integrity.duplicatePhaseIds.join(", "));
|
|
2730
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2731
|
+
},
|
|
2732
|
+
});
|
|
2733
|
+
pi.registerTool({
|
|
2734
|
+
name: "plan_get_handoff",
|
|
2735
|
+
label: "Plan Get Handoff",
|
|
2736
|
+
description: "Read the current .planner/HANDOFF.md if present.",
|
|
2737
|
+
parameters: Type.Object({}),
|
|
2738
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2739
|
+
const st = await requirePlan(ctx);
|
|
2740
|
+
if (!st)
|
|
2741
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2742
|
+
const handoff = await st.loadHandoff();
|
|
2743
|
+
if (!handoff)
|
|
2744
|
+
return { content: [{ type: "text", text: "No .planner/HANDOFF.md present." }], details: { exists: false } };
|
|
2745
|
+
return { content: [{ type: "text", text: handoff.content }], details: { exists: true, ...handoff } };
|
|
2746
|
+
},
|
|
2747
|
+
});
|
|
2748
|
+
pi.registerTool({
|
|
2749
|
+
name: "plan_write_handoff",
|
|
2750
|
+
label: "Plan Write Handoff",
|
|
2751
|
+
description: "Write or refresh the canonical .planner/HANDOFF.md document.",
|
|
2752
|
+
parameters: Type.Object({
|
|
2753
|
+
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2754
|
+
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
2755
|
+
howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
|
|
2756
|
+
}),
|
|
2757
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2758
|
+
const st = await requirePlan(ctx);
|
|
2759
|
+
if (!st)
|
|
2760
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2761
|
+
await writeProjectHandoff(st, params.reason?.trim() || "manual tool handoff", {
|
|
2762
|
+
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2763
|
+
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2764
|
+
});
|
|
2765
|
+
const handoff = await st.loadHandoff();
|
|
2766
|
+
return { content: [{ type: "text", text: "Wrote .planner/HANDOFF.md" }], details: handoff ?? {} };
|
|
2767
|
+
},
|
|
2768
|
+
});
|
|
2769
|
+
pi.registerTool({
|
|
2770
|
+
name: "plan_delete_handoff",
|
|
2771
|
+
label: "Plan Delete Handoff",
|
|
2772
|
+
description: "Delete .planner/HANDOFF.md after the resume has been fully consumed.",
|
|
2773
|
+
parameters: Type.Object({}),
|
|
2774
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2775
|
+
const st = await requirePlan(ctx);
|
|
2776
|
+
if (!st)
|
|
2777
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2778
|
+
await st.deleteHandoff();
|
|
2779
|
+
return { content: [{ type: "text", text: "Deleted .planner/HANDOFF.md" }], details: { deleted: true } };
|
|
2780
|
+
},
|
|
2781
|
+
});
|
|
2782
|
+
pi.registerTool({
|
|
2783
|
+
name: "plan_authorize_bypass",
|
|
2784
|
+
label: "Plan Authorize Guard Bypass",
|
|
2785
|
+
description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write tools can proceed even when no task is in-progress. Use this ONLY after the user explicitly authorizes proceeding without a task. Harness-agnostic: stored in resume.json so all adapters respect it.",
|
|
2786
|
+
parameters: Type.Object({
|
|
2787
|
+
durationMinutes: Type.Optional(Type.Number({ description: "Bypass window in minutes. Default 15." })),
|
|
2788
|
+
}),
|
|
2789
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2790
|
+
const st = await requirePlan(ctx);
|
|
2791
|
+
if (!st)
|
|
2792
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2793
|
+
const mins = params.durationMinutes ?? 15;
|
|
2794
|
+
const until = await st.authorizeGuardBypass(mins);
|
|
2795
|
+
return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. edit/write is allowed without a task in-progress for ${mins} minutes.` }], details: { until } };
|
|
2796
|
+
},
|
|
2797
|
+
});
|
|
2798
|
+
pi.registerTool({
|
|
2799
|
+
name: "plan_clear_bypass",
|
|
2800
|
+
label: "Plan Clear Guard Bypass",
|
|
2801
|
+
description: "Revoke any active guard bypass so edit/write again requires a task in-progress.",
|
|
2802
|
+
parameters: Type.Object({}),
|
|
2803
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2804
|
+
const st = await requirePlan(ctx);
|
|
2805
|
+
if (!st)
|
|
2806
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2807
|
+
await st.clearGuardBypass();
|
|
2808
|
+
return { content: [{ type: "text", text: "Guard bypass revoked." }], details: { cleared: true } };
|
|
2809
|
+
},
|
|
2810
|
+
});
|
|
2811
|
+
// ── features ────────────────────────────────────────────────────────
|
|
2812
|
+
pi.registerTool({
|
|
2813
|
+
name: "feature_list",
|
|
2814
|
+
label: "Feature List",
|
|
2815
|
+
description: "List all features with their ids, statuses, and phase counts.",
|
|
2816
|
+
parameters: Type.Object({}),
|
|
2817
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2818
|
+
const st = await requirePlan(ctx);
|
|
2819
|
+
if (!st)
|
|
2820
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2821
|
+
const features = (await st.loadFeatures()).features;
|
|
2822
|
+
const phases = await st.loadAllPhases();
|
|
2823
|
+
const summary = features.map((f) => {
|
|
2824
|
+
const fp = phases.filter((p) => p.featureId === f.id);
|
|
2825
|
+
return `- ${statusIcon(f.status)} ${f.id} — ${f.name} (${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
2826
|
+
}).join("\n");
|
|
2827
|
+
return { content: [{ type: "text", text: summary || "No features" }], details: { features } };
|
|
2828
|
+
},
|
|
2829
|
+
});
|
|
2830
|
+
pi.registerTool({
|
|
2831
|
+
name: "feature_get",
|
|
2832
|
+
label: "Feature Get",
|
|
2833
|
+
description: "Read full details of a feature, including its phases and their tasks.",
|
|
2834
|
+
parameters: Type.Object({
|
|
2835
|
+
featureId: Type.String({ description: "Feature ID, e.g. feature-001-dynamic-header" }),
|
|
2836
|
+
}),
|
|
2837
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2838
|
+
const st = await requirePlan(ctx);
|
|
2839
|
+
if (!st)
|
|
2840
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2841
|
+
const features = (await st.loadFeatures()).features;
|
|
2842
|
+
const feature = features.find((f) => f.id === params.featureId);
|
|
2843
|
+
if (!feature)
|
|
2844
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2845
|
+
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2846
|
+
return { content: [{ type: "text", text: `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${phases.length} phases` }], details: { feature, phases } };
|
|
2847
|
+
},
|
|
2848
|
+
});
|
|
2849
|
+
pi.registerTool({
|
|
2850
|
+
name: "feature_create",
|
|
2851
|
+
label: "Feature Create",
|
|
2852
|
+
description: "Create a new feature with a RICH description. REQUIRED: description must include code references (file:line), current implementation state (what exists, what is unimplemented), systems/structs/traits involved, concrete goals, and behaviors to preserve. The description is the primary context for future agents resuming this feature; one-liners cause misalignment. Returns the generated feature id. Feature status is generally derived from child phases/tasks, so prefer not to set it directly unless you truly need an explicit override during setup.",
|
|
2853
|
+
parameters: Type.Object({
|
|
2854
|
+
name: Type.String({ description: "Feature name/title" }),
|
|
2855
|
+
description: Type.String({ description: "REQUIRED — code references (file:line), current state of the art, structs/traits/systems involved, goals, behaviors to preserve. Not a one-liner. Prefix with 'design-only' for pre-implementation design tasks.", minLength: 50 }),
|
|
2856
|
+
status: Type.Optional(Type.String({ description: "Initial status. One of: planned, in-progress, done, blocked, canceled. Default: planned. Usually leave this alone: feature status is derived from child phases/tasks." })),
|
|
2857
|
+
}),
|
|
2858
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2859
|
+
const st = await requirePlan(ctx);
|
|
2860
|
+
if (!st)
|
|
2861
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2862
|
+
const now = nowISO();
|
|
2863
|
+
const status = params.status ?? "planned";
|
|
2864
|
+
const currentFeatures = (await st.loadFeatures()).features;
|
|
2865
|
+
const feature = {
|
|
2866
|
+
id: createFeatureId(),
|
|
2867
|
+
number: currentFeatures.length + 1,
|
|
2868
|
+
name: params.name,
|
|
2869
|
+
description: params.description ?? "",
|
|
2870
|
+
status,
|
|
2871
|
+
discussedAt: "",
|
|
2872
|
+
contextReady: false,
|
|
2873
|
+
contextReadyReason: "",
|
|
2874
|
+
startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
|
|
2875
|
+
endDate: "",
|
|
2876
|
+
workDone: "",
|
|
2877
|
+
workRemaining: "",
|
|
2878
|
+
acceptedDecisions: [],
|
|
2879
|
+
phaseIds: [],
|
|
2880
|
+
dependsOn: [],
|
|
2881
|
+
createdAt: now,
|
|
2882
|
+
updatedAt: now,
|
|
2883
|
+
};
|
|
2884
|
+
// Atomic read-modify-write: serializes concurrent feature_create calls
|
|
2885
|
+
// (batch) so they don't overwrite each other (last-write-wins race).
|
|
2886
|
+
await st.updateFeatures((doc) => {
|
|
2887
|
+
doc.features.push(feature);
|
|
2888
|
+
return doc;
|
|
2889
|
+
});
|
|
2890
|
+
await st.writeGenerated();
|
|
2891
|
+
return { content: [{ type: "text", text: `Feature created: ${feature.id}` }], details: feature };
|
|
2892
|
+
},
|
|
2893
|
+
});
|
|
2894
|
+
pi.registerTool({
|
|
2895
|
+
name: "feature_update",
|
|
2896
|
+
label: "Feature Update",
|
|
2897
|
+
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.",
|
|
2898
|
+
parameters: Type.Object({
|
|
2899
|
+
featureId: Type.String({ description: "Feature ID" }),
|
|
2900
|
+
name: Type.Optional(Type.String({ description: "New name" })),
|
|
2901
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
2902
|
+
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." })),
|
|
2903
|
+
startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
|
|
2904
|
+
endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
|
|
2905
|
+
workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
|
|
2906
|
+
workRemaining: Type.Optional(Type.String({ description: "Notes on remaining work" })),
|
|
2907
|
+
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
2908
|
+
id: Type.String(),
|
|
2909
|
+
title: Type.String(),
|
|
2910
|
+
decision: Type.String(),
|
|
2911
|
+
rationale: Type.String(),
|
|
2912
|
+
implementationNotes: Type.String(),
|
|
2913
|
+
acceptedAt: Type.String(),
|
|
2914
|
+
}), { description: "Replace accepted decisions list" })),
|
|
2915
|
+
}),
|
|
2916
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2917
|
+
const st = await requirePlan(ctx);
|
|
2918
|
+
if (!st)
|
|
2919
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2920
|
+
let foundDoc;
|
|
2921
|
+
try {
|
|
2922
|
+
foundDoc = await st.updateFeatures((doc) => {
|
|
2923
|
+
const feature = doc.features.find((f) => f.id === params.featureId);
|
|
2924
|
+
if (!feature)
|
|
2925
|
+
return doc; // not found: no-op, handled below
|
|
2926
|
+
if (params.name !== undefined)
|
|
2927
|
+
feature.name = params.name;
|
|
2928
|
+
if (params.description !== undefined)
|
|
2929
|
+
feature.description = params.description;
|
|
2930
|
+
if (params.workDone !== undefined)
|
|
2931
|
+
feature.workDone = params.workDone;
|
|
2932
|
+
if (params.workRemaining !== undefined)
|
|
2933
|
+
feature.workRemaining = params.workRemaining;
|
|
2934
|
+
if (params.startDate !== undefined)
|
|
2935
|
+
feature.startDate = params.startDate;
|
|
2936
|
+
if (params.endDate !== undefined)
|
|
2937
|
+
feature.endDate = params.endDate;
|
|
2938
|
+
if (params.acceptedDecisions !== undefined)
|
|
2939
|
+
feature.acceptedDecisions = params.acceptedDecisions;
|
|
2940
|
+
if (params.status !== undefined) {
|
|
2941
|
+
const status = params.status;
|
|
2942
|
+
if (feature.status !== status) {
|
|
2943
|
+
if (status === "in-progress" && !feature.startDate) {
|
|
2944
|
+
feature.startDate = new Date().toISOString().slice(0, 10);
|
|
2945
|
+
}
|
|
2946
|
+
if (status === "done" && !feature.endDate) {
|
|
2947
|
+
feature.endDate = new Date().toISOString().slice(0, 10);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
feature.status = status;
|
|
2951
|
+
}
|
|
2952
|
+
feature.updatedAt = nowISO();
|
|
2953
|
+
return doc;
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
catch (e) {
|
|
2957
|
+
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
2958
|
+
}
|
|
2959
|
+
const feature = foundDoc?.features.find((f) => f.id === params.featureId);
|
|
2960
|
+
if (!feature)
|
|
2961
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2962
|
+
await st.writeGenerated();
|
|
2963
|
+
return { content: [{ type: "text", text: `Feature updated: ${feature.id} (${feature.status})` }], details: feature };
|
|
2964
|
+
},
|
|
2965
|
+
});
|
|
2966
|
+
pi.registerTool({
|
|
2967
|
+
name: "feature_delete",
|
|
2968
|
+
label: "Feature Delete",
|
|
2969
|
+
description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
|
|
2970
|
+
parameters: Type.Object({
|
|
2971
|
+
featureId: Type.String({ description: "Feature ID to delete" }),
|
|
2972
|
+
cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
|
|
2973
|
+
}),
|
|
2974
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2975
|
+
const st = await requirePlan(ctx);
|
|
2976
|
+
if (!st)
|
|
2977
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2978
|
+
let deleted = false;
|
|
2979
|
+
await st.updateFeatures((doc) => {
|
|
2980
|
+
const before = doc.features.length;
|
|
2981
|
+
doc.features = doc.features.filter((f) => f.id !== params.featureId);
|
|
2982
|
+
if (doc.features.length !== before)
|
|
2983
|
+
deleted = true;
|
|
2984
|
+
return doc;
|
|
2985
|
+
});
|
|
2986
|
+
if (!deleted) {
|
|
2987
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2988
|
+
}
|
|
2989
|
+
let cascadeCount = 0;
|
|
2990
|
+
if (params.cascade) {
|
|
2991
|
+
const phases = await st.loadAllPhases();
|
|
2992
|
+
for (const phase of phases.filter((p) => p.featureId === params.featureId)) {
|
|
2993
|
+
await st.deletePhase(phase.id);
|
|
2994
|
+
cascadeCount += 1;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
await st.writeGenerated();
|
|
2998
|
+
return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
|
|
2999
|
+
},
|
|
3000
|
+
});
|
|
3001
|
+
// ── phases ──────────────────────────────────────────────────────────
|
|
3002
|
+
pi.registerTool({
|
|
3003
|
+
name: "phase_list",
|
|
3004
|
+
label: "Phase List",
|
|
3005
|
+
description: "List all phases (optionally filtered by featureId) with id, status, and task counts.",
|
|
3006
|
+
parameters: Type.Object({
|
|
3007
|
+
featureId: Type.Optional(Type.String({ description: "If provided, only list phases of this feature" })),
|
|
3008
|
+
}),
|
|
3009
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3010
|
+
const st = await requirePlan(ctx);
|
|
3011
|
+
if (!st)
|
|
3012
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3013
|
+
let phases = await st.loadAllPhases();
|
|
3014
|
+
if (params.featureId)
|
|
3015
|
+
phases = phases.filter((p) => p.featureId === params.featureId);
|
|
3016
|
+
const summary = phases.map((p) => `- ${statusIcon(p.status)} ${p.id} — ${p.title} (${p.tasks.length} tasks, feature ${p.featureId ?? "none"})`).join("\n");
|
|
3017
|
+
return { content: [{ type: "text", text: summary || "No phases" }], details: { phases } };
|
|
3018
|
+
},
|
|
3019
|
+
});
|
|
3020
|
+
pi.registerTool({
|
|
3021
|
+
name: "phase_get",
|
|
3022
|
+
label: "Phase Get",
|
|
3023
|
+
description: "Read full details of a phase, including its tasks.",
|
|
3024
|
+
parameters: Type.Object({
|
|
3025
|
+
phaseId: Type.String({ description: "Phase ID, e.g. phase-01-setup" }),
|
|
3026
|
+
}),
|
|
3027
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3028
|
+
const st = await requirePlan(ctx);
|
|
3029
|
+
if (!st)
|
|
3030
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3031
|
+
let phase;
|
|
3032
|
+
try {
|
|
3033
|
+
phase = await st.loadPhase(params.phaseId);
|
|
3034
|
+
}
|
|
3035
|
+
catch {
|
|
3036
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3037
|
+
}
|
|
3038
|
+
return { content: [{ type: "text", text: `${statusIcon(phase.status)} ${phase.title} (${phase.id}) — ${phase.tasks.length} tasks` }], details: phase };
|
|
3039
|
+
},
|
|
3040
|
+
});
|
|
3041
|
+
pi.registerTool({
|
|
3042
|
+
name: "phase_create",
|
|
3043
|
+
label: "Phase Create",
|
|
3044
|
+
description: "Create a new phase linked to a feature with a RICH description. REQUIRED: description must include code references (file:line), current implementation state, dependencies, specific files/systems to modify, and behaviors to preserve. The description is the primary context for future agents; one-liners cause misalignment. Status defaults to draft. featureId is required. Once tasks exist, phase status is generally derived from task statuses.",
|
|
3045
|
+
parameters: Type.Object({
|
|
3046
|
+
title: Type.String({ description: "Phase title" }),
|
|
3047
|
+
featureId: Type.String({ description: "Feature ID to link this phase to (required)" }),
|
|
3048
|
+
summary: Type.Optional(Type.String({ description: "One-line summary of the phase" })),
|
|
3049
|
+
description: Type.String({ description: "REQUIRED — code references (file:line), current state, structs/traits involved, concrete work items, behaviors to preserve. Not a one-liner. Prefix with 'design-only' for pre-implementation design tasks.", minLength: 50 }),
|
|
3050
|
+
status: Type.Optional(Type.String({ description: "Initial status. Default: draft. Usually leave this alone: once tasks exist, phase status is derived from task statuses." })),
|
|
3051
|
+
}),
|
|
3052
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3053
|
+
const st = await requirePlan(ctx);
|
|
3054
|
+
if (!st)
|
|
3055
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3056
|
+
if (!params.featureId?.trim())
|
|
3057
|
+
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3058
|
+
let phase;
|
|
3059
|
+
await withFeatureLock(params.featureId, async () => {
|
|
3060
|
+
const phases = await st.loadAllPhases();
|
|
3061
|
+
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3062
|
+
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
3063
|
+
const id = createPhaseId();
|
|
3064
|
+
const now = nowISO();
|
|
3065
|
+
phase = {
|
|
3066
|
+
id, number, slug: normalizeSlug(params.title), title: params.title,
|
|
3067
|
+
featureId: params.featureId,
|
|
3068
|
+
status: params.status ?? "draft",
|
|
3069
|
+
discussedAt: "",
|
|
3070
|
+
contextReady: false,
|
|
3071
|
+
contextReadyReason: "",
|
|
3072
|
+
summary: params.summary ?? "", description: params.description ?? "", notes: "",
|
|
3073
|
+
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
3074
|
+
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3075
|
+
createdAt: now, updatedAt: now,
|
|
3076
|
+
};
|
|
3077
|
+
await st.savePhase(phase);
|
|
3078
|
+
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3079
|
+
await st.updateFeatures((doc) => {
|
|
3080
|
+
const feature = doc.features.find((f) => f.id === params.featureId);
|
|
3081
|
+
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3082
|
+
feature.phaseIds.push(phase.id);
|
|
3083
|
+
feature.updatedAt = now;
|
|
3084
|
+
}
|
|
3085
|
+
return doc;
|
|
3086
|
+
});
|
|
3087
|
+
await st.writeGenerated();
|
|
3088
|
+
});
|
|
3089
|
+
if (!phase)
|
|
3090
|
+
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
3091
|
+
return { content: [{ type: "text", text: `Phase created: ${phase.id}` }], details: phase };
|
|
3092
|
+
},
|
|
3093
|
+
});
|
|
3094
|
+
pi.registerTool({
|
|
3095
|
+
name: "phase_update",
|
|
3096
|
+
label: "Phase Update",
|
|
3097
|
+
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.",
|
|
3098
|
+
parameters: Type.Object({
|
|
3099
|
+
phaseId: Type.String({ description: "Phase ID" }),
|
|
3100
|
+
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3101
|
+
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." })),
|
|
3102
|
+
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
3103
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
3104
|
+
featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
|
|
3105
|
+
goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
|
|
3106
|
+
nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
|
|
3107
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
|
|
3108
|
+
risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
|
|
3109
|
+
openQuestions: Type.Optional(Type.Array(Type.String(), { description: "Replace open questions list" })),
|
|
3110
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
|
|
3111
|
+
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
3112
|
+
id: Type.String(),
|
|
3113
|
+
title: Type.String(),
|
|
3114
|
+
decision: Type.String(),
|
|
3115
|
+
rationale: Type.String(),
|
|
3116
|
+
implementationNotes: Type.String(),
|
|
3117
|
+
acceptedAt: Type.String(),
|
|
3118
|
+
}), { description: "Replace accepted decisions list" })),
|
|
3119
|
+
completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
|
|
3120
|
+
}),
|
|
3121
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3122
|
+
const st = await requirePlan(ctx);
|
|
3123
|
+
if (!st)
|
|
3124
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3125
|
+
let phase;
|
|
3126
|
+
try {
|
|
3127
|
+
phase = await st.loadPhase(params.phaseId);
|
|
3128
|
+
}
|
|
3129
|
+
catch {
|
|
3130
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3131
|
+
}
|
|
3132
|
+
const prevFeatureId = phase.featureId;
|
|
3133
|
+
if (params.title !== undefined) {
|
|
3134
|
+
phase.title = params.title;
|
|
3135
|
+
phase.slug = normalizeSlug(params.title);
|
|
3136
|
+
}
|
|
3137
|
+
if (params.status !== undefined)
|
|
3138
|
+
phase.status = params.status;
|
|
3139
|
+
if (params.summary !== undefined)
|
|
3140
|
+
phase.summary = params.summary;
|
|
3141
|
+
if (params.description !== undefined)
|
|
3142
|
+
phase.description = params.description;
|
|
3143
|
+
if (params.goals !== undefined)
|
|
3144
|
+
phase.goals = params.goals;
|
|
3145
|
+
if (params.nonGoals !== undefined)
|
|
3146
|
+
phase.nonGoals = params.nonGoals;
|
|
3147
|
+
if (params.dependencies !== undefined)
|
|
3148
|
+
phase.dependencies = params.dependencies;
|
|
3149
|
+
if (params.risks !== undefined)
|
|
3150
|
+
phase.risks = params.risks;
|
|
3151
|
+
if (params.openQuestions !== undefined)
|
|
3152
|
+
phase.openQuestions = params.openQuestions;
|
|
3153
|
+
if (params.decisions !== undefined)
|
|
3154
|
+
phase.decisions = params.decisions;
|
|
3155
|
+
if (params.acceptedDecisions !== undefined)
|
|
3156
|
+
phase.acceptedDecisions = params.acceptedDecisions;
|
|
3157
|
+
if (params.completionCriteria !== undefined)
|
|
3158
|
+
phase.completionCriteria = params.completionCriteria;
|
|
3159
|
+
if (params.featureId !== undefined) {
|
|
3160
|
+
const nextFeatureId = params.featureId === "" ? undefined : params.featureId;
|
|
3161
|
+
phase.featureId = nextFeatureId;
|
|
3162
|
+
if (nextFeatureId !== prevFeatureId) {
|
|
3163
|
+
await st.updateFeatures((doc) => {
|
|
3164
|
+
for (const f of doc.features) {
|
|
3165
|
+
if (f.id === prevFeatureId)
|
|
3166
|
+
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3167
|
+
if (f.id === nextFeatureId && !f.phaseIds.includes(phase.id))
|
|
3168
|
+
f.phaseIds.push(phase.id);
|
|
3169
|
+
}
|
|
3170
|
+
return doc;
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
phase.updatedAt = nowISO();
|
|
3175
|
+
await st.savePhase(phase);
|
|
3176
|
+
await st.writeGenerated();
|
|
3177
|
+
return { content: [{ type: "text", text: `Phase updated: ${phase.id} (${phase.status})` }], details: phase };
|
|
3178
|
+
},
|
|
3179
|
+
});
|
|
3180
|
+
pi.registerTool({
|
|
3181
|
+
name: "phase_delete",
|
|
3182
|
+
label: "Phase Delete",
|
|
3183
|
+
description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
|
|
3184
|
+
parameters: Type.Object({
|
|
3185
|
+
phaseId: Type.String({ description: "Phase ID to delete" }),
|
|
3186
|
+
}),
|
|
3187
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3188
|
+
const st = await requirePlan(ctx);
|
|
3189
|
+
if (!st)
|
|
3190
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3191
|
+
let phase;
|
|
3192
|
+
try {
|
|
3193
|
+
phase = await st.loadPhase(params.phaseId);
|
|
3194
|
+
}
|
|
3195
|
+
catch {
|
|
3196
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3197
|
+
}
|
|
3198
|
+
await st.deletePhase(params.phaseId);
|
|
3199
|
+
if (phase.featureId) {
|
|
3200
|
+
await st.updateFeatures((doc) => {
|
|
3201
|
+
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3202
|
+
if (feature) {
|
|
3203
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== params.phaseId);
|
|
3204
|
+
feature.updatedAt = nowISO();
|
|
3205
|
+
}
|
|
3206
|
+
return doc;
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
await st.writeGenerated();
|
|
3210
|
+
return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
|
|
3211
|
+
},
|
|
3212
|
+
});
|
|
3213
|
+
// ── tasks ───────────────────────────────────────────────────────────
|
|
3214
|
+
pi.registerTool({
|
|
3215
|
+
name: "task_list",
|
|
3216
|
+
label: "Task List",
|
|
3217
|
+
description: "List tasks of a phase with id, status, and title.",
|
|
3218
|
+
parameters: Type.Object({
|
|
3219
|
+
phaseId: Type.String({ description: "Phase ID" }),
|
|
3220
|
+
}),
|
|
3221
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3222
|
+
const st = await requirePlan(ctx);
|
|
3223
|
+
if (!st)
|
|
3224
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3225
|
+
let phase;
|
|
3226
|
+
try {
|
|
3227
|
+
phase = await st.loadPhase(params.phaseId);
|
|
3228
|
+
}
|
|
3229
|
+
catch {
|
|
3230
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3231
|
+
}
|
|
3232
|
+
const summary = phase.tasks.map((t) => `- ${statusIcon(t.status)} ${t.id} — ${t.title}`).join("\n");
|
|
3233
|
+
return { content: [{ type: "text", text: summary || "No tasks" }], details: { tasks: phase.tasks } };
|
|
3234
|
+
},
|
|
3235
|
+
});
|
|
3236
|
+
pi.registerTool({
|
|
3237
|
+
name: "task_get",
|
|
3238
|
+
label: "Task Get",
|
|
3239
|
+
description: "Read full details of a single task.",
|
|
3240
|
+
parameters: Type.Object({
|
|
3241
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
3242
|
+
}),
|
|
3243
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3244
|
+
const st = await requirePlan(ctx);
|
|
3245
|
+
if (!st)
|
|
3246
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3247
|
+
const phases = await st.loadAllPhases();
|
|
3248
|
+
const phase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
|
|
3249
|
+
const task = phase?.tasks.find((t) => t.id === params.taskId);
|
|
3250
|
+
if (!task || !phase)
|
|
3251
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3252
|
+
return { content: [{ type: "text", text: `${statusIcon(task.status)} ${task.title} (${task.id}) — phase ${phase.id}` }], details: { task, phaseId: phase.id } };
|
|
3253
|
+
},
|
|
3254
|
+
});
|
|
3255
|
+
pi.registerTool({
|
|
3256
|
+
name: "task_create",
|
|
3257
|
+
label: "Task Create",
|
|
3258
|
+
description: "Add a task to a phase with a RICH description. REQUIRED: description must include code references (file:line), what already exists vs what needs to be built, specific structs/traits/systems to modify, concrete implementation steps, and edge cases to handle. The description is the execution context for agents; one-liners cause misalignment. Status defaults to planned.",
|
|
3259
|
+
parameters: Type.Object({
|
|
3260
|
+
phaseId: Type.String({ description: "Phase ID the task belongs to" }),
|
|
3261
|
+
title: Type.String({ description: "Task title" }),
|
|
3262
|
+
description: Type.String({ description: "REQUIRED — execution context: code references (file:line), current state vs desired state, structs/traits to modify, concrete implementation steps, edge cases. Not a one-liner. Prefix with 'design-only' for pre-implementation design tasks.", minLength: 50 }),
|
|
3263
|
+
status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
|
|
3264
|
+
shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
|
|
3265
|
+
}),
|
|
3266
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3267
|
+
const st = await requirePlan(ctx);
|
|
3268
|
+
if (!st)
|
|
3269
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3270
|
+
// Verify phase exists first (fast-fail before generating an id).
|
|
3271
|
+
try {
|
|
3272
|
+
await st.loadPhase(params.phaseId);
|
|
3273
|
+
}
|
|
3274
|
+
catch {
|
|
3275
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3276
|
+
}
|
|
3277
|
+
const rawShort = params.shortName ?? normalizeSlug(params.title).slice(0, 30);
|
|
3278
|
+
const shortName = rawShort.trim() || `task-${Date.now().toString(36)}`; // never empty (fixes regex invalid_string)
|
|
3279
|
+
const taskId = createTaskId();
|
|
3280
|
+
const now = nowISO();
|
|
3281
|
+
const initialStatus = params.status ?? "planned";
|
|
3282
|
+
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3283
|
+
const task = {
|
|
3284
|
+
id: taskId, phaseId: params.phaseId, number: existingPhase.tasks.length + 1, shortName,
|
|
3285
|
+
title: params.title,
|
|
3286
|
+
status: initialStatus,
|
|
3287
|
+
description: params.description ?? "",
|
|
3288
|
+
notes: "",
|
|
3289
|
+
statusLog: [],
|
|
3290
|
+
decisions: [],
|
|
3291
|
+
acceptedDecisions: [],
|
|
3292
|
+
checklist: [], subtasks: [],
|
|
3293
|
+
dependsOn: [],
|
|
3294
|
+
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3295
|
+
completedAt: initialStatus === "done" ? now : "",
|
|
3296
|
+
createdAt: now, updatedAt: now,
|
|
3297
|
+
};
|
|
3298
|
+
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3299
|
+
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3300
|
+
await st.updatePhase(params.phaseId, (phase) => {
|
|
3301
|
+
phase.tasks.push(task);
|
|
3302
|
+
phase.taskIds.push(taskId);
|
|
3303
|
+
phase.updatedAt = now;
|
|
3304
|
+
return phase;
|
|
3305
|
+
});
|
|
3306
|
+
await st.writeGenerated();
|
|
3307
|
+
return { content: [{ type: "text", text: `Task created: ${taskId}` }], details: task };
|
|
3308
|
+
},
|
|
3309
|
+
});
|
|
3310
|
+
pi.registerTool({
|
|
3311
|
+
name: "task_update",
|
|
3312
|
+
label: "Task Update",
|
|
3313
|
+
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.",
|
|
3314
|
+
parameters: Type.Object({
|
|
3315
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
3316
|
+
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3317
|
+
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
3318
|
+
description: Type.Optional(Type.String({ description: "New description" })),
|
|
3319
|
+
notes: Type.Optional(Type.String({ description: "New implementation notes" })),
|
|
3320
|
+
motivation: Type.Optional(Type.String({ description: "Motivation for status change. REQUIRED when changing to blocked, canceled, rejected, deferred, waiting, or back to planned from another status." })),
|
|
3321
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
|
|
3322
|
+
acceptedDecisions: Type.Optional(Type.Array(Type.Object({
|
|
3323
|
+
id: Type.String(),
|
|
3324
|
+
title: Type.String(),
|
|
3325
|
+
decision: Type.String(),
|
|
3326
|
+
rationale: Type.String(),
|
|
3327
|
+
implementationNotes: Type.String(),
|
|
3328
|
+
acceptedAt: Type.String(),
|
|
3329
|
+
}), { description: "Replace accepted decisions list" })),
|
|
3330
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
|
|
3331
|
+
}),
|
|
3332
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3333
|
+
const st = await requirePlan(ctx);
|
|
3334
|
+
if (!st)
|
|
3335
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3336
|
+
// Find the phase hosting this task first.
|
|
3337
|
+
const allPhases = await st.loadAllPhases();
|
|
3338
|
+
const hostPhase = allPhases.find((p) => p.tasks.some((t) => t.id === params.taskId));
|
|
3339
|
+
if (!hostPhase)
|
|
3340
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3341
|
+
const now = nowISO();
|
|
3342
|
+
let updatedTask;
|
|
3343
|
+
try {
|
|
3344
|
+
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3345
|
+
const task = phase.tasks.find((t) => t.id === params.taskId);
|
|
3346
|
+
if (!task)
|
|
3347
|
+
return phase;
|
|
3348
|
+
if (params.title !== undefined)
|
|
3349
|
+
task.title = params.title;
|
|
3350
|
+
if (params.description !== undefined)
|
|
3351
|
+
task.description = params.description;
|
|
3352
|
+
if (params.notes !== undefined)
|
|
3353
|
+
task.notes = params.notes;
|
|
3354
|
+
if (params.decisions !== undefined)
|
|
3355
|
+
task.decisions = params.decisions;
|
|
3356
|
+
if (params.acceptedDecisions !== undefined)
|
|
3357
|
+
task.acceptedDecisions = params.acceptedDecisions;
|
|
3358
|
+
if (params.checklist !== undefined) {
|
|
3359
|
+
task.checklist = params.checklist.map((itemTitle, index) => ({
|
|
3360
|
+
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
3361
|
+
title: itemTitle,
|
|
3362
|
+
checked: false,
|
|
3363
|
+
}));
|
|
3364
|
+
}
|
|
3365
|
+
if (params.status !== undefined) {
|
|
3366
|
+
if (params.status !== task.status) {
|
|
3367
|
+
const entry = {
|
|
3368
|
+
id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-${params.status}`),
|
|
3369
|
+
date: now,
|
|
3370
|
+
fromStatus: task.status,
|
|
3371
|
+
toStatus: params.status,
|
|
3372
|
+
title: params.motivation?.split("\n")[0]?.trim() || `${task.status} → ${params.status}`,
|
|
3373
|
+
description: params.motivation?.trim() || "",
|
|
3374
|
+
};
|
|
3375
|
+
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
3376
|
+
}
|
|
3377
|
+
applyTaskLifecycleDates(task, params.status, now);
|
|
3378
|
+
}
|
|
3379
|
+
task.updatedAt = now;
|
|
3380
|
+
phase.updatedAt = now;
|
|
3381
|
+
updatedTask = task;
|
|
3382
|
+
return phase;
|
|
3383
|
+
});
|
|
3384
|
+
void updatedPhase;
|
|
3385
|
+
}
|
|
3386
|
+
catch (e) {
|
|
3387
|
+
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
3388
|
+
}
|
|
3389
|
+
if (!updatedTask)
|
|
3390
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3391
|
+
await st.writeGenerated();
|
|
3392
|
+
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
|
|
3393
|
+
},
|
|
3394
|
+
});
|
|
3395
|
+
pi.registerTool({
|
|
3396
|
+
name: "task_delete",
|
|
3397
|
+
label: "Task Delete",
|
|
3398
|
+
description: "Delete a task from its phase.",
|
|
3399
|
+
parameters: Type.Object({
|
|
3400
|
+
taskId: Type.String({ description: "Task ID to delete" }),
|
|
3401
|
+
}),
|
|
3402
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3403
|
+
const st = await requirePlan(ctx);
|
|
3404
|
+
if (!st)
|
|
3405
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3406
|
+
const phases = await st.loadAllPhases();
|
|
3407
|
+
const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
|
|
3408
|
+
if (!hostPhase)
|
|
3409
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3410
|
+
await st.updatePhase(hostPhase.id, (phase) => {
|
|
3411
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== params.taskId);
|
|
3412
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== params.taskId);
|
|
3413
|
+
phase.updatedAt = nowISO();
|
|
3414
|
+
return phase;
|
|
3415
|
+
});
|
|
3416
|
+
await st.writeGenerated();
|
|
3417
|
+
return { content: [{ type: "text", text: `Task deleted: ${params.taskId}` }], details: { deleted: params.taskId } };
|
|
3418
|
+
},
|
|
3419
|
+
});
|
|
3420
|
+
// ── task start / complete tools ──────────────────────────────────
|
|
3421
|
+
pi.registerTool({
|
|
3422
|
+
name: "task_start",
|
|
3423
|
+
label: "Task Start",
|
|
3424
|
+
description: "Set a task to in-progress. Use this BEFORE starting implementation work on any task. Sets startedAt timestamp automatically.",
|
|
3425
|
+
parameters: Type.Object({
|
|
3426
|
+
taskId: Type.String({ description: "Task ID to start" }),
|
|
3427
|
+
}),
|
|
3428
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3429
|
+
const st = await requirePlan(ctx);
|
|
3430
|
+
if (!st)
|
|
3431
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3432
|
+
const phases = await st.loadAllPhases();
|
|
3433
|
+
const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
|
|
3434
|
+
const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
|
|
3435
|
+
if (!task || !hostPhase)
|
|
3436
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3437
|
+
if (task.status === "in-progress")
|
|
3438
|
+
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3439
|
+
if (task.status === "done")
|
|
3440
|
+
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
3441
|
+
const now = nowISO();
|
|
3442
|
+
let startedTask;
|
|
3443
|
+
await st.updatePhase(hostPhase.id, (phase) => {
|
|
3444
|
+
const t = phase.tasks.find((x) => x.id === params.taskId);
|
|
3445
|
+
if (!t)
|
|
3446
|
+
return phase;
|
|
3447
|
+
applyTaskLifecycleDates(t, "in-progress", now);
|
|
3448
|
+
t.updatedAt = now;
|
|
3449
|
+
phase.updatedAt = now;
|
|
3450
|
+
startedTask = t;
|
|
3451
|
+
return phase;
|
|
3452
|
+
});
|
|
3453
|
+
await st.writeGenerated();
|
|
3454
|
+
if (!startedTask)
|
|
3455
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3456
|
+
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)` }], details: startedTask };
|
|
3457
|
+
},
|
|
3458
|
+
});
|
|
3459
|
+
pi.registerTool({
|
|
3460
|
+
name: "task_complete",
|
|
3461
|
+
label: "Task Complete",
|
|
3462
|
+
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
3463
|
+
parameters: Type.Object({
|
|
3464
|
+
taskId: Type.String({ description: "Task ID to complete" }),
|
|
3465
|
+
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
3466
|
+
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." })),
|
|
3467
|
+
}),
|
|
3468
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3469
|
+
const st = await requirePlan(ctx);
|
|
3470
|
+
if (!st)
|
|
3471
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3472
|
+
const phases = await st.loadAllPhases();
|
|
3473
|
+
const hostPhase = phases.find((p) => p.tasks.some((t) => t.id === params.taskId));
|
|
3474
|
+
const task = hostPhase?.tasks.find((t) => t.id === params.taskId);
|
|
3475
|
+
if (!task || !hostPhase)
|
|
3476
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3477
|
+
if (task.status === "done")
|
|
3478
|
+
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3479
|
+
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
3480
|
+
if (unchecked.length > 0 && !params.force) {
|
|
3481
|
+
return {
|
|
3482
|
+
content: [{ type: "text", text: `⚠️ ${unchecked.length} checklist item(s) not done: ${unchecked.map((i) => i.title).join(", ")}. Use task_complete with force=true to override.` }],
|
|
3483
|
+
details: { task, uncheckedChecklistItems: unchecked },
|
|
3484
|
+
};
|
|
3485
|
+
}
|
|
3486
|
+
const now = nowISO();
|
|
3487
|
+
let completedTask;
|
|
3488
|
+
await st.updatePhase(hostPhase.id, (phase) => {
|
|
3489
|
+
const t = phase.tasks.find((x) => x.id === params.taskId);
|
|
3490
|
+
if (!t)
|
|
3491
|
+
return phase;
|
|
3492
|
+
applyTaskLifecycleDates(t, "done", now);
|
|
3493
|
+
if (params.description_update) {
|
|
3494
|
+
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
3495
|
+
t.description = t.description + sep + params.description_update;
|
|
3496
|
+
}
|
|
3497
|
+
t.updatedAt = now;
|
|
3498
|
+
phase.updatedAt = now;
|
|
3499
|
+
completedTask = t;
|
|
3500
|
+
return phase;
|
|
3501
|
+
});
|
|
3502
|
+
await st.writeGenerated();
|
|
3503
|
+
if (!completedTask)
|
|
3504
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3505
|
+
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
|
|
3506
|
+
},
|
|
3507
|
+
});
|
|
3508
|
+
// ── Context injection ─────────────────────────────────────────────
|
|
3509
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
3510
|
+
if (!plannerSessionEnabled)
|
|
3511
|
+
return;
|
|
3512
|
+
const st = ensureStore(ctx);
|
|
3513
|
+
if (!(await st.exists()))
|
|
3514
|
+
return;
|
|
3515
|
+
try {
|
|
3516
|
+
// Heavy one-time-per-session init: migration, language prefs, status
|
|
3517
|
+
// healing, and a fresh resume. These write/scan the whole plan and would
|
|
3518
|
+
// add seconds of latency on every turn if run unconditionally.
|
|
3519
|
+
if (!plannerHeavyInitDone) {
|
|
3520
|
+
await migrateToUuids(st);
|
|
3521
|
+
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3522
|
+
await maybeHealStatuses(st);
|
|
3523
|
+
await st.refreshResume();
|
|
3524
|
+
plannerHeavyInitDone = true;
|
|
3525
|
+
}
|
|
3526
|
+
// Fast path: on steady turns (no plan changes since last build) reuse
|
|
3527
|
+
// the cached context block and skip all the per-turn I/O + string build.
|
|
3528
|
+
// The cache is invalidated by the write-notify hook whenever the plan
|
|
3529
|
+
// is mutated, and on first turn (contextBlockDirty starts true).
|
|
3530
|
+
if (!contextBlockDirty && contextBlockCache) {
|
|
3531
|
+
return {
|
|
3532
|
+
systemPrompt: `${event.systemPrompt}\n\n---\n${contextBlockCache}`,
|
|
3533
|
+
};
|
|
3534
|
+
}
|
|
3535
|
+
const plan = await st.loadAll();
|
|
3536
|
+
const project = plan.project;
|
|
3537
|
+
const profile = await st.loadCodebaseProfile();
|
|
3538
|
+
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3539
|
+
const activity = await st.loadActivityLog();
|
|
3540
|
+
const recentActivity = activity.entries.slice(-8).reverse();
|
|
3541
|
+
const handoff = await st.loadHandoff();
|
|
3542
|
+
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3543
|
+
const orderedPhases = [
|
|
3544
|
+
...plan.features.features.flatMap((feature) => {
|
|
3545
|
+
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
3546
|
+
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
3547
|
+
const inferred = plan.phases.filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
|
|
3548
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
|
|
3549
|
+
return [...linked, ...inferred];
|
|
3550
|
+
}),
|
|
3551
|
+
...plan.phases.filter((phase) => !phase.featureId)
|
|
3552
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3553
|
+
];
|
|
3554
|
+
const phaseLines = orderedPhases
|
|
3555
|
+
.map((p) => `- ${statusIcon(p.status)} \`${p.id}\` — ${phaseLabel(p)} (${p.status})`)
|
|
3556
|
+
.join("\n");
|
|
3557
|
+
const active = orderedPhases
|
|
3558
|
+
.filter((p) => p.status === "in-progress")
|
|
3559
|
+
.map((p) => `\`${p.id}\``)
|
|
3560
|
+
.join(", ");
|
|
3561
|
+
const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
|
|
3562
|
+
const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
|
|
3563
|
+
const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
|
|
3564
|
+
const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
|
|
3565
|
+
const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
3566
|
+
const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
|
|
3567
|
+
const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
|
|
3568
|
+
const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
|
|
3569
|
+
const currentPhase = hasActiveWork
|
|
3570
|
+
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3571
|
+
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
3572
|
+
?? orderedPhases.find((phase) => phase.status === "in-progress")
|
|
3573
|
+
?? null
|
|
3574
|
+
: null;
|
|
3575
|
+
const currentTask = hasActiveWork
|
|
3576
|
+
? ([...(currentPhase?.tasks ?? [])]
|
|
3577
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
3578
|
+
.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
3579
|
+
?? [...(currentPhase?.tasks ?? [])]
|
|
3580
|
+
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
|
|
3581
|
+
.find((task) => task.status === "in-progress")
|
|
3582
|
+
?? null)
|
|
3583
|
+
: null;
|
|
3584
|
+
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3585
|
+
? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
|
|
3586
|
+
: hasActiveWork
|
|
3587
|
+
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3588
|
+
: null;
|
|
3589
|
+
const nextActivity = hasActiveWork
|
|
3590
|
+
? (resume.nextSteps[0]
|
|
3591
|
+
?? (currentTask ? `Resume task ${currentTask.id} — ${taskLabel(currentTask)}` : currentPhase ? `Resume phase ${currentPhase.id} — ${phaseLabel(currentPhase)}` : "Resume the active work stream"))
|
|
3592
|
+
: (handoff?.updatedAt
|
|
3593
|
+
? "Read the handoff, then validate current plan ordering and dependencies before picking the next task."
|
|
3594
|
+
: "Review the current plan and choose the next task.");
|
|
3595
|
+
const webUrl = server?.url ?? "";
|
|
3596
|
+
const openQuestions = plan.phases
|
|
3597
|
+
.flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
|
|
3598
|
+
.slice(0, 12);
|
|
3599
|
+
const acceptedDecisions = [
|
|
3600
|
+
...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
|
|
3601
|
+
...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
|
|
3602
|
+
...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
|
|
3603
|
+
...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
|
|
3604
|
+
].slice(0, 20);
|
|
3605
|
+
const pkgSummary = profile?.packageJson
|
|
3606
|
+
? `name=${profile.packageJson.name ?? "?"} pm=${profile.packageJson.packageManager ?? "npm"} scripts=${Object.keys(profile.packageJson.scripts).join(",") || "none"} deps=${Object.keys(profile.packageJson.dependencies).length} devDeps=${Object.keys(profile.packageJson.devDependencies).length}`
|
|
3607
|
+
: "(no package.json)";
|
|
3608
|
+
const ambient = profile?.ambient;
|
|
3609
|
+
const ambientSummary = ambient
|
|
3610
|
+
? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
|
|
3611
|
+
: "(not scanned)";
|
|
3612
|
+
if (startupResumeSummaryPending) {
|
|
3613
|
+
startupResumeSummaryText = await buildStartupResumeSummary(st).catch(() => "");
|
|
3614
|
+
}
|
|
3615
|
+
const startupResumeProtocol = startupResumePromptPending ? [
|
|
3616
|
+
"",
|
|
3617
|
+
"STARTUP RESUME PROTOCOL (first reply of the session — mandatory):",
|
|
3618
|
+
"- You are resuming a planner-backed project session.",
|
|
3619
|
+
"- FIRST give the user a concise summary of progress so far.",
|
|
3620
|
+
`- Mention progress counts: ${doneFeaturesCount}/${plan.features.features.length} features done, ${activeFeaturesCount} active; ${donePhasesCount}/${plan.phases.length} phases done, ${activePhasesCount} active; ${doneTasksCount}/${totalTasksCount} tasks done.`,
|
|
3621
|
+
currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
|
|
3622
|
+
currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
|
|
3623
|
+
currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
|
|
3624
|
+
!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." : "",
|
|
3625
|
+
webUrl ? `- Mention the active web dashboard URL: ${webUrl}` : "- Explicitly say whether the web dashboard is active in this session.",
|
|
3626
|
+
`- Mention the next suggested activity: ${nextActivity}`,
|
|
3627
|
+
"- THEN ask the user explicitly whether they want to resume that activity now.",
|
|
3628
|
+
"- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
|
|
3629
|
+
"- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any bash/edit/write.",
|
|
3630
|
+
].filter(Boolean).join("\n") : "";
|
|
3631
|
+
// Build/refresh the context block (slow path: cache miss).
|
|
3632
|
+
const contextBlock = [
|
|
3633
|
+
`[Plan Context — ${project.name}]`,
|
|
3634
|
+
// ── MANDATORY OPERATIONAL PROTOCOL (top priority, non-negotiable) ──
|
|
3635
|
+
"",
|
|
3636
|
+
"═══════════════════════════════════════════════════════════════",
|
|
3637
|
+
"MANDATORY OPERATIONAL PROTOCOL (violation = execution failure):",
|
|
3638
|
+
"═══════════════════════════════════════════════════════════════",
|
|
3639
|
+
"1. HANDOFF HYGIENE: If .planner/HANDOFF.md exists, READ and DELETE it IMMEDIATELY using the handoff delete tool. Do NOT summarize first. Do NOT ask for confirmation. Do NOT defer cleanup. DELETE \u2192 THEN work.",
|
|
3640
|
+
"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'.",
|
|
3641
|
+
"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.",
|
|
3642
|
+
"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.",
|
|
3643
|
+
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3644
|
+
"═══════════════════════════════════════════════════════════════",
|
|
3645
|
+
"",
|
|
3646
|
+
// ── Project details ──
|
|
3647
|
+
`Goal: ${project.goal || "(not set)"}`,
|
|
3648
|
+
project.description ? `Description: ${project.description}` : "",
|
|
3649
|
+
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
3650
|
+
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
3651
|
+
`Ambient: ${ambientSummary}`,
|
|
3652
|
+
"",
|
|
3653
|
+
"Global rules (re-read before every phase/task start):",
|
|
3654
|
+
project.globalRules.length ? project.globalRules.map((r) => `- ${r}`).join("\n") : "- (none)",
|
|
3655
|
+
"",
|
|
3656
|
+
"Workflow rules:",
|
|
3657
|
+
` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
|
|
3658
|
+
` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
|
|
3659
|
+
` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
3660
|
+
"",
|
|
3661
|
+
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
3662
|
+
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
3663
|
+
"",
|
|
3664
|
+
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
3665
|
+
...acceptedDecisions.map((d) => `- ${d}`),
|
|
3666
|
+
openQuestions.length ? "" : "",
|
|
3667
|
+
openQuestions.length ? "Open questions:" : "",
|
|
3668
|
+
...openQuestions.map((q) => `- ${q}`),
|
|
3669
|
+
"",
|
|
3670
|
+
"Resume focus (where we are now):",
|
|
3671
|
+
resume.currentPhaseId ? ` current phase: ${resume.currentPhaseId}` : " current phase: (none)",
|
|
3672
|
+
resume.inProgressTaskIds.length ? ` in-progress tasks: ${resume.inProgressTaskIds.join(", ")}` : " in-progress tasks: (none)",
|
|
3673
|
+
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3674
|
+
resume.nextSteps.length ? ` next steps: ${resume.nextSteps.join("; ")}` : " next steps: (none)",
|
|
3675
|
+
resume.lastSessionSummary ? ` last session: ${resume.lastSessionSummary}` : "",
|
|
3676
|
+
"",
|
|
3677
|
+
recentActivity.length ? "Recent activity (most recent first):" : "",
|
|
3678
|
+
...recentActivity.map((e) => `- ${e.at} [${e.type}] ${e.ref}: ${e.summary}`),
|
|
3679
|
+
"",
|
|
3680
|
+
`${plan.phases.length} phases defined.`,
|
|
3681
|
+
"Current phases:",
|
|
3682
|
+
phaseLines || " _none_",
|
|
3683
|
+
active ? `Active: ${active}` : "",
|
|
3684
|
+
"",
|
|
3685
|
+
"Primary command UX:",
|
|
3686
|
+
"- Use `/planner <TAB>` for grouped subcommand suggestions.",
|
|
3687
|
+
"- Use `/planner` with no args to open the navigable planner menu.",
|
|
3688
|
+
"- Feature commands: planner feature list|add|show|update|delete.",
|
|
3689
|
+
"- Phase commands: planner phase add|show|discuss|update|delete.",
|
|
3690
|
+
"- Task commands: planner task add|show|discuss|update|delete|start|complete.",
|
|
3691
|
+
"- Handoff commands: planner handoff prepare|show|write|clear.",
|
|
3692
|
+
"- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
|
|
3693
|
+
"Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
|
|
3694
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
|
|
3695
|
+
`Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
|
|
3696
|
+
"If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
|
|
3697
|
+
"Ask for language only if BOTH preferences are unset.",
|
|
3698
|
+
"If one preference is set and the other is unset, infer the missing one from the saved value and persist it immediately with project_set_language_preferences without asking the user again.",
|
|
3699
|
+
"Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
|
|
3700
|
+
"If you are creating a planner from an existing plan or document, do NOT leave project title, project description, project goal, or top-level requirements empty when the source material provides them.",
|
|
3701
|
+
"When deriving a project title from an existing document, prefer a concise human project title; if the candidate title is long or compound, ask the user to confirm before using it.",
|
|
3702
|
+
"Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
|
|
3703
|
+
"",
|
|
3704
|
+
"TASK STATUS RULES (strict — do not skip):",
|
|
3705
|
+
"- BEFORE you start implementation work on ANY task, you MUST call task_start to set it to in-progress. This sets startedAt.",
|
|
3706
|
+
"- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
|
|
3707
|
+
"- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
|
|
3708
|
+
"- Therefore, if not strictly necessary, do NOT change phase.status or feature.status directly. Move the task statuses instead and let rollup derive the parent statuses.",
|
|
3709
|
+
"- The extension blocks edit/write when no task is in-progress (bash stays free so git pull/build/test work). If you hit that block, either call task_start, OR ask the user to authorize a one-time bypass (they can run /planner bypass, or you can call plan_authorize_bypass) and then retry.",
|
|
3710
|
+
"- If you forget these task status calls, the derived rollup breaks: phase/feature can stay stale, the dashboard shows wrong active work, and resume focus is wrong.",
|
|
3711
|
+
"- 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.",
|
|
3712
|
+
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
3713
|
+
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
3714
|
+
handoff?.content ? "Handoff detected at `.planner/HANDOFF.md`. Read it as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
|
|
3715
|
+
handoff?.content ? "If planner state shows no task/phase in-progress, do NOT present the handoff target as the current focus; present it only as a candidate to validate." : "",
|
|
3716
|
+
handoff?.content ? "When the handoff has been fully consumed and work is safely resumed, delete `.planner/HANDOFF.md` using the dedicated handoff delete command/tool." : "",
|
|
3717
|
+
handoff?.content ? handoff.content : "",
|
|
3718
|
+
startupResumeProtocol,
|
|
3719
|
+
"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, plan_authorize_bypass, plan_clear_bypass",
|
|
3720
|
+
].filter(Boolean).join("\n");
|
|
3721
|
+
contextBlockCache = contextBlock;
|
|
3722
|
+
contextBlockDirty = false;
|
|
3723
|
+
startupResumePromptPending = false;
|
|
3724
|
+
return {
|
|
3725
|
+
systemPrompt: `${event.systemPrompt}\n\n---\n${contextBlockCache}`,
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
catch {
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3731
|
+
});
|
|
3732
|
+
}
|