@agent-plan/pi-adapter 0.2.19-next.2 → 0.2.19-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1152 -384
- package/package.json +3 -3
- package/web-ui-dist/assets/index-B4qv5MwQ.js +293 -0
- package/web-ui-dist/assets/index-CTAfas5X.css +1 -0
- package/web-ui-dist/index.html +3 -2
- package/web-ui-dist/assets/index-CZ27vQH6.js +0 -211
- package/web-ui-dist/assets/index-Jy8Yy6I4.css +0 -1
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, migrateToGlobalSequence, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, recommendNextTask } from "@agent-plan/core";
|
|
13
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
14
14
|
import { join, dirname } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
@@ -105,11 +105,13 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
105
105
|
{ value: "init", label: "init", description: "Initialize planner in this project" },
|
|
106
106
|
{ value: "show", label: "show", description: "Show planner overview" },
|
|
107
107
|
{ value: "repair", label: "repair", description: "Repair planner integrity" },
|
|
108
|
+
{ value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
|
|
108
109
|
{ value: "project discuss", label: "project discuss", description: "Run project discovery" },
|
|
109
110
|
{ value: "project language", label: "project language", description: "Set persistent language preferences" },
|
|
110
111
|
{ value: "feature list", label: "feature list", description: "List features" },
|
|
111
112
|
{ value: "feature add", label: "feature add", description: "Create a feature" },
|
|
112
113
|
{ value: "feature show", label: "feature show", description: "Show a feature" },
|
|
114
|
+
{ value: "feature discuss", label: "feature discuss", description: "Discuss a feature" },
|
|
113
115
|
{ value: "feature update", label: "feature update", description: "Update a feature" },
|
|
114
116
|
{ value: "feature delete", label: "feature delete", description: "Delete a feature" },
|
|
115
117
|
{ value: "phase add", label: "phase add", description: "Add a phase" },
|
|
@@ -124,11 +126,14 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
124
126
|
{ value: "task delete", label: "task delete", description: "Delete a task" },
|
|
125
127
|
{ value: "task start", label: "task start", description: "Mark a task in-progress" },
|
|
126
128
|
{ value: "task complete", label: "task complete", description: "Mark a task done" },
|
|
129
|
+
{ value: "task checklist-add", label: "task checklist-add", description: "Add a checklist step: /planner task checklist-add <T00x> <title>" },
|
|
130
|
+
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
131
|
+
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
127
132
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
128
133
|
{ value: "handoff prepare", label: "handoff prepare", description: "Tell the agent to write the handoff (legacy file-based)" },
|
|
129
|
-
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (
|
|
130
|
-
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff (
|
|
131
|
-
{ value: "handoff clear", label: "handoff clear [P00x]", description: "
|
|
134
|
+
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
|
|
135
|
+
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
|
|
136
|
+
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
|
|
132
137
|
{ value: "web start", label: "web start", description: "Start the web UI" },
|
|
133
138
|
{ value: "web stop", label: "web stop", description: "Stop the web UI" },
|
|
134
139
|
{ value: "web status", label: "web status", description: "Show web UI status" },
|
|
@@ -139,6 +144,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
139
144
|
{ value: "load", label: "load", description: "Enable planner + start web UI (LAN) and show resume summary" },
|
|
140
145
|
{ value: "stop", label: "stop", description: "Disable planner and shut down the web UI" },
|
|
141
146
|
];
|
|
147
|
+
/** A completed nested command followed by a space is now receiving free-form
|
|
148
|
+
* arguments (entity refs, titles, etc.), not another command completion. */
|
|
149
|
+
function hasPlannerCommandArgument(prefix) {
|
|
150
|
+
const normalized = prefix.trimStart().toLowerCase();
|
|
151
|
+
return PLANNER_COMMAND_COMPLETIONS.some((item) => normalized.startsWith(`${item.value.toLowerCase()} `));
|
|
152
|
+
}
|
|
142
153
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
143
154
|
function nowISO() {
|
|
144
155
|
return new Date().toISOString();
|
|
@@ -196,7 +207,7 @@ async function maybeHealStatuses(st, ctx) {
|
|
|
196
207
|
function normalizeLanguagePref(value) {
|
|
197
208
|
return value?.trim() ?? "";
|
|
198
209
|
}
|
|
199
|
-
async function ensureProjectLanguagePreferences(st) {
|
|
210
|
+
async function ensureProjectLanguagePreferences(st, persist = true) {
|
|
200
211
|
const project = await st.loadProject();
|
|
201
212
|
const contentLanguage = normalizeLanguagePref(project.contentLanguage);
|
|
202
213
|
const chatLanguage = normalizeLanguagePref(project.chatLanguage);
|
|
@@ -204,7 +215,8 @@ async function ensureProjectLanguagePreferences(st) {
|
|
|
204
215
|
if (project.contentLanguage !== contentLanguage || project.chatLanguage !== chatLanguage) {
|
|
205
216
|
project.contentLanguage = contentLanguage;
|
|
206
217
|
project.chatLanguage = chatLanguage;
|
|
207
|
-
|
|
218
|
+
if (persist)
|
|
219
|
+
await st.saveProject(project);
|
|
208
220
|
}
|
|
209
221
|
return project;
|
|
210
222
|
}
|
|
@@ -212,7 +224,8 @@ async function ensureProjectLanguagePreferences(st) {
|
|
|
212
224
|
const fallback = contentLanguage || chatLanguage;
|
|
213
225
|
project.contentLanguage = contentLanguage || fallback;
|
|
214
226
|
project.chatLanguage = chatLanguage || fallback;
|
|
215
|
-
|
|
227
|
+
if (persist)
|
|
228
|
+
await st.saveProject(project);
|
|
216
229
|
return project;
|
|
217
230
|
}
|
|
218
231
|
return project;
|
|
@@ -250,6 +263,34 @@ function phaseLabel(phase) {
|
|
|
250
263
|
function taskLabel(task) {
|
|
251
264
|
return `T${formatSequence(task.number)} — ${task.title}`;
|
|
252
265
|
}
|
|
266
|
+
function resolveFeatureRefStrict(features, ref) {
|
|
267
|
+
const raw = ref.trim();
|
|
268
|
+
if (!raw)
|
|
269
|
+
return { ok: false, error: "Feature ref is required." };
|
|
270
|
+
const normalized = raw.toLowerCase();
|
|
271
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
272
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
273
|
+
: undefined;
|
|
274
|
+
if (byNumber)
|
|
275
|
+
return { ok: true, feature: byNumber };
|
|
276
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
277
|
+
if (byShortId)
|
|
278
|
+
return { ok: true, feature: byShortId };
|
|
279
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
280
|
+
if (byId)
|
|
281
|
+
return { ok: true, feature: byId };
|
|
282
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
283
|
+
if (exactName.length === 1)
|
|
284
|
+
return { ok: true, feature: exactName[0] };
|
|
285
|
+
if (exactName.length > 1)
|
|
286
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
287
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
288
|
+
if (partialName.length === 1)
|
|
289
|
+
return { ok: true, feature: partialName[0] };
|
|
290
|
+
if (partialName.length > 1)
|
|
291
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
292
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
293
|
+
}
|
|
253
294
|
async function buildHandoffMarkdown(st, reason, overrides) {
|
|
254
295
|
const [plan, resume, activity] = await Promise.all([
|
|
255
296
|
st.loadAll(),
|
|
@@ -263,7 +304,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
263
304
|
const latestTaskUpdate = [...allTasks].sort((left, right) => right.task.updatedAt.localeCompare(left.task.updatedAt))[0] ?? null;
|
|
264
305
|
const latestPhaseUpdate = [...plan.phases].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
265
306
|
const latestFeatureUpdate = [...plan.features.features].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
266
|
-
const currentPhase = plan.phases.find((phase) => phase.id ===
|
|
307
|
+
const currentPhase = (overrides?.phaseId ? plan.phases.find((phase) => phase.id === overrides.phaseId) : undefined)
|
|
308
|
+
?? plan.phases.find((phase) => phase.id === resume.currentPhaseId)
|
|
267
309
|
?? plan.phases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
268
310
|
?? plan.phases.find((phase) => phase.status === "in-progress")
|
|
269
311
|
?? nonTerminalTasks[0]?.phase
|
|
@@ -352,8 +394,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
352
394
|
".planner/features/",
|
|
353
395
|
currentPhase ? `.planner/phases/${currentPhase.id}.json` : "",
|
|
354
396
|
latestPhaseUpdate && latestPhaseUpdate.id !== currentPhase?.id ? `.planner/phases/${latestPhaseUpdate.id}.json` : "",
|
|
355
|
-
".planner/resume.json",
|
|
356
|
-
".planner/generated/PLAN.md",
|
|
397
|
+
".planner/.local/resume.json",
|
|
398
|
+
".planner/.local/generated/PLAN.md",
|
|
357
399
|
].filter(Boolean);
|
|
358
400
|
const recentActivityLines = recentActivity.length > 0
|
|
359
401
|
? recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`)
|
|
@@ -379,7 +421,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
379
421
|
? ["", `## Current Task Statuses (phase ${currentPhase.id})`, ...currentPhaseTaskLines]
|
|
380
422
|
: [];
|
|
381
423
|
return [
|
|
382
|
-
|
|
424
|
+
`# ${overrides?.title?.trim() || `Handoff — ${reason}`}`,
|
|
383
425
|
"",
|
|
384
426
|
`Created at: ${createdAt}`,
|
|
385
427
|
`Updated at: ${updatedAt}`,
|
|
@@ -416,7 +458,7 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
416
458
|
...recentActivityLines,
|
|
417
459
|
"",
|
|
418
460
|
"## Reminder",
|
|
419
|
-
"-
|
|
461
|
+
"- This handoff is KEPT until a task in this phase starts (then auto-archived to .planner/.local/handoff-archive/) or the phase completes. No need to clear it manually.",
|
|
420
462
|
].join("\n");
|
|
421
463
|
}
|
|
422
464
|
async function writeProjectHandoff(st, reason, overrides) {
|
|
@@ -512,6 +554,9 @@ function statusIcon(status) {
|
|
|
512
554
|
};
|
|
513
555
|
return icons[status] ?? "❓";
|
|
514
556
|
}
|
|
557
|
+
function pad(n) {
|
|
558
|
+
return String(n).padStart(3, "0");
|
|
559
|
+
}
|
|
515
560
|
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
516
561
|
const previousStatus = task.status;
|
|
517
562
|
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
@@ -617,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
617
662
|
return;
|
|
618
663
|
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
619
664
|
const port = await pickProjectPort(ctx, requestedPort);
|
|
665
|
+
// A started server must be reflected in the next context block; the cache
|
|
666
|
+
// may hold a stale "Web UI: not running" block from a stop+prompt pair.
|
|
667
|
+
contextBlockDirty = true;
|
|
620
668
|
try {
|
|
621
669
|
server = await serve({
|
|
622
670
|
port,
|
|
@@ -629,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
629
677
|
});
|
|
630
678
|
}
|
|
631
679
|
catch (e) {
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
680
|
+
// Any listen failure at startup is potentially transient — EADDRINUSE from
|
|
681
|
+
// a racy port scan (another process grabbed the port between our check and
|
|
682
|
+
// bind), EMFILE/ENFILE under heavy load, EAGAIN, etc. Retry ONCE on a random
|
|
683
|
+
// free port (port 0) so a planner load never silently ends up with no server.
|
|
684
|
+
// serve() throws here only during startup binding/serving setup; if the retry
|
|
685
|
+
// also fails, surface the error to the user.
|
|
686
|
+
try {
|
|
687
|
+
server = await serve({
|
|
688
|
+
port: 0,
|
|
689
|
+
host,
|
|
690
|
+
planRoot: resolvePlanRoot(ctx.cwd),
|
|
691
|
+
staticDir: resolveStaticDir(),
|
|
692
|
+
quiet: true,
|
|
693
|
+
uiConfig: readUiConfig(ctx),
|
|
694
|
+
isBusy: () => planBusy,
|
|
695
|
+
});
|
|
696
|
+
ctx.ui.notify(`Planner web server: port ${port} unavailable (${e instanceof Error ? e.message : String(e)}), started on ${server?.url ?? "?"} instead.`, "info");
|
|
649
697
|
}
|
|
650
|
-
|
|
651
|
-
ctx.ui.notify(`Failed to start web server: ${
|
|
698
|
+
catch (e2) {
|
|
699
|
+
ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
|
|
652
700
|
}
|
|
653
701
|
}
|
|
654
702
|
if (server) {
|
|
@@ -681,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
681
729
|
catch { }
|
|
682
730
|
}
|
|
683
731
|
}
|
|
732
|
+
function disablePlannerSession() {
|
|
733
|
+
plannerSessionEnabled = false;
|
|
734
|
+
startupResumePromptPending = false;
|
|
735
|
+
startupResumeSummaryPending = false;
|
|
736
|
+
startupResumeSummaryText = "";
|
|
737
|
+
if (startupResumeSummaryTimer)
|
|
738
|
+
clearTimeout(startupResumeSummaryTimer);
|
|
739
|
+
startupResumeSummaryTimer = null;
|
|
740
|
+
contextBlockCache = "";
|
|
741
|
+
contextBlockDirty = true;
|
|
742
|
+
}
|
|
684
743
|
async function stopServer() {
|
|
685
744
|
if (server) {
|
|
686
745
|
await server.close();
|
|
687
746
|
server = null;
|
|
688
747
|
}
|
|
689
748
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
749
|
+
// The cached context block embeds the web UI address; after stop it must
|
|
750
|
+
// not keep claiming the dashboard is running (T241 — context invalidation).
|
|
751
|
+
contextBlockDirty = true;
|
|
690
752
|
}
|
|
691
753
|
// ─── Extension factory ──────────────────────────────────────────────────
|
|
692
754
|
export default function planPiExtension(pi) {
|
|
@@ -705,6 +767,9 @@ export default function planPiExtension(pi) {
|
|
|
705
767
|
}
|
|
706
768
|
const argPrefix = match[2];
|
|
707
769
|
const prefix = argPrefix === undefined ? match[1] : argPrefix;
|
|
770
|
+
if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
708
773
|
const normalized = (argPrefix ?? "").trimStart().toLowerCase();
|
|
709
774
|
const items = PLANNER_COMMAND_COMPLETIONS
|
|
710
775
|
.filter((item) => !normalized || item.value.startsWith(normalized))
|
|
@@ -744,9 +809,10 @@ export default function planPiExtension(pi) {
|
|
|
744
809
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
745
810
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
746
811
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
747
|
-
// until the safety-net timeout).
|
|
748
|
-
//
|
|
749
|
-
//
|
|
812
|
+
// until the safety-net timeout). Tracked upstream via planner task
|
|
813
|
+
// T151 · EBRST. The planner is always DISABLED at startup; the user
|
|
814
|
+
// enables it with '/planner load' (which also starts the web UI on LAN
|
|
815
|
+
// and triggers the resume summary showing the URL).
|
|
750
816
|
let enablePlanner = false;
|
|
751
817
|
if (exists) {
|
|
752
818
|
ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
|
|
@@ -757,6 +823,10 @@ export default function planPiExtension(pi) {
|
|
|
757
823
|
const handoffs = await st.listHandoffs().catch(() => []);
|
|
758
824
|
if (handoffs.length > 0) {
|
|
759
825
|
ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
|
|
826
|
+
// Mark each handoff as read (recap presented). Do NOT clear — content is
|
|
827
|
+
// kept until a task in the phase starts (auto-archived) or phase completes.
|
|
828
|
+
for (const h of handoffs)
|
|
829
|
+
await st.markHandoffRead(h.phaseId).catch(() => { });
|
|
760
830
|
}
|
|
761
831
|
}
|
|
762
832
|
// If the user declined enablement, skip EVERYTHING (no web prompt, no migration, no summary).
|
|
@@ -924,8 +994,45 @@ export default function planPiExtension(pi) {
|
|
|
924
994
|
});
|
|
925
995
|
// ── Commands ───────────────────────────────────────────────────────
|
|
926
996
|
// ── Planner handler (reusable from multiple commands) ──────────
|
|
997
|
+
const plannerInteractionCancelled = Symbol("planner-interaction-cancelled");
|
|
998
|
+
/**
|
|
999
|
+
* Pi returns `undefined` when Escape closes an input/editor/select. Treat
|
|
1000
|
+
* that as a transaction-wide cancellation, not as an omitted optional
|
|
1001
|
+
* field: interactive planner commands must never keep prompting or write a
|
|
1002
|
+
* partial edit after Escape.
|
|
1003
|
+
*/
|
|
1004
|
+
function withCancellablePlannerUi(ctx) {
|
|
1005
|
+
const cancellableCtx = Object.create(ctx);
|
|
1006
|
+
const ui = new Proxy(ctx.ui, {
|
|
1007
|
+
get(target, property, receiver) {
|
|
1008
|
+
const member = Reflect.get(target, property, receiver);
|
|
1009
|
+
if (typeof member !== "function")
|
|
1010
|
+
return member;
|
|
1011
|
+
if (property === "input" || property === "editor" || property === "select") {
|
|
1012
|
+
return async (...args) => {
|
|
1013
|
+
const result = await member.apply(target, args);
|
|
1014
|
+
if (result === undefined || result === null)
|
|
1015
|
+
throw plannerInteractionCancelled;
|
|
1016
|
+
return result;
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return member.bind(target);
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
Object.defineProperty(cancellableCtx, "ui", { value: ui, enumerable: true });
|
|
1023
|
+
return cancellableCtx;
|
|
1024
|
+
}
|
|
927
1025
|
async function handlePlanner(args, ctx) {
|
|
928
|
-
|
|
1026
|
+
try {
|
|
1027
|
+
await withPlanBusy(() => runPlanner(args, withCancellablePlannerUi(ctx)));
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
if (error === plannerInteractionCancelled) {
|
|
1031
|
+
ctx.ui.notify("Cancelled — no changes saved.", "info");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
929
1036
|
}
|
|
930
1037
|
async function runPlanner(args, ctx) {
|
|
931
1038
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -935,6 +1042,7 @@ export default function planPiExtension(pi) {
|
|
|
935
1042
|
"init",
|
|
936
1043
|
"show",
|
|
937
1044
|
"repair",
|
|
1045
|
+
"cleanup-orphans",
|
|
938
1046
|
"project discuss",
|
|
939
1047
|
"project language",
|
|
940
1048
|
"feature list",
|
|
@@ -968,9 +1076,9 @@ export default function planPiExtension(pi) {
|
|
|
968
1076
|
"load",
|
|
969
1077
|
"stop",
|
|
970
1078
|
];
|
|
971
|
-
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
972
|
-
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner
|
|
973
|
-
"Handoff actions: /planner handoff list | show
|
|
1079
|
+
const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1080
|
+
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
|
|
1081
|
+
"Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
|
|
974
1082
|
if (!a) {
|
|
975
1083
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
976
1084
|
if (!action) {
|
|
@@ -990,6 +1098,14 @@ export default function planPiExtension(pi) {
|
|
|
990
1098
|
return;
|
|
991
1099
|
}
|
|
992
1100
|
const oldProject = await st.loadProject();
|
|
1101
|
+
// Collect the replacement metadata before deleting the existing plan;
|
|
1102
|
+
// Escape must leave the old project intact.
|
|
1103
|
+
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1104
|
+
if (!name?.trim()) {
|
|
1105
|
+
ctx.ui.notify("Aborted", "warning");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
993
1109
|
ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
|
|
994
1110
|
if (server) {
|
|
995
1111
|
ctx.ui.notify("Stopping active web server...", "info");
|
|
@@ -997,12 +1113,6 @@ export default function planPiExtension(pi) {
|
|
|
997
1113
|
server = null;
|
|
998
1114
|
}
|
|
999
1115
|
await rm(st.root, { recursive: true, force: true });
|
|
1000
|
-
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1001
|
-
if (!name?.trim()) {
|
|
1002
|
-
ctx.ui.notify("Aborted", "warning");
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
1006
1116
|
await st.init(name.trim());
|
|
1007
1117
|
const project = await st.loadProject();
|
|
1008
1118
|
project.description = description?.trim() ?? oldProject.description;
|
|
@@ -1125,35 +1235,33 @@ export default function planPiExtension(pi) {
|
|
|
1125
1235
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1126
1236
|
?? null;
|
|
1127
1237
|
}
|
|
1128
|
-
function
|
|
1129
|
-
const
|
|
1130
|
-
if (!
|
|
1131
|
-
return
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
if (
|
|
1148
|
-
return
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1156
|
-
return null;
|
|
1238
|
+
function resolveFeatureRef(features, ref) {
|
|
1239
|
+
const raw = ref.trim();
|
|
1240
|
+
if (!raw)
|
|
1241
|
+
return { ok: false, error: "Feature ref is required." };
|
|
1242
|
+
const normalized = raw.toLowerCase();
|
|
1243
|
+
const byNumber = normalized.match(/^f(\d+)$/)
|
|
1244
|
+
? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
|
|
1245
|
+
: undefined;
|
|
1246
|
+
if (byNumber)
|
|
1247
|
+
return { ok: true, feature: byNumber };
|
|
1248
|
+
const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
|
|
1249
|
+
if (byShortId)
|
|
1250
|
+
return { ok: true, feature: byShortId };
|
|
1251
|
+
const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
|
|
1252
|
+
if (byId)
|
|
1253
|
+
return { ok: true, feature: byId };
|
|
1254
|
+
const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
|
|
1255
|
+
if (exactName.length == 1)
|
|
1256
|
+
return { ok: true, feature: exactName[0] };
|
|
1257
|
+
if (exactName.length > 1)
|
|
1258
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
|
|
1259
|
+
const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
|
|
1260
|
+
if (partialName.length == 1)
|
|
1261
|
+
return { ok: true, feature: partialName[0] };
|
|
1262
|
+
if (partialName.length > 1)
|
|
1263
|
+
return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
|
|
1264
|
+
return { ok: false, error: `Feature not found: ${raw}` };
|
|
1157
1265
|
}
|
|
1158
1266
|
function profile_packageManager(pkg, lockfile) {
|
|
1159
1267
|
if (pkg?.packageManager)
|
|
@@ -1168,7 +1276,7 @@ export default function planPiExtension(pi) {
|
|
|
1168
1276
|
return "npm";
|
|
1169
1277
|
return "";
|
|
1170
1278
|
}
|
|
1171
|
-
async function scanCodebase() {
|
|
1279
|
+
async function scanCodebase(persist = true) {
|
|
1172
1280
|
const root = ctx.cwd;
|
|
1173
1281
|
const rootFiles = [];
|
|
1174
1282
|
const directories = [];
|
|
@@ -1250,7 +1358,8 @@ export default function planPiExtension(pi) {
|
|
|
1250
1358
|
tree,
|
|
1251
1359
|
ambient,
|
|
1252
1360
|
};
|
|
1253
|
-
|
|
1361
|
+
if (persist)
|
|
1362
|
+
await st.saveCodebaseProfile(profile);
|
|
1254
1363
|
return profile;
|
|
1255
1364
|
}
|
|
1256
1365
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1262,8 +1371,8 @@ export default function planPiExtension(pi) {
|
|
|
1262
1371
|
return;
|
|
1263
1372
|
}
|
|
1264
1373
|
if (b === "discuss") {
|
|
1265
|
-
const project = await ensureProjectLanguagePreferences(st);
|
|
1266
|
-
const profile = await scanCodebase();
|
|
1374
|
+
const project = await ensureProjectLanguagePreferences(st, false);
|
|
1375
|
+
const profile = await scanCodebase(false);
|
|
1267
1376
|
ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
|
|
1268
1377
|
ctx.ui.notify("Opening text editors for the initial project profile. These will serve as the foundation for the agent's deep discovery process.", "info");
|
|
1269
1378
|
const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
|
|
@@ -1290,6 +1399,7 @@ export default function planPiExtension(pi) {
|
|
|
1290
1399
|
: project.acceptedDecisions,
|
|
1291
1400
|
};
|
|
1292
1401
|
await st.saveProject(draftProject);
|
|
1402
|
+
await st.saveCodebaseProfile(profile);
|
|
1293
1403
|
await st.writeGenerated();
|
|
1294
1404
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
1295
1405
|
if (capturedPi) {
|
|
@@ -1327,7 +1437,7 @@ export default function planPiExtension(pi) {
|
|
|
1327
1437
|
// ═══════════════════════════════════════════════════════════════
|
|
1328
1438
|
if (a === "feature") {
|
|
1329
1439
|
if (!b) {
|
|
1330
|
-
ctx.ui.notify("feature actions: list | add [name] | show [id|name] | update [id|name] | delete [id|name]", "info");
|
|
1440
|
+
ctx.ui.notify("feature actions: list | add [name] | show [id|name] | discuss [id|name] | update [id|name] | delete [id|name]", "info");
|
|
1331
1441
|
return;
|
|
1332
1442
|
}
|
|
1333
1443
|
if (b === "list") {
|
|
@@ -1359,7 +1469,7 @@ export default function planPiExtension(pi) {
|
|
|
1359
1469
|
return;
|
|
1360
1470
|
}
|
|
1361
1471
|
const now = nowISO();
|
|
1362
|
-
const featureNumber =
|
|
1472
|
+
const featureNumber = await st.allocFeatureNumber();
|
|
1363
1473
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1364
1474
|
const priority = await st.nextPriority("feature");
|
|
1365
1475
|
const feature = {
|
|
@@ -1380,6 +1490,7 @@ export default function planPiExtension(pi) {
|
|
|
1380
1490
|
acceptedDecisions: [],
|
|
1381
1491
|
phaseIds: [],
|
|
1382
1492
|
dependsOn: [],
|
|
1493
|
+
statusLog: [],
|
|
1383
1494
|
createdAt: now,
|
|
1384
1495
|
updatedAt: now,
|
|
1385
1496
|
};
|
|
@@ -1388,7 +1499,7 @@ export default function planPiExtension(pi) {
|
|
|
1388
1499
|
return doc;
|
|
1389
1500
|
});
|
|
1390
1501
|
await st.writeGenerated();
|
|
1391
|
-
ctx.ui.notify(`Feature created: ${feature.
|
|
1502
|
+
ctx.ui.notify(`Feature created: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1392
1503
|
return;
|
|
1393
1504
|
}
|
|
1394
1505
|
if (b === "show") {
|
|
@@ -1417,6 +1528,44 @@ export default function planPiExtension(pi) {
|
|
|
1417
1528
|
].filter(Boolean).join("\n"), "info");
|
|
1418
1529
|
return;
|
|
1419
1530
|
}
|
|
1531
|
+
if (b === "discuss") {
|
|
1532
|
+
const featuresDoc = await st.loadFeatures();
|
|
1533
|
+
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
1534
|
+
if (!feature) {
|
|
1535
|
+
feature = await pickFeature();
|
|
1536
|
+
if (!feature) {
|
|
1537
|
+
ctx.ui.notify("No features to discuss. Create one first.", "warning");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
|
|
1542
|
+
const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
|
|
1543
|
+
const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
|
|
1544
|
+
const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
|
|
1545
|
+
const featureId = feature.id;
|
|
1546
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
1547
|
+
const target = doc.features.find((entry) => entry.id === featureId);
|
|
1548
|
+
if (!target)
|
|
1549
|
+
return doc;
|
|
1550
|
+
if (description !== undefined)
|
|
1551
|
+
target.description = description.trim();
|
|
1552
|
+
if (workDone !== undefined)
|
|
1553
|
+
target.workDone = workDone.trim();
|
|
1554
|
+
if (workRemaining !== undefined)
|
|
1555
|
+
target.workRemaining = workRemaining.trim();
|
|
1556
|
+
if (dependencies !== undefined)
|
|
1557
|
+
target.dependsOn = splitCsv(dependencies);
|
|
1558
|
+
target.discussedAt = nowISO();
|
|
1559
|
+
target.contextReady = true;
|
|
1560
|
+
target.contextReadyReason = "Updated through /planner feature discuss.";
|
|
1561
|
+
target.updatedAt = nowISO();
|
|
1562
|
+
return doc;
|
|
1563
|
+
});
|
|
1564
|
+
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1565
|
+
await st.writeGenerated();
|
|
1566
|
+
ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1420
1569
|
if (b === "update") {
|
|
1421
1570
|
const featuresDoc = await st.loadFeatures();
|
|
1422
1571
|
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
@@ -1469,7 +1618,7 @@ export default function planPiExtension(pi) {
|
|
|
1469
1618
|
});
|
|
1470
1619
|
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1471
1620
|
await st.writeGenerated();
|
|
1472
|
-
ctx.ui.notify(`Feature updated: ${feature.
|
|
1621
|
+
ctx.ui.notify(`Feature updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name} (${feature.status})`, "info");
|
|
1473
1622
|
return;
|
|
1474
1623
|
}
|
|
1475
1624
|
if (b === "delete") {
|
|
@@ -1515,7 +1664,7 @@ export default function planPiExtension(pi) {
|
|
|
1515
1664
|
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1516
1665
|
return;
|
|
1517
1666
|
}
|
|
1518
|
-
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
|
|
1667
|
+
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
1519
1668
|
return;
|
|
1520
1669
|
}
|
|
1521
1670
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1542,11 +1691,15 @@ export default function planPiExtension(pi) {
|
|
|
1542
1691
|
ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
|
|
1543
1692
|
return;
|
|
1544
1693
|
}
|
|
1694
|
+
// This belongs to the creation transaction: Escape before the phase
|
|
1695
|
+
// is persisted cancels the whole command rather than creating a phase
|
|
1696
|
+
// and only then abandoning the follow-up question.
|
|
1697
|
+
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1545
1698
|
let phase;
|
|
1546
1699
|
await withFeatureLock(feature.id, async () => {
|
|
1547
1700
|
const phases = await st.loadAllPhases();
|
|
1548
1701
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1549
|
-
const number =
|
|
1702
|
+
const number = await st.allocPhaseNumber();
|
|
1550
1703
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1551
1704
|
const id = createPhaseId();
|
|
1552
1705
|
const shortId = createShortId(await st.assignedShortIds());
|
|
@@ -1557,6 +1710,9 @@ export default function planPiExtension(pi) {
|
|
|
1557
1710
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1558
1711
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1559
1712
|
handoff: "", handoffUpdatedAt: "",
|
|
1713
|
+
handoffReadAt: "",
|
|
1714
|
+
handoffHistory: [],
|
|
1715
|
+
statusLog: [],
|
|
1560
1716
|
};
|
|
1561
1717
|
await st.savePhase(phase);
|
|
1562
1718
|
await st.updateFeatures((doc) => {
|
|
@@ -1571,8 +1727,7 @@ export default function planPiExtension(pi) {
|
|
|
1571
1727
|
});
|
|
1572
1728
|
if (!phase)
|
|
1573
1729
|
return;
|
|
1574
|
-
ctx.ui.notify(`Phase created: ${phase.
|
|
1575
|
-
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1730
|
+
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
1576
1731
|
if (isYes(startDiscuss)) {
|
|
1577
1732
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
1578
1733
|
}
|
|
@@ -1581,7 +1736,7 @@ export default function planPiExtension(pi) {
|
|
|
1581
1736
|
if (b === "discuss") {
|
|
1582
1737
|
let phase = null;
|
|
1583
1738
|
if (subArgs.trim()) {
|
|
1584
|
-
phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1739
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1585
1740
|
}
|
|
1586
1741
|
if (!phase) {
|
|
1587
1742
|
phase = await pickPhase();
|
|
@@ -1593,10 +1748,7 @@ export default function planPiExtension(pi) {
|
|
|
1593
1748
|
if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
|
|
1594
1749
|
ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
|
|
1595
1750
|
}
|
|
1596
|
-
phase.
|
|
1597
|
-
phase.updatedAt = nowISO();
|
|
1598
|
-
await st.savePhase(phase);
|
|
1599
|
-
ctx.ui.notify(`Discovery for ${phase.id}…`, "info");
|
|
1751
|
+
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1600
1752
|
// ── Recap: give full context before planning tasks ──
|
|
1601
1753
|
const recapPlan = await st.loadAll();
|
|
1602
1754
|
const recapProject = recapPlan.project;
|
|
@@ -1661,13 +1813,13 @@ export default function planPiExtension(pi) {
|
|
|
1661
1813
|
phase.updatedAt = nowISO();
|
|
1662
1814
|
await st.savePhase(phase);
|
|
1663
1815
|
await st.writeGenerated();
|
|
1664
|
-
ctx.ui.notify(`Phase ${phase.
|
|
1816
|
+
ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1665
1817
|
return;
|
|
1666
1818
|
}
|
|
1667
1819
|
if (b === "show") {
|
|
1668
1820
|
let phase = null;
|
|
1669
1821
|
if (subArgs.trim()) {
|
|
1670
|
-
phase = await st.
|
|
1822
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1671
1823
|
}
|
|
1672
1824
|
if (!phase) {
|
|
1673
1825
|
phase = await pickPhase();
|
|
@@ -1693,7 +1845,7 @@ export default function planPiExtension(pi) {
|
|
|
1693
1845
|
if (b === "delete") {
|
|
1694
1846
|
let phase = null;
|
|
1695
1847
|
if (subArgs.trim()) {
|
|
1696
|
-
phase = await st.
|
|
1848
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1697
1849
|
}
|
|
1698
1850
|
if (!phase) {
|
|
1699
1851
|
phase = await pickPhase();
|
|
@@ -1709,13 +1861,13 @@ export default function planPiExtension(pi) {
|
|
|
1709
1861
|
}
|
|
1710
1862
|
await st.deletePhase(phase.id);
|
|
1711
1863
|
await st.writeGenerated();
|
|
1712
|
-
ctx.ui.notify(`Phase deleted: ${phase.
|
|
1864
|
+
ctx.ui.notify(`Phase deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1713
1865
|
return;
|
|
1714
1866
|
}
|
|
1715
1867
|
if (b === "update") {
|
|
1716
1868
|
let phase = null;
|
|
1717
1869
|
if (subArgs.trim()) {
|
|
1718
|
-
phase = await st.
|
|
1870
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1719
1871
|
}
|
|
1720
1872
|
if (!phase) {
|
|
1721
1873
|
phase = await pickPhase();
|
|
@@ -1761,7 +1913,7 @@ export default function planPiExtension(pi) {
|
|
|
1761
1913
|
phase.updatedAt = nowISO();
|
|
1762
1914
|
await st.savePhase(phase);
|
|
1763
1915
|
await st.writeGenerated();
|
|
1764
|
-
ctx.ui.notify(`Phase updated: ${phase.
|
|
1916
|
+
ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
|
|
1765
1917
|
return;
|
|
1766
1918
|
}
|
|
1767
1919
|
ctx.ui.notify(`Unknown phase action "${b}". Try: add, show, discuss, delete, update`, "warning");
|
|
@@ -1772,7 +1924,7 @@ export default function planPiExtension(pi) {
|
|
|
1772
1924
|
// ═══════════════════════════════════════════════════════════════
|
|
1773
1925
|
if (a === "task") {
|
|
1774
1926
|
if (!b) {
|
|
1775
|
-
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
|
|
1927
|
+
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
|
|
1776
1928
|
return;
|
|
1777
1929
|
}
|
|
1778
1930
|
if (b === "add") {
|
|
@@ -1786,8 +1938,11 @@ export default function planPiExtension(pi) {
|
|
|
1786
1938
|
ctx.ui.notify("Aborted", "warning");
|
|
1787
1939
|
return;
|
|
1788
1940
|
}
|
|
1941
|
+
// Keep the optional follow-up inside the creation transaction so
|
|
1942
|
+
// Escape can still cancel before a task is persisted.
|
|
1943
|
+
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1789
1944
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1790
|
-
const taskNum =
|
|
1945
|
+
const taskNum = await st.allocTaskNumber();
|
|
1791
1946
|
const taskId = createTaskId();
|
|
1792
1947
|
const shortId = createShortId(await st.assignedShortIds());
|
|
1793
1948
|
const priority = await st.nextPriority("task", phase.id);
|
|
@@ -1811,7 +1966,6 @@ export default function planPiExtension(pi) {
|
|
|
1811
1966
|
await st.savePhase(phase);
|
|
1812
1967
|
await st.writeGenerated();
|
|
1813
1968
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
1814
|
-
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1815
1969
|
if (isYes(startDiscuss)) {
|
|
1816
1970
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
1817
1971
|
}
|
|
@@ -1819,7 +1973,7 @@ export default function planPiExtension(pi) {
|
|
|
1819
1973
|
}
|
|
1820
1974
|
if (b === "discuss") {
|
|
1821
1975
|
const phases = await st.loadAllPhases();
|
|
1822
|
-
let resolved = subArgs.trim() ? findTaskByRef(phases, subArgs.trim()) : null;
|
|
1976
|
+
let resolved = subArgs.trim() ? findTaskByRef(phases, (await st.loadFeatures()).features, subArgs.trim()) : null;
|
|
1823
1977
|
if (!resolved) {
|
|
1824
1978
|
const phase = await pickPhase();
|
|
1825
1979
|
if (!phase) {
|
|
@@ -1843,7 +1997,7 @@ export default function planPiExtension(pi) {
|
|
|
1843
1997
|
if (description?.trim())
|
|
1844
1998
|
task.description = description.trim();
|
|
1845
1999
|
if (checklistSeed?.trim()) {
|
|
1846
|
-
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
2000
|
+
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
|
|
1847
2001
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
1848
2002
|
title: itemTitle,
|
|
1849
2003
|
checked: false,
|
|
@@ -1853,14 +2007,14 @@ export default function planPiExtension(pi) {
|
|
|
1853
2007
|
phase.updatedAt = nowISO();
|
|
1854
2008
|
await st.savePhase(phase);
|
|
1855
2009
|
await st.writeGenerated();
|
|
1856
|
-
ctx.ui.notify(`Task ${task.
|
|
2010
|
+
ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
|
|
1857
2011
|
return;
|
|
1858
2012
|
}
|
|
1859
2013
|
if (b === "show") {
|
|
1860
2014
|
let phase = null;
|
|
1861
2015
|
let task = null;
|
|
1862
2016
|
if (subArgs.trim()) {
|
|
1863
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2017
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
1864
2018
|
phase = resolved?.phase ?? null;
|
|
1865
2019
|
task = resolved?.task ?? null;
|
|
1866
2020
|
}
|
|
@@ -1894,7 +2048,7 @@ export default function planPiExtension(pi) {
|
|
|
1894
2048
|
if (b === "delete") {
|
|
1895
2049
|
let phase = null;
|
|
1896
2050
|
if (subArgs.trim()) {
|
|
1897
|
-
phase = await st.
|
|
2051
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1898
2052
|
}
|
|
1899
2053
|
if (!phase) {
|
|
1900
2054
|
phase = await pickPhase();
|
|
@@ -1922,13 +2076,13 @@ export default function planPiExtension(pi) {
|
|
|
1922
2076
|
phase.updatedAt = nowISO();
|
|
1923
2077
|
await st.savePhase(phase);
|
|
1924
2078
|
await st.writeGenerated();
|
|
1925
|
-
ctx.ui.notify(`Task deleted: ${task.
|
|
2079
|
+
ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
1926
2080
|
return;
|
|
1927
2081
|
}
|
|
1928
2082
|
if (b === "update") {
|
|
1929
2083
|
let phase = null;
|
|
1930
2084
|
if (subArgs.trim()) {
|
|
1931
|
-
phase = await st.
|
|
2085
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1932
2086
|
}
|
|
1933
2087
|
if (!phase) {
|
|
1934
2088
|
phase = await pickPhase();
|
|
@@ -1970,15 +2124,16 @@ export default function planPiExtension(pi) {
|
|
|
1970
2124
|
task.updatedAt = now;
|
|
1971
2125
|
phase.updatedAt = now;
|
|
1972
2126
|
await st.savePhase(phase);
|
|
2127
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
1973
2128
|
await st.writeGenerated();
|
|
1974
|
-
ctx.ui.notify(`Task updated: ${task.
|
|
2129
|
+
ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
1975
2130
|
return;
|
|
1976
2131
|
}
|
|
1977
2132
|
if (b === "start") {
|
|
1978
2133
|
let phase = null;
|
|
1979
2134
|
let task = null;
|
|
1980
2135
|
if (subArgs.trim()) {
|
|
1981
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2136
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
1982
2137
|
phase = resolved?.phase ?? null;
|
|
1983
2138
|
task = resolved?.task ?? null;
|
|
1984
2139
|
}
|
|
@@ -2002,13 +2157,9 @@ export default function planPiExtension(pi) {
|
|
|
2002
2157
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2003
2158
|
return;
|
|
2004
2159
|
}
|
|
2005
|
-
//
|
|
2006
|
-
//
|
|
2007
|
-
//
|
|
2008
|
-
// forcing deletion, which would lose context).
|
|
2009
|
-
if ((await st.listHandoffs()).length > 0) {
|
|
2010
|
-
ctx.ui.notify("ℹ️ One or more phases have a pending handoff — if relevant to the task you're starting, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with task start.", "info");
|
|
2011
|
-
}
|
|
2160
|
+
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2161
|
+
// It is archived only when the phase completes (auto) or the user
|
|
2162
|
+
// explicitly clears it.
|
|
2012
2163
|
const now = nowISO();
|
|
2013
2164
|
// Record status change in the incremental statusLog.
|
|
2014
2165
|
const prevStatus = task.status;
|
|
@@ -2025,15 +2176,16 @@ export default function planPiExtension(pi) {
|
|
|
2025
2176
|
task.updatedAt = now;
|
|
2026
2177
|
phase.updatedAt = now;
|
|
2027
2178
|
await st.savePhase(phase);
|
|
2179
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
2028
2180
|
await st.writeGenerated();
|
|
2029
|
-
ctx.ui.notify(`✅ Task started: ${task.
|
|
2181
|
+
ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
|
|
2030
2182
|
return;
|
|
2031
2183
|
}
|
|
2032
2184
|
if (b === "complete") {
|
|
2033
2185
|
let phase = null;
|
|
2034
2186
|
let task = null;
|
|
2035
2187
|
if (subArgs.trim()) {
|
|
2036
|
-
const resolved = findTaskByRef(await st.loadAllPhases(), subArgs.trim());
|
|
2188
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
|
|
2037
2189
|
phase = resolved?.phase ?? null;
|
|
2038
2190
|
task = resolved?.task ?? null;
|
|
2039
2191
|
}
|
|
@@ -2079,12 +2231,80 @@ export default function planPiExtension(pi) {
|
|
|
2079
2231
|
applyTaskLifecycleDates(task, "done", now);
|
|
2080
2232
|
task.updatedAt = now;
|
|
2081
2233
|
phase.updatedAt = now;
|
|
2082
|
-
await st.
|
|
2234
|
+
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2235
|
+
await st.writeGenerated();
|
|
2236
|
+
ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
2240
|
+
if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
|
|
2241
|
+
const [taskRef, ...itemParts] = rest;
|
|
2242
|
+
const itemText = itemParts.join(" ").trim();
|
|
2243
|
+
if (!taskRef || !itemText) {
|
|
2244
|
+
ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
|
|
2248
|
+
if (!resolved) {
|
|
2249
|
+
ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
let checked;
|
|
2253
|
+
if (b === "checklist-toggle") {
|
|
2254
|
+
const m = itemText.match(/\s+(on|off)$/i);
|
|
2255
|
+
if (m) {
|
|
2256
|
+
checked = m[1].toLowerCase() === "on";
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
|
|
2260
|
+
let msg = "";
|
|
2261
|
+
await st.updatePhase(resolved.phase.id, (phase) => {
|
|
2262
|
+
const task = phase.tasks.find((t) => t.id === resolved.task.id);
|
|
2263
|
+
if (!task) {
|
|
2264
|
+
msg = `Task not found: ${taskRef}`;
|
|
2265
|
+
return phase;
|
|
2266
|
+
}
|
|
2267
|
+
const ck = task.checklist ?? [];
|
|
2268
|
+
if (b === "checklist-add") {
|
|
2269
|
+
const item = addChecklistItem(ck, task.id, selector);
|
|
2270
|
+
ck.push(item);
|
|
2271
|
+
task.checklist = ck;
|
|
2272
|
+
msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
2273
|
+
}
|
|
2274
|
+
else if (b === "checklist-remove") {
|
|
2275
|
+
if (ck.length === 0) {
|
|
2276
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2277
|
+
return phase;
|
|
2278
|
+
}
|
|
2279
|
+
const removed = removeChecklistItem(ck, selector);
|
|
2280
|
+
if (!removed) {
|
|
2281
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2282
|
+
return phase;
|
|
2283
|
+
}
|
|
2284
|
+
msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
2285
|
+
}
|
|
2286
|
+
else {
|
|
2287
|
+
if (ck.length === 0) {
|
|
2288
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2289
|
+
return phase;
|
|
2290
|
+
}
|
|
2291
|
+
const target = toggleChecklistItem(ck, selector, checked);
|
|
2292
|
+
if (!target) {
|
|
2293
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2294
|
+
return phase;
|
|
2295
|
+
}
|
|
2296
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
2297
|
+
msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
2298
|
+
}
|
|
2299
|
+
task.updatedAt = nowISO();
|
|
2300
|
+
phase.updatedAt = nowISO();
|
|
2301
|
+
return phase;
|
|
2302
|
+
});
|
|
2083
2303
|
await st.writeGenerated();
|
|
2084
|
-
ctx.ui.notify(`✅
|
|
2304
|
+
ctx.ui.notify(`✅ ${msg}`, "info");
|
|
2085
2305
|
return;
|
|
2086
2306
|
}
|
|
2087
|
-
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
|
|
2307
|
+
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
|
|
2088
2308
|
return;
|
|
2089
2309
|
}
|
|
2090
2310
|
// ── discuss (legacy alias) ──
|
|
@@ -2130,6 +2350,11 @@ export default function planPiExtension(pi) {
|
|
|
2130
2350
|
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2131
2351
|
return;
|
|
2132
2352
|
}
|
|
2353
|
+
const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
|
|
2354
|
+
if (!confirmed) {
|
|
2355
|
+
ctx.ui.notify("Handoff write cancelled.", "info");
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2133
2358
|
await st.setPhaseHandoff(r.phase.id, text);
|
|
2134
2359
|
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2135
2360
|
return;
|
|
@@ -2139,22 +2364,17 @@ export default function planPiExtension(pi) {
|
|
|
2139
2364
|
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2140
2365
|
return;
|
|
2141
2366
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
"
|
|
2367
|
+
await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
|
|
2368
|
+
"If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
|
|
2369
|
+
"Before calling `handoff_write`, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. Only after confirmation call `handoff_write` with that exact phaseRef. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
|
|
2370
|
+
"After confirmation, the handoff MUST contain at minimum:\n" +
|
|
2145
2371
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2146
2372
|
"- `Reason:` why this handoff is being written\n" +
|
|
2147
|
-
"- `## Current focus`:
|
|
2148
|
-
"- `## What was being done`:
|
|
2149
|
-
"- `## How to resume`: explicit
|
|
2150
|
-
"- `## Files touched
|
|
2151
|
-
"
|
|
2152
|
-
"- `## Next steps`: the immediate next steps.\n" +
|
|
2153
|
-
"- `## Recent decisions`: accepted decisions made this session, if any.\n" +
|
|
2154
|
-
"- `## Reminder`: note that when the work is fully resumed, the agent should clear the phase handoff using `handoff_clear`.\n\n" +
|
|
2155
|
-
"The handoff is stored on the phase.handoff field of the current in-progress phase (entity-scoped). " +
|
|
2156
|
-
(handoffs.length > 0 ? "A previous phase handoff exists; refresh its `Updated at` and merge/replace stale sections. " : "") +
|
|
2157
|
-
"Once written, confirm to me with a one-line summary.");
|
|
2373
|
+
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2374
|
+
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2375
|
+
"- `## How to resume`: explicit ordered steps\n" +
|
|
2376
|
+
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2377
|
+
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
|
|
2158
2378
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2159
2379
|
return;
|
|
2160
2380
|
}
|
|
@@ -2164,7 +2384,7 @@ export default function planPiExtension(pi) {
|
|
|
2164
2384
|
ctx.ui.notify(r.error, "warning");
|
|
2165
2385
|
return;
|
|
2166
2386
|
}
|
|
2167
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2387
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2168
2388
|
ctx.ui.notify(`Cleared handoff on ${r.compositeRef}`, "info");
|
|
2169
2389
|
return;
|
|
2170
2390
|
}
|
|
@@ -2223,7 +2443,26 @@ export default function planPiExtension(pi) {
|
|
|
2223
2443
|
const m = report.migrated;
|
|
2224
2444
|
const dup = report.integrity.duplicatePhaseIds.length;
|
|
2225
2445
|
const dang = report.integrity.danglingPhaseIds.length;
|
|
2226
|
-
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}. Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2446
|
+
ctx.ui.notify(`Repair done: renamed ${m.renamed}, repaired ${m.repaired} refs, inferred ${m.inferred}, archived ${report.handoffs.archived} stale handoff(s). Integrity: ${dup} duplicate, ${dang} dangling.`, "info");
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
if (a === "cleanup-orphans") {
|
|
2450
|
+
const found = await st.listOrphanPhases();
|
|
2451
|
+
if (found.length === 0) {
|
|
2452
|
+
ctx.ui.notify("No orphan phases found.", "info");
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
const lines = [
|
|
2456
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
|
|
2457
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2458
|
+
];
|
|
2459
|
+
const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
|
|
2460
|
+
if (!confirmed) {
|
|
2461
|
+
ctx.ui.notify("Cleanup aborted.", "warning");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const report = await st.cleanupOrphanPhases();
|
|
2465
|
+
ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
|
|
2227
2466
|
return;
|
|
2228
2467
|
}
|
|
2229
2468
|
// ── export ──
|
|
@@ -2279,6 +2518,10 @@ export default function planPiExtension(pi) {
|
|
|
2279
2518
|
startupResumeSummaryPending = false;
|
|
2280
2519
|
startupResumeSummaryTimer = null;
|
|
2281
2520
|
}, 60000);
|
|
2521
|
+
// Safety timeout must not keep the Node process alive on its own
|
|
2522
|
+
// (relevant for test hosts that load this entrypoint). Inside Pi the
|
|
2523
|
+
// process is long-lived, so unref is a no-op there.
|
|
2524
|
+
startupResumeSummaryTimer.unref?.();
|
|
2282
2525
|
// Build the recap and embed it in the trigger content. The trigger
|
|
2283
2526
|
// message is the channel that reliably reaches the agent on a triggered
|
|
2284
2527
|
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
@@ -2303,7 +2546,7 @@ export default function planPiExtension(pi) {
|
|
|
2303
2546
|
return;
|
|
2304
2547
|
}
|
|
2305
2548
|
if (a === "stop" || a === "disable") {
|
|
2306
|
-
|
|
2549
|
+
disablePlannerSession();
|
|
2307
2550
|
await stopServer().catch(() => { });
|
|
2308
2551
|
try {
|
|
2309
2552
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -2319,6 +2562,11 @@ export default function planPiExtension(pi) {
|
|
|
2319
2562
|
description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
|
|
2320
2563
|
getArgumentCompletions: (prefix) => {
|
|
2321
2564
|
const normalized = prefix.trim().toLowerCase();
|
|
2565
|
+
// Once a complete command path is followed by whitespace, the remaining
|
|
2566
|
+
// text is a free-form human ref/title. Returning the fallback menu here
|
|
2567
|
+
// lets Pi select `init` and replace input such as `feature update F001`.
|
|
2568
|
+
if (hasPlannerCommandArgument(prefix))
|
|
2569
|
+
return null;
|
|
2322
2570
|
const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
2323
2571
|
return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
|
|
2324
2572
|
},
|
|
@@ -2595,7 +2843,7 @@ export default function planPiExtension(pi) {
|
|
|
2595
2843
|
pi.registerTool({
|
|
2596
2844
|
name: "plan_render",
|
|
2597
2845
|
label: "Plan Render",
|
|
2598
|
-
description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
|
|
2846
|
+
description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
|
|
2599
2847
|
parameters: Type.Object({}),
|
|
2600
2848
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2601
2849
|
const st = await requirePlan(ctx);
|
|
@@ -2608,7 +2856,7 @@ export default function planPiExtension(pi) {
|
|
|
2608
2856
|
pi.registerTool({
|
|
2609
2857
|
name: "plan_repair",
|
|
2610
2858
|
label: "Plan Repair",
|
|
2611
|
-
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/.",
|
|
2859
|
+
description: "Repair dangling feature→phase references, rebuild phase containment from each task's own phaseId (heals the migrateToGlobalSequence task-shuffle bug), and report plan integrity (duplicate phase ids, dangling phase refs). Safe to run anytime; run if the planner reports ENOENT/phase not found, tasks appear in the wrong phase, or after manual edits to .planner/.",
|
|
2612
2860
|
parameters: Type.Object({}),
|
|
2613
2861
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2614
2862
|
const st = await requirePlan(ctx);
|
|
@@ -2616,9 +2864,12 @@ export default function planPiExtension(pi) {
|
|
|
2616
2864
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2617
2865
|
const report = await st.repair();
|
|
2618
2866
|
const m = report.migrated;
|
|
2867
|
+
const c = report.containment;
|
|
2619
2868
|
const lines = [
|
|
2620
2869
|
`Repair complete.`,
|
|
2621
2870
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2871
|
+
`Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
|
|
2872
|
+
`Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
|
|
2622
2873
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2623
2874
|
];
|
|
2624
2875
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2628,6 +2879,38 @@ export default function planPiExtension(pi) {
|
|
|
2628
2879
|
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2629
2880
|
},
|
|
2630
2881
|
});
|
|
2882
|
+
pi.registerTool({
|
|
2883
|
+
name: "plan_cleanup_orphan_phases",
|
|
2884
|
+
label: "Plan Cleanup Orphan Phases",
|
|
2885
|
+
description: "Discover phase files that no longer resolve to a valid owning feature, and optionally delete them. Run once with confirm=false (default) to inspect, then rerun with confirm=true to remove them.",
|
|
2886
|
+
parameters: Type.Object({
|
|
2887
|
+
confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
|
|
2888
|
+
}),
|
|
2889
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2890
|
+
const st = await requirePlan(ctx);
|
|
2891
|
+
if (!st)
|
|
2892
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2893
|
+
const found = await st.listOrphanPhases();
|
|
2894
|
+
if (!params.confirm) {
|
|
2895
|
+
if (found.length === 0)
|
|
2896
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
|
|
2897
|
+
const lines = [
|
|
2898
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
2899
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2900
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
2901
|
+
];
|
|
2902
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
|
|
2903
|
+
}
|
|
2904
|
+
const report = await st.cleanupOrphanPhases();
|
|
2905
|
+
if (report.removed.length === 0)
|
|
2906
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
|
|
2907
|
+
const lines = [
|
|
2908
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
2909
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
2910
|
+
];
|
|
2911
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2912
|
+
},
|
|
2913
|
+
});
|
|
2631
2914
|
pi.registerTool({
|
|
2632
2915
|
name: "plan_get_handoff",
|
|
2633
2916
|
label: "Plan Get Handoff",
|
|
@@ -2647,9 +2930,12 @@ export default function planPiExtension(pi) {
|
|
|
2647
2930
|
pi.registerTool({
|
|
2648
2931
|
name: "plan_write_handoff",
|
|
2649
2932
|
label: "Plan Write Handoff",
|
|
2650
|
-
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write.
|
|
2933
|
+
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. phaseRef is required and must be the exact phase confirmed with the user; never infer a target from current status or resume pointers. Completed/canceled phases reject new handoffs.",
|
|
2651
2934
|
parameters: Type.Object({
|
|
2935
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
2936
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2652
2937
|
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2938
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Example: 'P049 — featureId validation: tests + adapter wiring'." })),
|
|
2653
2939
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
2654
2940
|
howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
|
|
2655
2941
|
extraSections: Type.Optional(Type.Array(Type.Object({
|
|
@@ -2661,7 +2947,14 @@ export default function planPiExtension(pi) {
|
|
|
2661
2947
|
const st = await requirePlan(ctx);
|
|
2662
2948
|
if (!st)
|
|
2663
2949
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2950
|
+
const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2951
|
+
if (!resolved.ok)
|
|
2952
|
+
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${resolved.error}` }], details: { deprecated: true, error: resolved.error } };
|
|
2953
|
+
if (!params.confirmed)
|
|
2954
|
+
return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${resolved.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, confirmationRequired: true } };
|
|
2664
2955
|
const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
|
|
2956
|
+
phaseId: resolved.phase.id,
|
|
2957
|
+
...(params.title?.trim() ? { title: params.title.trim() } : {}),
|
|
2665
2958
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2666
2959
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2667
2960
|
...(params.extraSections && params.extraSections.length > 0
|
|
@@ -2672,32 +2965,29 @@ export default function planPiExtension(pi) {
|
|
|
2672
2965
|
}
|
|
2673
2966
|
: {}),
|
|
2674
2967
|
});
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2678
|
-
await st.setPhaseHandoff(r.phase.id, markdown);
|
|
2679
|
-
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${r.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2968
|
+
await st.setPhaseHandoff(resolved.phase.id, markdown);
|
|
2969
|
+
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated — wrote handoff on ${resolved.compositeRef} (phase.handoff field) instead of .planner/HANDOFF.md. Prefer the handoff_write tool.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id } };
|
|
2680
2970
|
},
|
|
2681
2971
|
});
|
|
2682
2972
|
pi.registerTool({
|
|
2683
2973
|
name: "plan_delete_handoff",
|
|
2684
2974
|
label: "Plan Delete Handoff",
|
|
2685
|
-
description: "DEPRECATED — clears the entity-scoped handoff of
|
|
2686
|
-
parameters: Type.Object({}),
|
|
2687
|
-
async execute(_id,
|
|
2975
|
+
description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
|
|
2976
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
2977
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2688
2978
|
const st = await requirePlan(ctx);
|
|
2689
2979
|
if (!st)
|
|
2690
2980
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2691
|
-
const r = await resolvePhaseForHandoff(st,
|
|
2981
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2692
2982
|
if (!r.ok)
|
|
2693
2983
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2694
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2984
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2695
2985
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — cleared handoff on ${r.compositeRef} (phase.handoff). Prefer handoff_clear.` }], details: { deprecated: true, phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2696
2986
|
},
|
|
2697
2987
|
});
|
|
2698
2988
|
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2699
|
-
// UUID | title
|
|
2700
|
-
//
|
|
2989
|
+
// UUID | title. A write target is always explicit: never guess from the
|
|
2990
|
+
// first in-progress phase or a stale resume pointer.
|
|
2701
2991
|
async function resolvePhaseForHandoff(st, ref) {
|
|
2702
2992
|
const phases = await st.loadAllPhases();
|
|
2703
2993
|
const features = (await st.loadFeatures()).features;
|
|
@@ -2705,16 +2995,12 @@ export default function planPiExtension(pi) {
|
|
|
2705
2995
|
if (ref && ref.trim()) {
|
|
2706
2996
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
2707
2997
|
}
|
|
2708
|
-
else {
|
|
2709
|
-
phase = phases.find((p) => p.status === "in-progress")
|
|
2710
|
-
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
2711
|
-
}
|
|
2712
2998
|
if (!phase) {
|
|
2713
2999
|
return {
|
|
2714
3000
|
ok: false,
|
|
2715
3001
|
error: ref && ref.trim()
|
|
2716
3002
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
2717
|
-
: "
|
|
3003
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
2718
3004
|
};
|
|
2719
3005
|
}
|
|
2720
3006
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -2739,8 +3025,8 @@ export default function planPiExtension(pi) {
|
|
|
2739
3025
|
pi.registerTool({
|
|
2740
3026
|
name: "handoff_show",
|
|
2741
3027
|
label: "Handoff Show",
|
|
2742
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title.
|
|
2743
|
-
parameters: Type.Object({ phaseRef: Type.
|
|
3028
|
+
description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
|
|
3029
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
|
|
2744
3030
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2745
3031
|
const st = await requirePlan(ctx);
|
|
2746
3032
|
if (!st)
|
|
@@ -2757,9 +3043,11 @@ export default function planPiExtension(pi) {
|
|
|
2757
3043
|
pi.registerTool({
|
|
2758
3044
|
name: "handoff_write",
|
|
2759
3045
|
label: "Handoff Write",
|
|
2760
|
-
description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content).
|
|
3046
|
+
description: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). Never guess the phase from in-progress status or stale context. Completed/canceled phases reject new handoffs.",
|
|
2761
3047
|
parameters: Type.Object({
|
|
2762
|
-
phaseRef: Type.
|
|
3048
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3049
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'." })),
|
|
3050
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2763
3051
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2764
3052
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2765
3053
|
}),
|
|
@@ -2767,12 +3055,38 @@ export default function planPiExtension(pi) {
|
|
|
2767
3055
|
const st = await requirePlan(ctx);
|
|
2768
3056
|
if (!st)
|
|
2769
3057
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2770
|
-
|
|
3058
|
+
let body = (params.markdown_content ?? params.content ?? "").trim();
|
|
2771
3059
|
if (!body)
|
|
2772
3060
|
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3061
|
+
const title = params.title?.trim();
|
|
3062
|
+
// Derive the current first non-empty line (H1) of the supplied body.
|
|
3063
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3064
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3065
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
3066
|
+
if (!title && isGeneric) {
|
|
3067
|
+
return {
|
|
3068
|
+
content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted." }],
|
|
3069
|
+
details: { error: "generic title", firstHeadingText },
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3073
|
+
if (title) {
|
|
3074
|
+
const lines = body.split(/\r?\n/);
|
|
3075
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
3076
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
3077
|
+
lines[firstIdx] = `# ${title}`;
|
|
3078
|
+
body = lines.join("\n");
|
|
3079
|
+
}
|
|
3080
|
+
else {
|
|
3081
|
+
body = `# ${title}\n\n${body}`;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
2773
3084
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2774
3085
|
if (!r.ok)
|
|
2775
3086
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3087
|
+
if (!params.confirmed) {
|
|
3088
|
+
return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
|
|
3089
|
+
}
|
|
2776
3090
|
await st.setPhaseHandoff(r.phase.id, body);
|
|
2777
3091
|
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2778
3092
|
},
|
|
@@ -2780,8 +3094,8 @@ export default function planPiExtension(pi) {
|
|
|
2780
3094
|
pi.registerTool({
|
|
2781
3095
|
name: "handoff_clear",
|
|
2782
3096
|
label: "Handoff Clear",
|
|
2783
|
-
description: "Clear the entity-scoped handoff of a phase (
|
|
2784
|
-
parameters: Type.Object({ phaseRef: Type.
|
|
3097
|
+
description: "Clear the entity-scoped handoff of a phase (archives it and keeps handoffUpdatedAt as an audit trail). phaseRef is required; never infer a phase automatically.",
|
|
3098
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
2785
3099
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2786
3100
|
const st = await requirePlan(ctx);
|
|
2787
3101
|
if (!st)
|
|
@@ -2789,8 +3103,8 @@ export default function planPiExtension(pi) {
|
|
|
2789
3103
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2790
3104
|
if (!r.ok)
|
|
2791
3105
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2792
|
-
await st.clearPhaseHandoff(r.phase.id);
|
|
2793
|
-
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef}
|
|
3106
|
+
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
3107
|
+
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/.local/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2794
3108
|
},
|
|
2795
3109
|
});
|
|
2796
3110
|
pi.registerTool({
|
|
@@ -2826,38 +3140,49 @@ export default function planPiExtension(pi) {
|
|
|
2826
3140
|
pi.registerTool({
|
|
2827
3141
|
name: "feature_list",
|
|
2828
3142
|
label: "Feature List",
|
|
2829
|
-
description: "List
|
|
2830
|
-
parameters: Type.Object({}),
|
|
2831
|
-
async execute(_id,
|
|
3143
|
+
description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
|
|
3144
|
+
parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
|
|
3145
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2832
3146
|
const st = await requirePlan(ctx);
|
|
2833
3147
|
if (!st)
|
|
2834
3148
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2835
|
-
const
|
|
3149
|
+
const all = (await st.loadFeatures()).features;
|
|
3150
|
+
const ref = params.featureRef?.trim();
|
|
3151
|
+
const matchF = (f) => !ref || formatFeatureRef(f.number) === ref || f.shortId === ref || f.id === ref || f.name.toLowerCase() === ref.toLowerCase();
|
|
3152
|
+
const features = all.filter(matchF);
|
|
2836
3153
|
const phases = await st.loadAllPhases();
|
|
2837
3154
|
const summary = features.map((f) => {
|
|
2838
3155
|
const fp = phases.filter((p) => p.featureId === f.id);
|
|
2839
|
-
return `- ${
|
|
3156
|
+
return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
|
|
2840
3157
|
}).join("\n");
|
|
2841
|
-
return { content: [{ type: "text", text: summary || "No features" }], details: {
|
|
3158
|
+
return { content: [{ type: "text", text: summary || "No features" }], details: {} };
|
|
2842
3159
|
},
|
|
2843
3160
|
});
|
|
2844
3161
|
pi.registerTool({
|
|
2845
3162
|
name: "feature_get",
|
|
2846
3163
|
label: "Feature Get",
|
|
2847
|
-
description: "
|
|
3164
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
2848
3165
|
parameters: Type.Object({
|
|
2849
|
-
featureId: Type.String({ description: "Feature
|
|
3166
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3167
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
2850
3168
|
}),
|
|
2851
3169
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2852
3170
|
const st = await requirePlan(ctx);
|
|
2853
3171
|
if (!st)
|
|
2854
3172
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2855
3173
|
const features = (await st.loadFeatures()).features;
|
|
2856
|
-
const
|
|
2857
|
-
|
|
2858
|
-
|
|
3174
|
+
const ref = params.featureId.trim();
|
|
3175
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
3176
|
+
if (!resolvedFeature.ok)
|
|
3177
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3178
|
+
const feature = resolvedFeature.feature;
|
|
2859
3179
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2860
|
-
|
|
3180
|
+
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3181
|
+
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
|
|
3182
|
+
const requirementsBlock = linkedRequirements.length > 0
|
|
3183
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3184
|
+
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3185
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
2861
3186
|
},
|
|
2862
3187
|
});
|
|
2863
3188
|
pi.registerTool({
|
|
@@ -2880,7 +3205,7 @@ export default function planPiExtension(pi) {
|
|
|
2880
3205
|
const priority = await st.nextPriority("feature");
|
|
2881
3206
|
const feature = {
|
|
2882
3207
|
id: createFeatureId(),
|
|
2883
|
-
number:
|
|
3208
|
+
number: await st.allocFeatureNumber(),
|
|
2884
3209
|
shortId,
|
|
2885
3210
|
priority,
|
|
2886
3211
|
name: params.name,
|
|
@@ -2896,6 +3221,7 @@ export default function planPiExtension(pi) {
|
|
|
2896
3221
|
acceptedDecisions: [],
|
|
2897
3222
|
phaseIds: [],
|
|
2898
3223
|
dependsOn: [],
|
|
3224
|
+
statusLog: [],
|
|
2899
3225
|
createdAt: now,
|
|
2900
3226
|
updatedAt: now,
|
|
2901
3227
|
};
|
|
@@ -2906,7 +3232,52 @@ export default function planPiExtension(pi) {
|
|
|
2906
3232
|
return doc;
|
|
2907
3233
|
});
|
|
2908
3234
|
await st.writeGenerated();
|
|
2909
|
-
return { content: [{ type: "text", text:
|
|
3235
|
+
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3236
|
+
},
|
|
3237
|
+
});
|
|
3238
|
+
pi.registerTool({
|
|
3239
|
+
name: "feature_discuss",
|
|
3240
|
+
label: "Feature Discuss",
|
|
3241
|
+
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
3242
|
+
parameters: Type.Object({
|
|
3243
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3244
|
+
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
3245
|
+
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3246
|
+
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3247
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
3248
|
+
}),
|
|
3249
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3250
|
+
const st = await requirePlan(ctx);
|
|
3251
|
+
if (!st)
|
|
3252
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3253
|
+
const features = (await st.loadFeatures()).features;
|
|
3254
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3255
|
+
if (!resolvedFeature.ok)
|
|
3256
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3257
|
+
const featureId = resolvedFeature.feature.id;
|
|
3258
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3259
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3260
|
+
if (!feature)
|
|
3261
|
+
return doc;
|
|
3262
|
+
if (params.description !== undefined)
|
|
3263
|
+
feature.description = params.description;
|
|
3264
|
+
if (params.workDone !== undefined)
|
|
3265
|
+
feature.workDone = params.workDone;
|
|
3266
|
+
if (params.workRemaining !== undefined)
|
|
3267
|
+
feature.workRemaining = params.workRemaining;
|
|
3268
|
+
if (params.dependencies !== undefined)
|
|
3269
|
+
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3270
|
+
feature.discussedAt = nowISO();
|
|
3271
|
+
feature.contextReady = true;
|
|
3272
|
+
feature.contextReadyReason = "Updated through feature_discuss tool.";
|
|
3273
|
+
feature.updatedAt = nowISO();
|
|
3274
|
+
return doc;
|
|
3275
|
+
});
|
|
3276
|
+
const feature = updatedDoc.features.find((f) => f.id === featureId);
|
|
3277
|
+
if (!feature)
|
|
3278
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3279
|
+
await st.writeGenerated();
|
|
3280
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2910
3281
|
},
|
|
2911
3282
|
});
|
|
2912
3283
|
pi.registerTool({
|
|
@@ -2914,7 +3285,7 @@ export default function planPiExtension(pi) {
|
|
|
2914
3285
|
label: "Feature Update",
|
|
2915
3286
|
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.",
|
|
2916
3287
|
parameters: Type.Object({
|
|
2917
|
-
featureId: Type.String({ description: "Feature
|
|
3288
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2918
3289
|
name: Type.Optional(Type.String({ description: "New name" })),
|
|
2919
3290
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
2920
3291
|
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." })),
|
|
@@ -2936,10 +3307,36 @@ export default function planPiExtension(pi) {
|
|
|
2936
3307
|
const st = await requirePlan(ctx);
|
|
2937
3308
|
if (!st)
|
|
2938
3309
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3310
|
+
const features = (await st.loadFeatures()).features;
|
|
3311
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3312
|
+
if (!resolvedFeature.ok)
|
|
3313
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3314
|
+
const featureId = resolvedFeature.feature.id;
|
|
3315
|
+
const mutableFields = [
|
|
3316
|
+
"name",
|
|
3317
|
+
"description",
|
|
3318
|
+
"status",
|
|
3319
|
+
"startDate",
|
|
3320
|
+
"endDate",
|
|
3321
|
+
"workDone",
|
|
3322
|
+
"workRemaining",
|
|
3323
|
+
"priority",
|
|
3324
|
+
"acceptedDecisions",
|
|
3325
|
+
];
|
|
3326
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3327
|
+
if (receivedFields.length === 0) {
|
|
3328
|
+
return {
|
|
3329
|
+
content: [{
|
|
3330
|
+
type: "text",
|
|
3331
|
+
text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
|
|
3332
|
+
}],
|
|
3333
|
+
details: { updated: false, reason: "no-mutable-fields" },
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
2939
3336
|
let foundDoc;
|
|
2940
3337
|
try {
|
|
2941
3338
|
foundDoc = await st.updateFeatures((doc) => {
|
|
2942
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3339
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
2943
3340
|
if (!feature)
|
|
2944
3341
|
return doc; // not found: no-op, handled below
|
|
2945
3342
|
if (params.name !== undefined)
|
|
@@ -2977,11 +3374,17 @@ export default function planPiExtension(pi) {
|
|
|
2977
3374
|
catch (e) {
|
|
2978
3375
|
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
2979
3376
|
}
|
|
2980
|
-
const feature = foundDoc?.features.find((f) => f.id ===
|
|
3377
|
+
const feature = foundDoc?.features.find((f) => f.id === featureId);
|
|
2981
3378
|
if (!feature)
|
|
2982
3379
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2983
3380
|
await st.writeGenerated();
|
|
2984
|
-
return {
|
|
3381
|
+
return {
|
|
3382
|
+
content: [{
|
|
3383
|
+
type: "text",
|
|
3384
|
+
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
3385
|
+
}],
|
|
3386
|
+
details: { ...feature, updated: true, updatedFields: receivedFields },
|
|
3387
|
+
};
|
|
2985
3388
|
},
|
|
2986
3389
|
});
|
|
2987
3390
|
pi.registerTool({
|
|
@@ -2989,28 +3392,30 @@ export default function planPiExtension(pi) {
|
|
|
2989
3392
|
label: "Feature Delete",
|
|
2990
3393
|
description: "Delete a feature. Its phases are unlinked (featureId cleared) but NOT deleted unless cascade=true.",
|
|
2991
3394
|
parameters: Type.Object({
|
|
2992
|
-
featureId: Type.String({ description: "Feature
|
|
3395
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name to delete" }),
|
|
2993
3396
|
cascade: Type.Optional(Type.Boolean({ description: "If true, also delete all phases belonging to this feature. Default: false" })),
|
|
2994
3397
|
}),
|
|
2995
3398
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2996
3399
|
const st = await requirePlan(ctx);
|
|
2997
3400
|
if (!st)
|
|
2998
3401
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3402
|
+
const features = (await st.loadFeatures()).features;
|
|
3403
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3404
|
+
if (!resolvedFeature.ok)
|
|
3405
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3406
|
+
const featureId = resolvedFeature.feature.id;
|
|
2999
3407
|
let deleted = false;
|
|
3000
3408
|
await st.updateFeatures((doc) => {
|
|
3001
3409
|
const before = doc.features.length;
|
|
3002
|
-
doc.features = doc.features.filter((f) => f.id !==
|
|
3410
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3003
3411
|
if (doc.features.length !== before)
|
|
3004
3412
|
deleted = true;
|
|
3005
3413
|
return doc;
|
|
3006
3414
|
});
|
|
3007
|
-
if (!deleted) {
|
|
3008
|
-
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3009
|
-
}
|
|
3010
3415
|
let cascadeCount = 0;
|
|
3011
3416
|
if (params.cascade) {
|
|
3012
3417
|
const phases = await st.loadAllPhases();
|
|
3013
|
-
for (const phase of phases.filter((p) => p.featureId ===
|
|
3418
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
3014
3419
|
await st.deletePhase(phase.id);
|
|
3015
3420
|
cascadeCount += 1;
|
|
3016
3421
|
}
|
|
@@ -3023,40 +3428,57 @@ export default function planPiExtension(pi) {
|
|
|
3023
3428
|
pi.registerTool({
|
|
3024
3429
|
name: "phase_list",
|
|
3025
3430
|
label: "Phase List",
|
|
3026
|
-
description: "List
|
|
3431
|
+
description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3027
3432
|
parameters: Type.Object({
|
|
3028
|
-
|
|
3433
|
+
featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
|
|
3434
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3029
3435
|
}),
|
|
3030
3436
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3031
3437
|
const st = await requirePlan(ctx);
|
|
3032
3438
|
if (!st)
|
|
3033
3439
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3440
|
+
const features = (await st.loadFeatures()).features;
|
|
3034
3441
|
let phases = await st.loadAllPhases();
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3442
|
+
const fref = params.featureRef?.trim();
|
|
3443
|
+
if (fref) {
|
|
3444
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3445
|
+
if (!f)
|
|
3446
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3447
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3448
|
+
}
|
|
3449
|
+
if (params.status)
|
|
3450
|
+
phases = phases.filter((p) => p.status === params.status);
|
|
3451
|
+
const summary = phases.map((p) => {
|
|
3452
|
+
const fNum = featureNumberOfPhase(p, features);
|
|
3453
|
+
const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
|
|
3454
|
+
return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
|
|
3455
|
+
}).join("\n");
|
|
3456
|
+
return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
|
|
3039
3457
|
},
|
|
3040
3458
|
});
|
|
3041
3459
|
pi.registerTool({
|
|
3042
3460
|
name: "phase_get",
|
|
3043
3461
|
label: "Phase Get",
|
|
3044
|
-
description: "
|
|
3462
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3045
3463
|
parameters: Type.Object({
|
|
3046
|
-
phaseId: Type.String({ description: "Phase
|
|
3464
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3465
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
3047
3466
|
}),
|
|
3048
3467
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3049
3468
|
const st = await requirePlan(ctx);
|
|
3050
3469
|
if (!st)
|
|
3051
3470
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
}
|
|
3056
|
-
catch {
|
|
3471
|
+
const features = (await st.loadFeatures()).features;
|
|
3472
|
+
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3473
|
+
if (!phase)
|
|
3057
3474
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3058
|
-
|
|
3059
|
-
|
|
3475
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3476
|
+
const reqCount = linkedRequirements.length;
|
|
3477
|
+
const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
|
|
3478
|
+
const requirementsBlock = reqCount > 0
|
|
3479
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3480
|
+
: "";
|
|
3481
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3060
3482
|
},
|
|
3061
3483
|
});
|
|
3062
3484
|
pi.registerTool({
|
|
@@ -3076,18 +3498,27 @@ export default function planPiExtension(pi) {
|
|
|
3076
3498
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3077
3499
|
if (!params.featureId?.trim())
|
|
3078
3500
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3501
|
+
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
3502
|
+
const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
|
|
3503
|
+
if (!resolvedFeature.ok)
|
|
3504
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3505
|
+
const featureId = resolvedFeature.feature.id;
|
|
3506
|
+
const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
|
|
3507
|
+
if (!featureValidation.ok)
|
|
3508
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3509
|
+
const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
|
|
3510
|
+
if (!existingFeature)
|
|
3511
|
+
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3079
3512
|
let phase;
|
|
3080
|
-
await withFeatureLock(
|
|
3081
|
-
const
|
|
3082
|
-
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3083
|
-
const number = featurePhases.reduce((max, p) => Math.max(max, p.number), 0) + 1;
|
|
3513
|
+
await withFeatureLock(featureId, async () => {
|
|
3514
|
+
const number = await st.allocPhaseNumber();
|
|
3084
3515
|
const id = createPhaseId();
|
|
3085
3516
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3086
|
-
const priority = await st.nextPriority("phase",
|
|
3517
|
+
const priority = await st.nextPriority("phase", featureId);
|
|
3087
3518
|
const now = nowISO();
|
|
3088
3519
|
phase = {
|
|
3089
3520
|
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3090
|
-
featureId
|
|
3521
|
+
featureId,
|
|
3091
3522
|
status: params.status ?? "draft",
|
|
3092
3523
|
discussedAt: "",
|
|
3093
3524
|
contextReady: false,
|
|
@@ -3097,11 +3528,14 @@ export default function planPiExtension(pi) {
|
|
|
3097
3528
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3098
3529
|
createdAt: now, updatedAt: now,
|
|
3099
3530
|
handoff: "", handoffUpdatedAt: "",
|
|
3531
|
+
handoffReadAt: "",
|
|
3532
|
+
handoffHistory: [],
|
|
3533
|
+
statusLog: [],
|
|
3100
3534
|
};
|
|
3101
3535
|
await st.savePhase(phase);
|
|
3102
3536
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3103
3537
|
await st.updateFeatures((doc) => {
|
|
3104
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3538
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3105
3539
|
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3106
3540
|
feature.phaseIds.push(phase.id);
|
|
3107
3541
|
feature.updatedAt = now;
|
|
@@ -3112,7 +3546,8 @@ export default function planPiExtension(pi) {
|
|
|
3112
3546
|
});
|
|
3113
3547
|
if (!phase)
|
|
3114
3548
|
return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
|
|
3115
|
-
|
|
3549
|
+
const phaseCreateFeatures = (await st.loadFeatures()).features;
|
|
3550
|
+
return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3116
3551
|
},
|
|
3117
3552
|
});
|
|
3118
3553
|
pi.registerTool({
|
|
@@ -3120,7 +3555,7 @@ export default function planPiExtension(pi) {
|
|
|
3120
3555
|
label: "Phase Update",
|
|
3121
3556
|
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.",
|
|
3122
3557
|
parameters: Type.Object({
|
|
3123
|
-
phaseId: Type.String({ description: "Phase
|
|
3558
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3124
3559
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3125
3560
|
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." })),
|
|
3126
3561
|
summary: Type.Optional(Type.String({ description: "New summary" })),
|
|
@@ -3147,14 +3582,20 @@ export default function planPiExtension(pi) {
|
|
|
3147
3582
|
const st = await requirePlan(ctx);
|
|
3148
3583
|
if (!st)
|
|
3149
3584
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3585
|
+
const features = (await st.loadFeatures()).features;
|
|
3586
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3587
|
+
if (!resolvedPhase)
|
|
3588
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3589
|
+
const phaseId = resolvedPhase.id;
|
|
3150
3590
|
let phase;
|
|
3151
3591
|
try {
|
|
3152
|
-
phase = await st.loadPhase(
|
|
3592
|
+
phase = await st.loadPhase(phaseId);
|
|
3153
3593
|
}
|
|
3154
3594
|
catch {
|
|
3155
3595
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3156
3596
|
}
|
|
3157
3597
|
const prevFeatureId = phase.featureId;
|
|
3598
|
+
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3158
3599
|
if (params.title !== undefined) {
|
|
3159
3600
|
phase.title = params.title;
|
|
3160
3601
|
phase.slug = normalizeSlug(params.title);
|
|
@@ -3184,14 +3625,22 @@ export default function planPiExtension(pi) {
|
|
|
3184
3625
|
if (params.completionCriteria !== undefined)
|
|
3185
3626
|
phase.completionCriteria = params.completionCriteria;
|
|
3186
3627
|
if (params.featureId !== undefined) {
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3628
|
+
if (params.featureId === "") {
|
|
3629
|
+
nextFeatureIdForUpdate = undefined;
|
|
3630
|
+
}
|
|
3631
|
+
else {
|
|
3632
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3633
|
+
if (!resolvedFeature.ok)
|
|
3634
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3635
|
+
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3636
|
+
}
|
|
3637
|
+
phase.featureId = nextFeatureIdForUpdate;
|
|
3638
|
+
if (nextFeatureIdForUpdate !== prevFeatureId) {
|
|
3190
3639
|
await st.updateFeatures((doc) => {
|
|
3191
3640
|
for (const f of doc.features) {
|
|
3192
3641
|
if (f.id === prevFeatureId)
|
|
3193
3642
|
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3194
|
-
if (f.id ===
|
|
3643
|
+
if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
|
|
3195
3644
|
f.phaseIds.push(phase.id);
|
|
3196
3645
|
}
|
|
3197
3646
|
return doc;
|
|
@@ -3201,7 +3650,79 @@ export default function planPiExtension(pi) {
|
|
|
3201
3650
|
phase.updatedAt = nowISO();
|
|
3202
3651
|
await st.savePhase(phase);
|
|
3203
3652
|
await st.writeGenerated();
|
|
3204
|
-
|
|
3653
|
+
const phaseUpdateFeatures = (await st.loadFeatures()).features;
|
|
3654
|
+
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3655
|
+
},
|
|
3656
|
+
});
|
|
3657
|
+
pi.registerTool({
|
|
3658
|
+
name: "decision_record",
|
|
3659
|
+
label: "Decision Record",
|
|
3660
|
+
description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
|
|
3661
|
+
parameters: Type.Object({
|
|
3662
|
+
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
3663
|
+
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
3664
|
+
title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
|
|
3665
|
+
decision: Type.String({ minLength: 1, description: "The agreed decision" }),
|
|
3666
|
+
rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
|
|
3667
|
+
implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
|
|
3668
|
+
}),
|
|
3669
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3670
|
+
const st = await requirePlan(ctx);
|
|
3671
|
+
if (!st)
|
|
3672
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3673
|
+
const features = (await st.loadFeatures()).features;
|
|
3674
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3675
|
+
if (!resolvedFeature.ok)
|
|
3676
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3677
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
|
|
3678
|
+
if (!resolvedPhase)
|
|
3679
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3680
|
+
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
3681
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
3682
|
+
}
|
|
3683
|
+
const acceptedAt = nowISO();
|
|
3684
|
+
const acceptedDecision = {
|
|
3685
|
+
id: crypto.randomUUID(),
|
|
3686
|
+
title: params.title.trim(),
|
|
3687
|
+
decision: params.decision.trim(),
|
|
3688
|
+
rationale: params.rationale.trim(),
|
|
3689
|
+
implementationNotes: params.implementationNotes.trim(),
|
|
3690
|
+
acceptedAt,
|
|
3691
|
+
};
|
|
3692
|
+
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
3693
|
+
let featureWritten = false;
|
|
3694
|
+
try {
|
|
3695
|
+
await st.updateFeatures((doc) => {
|
|
3696
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3697
|
+
if (!feature)
|
|
3698
|
+
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
3699
|
+
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
3700
|
+
feature.updatedAt = acceptedAt;
|
|
3701
|
+
return doc;
|
|
3702
|
+
});
|
|
3703
|
+
featureWritten = true;
|
|
3704
|
+
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
3705
|
+
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
3706
|
+
phase.updatedAt = acceptedAt;
|
|
3707
|
+
return phase;
|
|
3708
|
+
});
|
|
3709
|
+
}
|
|
3710
|
+
catch (error) {
|
|
3711
|
+
// Best-effort compensation keeps a failed dual-write from reporting
|
|
3712
|
+
// success with a decision persisted only on the feature.
|
|
3713
|
+
if (featureWritten) {
|
|
3714
|
+
await st.updateFeatures((doc) => {
|
|
3715
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3716
|
+
if (feature)
|
|
3717
|
+
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
3718
|
+
return doc;
|
|
3719
|
+
}).catch(() => { });
|
|
3720
|
+
}
|
|
3721
|
+
throw error;
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3724
|
+
await st.writeGenerated();
|
|
3725
|
+
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
3205
3726
|
},
|
|
3206
3727
|
});
|
|
3207
3728
|
pi.registerTool({
|
|
@@ -3209,25 +3730,30 @@ export default function planPiExtension(pi) {
|
|
|
3209
3730
|
label: "Phase Delete",
|
|
3210
3731
|
description: "Delete a phase. Its tasks are deleted with it (cascade). Unlinks the phase from its feature.",
|
|
3211
3732
|
parameters: Type.Object({
|
|
3212
|
-
phaseId: Type.String({ description: "Phase
|
|
3733
|
+
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title to delete" }),
|
|
3213
3734
|
}),
|
|
3214
3735
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3215
3736
|
const st = await requirePlan(ctx);
|
|
3216
3737
|
if (!st)
|
|
3217
3738
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3739
|
+
const features = (await st.loadFeatures()).features;
|
|
3740
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3741
|
+
if (!resolvedPhase)
|
|
3742
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3743
|
+
const phaseId = resolvedPhase.id;
|
|
3218
3744
|
let phase;
|
|
3219
3745
|
try {
|
|
3220
|
-
phase = await st.loadPhase(
|
|
3746
|
+
phase = await st.loadPhase(phaseId);
|
|
3221
3747
|
}
|
|
3222
3748
|
catch {
|
|
3223
3749
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3224
3750
|
}
|
|
3225
|
-
await st.deletePhase(
|
|
3751
|
+
await st.deletePhase(phaseId);
|
|
3226
3752
|
if (phase.featureId) {
|
|
3227
3753
|
await st.updateFeatures((doc) => {
|
|
3228
3754
|
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3229
3755
|
if (feature) {
|
|
3230
|
-
feature.phaseIds = feature.phaseIds.filter((pid) => pid !==
|
|
3756
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
3231
3757
|
feature.updatedAt = nowISO();
|
|
3232
3758
|
}
|
|
3233
3759
|
return doc;
|
|
@@ -3241,42 +3767,64 @@ export default function planPiExtension(pi) {
|
|
|
3241
3767
|
pi.registerTool({
|
|
3242
3768
|
name: "task_list",
|
|
3243
3769
|
label: "Task List",
|
|
3244
|
-
description: "List tasks
|
|
3770
|
+
description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
|
|
3245
3771
|
parameters: Type.Object({
|
|
3246
|
-
|
|
3772
|
+
featureRef: Type.Optional(Type.String({})),
|
|
3773
|
+
phaseRef: Type.Optional(Type.String({})),
|
|
3774
|
+
status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
|
|
3247
3775
|
}),
|
|
3248
3776
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3249
3777
|
const st = await requirePlan(ctx);
|
|
3250
3778
|
if (!st)
|
|
3251
3779
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3780
|
+
const features = (await st.loadFeatures()).features;
|
|
3781
|
+
let phases = await st.loadAllPhases();
|
|
3782
|
+
const fref = params.featureRef?.trim();
|
|
3783
|
+
if (fref) {
|
|
3784
|
+
const f = features.find((x) => formatFeatureRef(x.number) === fref || x.shortId === fref || x.id === fref || x.name.toLowerCase() === fref.toLowerCase());
|
|
3785
|
+
if (!f)
|
|
3786
|
+
return { content: [{ type: "text", text: `Feature not found: ${fref}` }], details: {} };
|
|
3787
|
+
phases = phases.filter((p) => p.featureId === f.id);
|
|
3255
3788
|
}
|
|
3256
|
-
|
|
3257
|
-
|
|
3789
|
+
const pref = params.phaseRef?.trim();
|
|
3790
|
+
if (pref) {
|
|
3791
|
+
const p = findPhaseByRef(phases, features, pref);
|
|
3792
|
+
if (!p)
|
|
3793
|
+
return { content: [{ type: "text", text: `Phase not found: ${pref}` }], details: {} };
|
|
3794
|
+
phases = [p];
|
|
3258
3795
|
}
|
|
3259
|
-
const
|
|
3260
|
-
|
|
3796
|
+
const out = [];
|
|
3797
|
+
for (const phase of phases) {
|
|
3798
|
+
for (const task of phase.tasks) {
|
|
3799
|
+
if (params.status && task.status !== params.status)
|
|
3800
|
+
continue;
|
|
3801
|
+
out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
|
|
3261
3805
|
},
|
|
3262
3806
|
});
|
|
3263
3807
|
pi.registerTool({
|
|
3264
3808
|
name: "task_get",
|
|
3265
3809
|
label: "Task Get",
|
|
3266
|
-
description: "
|
|
3810
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
|
|
3267
3811
|
parameters: Type.Object({
|
|
3268
|
-
taskId: Type.String({ description: "Task
|
|
3812
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3813
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
|
|
3269
3814
|
}),
|
|
3270
3815
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3271
3816
|
const st = await requirePlan(ctx);
|
|
3272
3817
|
if (!st)
|
|
3273
3818
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3274
|
-
const
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3277
|
-
if (!task || !phase)
|
|
3819
|
+
const features = (await st.loadFeatures()).features;
|
|
3820
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3821
|
+
if (!found)
|
|
3278
3822
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3279
|
-
|
|
3823
|
+
const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
3824
|
+
if (!params.full)
|
|
3825
|
+
return { content: [{ type: "text", text: summary }], details: {} };
|
|
3826
|
+
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3827
|
+
return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
|
|
3280
3828
|
},
|
|
3281
3829
|
});
|
|
3282
3830
|
pi.registerTool({
|
|
@@ -3284,32 +3832,52 @@ export default function planPiExtension(pi) {
|
|
|
3284
3832
|
label: "Task Create",
|
|
3285
3833
|
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.",
|
|
3286
3834
|
parameters: Type.Object({
|
|
3835
|
+
featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
|
|
3287
3836
|
phaseId: Type.String({ description: "Phase ID the task belongs to" }),
|
|
3288
3837
|
title: Type.String({ description: "Task title" }),
|
|
3289
3838
|
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 }),
|
|
3290
3839
|
status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
|
|
3291
3840
|
shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
|
|
3841
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
|
|
3292
3842
|
}),
|
|
3293
3843
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3294
3844
|
const st = await requirePlan(ctx);
|
|
3295
3845
|
if (!st)
|
|
3296
3846
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3847
|
+
if (!params.featureId?.trim())
|
|
3848
|
+
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3849
|
+
const features = (await st.loadFeatures()).features;
|
|
3850
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
|
|
3851
|
+
if (!resolvedFeature.ok)
|
|
3852
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3853
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
3854
|
+
if (!featureValidation.ok)
|
|
3855
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3856
|
+
const feature = resolvedFeature.feature;
|
|
3857
|
+
const existingFeature = features.find((f) => f.id === feature.id);
|
|
3858
|
+
if (!existingFeature)
|
|
3859
|
+
return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
|
|
3860
|
+
// Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
|
|
3861
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3862
|
+
if (!resolvedPhase)
|
|
3302
3863
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3303
|
-
|
|
3864
|
+
if (resolvedPhase.featureId !== feature.id)
|
|
3865
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(feature.number)}. Refusing to create task.` }], details: {} };
|
|
3866
|
+
const phaseId = resolvedPhase.id;
|
|
3867
|
+
const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
|
|
3868
|
+
if (!phaseValidation.ok)
|
|
3869
|
+
return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
|
|
3870
|
+
const existingPhase = await st.loadPhase(phaseId);
|
|
3871
|
+
if (!existingPhase)
|
|
3872
|
+
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3304
3873
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3305
3874
|
const taskId = createTaskId();
|
|
3306
3875
|
const shortId = createShortId(await st.assignedShortIds());
|
|
3307
|
-
const priority = await st.nextPriority("task",
|
|
3876
|
+
const priority = await st.nextPriority("task", phaseId);
|
|
3308
3877
|
const now = nowISO();
|
|
3309
3878
|
const initialStatus = params.status ?? "planned";
|
|
3310
|
-
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3311
3879
|
const task = {
|
|
3312
|
-
id: taskId, phaseId
|
|
3880
|
+
id: taskId, phaseId, number: await st.allocTaskNumber(), shortId, priority, shortName,
|
|
3313
3881
|
title: params.title,
|
|
3314
3882
|
status: initialStatus,
|
|
3315
3883
|
description: params.description ?? "",
|
|
@@ -3317,7 +3885,10 @@ export default function planPiExtension(pi) {
|
|
|
3317
3885
|
statusLog: [],
|
|
3318
3886
|
decisions: [],
|
|
3319
3887
|
acceptedDecisions: [],
|
|
3320
|
-
checklist:
|
|
3888
|
+
checklist: params.checklist
|
|
3889
|
+
? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
|
|
3890
|
+
: [],
|
|
3891
|
+
subtasks: [],
|
|
3321
3892
|
dependsOn: [],
|
|
3322
3893
|
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3323
3894
|
completedAt: initialStatus === "done" ? now : "",
|
|
@@ -3325,14 +3896,15 @@ export default function planPiExtension(pi) {
|
|
|
3325
3896
|
};
|
|
3326
3897
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3327
3898
|
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3328
|
-
await st.updatePhase(
|
|
3899
|
+
await st.updatePhase(phaseId, (phase) => {
|
|
3329
3900
|
phase.tasks.push(task);
|
|
3330
3901
|
phase.taskIds.push(taskId);
|
|
3331
3902
|
phase.updatedAt = now;
|
|
3332
3903
|
return phase;
|
|
3333
3904
|
});
|
|
3334
3905
|
await st.writeGenerated();
|
|
3335
|
-
|
|
3906
|
+
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
3907
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
3336
3908
|
},
|
|
3337
3909
|
});
|
|
3338
3910
|
pi.registerTool({
|
|
@@ -3340,7 +3912,7 @@ export default function planPiExtension(pi) {
|
|
|
3340
3912
|
label: "Task Update",
|
|
3341
3913
|
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.",
|
|
3342
3914
|
parameters: Type.Object({
|
|
3343
|
-
taskId: Type.String({ description: "Task
|
|
3915
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3344
3916
|
title: Type.Optional(Type.String({ description: "New title" })),
|
|
3345
3917
|
status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
|
|
3346
3918
|
description: Type.Optional(Type.String({ description: "New description" })),
|
|
@@ -3363,15 +3935,29 @@ export default function planPiExtension(pi) {
|
|
|
3363
3935
|
if (!st)
|
|
3364
3936
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3365
3937
|
// Find the phase hosting this task first.
|
|
3366
|
-
const
|
|
3367
|
-
const
|
|
3368
|
-
if (!
|
|
3938
|
+
const features = (await st.loadFeatures()).features;
|
|
3939
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3940
|
+
if (!found)
|
|
3369
3941
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3942
|
+
const hostPhase = found.phase;
|
|
3370
3943
|
const now = nowISO();
|
|
3371
3944
|
let updatedTask;
|
|
3945
|
+
// Validate the motivation requirement for restrictive status transitions,
|
|
3946
|
+
// mirroring the MCP adapter and the /planner task update command
|
|
3947
|
+
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
3948
|
+
if (params.status !== undefined &&
|
|
3949
|
+
params.status !== found.task.status &&
|
|
3950
|
+
needsMotivation(found.task.status, params.status)) {
|
|
3951
|
+
if (!params.motivation || !params.motivation.trim()) {
|
|
3952
|
+
return {
|
|
3953
|
+
content: [{ type: "text", text: `Status transition "${found.task.status} → ${params.status}" requires a motivation. Provide the "motivation" parameter with a detailed explanation of why this change is needed.` }],
|
|
3954
|
+
details: {},
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3372
3958
|
try {
|
|
3373
3959
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3374
|
-
const task = phase.tasks.find((t) => t.id ===
|
|
3960
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3375
3961
|
if (!task)
|
|
3376
3962
|
return phase;
|
|
3377
3963
|
if (params.title !== undefined)
|
|
@@ -3387,7 +3973,7 @@ export default function planPiExtension(pi) {
|
|
|
3387
3973
|
if (params.acceptedDecisions !== undefined)
|
|
3388
3974
|
task.acceptedDecisions = params.acceptedDecisions;
|
|
3389
3975
|
if (params.checklist !== undefined) {
|
|
3390
|
-
task.checklist = params.checklist.map((itemTitle, index) => ({
|
|
3976
|
+
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
3391
3977
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
3392
3978
|
title: itemTitle,
|
|
3393
3979
|
checked: false,
|
|
@@ -3419,6 +4005,7 @@ export default function planPiExtension(pi) {
|
|
|
3419
4005
|
}
|
|
3420
4006
|
if (!updatedTask)
|
|
3421
4007
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4008
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
3422
4009
|
await st.writeGenerated();
|
|
3423
4010
|
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
|
|
3424
4011
|
},
|
|
@@ -3428,19 +4015,19 @@ export default function planPiExtension(pi) {
|
|
|
3428
4015
|
label: "Task Delete",
|
|
3429
4016
|
description: "Delete a task from its phase.",
|
|
3430
4017
|
parameters: Type.Object({
|
|
3431
|
-
taskId: Type.String({ description: "Task
|
|
4018
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to delete" }),
|
|
3432
4019
|
}),
|
|
3433
4020
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3434
4021
|
const st = await requirePlan(ctx);
|
|
3435
4022
|
if (!st)
|
|
3436
4023
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3437
|
-
const
|
|
3438
|
-
const
|
|
3439
|
-
if (!
|
|
4024
|
+
const features = (await st.loadFeatures()).features;
|
|
4025
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4026
|
+
if (!found)
|
|
3440
4027
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3441
|
-
await st.updatePhase(
|
|
3442
|
-
phase.tasks = phase.tasks.filter((t) => t.id !==
|
|
3443
|
-
phase.taskIds = phase.taskIds.filter((id) => id !==
|
|
4028
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4029
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
|
|
4030
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
|
|
3444
4031
|
phase.updatedAt = nowISO();
|
|
3445
4032
|
return phase;
|
|
3446
4033
|
});
|
|
@@ -3449,30 +4036,99 @@ export default function planPiExtension(pi) {
|
|
|
3449
4036
|
},
|
|
3450
4037
|
});
|
|
3451
4038
|
// ── task start / complete tools ──────────────────────────────────
|
|
4039
|
+
pi.registerTool({
|
|
4040
|
+
name: "task_recommend",
|
|
4041
|
+
label: "Task Recommend",
|
|
4042
|
+
description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature → phase → task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
|
|
4043
|
+
parameters: Type.Object({}),
|
|
4044
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4045
|
+
const st = await requirePlan(ctx);
|
|
4046
|
+
if (!st)
|
|
4047
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4048
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4049
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4050
|
+
if (!result.candidate)
|
|
4051
|
+
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4052
|
+
const { candidate } = result;
|
|
4053
|
+
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
4054
|
+
return {
|
|
4055
|
+
content: [{ type: "text", text: `Recommended (${result.kind}): ${reference} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}` }],
|
|
4056
|
+
details: result,
|
|
4057
|
+
};
|
|
4058
|
+
},
|
|
4059
|
+
});
|
|
4060
|
+
pi.registerTool({
|
|
4061
|
+
name: "task_deviation",
|
|
4062
|
+
label: "Task Deviation",
|
|
4063
|
+
description: "Record a user-approved temporary task deviation. It preserves the recommended/resume task but never starts, pauses, or blocks work; use normal task lifecycle tools for those transitions.",
|
|
4064
|
+
parameters: Type.Object({
|
|
4065
|
+
temporary_task: Type.String({ description: "Temporary task ref" }),
|
|
4066
|
+
resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
|
|
4067
|
+
reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
|
|
4068
|
+
}),
|
|
4069
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4070
|
+
const st = await requirePlan(ctx);
|
|
4071
|
+
if (!st)
|
|
4072
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4073
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4074
|
+
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4075
|
+
if (!temporary)
|
|
4076
|
+
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4077
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4078
|
+
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4079
|
+
if (!resume)
|
|
4080
|
+
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
4081
|
+
if (temporary.task.id === resume.task.id)
|
|
4082
|
+
return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
|
|
4083
|
+
if (temporary.task.status !== "planned")
|
|
4084
|
+
return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
|
|
4085
|
+
const timestamp = nowISO();
|
|
4086
|
+
const record = { id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id, temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason: params.reason, requestedBy: "user", approvedBy: "user", state: "approved", createdAt: timestamp, activatedAt: "", resolvedAt: "" };
|
|
4087
|
+
await st.addWorkDeviation(record);
|
|
4088
|
+
await st.writeGenerated();
|
|
4089
|
+
const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
|
|
4090
|
+
const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
|
|
4091
|
+
return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
|
|
4092
|
+
},
|
|
4093
|
+
});
|
|
3452
4094
|
pi.registerTool({
|
|
3453
4095
|
name: "task_start",
|
|
3454
4096
|
label: "Task Start",
|
|
3455
|
-
description: "Set a task to in-progress.
|
|
4097
|
+
description: "Set a task to in-progress. BEFORE calling, read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true). Continue an already in-progress task; otherwise select ready work by feature → phase → task priority (lower number first), respecting dependencies and blocked/waiting states. Sets startedAt automatically and returns the ordered feature/phase requirements briefing.",
|
|
3456
4098
|
parameters: Type.Object({
|
|
3457
|
-
taskId: Type.String({ description: "Task
|
|
4099
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3458
4100
|
}),
|
|
3459
4101
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3460
4102
|
const st = await requirePlan(ctx);
|
|
3461
4103
|
if (!st)
|
|
3462
4104
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3463
|
-
const
|
|
3464
|
-
const
|
|
3465
|
-
|
|
3466
|
-
if (!task || !hostPhase)
|
|
4105
|
+
const features = (await st.loadFeatures()).features;
|
|
4106
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4107
|
+
if (!found)
|
|
3467
4108
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4109
|
+
const task = found.task;
|
|
3468
4110
|
if (task.status === "in-progress")
|
|
3469
4111
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3470
4112
|
if (task.status === "done")
|
|
3471
4113
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
4114
|
+
const project = await st.loadProject();
|
|
4115
|
+
const selection = recommendNextTask(features, await st.loadAllPhases(), project.workDeviations);
|
|
4116
|
+
if (!selection.candidate || selection.candidate.task.id !== task.id) {
|
|
4117
|
+
const recommended = selection.candidate ? ` Start ${selection.candidate.task.id} instead, or record a user-approved task_deviation first.` : "";
|
|
4118
|
+
return { content: [{ type: "text", text: `Task start denied: ${selection.reason}.${recommended}` }], details: selection };
|
|
4119
|
+
}
|
|
4120
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4121
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
4122
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
4123
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
4124
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
4125
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
4126
|
+
]);
|
|
4127
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
3472
4128
|
const now = nowISO();
|
|
3473
4129
|
let startedTask;
|
|
3474
|
-
await st.updatePhase(
|
|
3475
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4130
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4131
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3476
4132
|
if (!t)
|
|
3477
4133
|
return phase;
|
|
3478
4134
|
applyTaskLifecycleDates(t, "in-progress", now);
|
|
@@ -3481,10 +4137,14 @@ export default function planPiExtension(pi) {
|
|
|
3481
4137
|
startedTask = t;
|
|
3482
4138
|
return phase;
|
|
3483
4139
|
});
|
|
4140
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
4141
|
+
if (selection.deviation?.temporaryTaskId === task.id && selection.deviation.state === "approved") {
|
|
4142
|
+
await st.setWorkDeviationState(selection.deviation.id, "active", now);
|
|
4143
|
+
}
|
|
3484
4144
|
await st.writeGenerated();
|
|
3485
4145
|
if (!startedTask)
|
|
3486
4146
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3487
|
-
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)` }], details: startedTask };
|
|
4147
|
+
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.id} — ${startedTask.title} (in-progress)${phaseContext}` }], details: startedTask };
|
|
3488
4148
|
},
|
|
3489
4149
|
});
|
|
3490
4150
|
pi.registerTool({
|
|
@@ -3492,7 +4152,7 @@ export default function planPiExtension(pi) {
|
|
|
3492
4152
|
label: "Task Complete",
|
|
3493
4153
|
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
3494
4154
|
parameters: Type.Object({
|
|
3495
|
-
taskId: Type.String({ description: "Task
|
|
4155
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
3496
4156
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
3497
4157
|
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." })),
|
|
3498
4158
|
}),
|
|
@@ -3500,11 +4160,11 @@ export default function planPiExtension(pi) {
|
|
|
3500
4160
|
const st = await requirePlan(ctx);
|
|
3501
4161
|
if (!st)
|
|
3502
4162
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3503
|
-
const
|
|
3504
|
-
const
|
|
3505
|
-
|
|
3506
|
-
if (!task || !hostPhase)
|
|
4163
|
+
const features = (await st.loadFeatures()).features;
|
|
4164
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4165
|
+
if (!found)
|
|
3507
4166
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4167
|
+
const task = found.task;
|
|
3508
4168
|
if (task.status === "done")
|
|
3509
4169
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3510
4170
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -3516,8 +4176,8 @@ export default function planPiExtension(pi) {
|
|
|
3516
4176
|
}
|
|
3517
4177
|
const now = nowISO();
|
|
3518
4178
|
let completedTask;
|
|
3519
|
-
await st.updatePhase(
|
|
3520
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4179
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4180
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3521
4181
|
if (!t)
|
|
3522
4182
|
return phase;
|
|
3523
4183
|
applyTaskLifecycleDates(t, "done", now);
|
|
@@ -3530,10 +4190,135 @@ export default function planPiExtension(pi) {
|
|
|
3530
4190
|
completedTask = t;
|
|
3531
4191
|
return phase;
|
|
3532
4192
|
});
|
|
4193
|
+
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
4194
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
4195
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
|
|
4196
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4197
|
+
if (completedDeviation)
|
|
4198
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
|
|
3533
4199
|
await st.writeGenerated();
|
|
3534
4200
|
if (!completedTask)
|
|
3535
4201
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3536
|
-
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
|
|
4202
|
+
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
|
|
4203
|
+
},
|
|
4204
|
+
});
|
|
4205
|
+
pi.registerTool({
|
|
4206
|
+
name: "task_checklist_toggle",
|
|
4207
|
+
label: "Task Checklist Toggle",
|
|
4208
|
+
description: "Tick/untick a task checklist item (a 'step') without rewriting the whole list. Accepts the item as C{n} (e.g. C2), item id, or title (case-insensitive, first match). Pass checked=true/false to set explicitly, or omit to toggle. Use this instead of writing DONE in step titles. Items are numbered C1/C2… per task.",
|
|
4209
|
+
parameters: Type.Object({
|
|
4210
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4211
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4212
|
+
checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
|
|
4213
|
+
}),
|
|
4214
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4215
|
+
const st = await requirePlan(ctx);
|
|
4216
|
+
if (!st)
|
|
4217
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4218
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4219
|
+
if (!found)
|
|
4220
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4221
|
+
let result = "";
|
|
4222
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4223
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4224
|
+
if (!task) {
|
|
4225
|
+
result = `Task not found: ${params.taskId}`;
|
|
4226
|
+
return phase;
|
|
4227
|
+
}
|
|
4228
|
+
const ck = task.checklist ?? [];
|
|
4229
|
+
if (ck.length === 0) {
|
|
4230
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4231
|
+
return phase;
|
|
4232
|
+
}
|
|
4233
|
+
const target = toggleChecklistItem(ck, params.item, params.checked);
|
|
4234
|
+
if (!target) {
|
|
4235
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4236
|
+
return phase;
|
|
4237
|
+
}
|
|
4238
|
+
task.updatedAt = nowISO();
|
|
4239
|
+
phase.updatedAt = nowISO();
|
|
4240
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
4241
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
4242
|
+
return phase;
|
|
4243
|
+
});
|
|
4244
|
+
await st.writeGenerated();
|
|
4245
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4246
|
+
},
|
|
4247
|
+
});
|
|
4248
|
+
pi.registerTool({
|
|
4249
|
+
name: "task_checklist_add",
|
|
4250
|
+
label: "Task Checklist Add",
|
|
4251
|
+
description: "Add a single checklist item (a 'step') to a task without rewriting the list. The new item is appended as C{n} (next progressive number, stable id, unchecked). Use this to subdivide a task into smaller steps INSTEAD of spawning sub-tasks — the checklist keeps description, notes, statusLog and steps concentrated in one task (sub-tasks disperse context). Also use for granular adds instead of replacing the whole checklist via task_update.",
|
|
4252
|
+
parameters: Type.Object({
|
|
4253
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4254
|
+
title: Type.String({ description: "Checklist item text (a single step)" }),
|
|
4255
|
+
}),
|
|
4256
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4257
|
+
const st = await requirePlan(ctx);
|
|
4258
|
+
if (!st)
|
|
4259
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4260
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4261
|
+
if (!found)
|
|
4262
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4263
|
+
let result = "";
|
|
4264
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4265
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4266
|
+
if (!task) {
|
|
4267
|
+
result = `Task not found: ${params.taskId}`;
|
|
4268
|
+
return phase;
|
|
4269
|
+
}
|
|
4270
|
+
const ck = task.checklist ?? [];
|
|
4271
|
+
const item = addChecklistItem(ck, task.id, params.title);
|
|
4272
|
+
ck.push(item);
|
|
4273
|
+
task.checklist = ck;
|
|
4274
|
+
task.updatedAt = nowISO();
|
|
4275
|
+
phase.updatedAt = nowISO();
|
|
4276
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
4277
|
+
return phase;
|
|
4278
|
+
});
|
|
4279
|
+
await st.writeGenerated();
|
|
4280
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4281
|
+
},
|
|
4282
|
+
});
|
|
4283
|
+
pi.registerTool({
|
|
4284
|
+
name: "task_checklist_remove",
|
|
4285
|
+
label: "Task Checklist Remove",
|
|
4286
|
+
description: "Remove a single checklist item (a 'step') from a task by C{n} (e.g. C2), item id, or title (case-insensitive). Remaining items are renumbered C1..Cn for readability; their stable ids are preserved. Use this for granular removes instead of replacing the whole checklist via task_update.",
|
|
4287
|
+
parameters: Type.Object({
|
|
4288
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4289
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4290
|
+
}),
|
|
4291
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4292
|
+
const st = await requirePlan(ctx);
|
|
4293
|
+
if (!st)
|
|
4294
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4295
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4296
|
+
if (!found)
|
|
4297
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4298
|
+
let result = "";
|
|
4299
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4300
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4301
|
+
if (!task) {
|
|
4302
|
+
result = `Task not found: ${params.taskId}`;
|
|
4303
|
+
return phase;
|
|
4304
|
+
}
|
|
4305
|
+
const ck = task.checklist ?? [];
|
|
4306
|
+
if (ck.length === 0) {
|
|
4307
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4308
|
+
return phase;
|
|
4309
|
+
}
|
|
4310
|
+
const removed = removeChecklistItem(ck, params.item);
|
|
4311
|
+
if (!removed) {
|
|
4312
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4313
|
+
return phase;
|
|
4314
|
+
}
|
|
4315
|
+
task.updatedAt = nowISO();
|
|
4316
|
+
phase.updatedAt = nowISO();
|
|
4317
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
4318
|
+
return phase;
|
|
4319
|
+
});
|
|
4320
|
+
await st.writeGenerated();
|
|
4321
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
3537
4322
|
},
|
|
3538
4323
|
});
|
|
3539
4324
|
// ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
|
|
@@ -3610,7 +4395,7 @@ export default function planPiExtension(pi) {
|
|
|
3610
4395
|
description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
|
|
3611
4396
|
parameters: Type.Object({}),
|
|
3612
4397
|
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
3613
|
-
|
|
4398
|
+
disablePlannerSession();
|
|
3614
4399
|
await stopServer().catch(() => { });
|
|
3615
4400
|
try {
|
|
3616
4401
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -3632,6 +4417,7 @@ export default function planPiExtension(pi) {
|
|
|
3632
4417
|
// add seconds of latency on every turn if run unconditionally.
|
|
3633
4418
|
if (!plannerHeavyInitDone) {
|
|
3634
4419
|
await migrateToUuids(st);
|
|
4420
|
+
await migrateToGlobalSequence(st).catch(() => null);
|
|
3635
4421
|
await st.ensureShortIdsAndPriority().catch(() => null);
|
|
3636
4422
|
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3637
4423
|
await maybeHealStatuses(st, ctx);
|
|
@@ -3664,8 +4450,9 @@ export default function planPiExtension(pi) {
|
|
|
3664
4450
|
const profile = await st.loadCodebaseProfile();
|
|
3665
4451
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3666
4452
|
const activity = await st.loadActivityLog();
|
|
3667
|
-
const recentActivity = activity.entries.slice(-
|
|
4453
|
+
const recentActivity = activity.entries.slice(-3).reverse();
|
|
3668
4454
|
const handoffs = await st.listHandoffs();
|
|
4455
|
+
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
3669
4456
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3670
4457
|
const orderedPhases = [
|
|
3671
4458
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3678,21 +4465,9 @@ export default function planPiExtension(pi) {
|
|
|
3678
4465
|
...plan.phases.filter((phase) => !phase.featureId)
|
|
3679
4466
|
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3680
4467
|
];
|
|
3681
|
-
const
|
|
3682
|
-
.
|
|
3683
|
-
.
|
|
3684
|
-
const active = orderedPhases
|
|
3685
|
-
.filter((p) => p.status === "in-progress")
|
|
3686
|
-
.map((p) => `\`${p.id}\``)
|
|
3687
|
-
.join(", ");
|
|
3688
|
-
const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
|
|
3689
|
-
const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
|
|
3690
|
-
const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
|
|
3691
|
-
const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
|
|
3692
|
-
const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
3693
|
-
const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
|
|
3694
|
-
const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
|
|
3695
|
-
const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
|
|
4468
|
+
const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
|
|
4469
|
+
|| plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
|
|
4470
|
+
|| orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
3696
4471
|
const currentPhase = hasActiveWork
|
|
3697
4472
|
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3698
4473
|
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
@@ -3709,16 +4484,51 @@ export default function planPiExtension(pi) {
|
|
|
3709
4484
|
?? null)
|
|
3710
4485
|
: null;
|
|
3711
4486
|
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3712
|
-
?
|
|
4487
|
+
? featureById.get(currentPhase.featureId) ?? null
|
|
3713
4488
|
: hasActiveWork
|
|
3714
4489
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3715
4490
|
: null;
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
4491
|
+
const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
|
|
4492
|
+
const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
|
|
4493
|
+
const currentTaskRef = currentTask && currentPhase
|
|
4494
|
+
? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
|
|
4495
|
+
: "";
|
|
4496
|
+
const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
|
|
4497
|
+
const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
|
|
4498
|
+
for (const phase of plan.phases) {
|
|
4499
|
+
phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
|
|
4500
|
+
}
|
|
4501
|
+
const phaseCountsSummary = phaseStatusOrder
|
|
4502
|
+
.map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
|
|
4503
|
+
.join(" ");
|
|
4504
|
+
const activeTaskMap = new Map();
|
|
4505
|
+
for (const phase of orderedPhases) {
|
|
4506
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4507
|
+
for (const task of phase.tasks) {
|
|
4508
|
+
if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
|
|
4509
|
+
continue;
|
|
4510
|
+
activeTaskMap.set(task.id, { phase, task, featureNumber });
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
const inProgressTaskLines = [...activeTaskMap.values()]
|
|
4514
|
+
.sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
|
|
4515
|
+
.map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
|
|
4516
|
+
const otherActiveOrBlockedPhases = orderedPhases
|
|
4517
|
+
.filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
|
|
4518
|
+
.slice(0, 6)
|
|
4519
|
+
.map((phase) => {
|
|
4520
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4521
|
+
return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
|
|
4522
|
+
});
|
|
4523
|
+
const openQuestions = plan.phases
|
|
4524
|
+
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
4525
|
+
.slice(0, 8);
|
|
4526
|
+
const acceptedDecisions = [
|
|
4527
|
+
...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
|
|
4528
|
+
...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
|
|
4529
|
+
...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
|
|
4530
|
+
...plan.phases.flatMap((phase) => phase.tasks.flatMap((task) => task.acceptedDecisions.map((decision) => `[task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}] ${decision.title}`))),
|
|
4531
|
+
].slice(0, 20);
|
|
3722
4532
|
const webServer = server;
|
|
3723
4533
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3724
4534
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3731,15 +4541,6 @@ export default function planPiExtension(pi) {
|
|
|
3731
4541
|
catch { }
|
|
3732
4542
|
const webUrl = webLocalUrl;
|
|
3733
4543
|
const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
|
|
3734
|
-
const openQuestions = plan.phases
|
|
3735
|
-
.flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
|
|
3736
|
-
.slice(0, 12);
|
|
3737
|
-
const acceptedDecisions = [
|
|
3738
|
-
...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
|
|
3739
|
-
...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
|
|
3740
|
-
...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
|
|
3741
|
-
...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
|
|
3742
|
-
].slice(0, 20);
|
|
3743
4544
|
const pkgSummary = profile?.packageJson
|
|
3744
4545
|
? `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}`
|
|
3745
4546
|
: "(no package.json)";
|
|
@@ -3761,96 +4562,63 @@ export default function planPiExtension(pi) {
|
|
|
3761
4562
|
// Build/refresh the context block (slow path: cache miss).
|
|
3762
4563
|
const contextBlock = [
|
|
3763
4564
|
`[Plan Context — ${project.name}]`,
|
|
3764
|
-
// ── Web UI status (prominent — agent must surface URL in its summary) ──
|
|
3765
4565
|
webUrl ? `🌐 WEB UI RUNNING: ${webUrlFull}${webPort ? ` (port ${webPort})` : ""}. Include this address+port at the end of your resume summary.` : `Web UI: not running. Start it with '/planner web start'.`,
|
|
3766
4566
|
"",
|
|
3767
|
-
|
|
3768
|
-
"",
|
|
3769
|
-
"
|
|
3770
|
-
"
|
|
3771
|
-
"
|
|
3772
|
-
"
|
|
3773
|
-
"
|
|
3774
|
-
"
|
|
3775
|
-
"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.",
|
|
3776
|
-
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3777
|
-
"6. ENTITY REFERENCES: When you mention a feature/phase/task in chat, use its composite ID (F00x / P00x / T00x, e.g. T003(P002/F001)) or its 5-char shortId (e.g. UUXD1) — NEVER a raw UUID like bd6ed366. Tool responses already show these refs (compositeRef, shortId); reuse them.",
|
|
3778
|
-
"═══════════════════════════════════════════════════════════════",
|
|
4567
|
+
"Operational rules:",
|
|
4568
|
+
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4569
|
+
"- BEFORE work: read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true); only then call task_start before any code edit/write. AFTER finishing a task: task_complete.",
|
|
4570
|
+
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4571
|
+
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4572
|
+
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4573
|
+
"- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
|
|
4574
|
+
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
3779
4575
|
"",
|
|
3780
|
-
// ── Project details ──
|
|
3781
4576
|
`Goal: ${project.goal || "(not set)"}`,
|
|
3782
4577
|
project.description ? `Description: ${project.description}` : "",
|
|
3783
4578
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
3784
4579
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
3785
4580
|
`Ambient: ${ambientSummary}`,
|
|
3786
4581
|
"",
|
|
3787
|
-
"Global rules
|
|
3788
|
-
project.globalRules.length ? project.globalRules.map((
|
|
3789
|
-
"",
|
|
3790
|
-
"Workflow rules:",
|
|
3791
|
-
` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
|
|
3792
|
-
` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
|
|
3793
|
-
` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
4582
|
+
"Global rules:",
|
|
4583
|
+
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
3794
4584
|
"",
|
|
4585
|
+
`Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
3795
4586
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
3796
4587
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
3797
4588
|
"",
|
|
3798
4589
|
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
3799
|
-
...acceptedDecisions.map((
|
|
4590
|
+
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
3800
4591
|
openQuestions.length ? "" : "",
|
|
3801
4592
|
openQuestions.length ? "Open questions:" : "",
|
|
3802
|
-
...openQuestions.map((
|
|
4593
|
+
...openQuestions.map((question) => `- ${question}`),
|
|
3803
4594
|
"",
|
|
3804
|
-
"Resume focus (
|
|
3805
|
-
|
|
3806
|
-
|
|
4595
|
+
"Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
|
|
4596
|
+
currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
|
|
4597
|
+
currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
|
|
4598
|
+
currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
|
|
4599
|
+
inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
|
|
3807
4600
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3808
|
-
|
|
3809
|
-
|
|
4601
|
+
handoffs.length === 0 && resume.nextSteps.length
|
|
4602
|
+
? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
|
|
4603
|
+
: "",
|
|
4604
|
+
"",
|
|
4605
|
+
"CRITICAL: The 'Resume focus' pointers above are persisted across sessions. They are NOT necessarily the phase you worked on right now. When asked to write a handoff, use the phase the user explicitly identifies as the one just completed in this session, not a stale pointer.",
|
|
3810
4606
|
"",
|
|
3811
|
-
|
|
3812
|
-
|
|
4607
|
+
"Phase overview:",
|
|
4608
|
+
` counts: ${phaseCountsSummary}`,
|
|
4609
|
+
currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
|
|
4610
|
+
otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
|
|
4611
|
+
...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
|
|
3813
4612
|
"",
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
phaseLines || " _none_",
|
|
3817
|
-
active ? `Active: ${active}` : "",
|
|
4613
|
+
recentActivity.length ? "Recent activity:" : "",
|
|
4614
|
+
...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
|
|
3818
4615
|
"",
|
|
3819
|
-
"
|
|
3820
|
-
|
|
3821
|
-
"
|
|
3822
|
-
"- Feature commands: planner feature list|add|show|update|delete.",
|
|
3823
|
-
"- Phase commands: planner phase add|show|discuss|update|delete.",
|
|
3824
|
-
"- Task commands: planner task add|show|discuss|update|delete|start|complete.",
|
|
3825
|
-
"- Handoff commands: planner handoff prepare|show|write|clear.",
|
|
3826
|
-
"- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
|
|
3827
|
-
"Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
|
|
3828
|
-
"Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
|
|
3829
|
-
`Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
|
|
3830
|
-
"If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
|
|
3831
|
-
"Ask for language only if BOTH preferences are unset.",
|
|
3832
|
-
"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.",
|
|
3833
|
-
"Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
|
|
3834
|
-
"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.",
|
|
3835
|
-
"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.",
|
|
3836
|
-
"Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
|
|
4616
|
+
handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
|
|
4617
|
+
...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
|
|
4618
|
+
handoffs.length > 0 ? "Use handoff show <ref> for context. A pending handoff is a candidate to validate; do not default to it as the current focus unless it matches the phase you actually worked on in this session." : "",
|
|
3837
4619
|
"",
|
|
3838
|
-
|
|
3839
|
-
"
|
|
3840
|
-
"- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
|
|
3841
|
-
"- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
|
|
3842
|
-
"- 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.",
|
|
3843
|
-
"- 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.",
|
|
3844
|
-
"- 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.",
|
|
3845
|
-
"- 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.",
|
|
3846
|
-
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
3847
|
-
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
3848
|
-
handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
|
|
3849
|
-
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3850
|
-
handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
|
|
3851
|
-
handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
|
|
3852
|
-
handoffs.length > 0 ? "Once a handoff is fully consumed and work is safely resumed, clear it with handoff clear <ref> (or /planner handoff clear)." : "",
|
|
3853
|
-
"Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
|
|
4620
|
+
`Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
|
|
4621
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
|
|
3854
4622
|
].filter(Boolean).join("\n");
|
|
3855
4623
|
contextBlockCache = contextBlock;
|
|
3856
4624
|
contextBlockDirty = false;
|