@agent-plan/pi-adapter 0.2.19-next.9 → 0.2.20
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 +1060 -336
- package/package.json +3 -3
- package/web-ui-dist/assets/index-CTAfas5X.css +1 -0
- package/web-ui-dist/assets/index-CyrzGuWc.js +293 -0
- package/web-ui-dist/index.html +3 -2
- package/web-ui-dist/assets/index-C7etQBm0.js +0 -216
- package/web-ui-dist/assets/index-CQ9nwG4q.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,
|
|
13
|
-
import { createChecklistItemId, createFeatureId, createPhaseId,
|
|
12
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
|
|
13
|
+
import { createChecklistItemId, createFeatureId, createPhaseId, 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
|
-
"- This handoff
|
|
461
|
+
"- This handoff remains active until every task in this phase is done/canceled, a newer handoff replaces it, or the user explicitly clears it. Reading or starting a task never clears it.",
|
|
420
462
|
].join("\n");
|
|
421
463
|
}
|
|
422
464
|
async function writeProjectHandoff(st, reason, overrides) {
|
|
@@ -620,6 +662,9 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
620
662
|
return;
|
|
621
663
|
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
622
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;
|
|
623
668
|
try {
|
|
624
669
|
server = await serve({
|
|
625
670
|
port,
|
|
@@ -632,26 +677,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
632
677
|
});
|
|
633
678
|
}
|
|
634
679
|
catch (e) {
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
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");
|
|
652
697
|
}
|
|
653
|
-
|
|
654
|
-
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");
|
|
655
700
|
}
|
|
656
701
|
}
|
|
657
702
|
if (server) {
|
|
@@ -684,12 +729,26 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
684
729
|
catch { }
|
|
685
730
|
}
|
|
686
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
|
+
}
|
|
687
743
|
async function stopServer() {
|
|
688
744
|
if (server) {
|
|
689
745
|
await server.close();
|
|
690
746
|
server = null;
|
|
691
747
|
}
|
|
692
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;
|
|
693
752
|
}
|
|
694
753
|
// ─── Extension factory ──────────────────────────────────────────────────
|
|
695
754
|
export default function planPiExtension(pi) {
|
|
@@ -708,6 +767,9 @@ export default function planPiExtension(pi) {
|
|
|
708
767
|
}
|
|
709
768
|
const argPrefix = match[2];
|
|
710
769
|
const prefix = argPrefix === undefined ? match[1] : argPrefix;
|
|
770
|
+
if (argPrefix !== undefined && hasPlannerCommandArgument(argPrefix)) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
711
773
|
const normalized = (argPrefix ?? "").trimStart().toLowerCase();
|
|
712
774
|
const items = PLANNER_COMMAND_COMPLETIONS
|
|
713
775
|
.filter((item) => !normalized || item.value.startsWith(normalized))
|
|
@@ -747,9 +809,10 @@ export default function planPiExtension(pi) {
|
|
|
747
809
|
// ── Step 1: Enable gating ───────────────────────────────────────
|
|
748
810
|
// No blocking prompt in session_start: ctx.ui.input()/select() do not
|
|
749
811
|
// render for NPM-loaded ESM extensions during session_start (they hang
|
|
750
|
-
// until the safety-net timeout).
|
|
751
|
-
//
|
|
752
|
-
//
|
|
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).
|
|
753
816
|
let enablePlanner = false;
|
|
754
817
|
if (exists) {
|
|
755
818
|
ctx.ui.notify("Planner detected in this project. Run '/planner load' to enable the planner and start the web UI (LAN).", "info");
|
|
@@ -760,8 +823,8 @@ export default function planPiExtension(pi) {
|
|
|
760
823
|
const handoffs = await st.listHandoffs().catch(() => []);
|
|
761
824
|
if (handoffs.length > 0) {
|
|
762
825
|
ctx.ui.notify(`ℹ️ ${handoffs.length} phase handoff(s) pending — review with /planner handoff list.`, "info");
|
|
763
|
-
// Mark each handoff as read (recap presented). Do NOT clear —
|
|
764
|
-
//
|
|
826
|
+
// Mark each handoff as read (recap presented). Do NOT clear — reading/load
|
|
827
|
+
// is non-mutating; only phase completion, replacement, or explicit clear archives it.
|
|
765
828
|
for (const h of handoffs)
|
|
766
829
|
await st.markHandoffRead(h.phaseId).catch(() => { });
|
|
767
830
|
}
|
|
@@ -931,8 +994,45 @@ export default function planPiExtension(pi) {
|
|
|
931
994
|
});
|
|
932
995
|
// ── Commands ───────────────────────────────────────────────────────
|
|
933
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
|
+
}
|
|
934
1025
|
async function handlePlanner(args, ctx) {
|
|
935
|
-
|
|
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
|
+
}
|
|
936
1036
|
}
|
|
937
1037
|
async function runPlanner(args, ctx) {
|
|
938
1038
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -942,6 +1042,7 @@ export default function planPiExtension(pi) {
|
|
|
942
1042
|
"init",
|
|
943
1043
|
"show",
|
|
944
1044
|
"repair",
|
|
1045
|
+
"cleanup-orphans",
|
|
945
1046
|
"project discuss",
|
|
946
1047
|
"project language",
|
|
947
1048
|
"feature list",
|
|
@@ -975,9 +1076,9 @@ export default function planPiExtension(pi) {
|
|
|
975
1076
|
"load",
|
|
976
1077
|
"stop",
|
|
977
1078
|
];
|
|
978
|
-
const SUB_HELP = "Available: init, show, repair, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
979
|
-
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner
|
|
980
|
-
"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)";
|
|
981
1082
|
if (!a) {
|
|
982
1083
|
const action = await ctx.ui.select("Planner action", PLANNER_MENU_ACTIONS);
|
|
983
1084
|
if (!action) {
|
|
@@ -997,6 +1098,14 @@ export default function planPiExtension(pi) {
|
|
|
997
1098
|
return;
|
|
998
1099
|
}
|
|
999
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 || ""}]`);
|
|
1000
1109
|
ctx.ui.notify("Resetting project... all current plan data will be lost.", "warning");
|
|
1001
1110
|
if (server) {
|
|
1002
1111
|
ctx.ui.notify("Stopping active web server...", "info");
|
|
@@ -1004,12 +1113,6 @@ export default function planPiExtension(pi) {
|
|
|
1004
1113
|
server = null;
|
|
1005
1114
|
}
|
|
1006
1115
|
await rm(st.root, { recursive: true, force: true });
|
|
1007
|
-
const name = await ctx.ui.input(`Project title [${oldProject.name}]`);
|
|
1008
|
-
if (!name?.trim()) {
|
|
1009
|
-
ctx.ui.notify("Aborted", "warning");
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
const description = await ctx.ui.input(`Short project description [${oldProject.description || ""}]`);
|
|
1013
1116
|
await st.init(name.trim());
|
|
1014
1117
|
const project = await st.loadProject();
|
|
1015
1118
|
project.description = description?.trim() ?? oldProject.description;
|
|
@@ -1132,22 +1235,33 @@ export default function planPiExtension(pi) {
|
|
|
1132
1235
|
?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
|
|
1133
1236
|
?? null;
|
|
1134
1237
|
}
|
|
1135
|
-
function
|
|
1136
|
-
const
|
|
1137
|
-
if (!
|
|
1138
|
-
return
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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}` };
|
|
1151
1265
|
}
|
|
1152
1266
|
function profile_packageManager(pkg, lockfile) {
|
|
1153
1267
|
if (pkg?.packageManager)
|
|
@@ -1162,7 +1276,7 @@ export default function planPiExtension(pi) {
|
|
|
1162
1276
|
return "npm";
|
|
1163
1277
|
return "";
|
|
1164
1278
|
}
|
|
1165
|
-
async function scanCodebase() {
|
|
1279
|
+
async function scanCodebase(persist = true) {
|
|
1166
1280
|
const root = ctx.cwd;
|
|
1167
1281
|
const rootFiles = [];
|
|
1168
1282
|
const directories = [];
|
|
@@ -1244,7 +1358,8 @@ export default function planPiExtension(pi) {
|
|
|
1244
1358
|
tree,
|
|
1245
1359
|
ambient,
|
|
1246
1360
|
};
|
|
1247
|
-
|
|
1361
|
+
if (persist)
|
|
1362
|
+
await st.saveCodebaseProfile(profile);
|
|
1248
1363
|
return profile;
|
|
1249
1364
|
}
|
|
1250
1365
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1256,8 +1371,8 @@ export default function planPiExtension(pi) {
|
|
|
1256
1371
|
return;
|
|
1257
1372
|
}
|
|
1258
1373
|
if (b === "discuss") {
|
|
1259
|
-
const project = await ensureProjectLanguagePreferences(st);
|
|
1260
|
-
const profile = await scanCodebase();
|
|
1374
|
+
const project = await ensureProjectLanguagePreferences(st, false);
|
|
1375
|
+
const profile = await scanCodebase(false);
|
|
1261
1376
|
ctx.ui.notify(`Codebase scanned: ${profile.directories.length} dirs, ${profile.rootFiles.length} root files${profile.packageJson?.name ? `, pkg ${profile.packageJson.name}` : ""}.`, "info");
|
|
1262
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");
|
|
1263
1378
|
const goal = await ctx.ui.editor("Project goal: What is the main, single-focus objective of this project?", project.goal || "");
|
|
@@ -1284,6 +1399,7 @@ export default function planPiExtension(pi) {
|
|
|
1284
1399
|
: project.acceptedDecisions,
|
|
1285
1400
|
};
|
|
1286
1401
|
await st.saveProject(draftProject);
|
|
1402
|
+
await st.saveCodebaseProfile(profile);
|
|
1287
1403
|
await st.writeGenerated();
|
|
1288
1404
|
ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
|
|
1289
1405
|
if (capturedPi) {
|
|
@@ -1321,7 +1437,7 @@ export default function planPiExtension(pi) {
|
|
|
1321
1437
|
// ═══════════════════════════════════════════════════════════════
|
|
1322
1438
|
if (a === "feature") {
|
|
1323
1439
|
if (!b) {
|
|
1324
|
-
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");
|
|
1325
1441
|
return;
|
|
1326
1442
|
}
|
|
1327
1443
|
if (b === "list") {
|
|
@@ -1353,16 +1469,17 @@ export default function planPiExtension(pi) {
|
|
|
1353
1469
|
return;
|
|
1354
1470
|
}
|
|
1355
1471
|
const now = nowISO();
|
|
1356
|
-
const
|
|
1357
|
-
const
|
|
1472
|
+
const id = createFeatureId();
|
|
1473
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
1358
1474
|
const priority = await st.nextPriority("feature");
|
|
1359
1475
|
const feature = {
|
|
1360
|
-
id
|
|
1361
|
-
number:
|
|
1362
|
-
shortId,
|
|
1476
|
+
id,
|
|
1477
|
+
number: identity.number,
|
|
1478
|
+
shortId: identity.shortId,
|
|
1363
1479
|
priority,
|
|
1364
1480
|
name: nameInput.trim(),
|
|
1365
1481
|
description: description?.trim() ?? "",
|
|
1482
|
+
descriptionUpdatedAt: now,
|
|
1366
1483
|
status: status,
|
|
1367
1484
|
discussedAt: "",
|
|
1368
1485
|
contextReady: false,
|
|
@@ -1374,6 +1491,7 @@ export default function planPiExtension(pi) {
|
|
|
1374
1491
|
acceptedDecisions: [],
|
|
1375
1492
|
phaseIds: [],
|
|
1376
1493
|
dependsOn: [],
|
|
1494
|
+
statusLog: [],
|
|
1377
1495
|
createdAt: now,
|
|
1378
1496
|
updatedAt: now,
|
|
1379
1497
|
};
|
|
@@ -1411,6 +1529,44 @@ export default function planPiExtension(pi) {
|
|
|
1411
1529
|
].filter(Boolean).join("\n"), "info");
|
|
1412
1530
|
return;
|
|
1413
1531
|
}
|
|
1532
|
+
if (b === "discuss") {
|
|
1533
|
+
const featuresDoc = await st.loadFeatures();
|
|
1534
|
+
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
1535
|
+
if (!feature) {
|
|
1536
|
+
feature = await pickFeature();
|
|
1537
|
+
if (!feature) {
|
|
1538
|
+
ctx.ui.notify("No features to discuss. Create one first.", "warning");
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
|
|
1543
|
+
const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
|
|
1544
|
+
const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
|
|
1545
|
+
const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
|
|
1546
|
+
const featureId = feature.id;
|
|
1547
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
1548
|
+
const target = doc.features.find((entry) => entry.id === featureId);
|
|
1549
|
+
if (!target)
|
|
1550
|
+
return doc;
|
|
1551
|
+
if (description !== undefined)
|
|
1552
|
+
target.description = description.trim();
|
|
1553
|
+
if (workDone !== undefined)
|
|
1554
|
+
target.workDone = workDone.trim();
|
|
1555
|
+
if (workRemaining !== undefined)
|
|
1556
|
+
target.workRemaining = workRemaining.trim();
|
|
1557
|
+
if (dependencies !== undefined)
|
|
1558
|
+
target.dependsOn = splitCsv(dependencies);
|
|
1559
|
+
target.discussedAt = nowISO();
|
|
1560
|
+
target.contextReady = true;
|
|
1561
|
+
target.contextReadyReason = "Updated through /planner feature discuss.";
|
|
1562
|
+
target.updatedAt = nowISO();
|
|
1563
|
+
return doc;
|
|
1564
|
+
});
|
|
1565
|
+
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1566
|
+
await st.writeGenerated();
|
|
1567
|
+
ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1414
1570
|
if (b === "update") {
|
|
1415
1571
|
const featuresDoc = await st.loadFeatures();
|
|
1416
1572
|
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
@@ -1509,7 +1665,7 @@ export default function planPiExtension(pi) {
|
|
|
1509
1665
|
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1510
1666
|
return;
|
|
1511
1667
|
}
|
|
1512
|
-
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
|
|
1668
|
+
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
1513
1669
|
return;
|
|
1514
1670
|
}
|
|
1515
1671
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1536,23 +1692,27 @@ export default function planPiExtension(pi) {
|
|
|
1536
1692
|
ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
|
|
1537
1693
|
return;
|
|
1538
1694
|
}
|
|
1695
|
+
// This belongs to the creation transaction: Escape before the phase
|
|
1696
|
+
// is persisted cancels the whole command rather than creating a phase
|
|
1697
|
+
// and only then abandoning the follow-up question.
|
|
1698
|
+
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1539
1699
|
let phase;
|
|
1540
1700
|
await withFeatureLock(feature.id, async () => {
|
|
1541
1701
|
const phases = await st.loadAllPhases();
|
|
1542
1702
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1543
|
-
const number = await st.allocPhaseNumber();
|
|
1544
1703
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1545
1704
|
const id = createPhaseId();
|
|
1546
|
-
const
|
|
1705
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
1547
1706
|
const priority = await st.nextPriority("phase", feature.id);
|
|
1548
1707
|
phase = {
|
|
1549
|
-
id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1708
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", descriptionUpdatedAt: "", notes: "",
|
|
1550
1709
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
1551
1710
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1552
1711
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1553
1712
|
handoff: "", handoffUpdatedAt: "",
|
|
1554
1713
|
handoffReadAt: "",
|
|
1555
1714
|
handoffHistory: [],
|
|
1715
|
+
statusLog: [],
|
|
1556
1716
|
};
|
|
1557
1717
|
await st.savePhase(phase);
|
|
1558
1718
|
await st.updateFeatures((doc) => {
|
|
@@ -1568,7 +1728,6 @@ export default function planPiExtension(pi) {
|
|
|
1568
1728
|
if (!phase)
|
|
1569
1729
|
return;
|
|
1570
1730
|
ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
|
|
1571
|
-
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1572
1731
|
if (isYes(startDiscuss)) {
|
|
1573
1732
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
1574
1733
|
}
|
|
@@ -1577,7 +1736,7 @@ export default function planPiExtension(pi) {
|
|
|
1577
1736
|
if (b === "discuss") {
|
|
1578
1737
|
let phase = null;
|
|
1579
1738
|
if (subArgs.trim()) {
|
|
1580
|
-
phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1739
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1581
1740
|
}
|
|
1582
1741
|
if (!phase) {
|
|
1583
1742
|
phase = await pickPhase();
|
|
@@ -1589,9 +1748,6 @@ export default function planPiExtension(pi) {
|
|
|
1589
1748
|
if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
|
|
1590
1749
|
ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
|
|
1591
1750
|
}
|
|
1592
|
-
phase.status = "discovery";
|
|
1593
|
-
phase.updatedAt = nowISO();
|
|
1594
|
-
await st.savePhase(phase);
|
|
1595
1751
|
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1596
1752
|
// ── Recap: give full context before planning tasks ──
|
|
1597
1753
|
const recapPlan = await st.loadAll();
|
|
@@ -1663,7 +1819,7 @@ export default function planPiExtension(pi) {
|
|
|
1663
1819
|
if (b === "show") {
|
|
1664
1820
|
let phase = null;
|
|
1665
1821
|
if (subArgs.trim()) {
|
|
1666
|
-
phase = await st.
|
|
1822
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1667
1823
|
}
|
|
1668
1824
|
if (!phase) {
|
|
1669
1825
|
phase = await pickPhase();
|
|
@@ -1689,7 +1845,7 @@ export default function planPiExtension(pi) {
|
|
|
1689
1845
|
if (b === "delete") {
|
|
1690
1846
|
let phase = null;
|
|
1691
1847
|
if (subArgs.trim()) {
|
|
1692
|
-
phase = await st.
|
|
1848
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1693
1849
|
}
|
|
1694
1850
|
if (!phase) {
|
|
1695
1851
|
phase = await pickPhase();
|
|
@@ -1711,7 +1867,7 @@ export default function planPiExtension(pi) {
|
|
|
1711
1867
|
if (b === "update") {
|
|
1712
1868
|
let phase = null;
|
|
1713
1869
|
if (subArgs.trim()) {
|
|
1714
|
-
phase = await st.
|
|
1870
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1715
1871
|
}
|
|
1716
1872
|
if (!phase) {
|
|
1717
1873
|
phase = await pickPhase();
|
|
@@ -1768,7 +1924,7 @@ export default function planPiExtension(pi) {
|
|
|
1768
1924
|
// ═══════════════════════════════════════════════════════════════
|
|
1769
1925
|
if (a === "task") {
|
|
1770
1926
|
if (!b) {
|
|
1771
|
-
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");
|
|
1772
1928
|
return;
|
|
1773
1929
|
}
|
|
1774
1930
|
if (b === "add") {
|
|
@@ -1782,16 +1938,19 @@ export default function planPiExtension(pi) {
|
|
|
1782
1938
|
ctx.ui.notify("Aborted", "warning");
|
|
1783
1939
|
return;
|
|
1784
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");
|
|
1785
1944
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1786
|
-
const taskNum = await st.allocTaskNumber();
|
|
1787
1945
|
const taskId = createTaskId();
|
|
1788
|
-
const
|
|
1946
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
1789
1947
|
const priority = await st.nextPriority("task", phase.id);
|
|
1790
1948
|
const now = nowISO();
|
|
1791
1949
|
const task = {
|
|
1792
|
-
id: taskId, phaseId: phase.id, number:
|
|
1950
|
+
id: taskId, phaseId: phase.id, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
1793
1951
|
title: title.trim(), status: "planned",
|
|
1794
1952
|
description: "",
|
|
1953
|
+
descriptionUpdatedAt: "",
|
|
1795
1954
|
notes: "",
|
|
1796
1955
|
statusLog: [],
|
|
1797
1956
|
decisions: [],
|
|
@@ -1807,7 +1966,6 @@ export default function planPiExtension(pi) {
|
|
|
1807
1966
|
await st.savePhase(phase);
|
|
1808
1967
|
await st.writeGenerated();
|
|
1809
1968
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
1810
|
-
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1811
1969
|
if (isYes(startDiscuss)) {
|
|
1812
1970
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
1813
1971
|
}
|
|
@@ -1839,7 +1997,7 @@ export default function planPiExtension(pi) {
|
|
|
1839
1997
|
if (description?.trim())
|
|
1840
1998
|
task.description = description.trim();
|
|
1841
1999
|
if (checklistSeed?.trim()) {
|
|
1842
|
-
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
2000
|
+
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
|
|
1843
2001
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
1844
2002
|
title: itemTitle,
|
|
1845
2003
|
checked: false,
|
|
@@ -1890,7 +2048,7 @@ export default function planPiExtension(pi) {
|
|
|
1890
2048
|
if (b === "delete") {
|
|
1891
2049
|
let phase = null;
|
|
1892
2050
|
if (subArgs.trim()) {
|
|
1893
|
-
phase = await st.
|
|
2051
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1894
2052
|
}
|
|
1895
2053
|
if (!phase) {
|
|
1896
2054
|
phase = await pickPhase();
|
|
@@ -1924,7 +2082,7 @@ export default function planPiExtension(pi) {
|
|
|
1924
2082
|
if (b === "update") {
|
|
1925
2083
|
let phase = null;
|
|
1926
2084
|
if (subArgs.trim()) {
|
|
1927
|
-
phase = await st.
|
|
2085
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1928
2086
|
}
|
|
1929
2087
|
if (!phase) {
|
|
1930
2088
|
phase = await pickPhase();
|
|
@@ -1966,6 +2124,7 @@ export default function planPiExtension(pi) {
|
|
|
1966
2124
|
task.updatedAt = now;
|
|
1967
2125
|
phase.updatedAt = now;
|
|
1968
2126
|
await st.savePhase(phase);
|
|
2127
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
1969
2128
|
await st.writeGenerated();
|
|
1970
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");
|
|
1971
2130
|
return;
|
|
@@ -1998,17 +2157,9 @@ export default function planPiExtension(pi) {
|
|
|
1998
2157
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
1999
2158
|
return;
|
|
2000
2159
|
}
|
|
2001
|
-
//
|
|
2002
|
-
//
|
|
2003
|
-
//
|
|
2004
|
-
if ((phase.handoff ?? "") !== "") {
|
|
2005
|
-
await st.clearPhaseHandoff(phase.id, "task-started").catch(() => { });
|
|
2006
|
-
ctx.ui.notify("📦 Archived handoff for this phase (task started) — recover via /planner handoff list + .planner/handoff-archive/.", "info");
|
|
2007
|
-
}
|
|
2008
|
-
const _otherHandoffs = (await st.listHandoffs()).filter((h) => h.phaseId !== phase.id);
|
|
2009
|
-
if (_otherHandoffs.length > 0) {
|
|
2010
|
-
ctx.ui.notify(`ℹ️ ${_otherHandoffs.length} other phase handoff(s) pending — review with /planner handoff list if relevant.`, "info");
|
|
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,6 +2176,7 @@ 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
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;
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -2621,6 +2869,7 @@ export default function planPiExtension(pi) {
|
|
|
2621
2869
|
`Repair complete.`,
|
|
2622
2870
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2623
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).`,
|
|
2624
2873
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2625
2874
|
];
|
|
2626
2875
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2630,6 +2879,38 @@ export default function planPiExtension(pi) {
|
|
|
2630
2879
|
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2631
2880
|
},
|
|
2632
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
|
+
});
|
|
2633
2914
|
pi.registerTool({
|
|
2634
2915
|
name: "plan_get_handoff",
|
|
2635
2916
|
label: "Plan Get Handoff",
|
|
@@ -2649,9 +2930,12 @@ export default function planPiExtension(pi) {
|
|
|
2649
2930
|
pi.registerTool({
|
|
2650
2931
|
name: "plan_write_handoff",
|
|
2651
2932
|
label: "Plan Write Handoff",
|
|
2652
|
-
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.",
|
|
2653
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." }),
|
|
2654
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'." })),
|
|
2655
2939
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
2656
2940
|
howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
|
|
2657
2941
|
extraSections: Type.Optional(Type.Array(Type.Object({
|
|
@@ -2663,7 +2947,14 @@ export default function planPiExtension(pi) {
|
|
|
2663
2947
|
const st = await requirePlan(ctx);
|
|
2664
2948
|
if (!st)
|
|
2665
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 } };
|
|
2666
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() } : {}),
|
|
2667
2958
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2668
2959
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2669
2960
|
...(params.extraSections && params.extraSections.length > 0
|
|
@@ -2674,23 +2965,20 @@ export default function planPiExtension(pi) {
|
|
|
2674
2965
|
}
|
|
2675
2966
|
: {}),
|
|
2676
2967
|
});
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
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 } };
|
|
2680
|
-
await st.setPhaseHandoff(r.phase.id, markdown);
|
|
2681
|
-
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 } };
|
|
2682
2970
|
},
|
|
2683
2971
|
});
|
|
2684
2972
|
pi.registerTool({
|
|
2685
2973
|
name: "plan_delete_handoff",
|
|
2686
2974
|
label: "Plan Delete Handoff",
|
|
2687
|
-
description: "DEPRECATED — clears the entity-scoped handoff of
|
|
2688
|
-
parameters: Type.Object({}),
|
|
2689
|
-
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) {
|
|
2690
2978
|
const st = await requirePlan(ctx);
|
|
2691
2979
|
if (!st)
|
|
2692
2980
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2693
|
-
const r = await resolvePhaseForHandoff(st,
|
|
2981
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2694
2982
|
if (!r.ok)
|
|
2695
2983
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2696
2984
|
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
@@ -2698,8 +2986,8 @@ export default function planPiExtension(pi) {
|
|
|
2698
2986
|
},
|
|
2699
2987
|
});
|
|
2700
2988
|
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2701
|
-
// UUID | title
|
|
2702
|
-
//
|
|
2989
|
+
// UUID | title. A write target is always explicit: never guess from the
|
|
2990
|
+
// first in-progress phase or a stale resume pointer.
|
|
2703
2991
|
async function resolvePhaseForHandoff(st, ref) {
|
|
2704
2992
|
const phases = await st.loadAllPhases();
|
|
2705
2993
|
const features = (await st.loadFeatures()).features;
|
|
@@ -2707,16 +2995,12 @@ export default function planPiExtension(pi) {
|
|
|
2707
2995
|
if (ref && ref.trim()) {
|
|
2708
2996
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
2709
2997
|
}
|
|
2710
|
-
else {
|
|
2711
|
-
phase = phases.find((p) => p.status === "in-progress")
|
|
2712
|
-
?? phases.find((p) => p.tasks.some((t) => t.status === "in-progress"));
|
|
2713
|
-
}
|
|
2714
2998
|
if (!phase) {
|
|
2715
2999
|
return {
|
|
2716
3000
|
ok: false,
|
|
2717
3001
|
error: ref && ref.trim()
|
|
2718
3002
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
2719
|
-
: "
|
|
3003
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
2720
3004
|
};
|
|
2721
3005
|
}
|
|
2722
3006
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -2741,8 +3025,8 @@ export default function planPiExtension(pi) {
|
|
|
2741
3025
|
pi.registerTool({
|
|
2742
3026
|
name: "handoff_show",
|
|
2743
3027
|
label: "Handoff Show",
|
|
2744
|
-
description: "Read the entity-scoped handoff of a phase. phaseRef accepts P00x, P00x(F00x), UUID, or title.
|
|
2745
|
-
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." }) }),
|
|
2746
3030
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2747
3031
|
const st = await requirePlan(ctx);
|
|
2748
3032
|
if (!st)
|
|
@@ -2759,9 +3043,11 @@ export default function planPiExtension(pi) {
|
|
|
2759
3043
|
pi.registerTool({
|
|
2760
3044
|
name: "handoff_write",
|
|
2761
3045
|
label: "Handoff Write",
|
|
2762
|
-
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.",
|
|
2763
3047
|
parameters: Type.Object({
|
|
2764
|
-
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." }),
|
|
2765
3051
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2766
3052
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2767
3053
|
}),
|
|
@@ -2769,12 +3055,38 @@ export default function planPiExtension(pi) {
|
|
|
2769
3055
|
const st = await requirePlan(ctx);
|
|
2770
3056
|
if (!st)
|
|
2771
3057
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2772
|
-
|
|
3058
|
+
let body = (params.markdown_content ?? params.content ?? "").trim();
|
|
2773
3059
|
if (!body)
|
|
2774
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
|
+
}
|
|
2775
3084
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2776
3085
|
if (!r.ok)
|
|
2777
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
|
+
}
|
|
2778
3090
|
await st.setPhaseHandoff(r.phase.id, body);
|
|
2779
3091
|
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2780
3092
|
},
|
|
@@ -2782,8 +3094,8 @@ export default function planPiExtension(pi) {
|
|
|
2782
3094
|
pi.registerTool({
|
|
2783
3095
|
name: "handoff_clear",
|
|
2784
3096
|
label: "Handoff Clear",
|
|
2785
|
-
description: "Clear the entity-scoped handoff of a phase (
|
|
2786
|
-
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." }) }),
|
|
2787
3099
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2788
3100
|
const st = await requirePlan(ctx);
|
|
2789
3101
|
if (!st)
|
|
@@ -2792,7 +3104,7 @@ export default function planPiExtension(pi) {
|
|
|
2792
3104
|
if (!r.ok)
|
|
2793
3105
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2794
3106
|
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
2795
|
-
return { content: [{ type: "text", text: `✅ Cleared handoff on ${r.compositeRef} (handoffUpdatedAt preserved as audit; archived to .planner/handoff-archive/).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
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 } };
|
|
2796
3108
|
},
|
|
2797
3109
|
});
|
|
2798
3110
|
pi.registerTool({
|
|
@@ -2849,7 +3161,7 @@ export default function planPiExtension(pi) {
|
|
|
2849
3161
|
pi.registerTool({
|
|
2850
3162
|
name: "feature_get",
|
|
2851
3163
|
label: "Feature Get",
|
|
2852
|
-
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3164
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
2853
3165
|
parameters: Type.Object({
|
|
2854
3166
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2855
3167
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
@@ -2860,12 +3172,17 @@ export default function planPiExtension(pi) {
|
|
|
2860
3172
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2861
3173
|
const features = (await st.loadFeatures()).features;
|
|
2862
3174
|
const ref = params.featureId.trim();
|
|
2863
|
-
const
|
|
2864
|
-
if (!
|
|
2865
|
-
return { content: [{ type: "text", text:
|
|
3175
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
3176
|
+
if (!resolvedFeature.ok)
|
|
3177
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3178
|
+
const feature = resolvedFeature.feature;
|
|
2866
3179
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2867
|
-
const
|
|
2868
|
-
|
|
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 } };
|
|
2869
3186
|
},
|
|
2870
3187
|
});
|
|
2871
3188
|
pi.registerTool({
|
|
@@ -2884,15 +3201,17 @@ export default function planPiExtension(pi) {
|
|
|
2884
3201
|
const now = nowISO();
|
|
2885
3202
|
const status = params.status ?? "planned";
|
|
2886
3203
|
const currentFeatures = (await st.loadFeatures()).features;
|
|
2887
|
-
const
|
|
3204
|
+
const id = createFeatureId();
|
|
3205
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
2888
3206
|
const priority = await st.nextPriority("feature");
|
|
2889
3207
|
const feature = {
|
|
2890
|
-
id
|
|
2891
|
-
number:
|
|
2892
|
-
shortId,
|
|
3208
|
+
id,
|
|
3209
|
+
number: identity.number,
|
|
3210
|
+
shortId: identity.shortId,
|
|
2893
3211
|
priority,
|
|
2894
3212
|
name: params.name,
|
|
2895
3213
|
description: params.description ?? "",
|
|
3214
|
+
descriptionUpdatedAt: now,
|
|
2896
3215
|
status,
|
|
2897
3216
|
discussedAt: "",
|
|
2898
3217
|
contextReady: false,
|
|
@@ -2904,6 +3223,7 @@ export default function planPiExtension(pi) {
|
|
|
2904
3223
|
acceptedDecisions: [],
|
|
2905
3224
|
phaseIds: [],
|
|
2906
3225
|
dependsOn: [],
|
|
3226
|
+
statusLog: [],
|
|
2907
3227
|
createdAt: now,
|
|
2908
3228
|
updatedAt: now,
|
|
2909
3229
|
};
|
|
@@ -2917,6 +3237,51 @@ export default function planPiExtension(pi) {
|
|
|
2917
3237
|
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2918
3238
|
},
|
|
2919
3239
|
});
|
|
3240
|
+
pi.registerTool({
|
|
3241
|
+
name: "feature_discuss",
|
|
3242
|
+
label: "Feature Discuss",
|
|
3243
|
+
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
3244
|
+
parameters: Type.Object({
|
|
3245
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3246
|
+
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
3247
|
+
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3248
|
+
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3249
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
3250
|
+
}),
|
|
3251
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3252
|
+
const st = await requirePlan(ctx);
|
|
3253
|
+
if (!st)
|
|
3254
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3255
|
+
const features = (await st.loadFeatures()).features;
|
|
3256
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3257
|
+
if (!resolvedFeature.ok)
|
|
3258
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3259
|
+
const featureId = resolvedFeature.feature.id;
|
|
3260
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3261
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3262
|
+
if (!feature)
|
|
3263
|
+
return doc;
|
|
3264
|
+
if (params.description !== undefined)
|
|
3265
|
+
feature.description = params.description;
|
|
3266
|
+
if (params.workDone !== undefined)
|
|
3267
|
+
feature.workDone = params.workDone;
|
|
3268
|
+
if (params.workRemaining !== undefined)
|
|
3269
|
+
feature.workRemaining = params.workRemaining;
|
|
3270
|
+
if (params.dependencies !== undefined)
|
|
3271
|
+
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3272
|
+
feature.discussedAt = nowISO();
|
|
3273
|
+
feature.contextReady = true;
|
|
3274
|
+
feature.contextReadyReason = "Updated through feature_discuss tool.";
|
|
3275
|
+
feature.updatedAt = nowISO();
|
|
3276
|
+
return doc;
|
|
3277
|
+
});
|
|
3278
|
+
const feature = updatedDoc.features.find((f) => f.id === featureId);
|
|
3279
|
+
if (!feature)
|
|
3280
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3281
|
+
await st.writeGenerated();
|
|
3282
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3283
|
+
},
|
|
3284
|
+
});
|
|
2920
3285
|
pi.registerTool({
|
|
2921
3286
|
name: "feature_update",
|
|
2922
3287
|
label: "Feature Update",
|
|
@@ -2944,10 +3309,36 @@ export default function planPiExtension(pi) {
|
|
|
2944
3309
|
const st = await requirePlan(ctx);
|
|
2945
3310
|
if (!st)
|
|
2946
3311
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3312
|
+
const features = (await st.loadFeatures()).features;
|
|
3313
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3314
|
+
if (!resolvedFeature.ok)
|
|
3315
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3316
|
+
const featureId = resolvedFeature.feature.id;
|
|
3317
|
+
const mutableFields = [
|
|
3318
|
+
"name",
|
|
3319
|
+
"description",
|
|
3320
|
+
"status",
|
|
3321
|
+
"startDate",
|
|
3322
|
+
"endDate",
|
|
3323
|
+
"workDone",
|
|
3324
|
+
"workRemaining",
|
|
3325
|
+
"priority",
|
|
3326
|
+
"acceptedDecisions",
|
|
3327
|
+
];
|
|
3328
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3329
|
+
if (receivedFields.length === 0) {
|
|
3330
|
+
return {
|
|
3331
|
+
content: [{
|
|
3332
|
+
type: "text",
|
|
3333
|
+
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.",
|
|
3334
|
+
}],
|
|
3335
|
+
details: { updated: false, reason: "no-mutable-fields" },
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
2947
3338
|
let foundDoc;
|
|
2948
3339
|
try {
|
|
2949
3340
|
foundDoc = await st.updateFeatures((doc) => {
|
|
2950
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3341
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
2951
3342
|
if (!feature)
|
|
2952
3343
|
return doc; // not found: no-op, handled below
|
|
2953
3344
|
if (params.name !== undefined)
|
|
@@ -2985,11 +3376,17 @@ export default function planPiExtension(pi) {
|
|
|
2985
3376
|
catch (e) {
|
|
2986
3377
|
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
2987
3378
|
}
|
|
2988
|
-
const feature = foundDoc?.features.find((f) => f.id ===
|
|
3379
|
+
const feature = foundDoc?.features.find((f) => f.id === featureId);
|
|
2989
3380
|
if (!feature)
|
|
2990
3381
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2991
3382
|
await st.writeGenerated();
|
|
2992
|
-
return {
|
|
3383
|
+
return {
|
|
3384
|
+
content: [{
|
|
3385
|
+
type: "text",
|
|
3386
|
+
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
3387
|
+
}],
|
|
3388
|
+
details: { ...feature, updated: true, updatedFields: receivedFields },
|
|
3389
|
+
};
|
|
2993
3390
|
},
|
|
2994
3391
|
});
|
|
2995
3392
|
pi.registerTool({
|
|
@@ -3004,21 +3401,23 @@ export default function planPiExtension(pi) {
|
|
|
3004
3401
|
const st = await requirePlan(ctx);
|
|
3005
3402
|
if (!st)
|
|
3006
3403
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3404
|
+
const features = (await st.loadFeatures()).features;
|
|
3405
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3406
|
+
if (!resolvedFeature.ok)
|
|
3407
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3408
|
+
const featureId = resolvedFeature.feature.id;
|
|
3007
3409
|
let deleted = false;
|
|
3008
3410
|
await st.updateFeatures((doc) => {
|
|
3009
3411
|
const before = doc.features.length;
|
|
3010
|
-
doc.features = doc.features.filter((f) => f.id !==
|
|
3412
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3011
3413
|
if (doc.features.length !== before)
|
|
3012
3414
|
deleted = true;
|
|
3013
3415
|
return doc;
|
|
3014
3416
|
});
|
|
3015
|
-
if (!deleted) {
|
|
3016
|
-
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3017
|
-
}
|
|
3018
3417
|
let cascadeCount = 0;
|
|
3019
3418
|
if (params.cascade) {
|
|
3020
3419
|
const phases = await st.loadAllPhases();
|
|
3021
|
-
for (const phase of phases.filter((p) => p.featureId ===
|
|
3420
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
3022
3421
|
await st.deletePhase(phase.id);
|
|
3023
3422
|
cascadeCount += 1;
|
|
3024
3423
|
}
|
|
@@ -3062,7 +3461,7 @@ export default function planPiExtension(pi) {
|
|
|
3062
3461
|
pi.registerTool({
|
|
3063
3462
|
name: "phase_get",
|
|
3064
3463
|
label: "Phase Get",
|
|
3065
|
-
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3464
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3066
3465
|
parameters: Type.Object({
|
|
3067
3466
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3068
3467
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
@@ -3075,8 +3474,13 @@ export default function planPiExtension(pi) {
|
|
|
3075
3474
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3076
3475
|
if (!phase)
|
|
3077
3476
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3078
|
-
const
|
|
3079
|
-
|
|
3477
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3478
|
+
const reqCount = linkedRequirements.length;
|
|
3479
|
+
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"}` : ""})`;
|
|
3480
|
+
const requirementsBlock = reqCount > 0
|
|
3481
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3482
|
+
: "";
|
|
3483
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3080
3484
|
},
|
|
3081
3485
|
});
|
|
3082
3486
|
pi.registerTool({
|
|
@@ -3096,34 +3500,43 @@ export default function planPiExtension(pi) {
|
|
|
3096
3500
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3097
3501
|
if (!params.featureId?.trim())
|
|
3098
3502
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3503
|
+
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
3504
|
+
const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
|
|
3505
|
+
if (!resolvedFeature.ok)
|
|
3506
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3507
|
+
const featureId = resolvedFeature.feature.id;
|
|
3508
|
+
const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
|
|
3509
|
+
if (!featureValidation.ok)
|
|
3510
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3511
|
+
const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
|
|
3512
|
+
if (!existingFeature)
|
|
3513
|
+
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3099
3514
|
let phase;
|
|
3100
|
-
await withFeatureLock(
|
|
3101
|
-
const phases = await st.loadAllPhases();
|
|
3102
|
-
const featurePhases = phases.filter((p) => p.featureId === params.featureId);
|
|
3103
|
-
const number = await st.allocPhaseNumber();
|
|
3515
|
+
await withFeatureLock(featureId, async () => {
|
|
3104
3516
|
const id = createPhaseId();
|
|
3105
|
-
const
|
|
3106
|
-
const priority = await st.nextPriority("phase",
|
|
3517
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
3518
|
+
const priority = await st.nextPriority("phase", featureId);
|
|
3107
3519
|
const now = nowISO();
|
|
3108
3520
|
phase = {
|
|
3109
|
-
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3110
|
-
featureId
|
|
3521
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3522
|
+
featureId,
|
|
3111
3523
|
status: params.status ?? "draft",
|
|
3112
3524
|
discussedAt: "",
|
|
3113
3525
|
contextReady: false,
|
|
3114
3526
|
contextReadyReason: "",
|
|
3115
|
-
summary: params.summary ?? "", description: params.description ?? "", notes: "",
|
|
3527
|
+
summary: params.summary ?? "", description: params.description ?? "", descriptionUpdatedAt: now, notes: "",
|
|
3116
3528
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
3117
3529
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
3118
3530
|
createdAt: now, updatedAt: now,
|
|
3119
3531
|
handoff: "", handoffUpdatedAt: "",
|
|
3120
3532
|
handoffReadAt: "",
|
|
3121
3533
|
handoffHistory: [],
|
|
3534
|
+
statusLog: [],
|
|
3122
3535
|
};
|
|
3123
3536
|
await st.savePhase(phase);
|
|
3124
3537
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3125
3538
|
await st.updateFeatures((doc) => {
|
|
3126
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3539
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3127
3540
|
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3128
3541
|
feature.phaseIds.push(phase.id);
|
|
3129
3542
|
feature.updatedAt = now;
|
|
@@ -3170,14 +3583,20 @@ export default function planPiExtension(pi) {
|
|
|
3170
3583
|
const st = await requirePlan(ctx);
|
|
3171
3584
|
if (!st)
|
|
3172
3585
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3586
|
+
const features = (await st.loadFeatures()).features;
|
|
3587
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3588
|
+
if (!resolvedPhase)
|
|
3589
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3590
|
+
const phaseId = resolvedPhase.id;
|
|
3173
3591
|
let phase;
|
|
3174
3592
|
try {
|
|
3175
|
-
phase = await st.loadPhase(
|
|
3593
|
+
phase = await st.loadPhase(phaseId);
|
|
3176
3594
|
}
|
|
3177
3595
|
catch {
|
|
3178
3596
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3179
3597
|
}
|
|
3180
3598
|
const prevFeatureId = phase.featureId;
|
|
3599
|
+
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3181
3600
|
if (params.title !== undefined) {
|
|
3182
3601
|
phase.title = params.title;
|
|
3183
3602
|
phase.slug = normalizeSlug(params.title);
|
|
@@ -3207,14 +3626,22 @@ export default function planPiExtension(pi) {
|
|
|
3207
3626
|
if (params.completionCriteria !== undefined)
|
|
3208
3627
|
phase.completionCriteria = params.completionCriteria;
|
|
3209
3628
|
if (params.featureId !== undefined) {
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3629
|
+
if (params.featureId === "") {
|
|
3630
|
+
nextFeatureIdForUpdate = undefined;
|
|
3631
|
+
}
|
|
3632
|
+
else {
|
|
3633
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3634
|
+
if (!resolvedFeature.ok)
|
|
3635
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3636
|
+
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3637
|
+
}
|
|
3638
|
+
phase.featureId = nextFeatureIdForUpdate;
|
|
3639
|
+
if (nextFeatureIdForUpdate !== prevFeatureId) {
|
|
3213
3640
|
await st.updateFeatures((doc) => {
|
|
3214
3641
|
for (const f of doc.features) {
|
|
3215
3642
|
if (f.id === prevFeatureId)
|
|
3216
3643
|
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3217
|
-
if (f.id ===
|
|
3644
|
+
if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
|
|
3218
3645
|
f.phaseIds.push(phase.id);
|
|
3219
3646
|
}
|
|
3220
3647
|
return doc;
|
|
@@ -3228,6 +3655,77 @@ export default function planPiExtension(pi) {
|
|
|
3228
3655
|
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3229
3656
|
},
|
|
3230
3657
|
});
|
|
3658
|
+
pi.registerTool({
|
|
3659
|
+
name: "decision_record",
|
|
3660
|
+
label: "Decision Record",
|
|
3661
|
+
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.",
|
|
3662
|
+
parameters: Type.Object({
|
|
3663
|
+
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
3664
|
+
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
3665
|
+
title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
|
|
3666
|
+
decision: Type.String({ minLength: 1, description: "The agreed decision" }),
|
|
3667
|
+
rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
|
|
3668
|
+
implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
|
|
3669
|
+
}),
|
|
3670
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3671
|
+
const st = await requirePlan(ctx);
|
|
3672
|
+
if (!st)
|
|
3673
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3674
|
+
const features = (await st.loadFeatures()).features;
|
|
3675
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3676
|
+
if (!resolvedFeature.ok)
|
|
3677
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3678
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
|
|
3679
|
+
if (!resolvedPhase)
|
|
3680
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3681
|
+
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
3682
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
3683
|
+
}
|
|
3684
|
+
const acceptedAt = nowISO();
|
|
3685
|
+
const acceptedDecision = {
|
|
3686
|
+
id: crypto.randomUUID(),
|
|
3687
|
+
title: params.title.trim(),
|
|
3688
|
+
decision: params.decision.trim(),
|
|
3689
|
+
rationale: params.rationale.trim(),
|
|
3690
|
+
implementationNotes: params.implementationNotes.trim(),
|
|
3691
|
+
acceptedAt,
|
|
3692
|
+
};
|
|
3693
|
+
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
3694
|
+
let featureWritten = false;
|
|
3695
|
+
try {
|
|
3696
|
+
await st.updateFeatures((doc) => {
|
|
3697
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3698
|
+
if (!feature)
|
|
3699
|
+
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
3700
|
+
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
3701
|
+
feature.updatedAt = acceptedAt;
|
|
3702
|
+
return doc;
|
|
3703
|
+
});
|
|
3704
|
+
featureWritten = true;
|
|
3705
|
+
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
3706
|
+
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
3707
|
+
phase.updatedAt = acceptedAt;
|
|
3708
|
+
return phase;
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
catch (error) {
|
|
3712
|
+
// Best-effort compensation keeps a failed dual-write from reporting
|
|
3713
|
+
// success with a decision persisted only on the feature.
|
|
3714
|
+
if (featureWritten) {
|
|
3715
|
+
await st.updateFeatures((doc) => {
|
|
3716
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3717
|
+
if (feature)
|
|
3718
|
+
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
3719
|
+
return doc;
|
|
3720
|
+
}).catch(() => { });
|
|
3721
|
+
}
|
|
3722
|
+
throw error;
|
|
3723
|
+
}
|
|
3724
|
+
});
|
|
3725
|
+
await st.writeGenerated();
|
|
3726
|
+
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
3727
|
+
},
|
|
3728
|
+
});
|
|
3231
3729
|
pi.registerTool({
|
|
3232
3730
|
name: "phase_delete",
|
|
3233
3731
|
label: "Phase Delete",
|
|
@@ -3239,19 +3737,24 @@ export default function planPiExtension(pi) {
|
|
|
3239
3737
|
const st = await requirePlan(ctx);
|
|
3240
3738
|
if (!st)
|
|
3241
3739
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3740
|
+
const features = (await st.loadFeatures()).features;
|
|
3741
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3742
|
+
if (!resolvedPhase)
|
|
3743
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3744
|
+
const phaseId = resolvedPhase.id;
|
|
3242
3745
|
let phase;
|
|
3243
3746
|
try {
|
|
3244
|
-
phase = await st.loadPhase(
|
|
3747
|
+
phase = await st.loadPhase(phaseId);
|
|
3245
3748
|
}
|
|
3246
3749
|
catch {
|
|
3247
3750
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3248
3751
|
}
|
|
3249
|
-
await st.deletePhase(
|
|
3752
|
+
await st.deletePhase(phaseId);
|
|
3250
3753
|
if (phase.featureId) {
|
|
3251
3754
|
await st.updateFeatures((doc) => {
|
|
3252
3755
|
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3253
3756
|
if (feature) {
|
|
3254
|
-
feature.phaseIds = feature.phaseIds.filter((pid) => pid !==
|
|
3757
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
3255
3758
|
feature.updatedAt = nowISO();
|
|
3256
3759
|
}
|
|
3257
3760
|
return doc;
|
|
@@ -3330,40 +3833,64 @@ export default function planPiExtension(pi) {
|
|
|
3330
3833
|
label: "Task Create",
|
|
3331
3834
|
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.",
|
|
3332
3835
|
parameters: Type.Object({
|
|
3836
|
+
featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
|
|
3333
3837
|
phaseId: Type.String({ description: "Phase ID the task belongs to" }),
|
|
3334
3838
|
title: Type.String({ description: "Task title" }),
|
|
3335
3839
|
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 }),
|
|
3336
3840
|
status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
|
|
3337
3841
|
shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
|
|
3842
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
|
|
3338
3843
|
}),
|
|
3339
3844
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3340
3845
|
const st = await requirePlan(ctx);
|
|
3341
3846
|
if (!st)
|
|
3342
3847
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3848
|
+
if (!params.featureId?.trim())
|
|
3849
|
+
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3850
|
+
const features = (await st.loadFeatures()).features;
|
|
3851
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
|
|
3852
|
+
if (!resolvedFeature.ok)
|
|
3853
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3854
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
3855
|
+
if (!featureValidation.ok)
|
|
3856
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3857
|
+
const feature = resolvedFeature.feature;
|
|
3858
|
+
const existingFeature = features.find((f) => f.id === feature.id);
|
|
3859
|
+
if (!existingFeature)
|
|
3860
|
+
return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
|
|
3861
|
+
// Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
|
|
3862
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3863
|
+
if (!resolvedPhase)
|
|
3348
3864
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3349
|
-
|
|
3865
|
+
if (resolvedPhase.featureId !== feature.id)
|
|
3866
|
+
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: {} };
|
|
3867
|
+
const phaseId = resolvedPhase.id;
|
|
3868
|
+
const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
|
|
3869
|
+
if (!phaseValidation.ok)
|
|
3870
|
+
return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
|
|
3871
|
+
const existingPhase = await st.loadPhase(phaseId);
|
|
3872
|
+
if (!existingPhase)
|
|
3873
|
+
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3350
3874
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3351
3875
|
const taskId = createTaskId();
|
|
3352
|
-
const
|
|
3353
|
-
const priority = await st.nextPriority("task",
|
|
3876
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
3877
|
+
const priority = await st.nextPriority("task", phaseId);
|
|
3354
3878
|
const now = nowISO();
|
|
3355
3879
|
const initialStatus = params.status ?? "planned";
|
|
3356
|
-
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3357
3880
|
const task = {
|
|
3358
|
-
id: taskId, phaseId
|
|
3881
|
+
id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
3359
3882
|
title: params.title,
|
|
3360
3883
|
status: initialStatus,
|
|
3361
3884
|
description: params.description ?? "",
|
|
3885
|
+
descriptionUpdatedAt: now,
|
|
3362
3886
|
notes: "",
|
|
3363
3887
|
statusLog: [],
|
|
3364
3888
|
decisions: [],
|
|
3365
3889
|
acceptedDecisions: [],
|
|
3366
|
-
checklist:
|
|
3890
|
+
checklist: params.checklist
|
|
3891
|
+
? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
|
|
3892
|
+
: [],
|
|
3893
|
+
subtasks: [],
|
|
3367
3894
|
dependsOn: [],
|
|
3368
3895
|
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3369
3896
|
completedAt: initialStatus === "done" ? now : "",
|
|
@@ -3371,14 +3898,15 @@ export default function planPiExtension(pi) {
|
|
|
3371
3898
|
};
|
|
3372
3899
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3373
3900
|
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3374
|
-
await st.updatePhase(
|
|
3901
|
+
await st.updatePhase(phaseId, (phase) => {
|
|
3375
3902
|
phase.tasks.push(task);
|
|
3376
3903
|
phase.taskIds.push(taskId);
|
|
3377
3904
|
phase.updatedAt = now;
|
|
3378
3905
|
return phase;
|
|
3379
3906
|
});
|
|
3380
3907
|
await st.writeGenerated();
|
|
3381
|
-
|
|
3908
|
+
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
3909
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
3382
3910
|
},
|
|
3383
3911
|
});
|
|
3384
3912
|
pi.registerTool({
|
|
@@ -3409,15 +3937,29 @@ export default function planPiExtension(pi) {
|
|
|
3409
3937
|
if (!st)
|
|
3410
3938
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3411
3939
|
// Find the phase hosting this task first.
|
|
3412
|
-
const
|
|
3413
|
-
const
|
|
3414
|
-
if (!
|
|
3940
|
+
const features = (await st.loadFeatures()).features;
|
|
3941
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3942
|
+
if (!found)
|
|
3415
3943
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3944
|
+
const hostPhase = found.phase;
|
|
3416
3945
|
const now = nowISO();
|
|
3417
3946
|
let updatedTask;
|
|
3947
|
+
// Validate the motivation requirement for restrictive status transitions,
|
|
3948
|
+
// mirroring the MCP adapter and the /planner task update command
|
|
3949
|
+
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
3950
|
+
if (params.status !== undefined &&
|
|
3951
|
+
params.status !== found.task.status &&
|
|
3952
|
+
needsMotivation(found.task.status, params.status)) {
|
|
3953
|
+
if (!params.motivation || !params.motivation.trim()) {
|
|
3954
|
+
return {
|
|
3955
|
+
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.` }],
|
|
3956
|
+
details: {},
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3418
3960
|
try {
|
|
3419
3961
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3420
|
-
const task = phase.tasks.find((t) => t.id ===
|
|
3962
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3421
3963
|
if (!task)
|
|
3422
3964
|
return phase;
|
|
3423
3965
|
if (params.title !== undefined)
|
|
@@ -3433,7 +3975,7 @@ export default function planPiExtension(pi) {
|
|
|
3433
3975
|
if (params.acceptedDecisions !== undefined)
|
|
3434
3976
|
task.acceptedDecisions = params.acceptedDecisions;
|
|
3435
3977
|
if (params.checklist !== undefined) {
|
|
3436
|
-
task.checklist = params.checklist.map((itemTitle, index) => ({
|
|
3978
|
+
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
3437
3979
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
3438
3980
|
title: itemTitle,
|
|
3439
3981
|
checked: false,
|
|
@@ -3465,6 +4007,7 @@ export default function planPiExtension(pi) {
|
|
|
3465
4007
|
}
|
|
3466
4008
|
if (!updatedTask)
|
|
3467
4009
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4010
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
3468
4011
|
await st.writeGenerated();
|
|
3469
4012
|
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
|
|
3470
4013
|
},
|
|
@@ -3480,13 +4023,13 @@ export default function planPiExtension(pi) {
|
|
|
3480
4023
|
const st = await requirePlan(ctx);
|
|
3481
4024
|
if (!st)
|
|
3482
4025
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3483
|
-
const
|
|
3484
|
-
const
|
|
3485
|
-
if (!
|
|
4026
|
+
const features = (await st.loadFeatures()).features;
|
|
4027
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4028
|
+
if (!found)
|
|
3486
4029
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3487
|
-
await st.updatePhase(
|
|
3488
|
-
phase.tasks = phase.tasks.filter((t) => t.id !==
|
|
3489
|
-
phase.taskIds = phase.taskIds.filter((id) => id !==
|
|
4030
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4031
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
|
|
4032
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
|
|
3490
4033
|
phase.updatedAt = nowISO();
|
|
3491
4034
|
return phase;
|
|
3492
4035
|
});
|
|
@@ -3495,10 +4038,65 @@ export default function planPiExtension(pi) {
|
|
|
3495
4038
|
},
|
|
3496
4039
|
});
|
|
3497
4040
|
// ── task start / complete tools ──────────────────────────────────
|
|
4041
|
+
pi.registerTool({
|
|
4042
|
+
name: "task_recommend",
|
|
4043
|
+
label: "Task Recommend",
|
|
4044
|
+
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.",
|
|
4045
|
+
parameters: Type.Object({}),
|
|
4046
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4047
|
+
const st = await requirePlan(ctx);
|
|
4048
|
+
if (!st)
|
|
4049
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4050
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4051
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4052
|
+
if (!result.candidate)
|
|
4053
|
+
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4054
|
+
const { candidate } = result;
|
|
4055
|
+
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
4056
|
+
return {
|
|
4057
|
+
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}.` : ""}` }],
|
|
4058
|
+
details: result,
|
|
4059
|
+
};
|
|
4060
|
+
},
|
|
4061
|
+
});
|
|
4062
|
+
pi.registerTool({
|
|
4063
|
+
name: "task_deviation",
|
|
4064
|
+
label: "Task Deviation",
|
|
4065
|
+
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.",
|
|
4066
|
+
parameters: Type.Object({
|
|
4067
|
+
temporary_task: Type.String({ description: "Temporary task ref" }),
|
|
4068
|
+
resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
|
|
4069
|
+
reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
|
|
4070
|
+
}),
|
|
4071
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4072
|
+
const st = await requirePlan(ctx);
|
|
4073
|
+
if (!st)
|
|
4074
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4075
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4076
|
+
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4077
|
+
if (!temporary)
|
|
4078
|
+
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4079
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4080
|
+
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4081
|
+
if (!resume)
|
|
4082
|
+
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
4083
|
+
if (temporary.task.id === resume.task.id)
|
|
4084
|
+
return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
|
|
4085
|
+
if (temporary.task.status !== "planned")
|
|
4086
|
+
return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
|
|
4087
|
+
const timestamp = nowISO();
|
|
4088
|
+
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: "" };
|
|
4089
|
+
await st.addWorkDeviation(record);
|
|
4090
|
+
await st.writeGenerated();
|
|
4091
|
+
const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
|
|
4092
|
+
const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
|
|
4093
|
+
return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
|
|
4094
|
+
},
|
|
4095
|
+
});
|
|
3498
4096
|
pi.registerTool({
|
|
3499
4097
|
name: "task_start",
|
|
3500
4098
|
label: "Task Start",
|
|
3501
|
-
description: "Set a task to in-progress.
|
|
4099
|
+
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.",
|
|
3502
4100
|
parameters: Type.Object({
|
|
3503
4101
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3504
4102
|
}),
|
|
@@ -3506,19 +4104,40 @@ export default function planPiExtension(pi) {
|
|
|
3506
4104
|
const st = await requirePlan(ctx);
|
|
3507
4105
|
if (!st)
|
|
3508
4106
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3509
|
-
const
|
|
3510
|
-
const
|
|
3511
|
-
|
|
3512
|
-
if (!task || !hostPhase)
|
|
4107
|
+
const features = (await st.loadFeatures()).features;
|
|
4108
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4109
|
+
if (!found)
|
|
3513
4110
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4111
|
+
const task = found.task;
|
|
3514
4112
|
if (task.status === "in-progress")
|
|
3515
4113
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3516
4114
|
if (task.status === "done")
|
|
3517
4115
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
4116
|
+
const project = await st.loadProject();
|
|
4117
|
+
const phases = await st.loadAllPhases();
|
|
4118
|
+
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4119
|
+
if (!eligibility.eligible)
|
|
4120
|
+
return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
|
|
4121
|
+
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4122
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4123
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
4124
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
4125
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
4126
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
4127
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
4128
|
+
]);
|
|
4129
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
4130
|
+
const advisory = selection.kind === "conflict"
|
|
4131
|
+
? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
|
|
4132
|
+
: selection.candidate && selection.candidate.task.id !== task.id
|
|
4133
|
+
? selection.kind === "active"
|
|
4134
|
+
? `\n\n⚠️ Active-task advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is already in progress. Explicit task request honored.`
|
|
4135
|
+
: `\n\n⚠️ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
|
|
4136
|
+
: "";
|
|
3518
4137
|
const now = nowISO();
|
|
3519
4138
|
let startedTask;
|
|
3520
|
-
await st.updatePhase(
|
|
3521
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4139
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4140
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3522
4141
|
if (!t)
|
|
3523
4142
|
return phase;
|
|
3524
4143
|
applyTaskLifecycleDates(t, "in-progress", now);
|
|
@@ -3527,10 +4146,14 @@ export default function planPiExtension(pi) {
|
|
|
3527
4146
|
startedTask = t;
|
|
3528
4147
|
return phase;
|
|
3529
4148
|
});
|
|
4149
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
4150
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
|
|
4151
|
+
if (approvedDeviation)
|
|
4152
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", now);
|
|
3530
4153
|
await st.writeGenerated();
|
|
3531
4154
|
if (!startedTask)
|
|
3532
4155
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3533
|
-
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.
|
|
4156
|
+
return { content: [{ type: "text", text: `✅ Task started: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(startedTask.number).padStart(3, "0")} — ${startedTask.title} (in-progress)${startedTask.shortId ? ` · ${startedTask.shortId}` : ""}${phaseContext}${advisory}` }], details: startedTask };
|
|
3534
4157
|
},
|
|
3535
4158
|
});
|
|
3536
4159
|
pi.registerTool({
|
|
@@ -3546,11 +4169,11 @@ export default function planPiExtension(pi) {
|
|
|
3546
4169
|
const st = await requirePlan(ctx);
|
|
3547
4170
|
if (!st)
|
|
3548
4171
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3549
|
-
const
|
|
3550
|
-
const
|
|
3551
|
-
|
|
3552
|
-
if (!task || !hostPhase)
|
|
4172
|
+
const features = (await st.loadFeatures()).features;
|
|
4173
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4174
|
+
if (!found)
|
|
3553
4175
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4176
|
+
const task = found.task;
|
|
3554
4177
|
if (task.status === "done")
|
|
3555
4178
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3556
4179
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -3562,8 +4185,8 @@ export default function planPiExtension(pi) {
|
|
|
3562
4185
|
}
|
|
3563
4186
|
const now = nowISO();
|
|
3564
4187
|
let completedTask;
|
|
3565
|
-
await st.updatePhase(
|
|
3566
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4188
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4189
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3567
4190
|
if (!t)
|
|
3568
4191
|
return phase;
|
|
3569
4192
|
applyTaskLifecycleDates(t, "done", now);
|
|
@@ -3576,10 +4199,135 @@ export default function planPiExtension(pi) {
|
|
|
3576
4199
|
completedTask = t;
|
|
3577
4200
|
return phase;
|
|
3578
4201
|
});
|
|
4202
|
+
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
4203
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
4204
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
|
|
4205
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4206
|
+
if (completedDeviation)
|
|
4207
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
|
|
3579
4208
|
await st.writeGenerated();
|
|
3580
4209
|
if (!completedTask)
|
|
3581
4210
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3582
|
-
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)` }], details: completedTask };
|
|
4211
|
+
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
|
|
4212
|
+
},
|
|
4213
|
+
});
|
|
4214
|
+
pi.registerTool({
|
|
4215
|
+
name: "task_checklist_toggle",
|
|
4216
|
+
label: "Task Checklist Toggle",
|
|
4217
|
+
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.",
|
|
4218
|
+
parameters: Type.Object({
|
|
4219
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4220
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4221
|
+
checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
|
|
4222
|
+
}),
|
|
4223
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4224
|
+
const st = await requirePlan(ctx);
|
|
4225
|
+
if (!st)
|
|
4226
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4227
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4228
|
+
if (!found)
|
|
4229
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4230
|
+
let result = "";
|
|
4231
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4232
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4233
|
+
if (!task) {
|
|
4234
|
+
result = `Task not found: ${params.taskId}`;
|
|
4235
|
+
return phase;
|
|
4236
|
+
}
|
|
4237
|
+
const ck = task.checklist ?? [];
|
|
4238
|
+
if (ck.length === 0) {
|
|
4239
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4240
|
+
return phase;
|
|
4241
|
+
}
|
|
4242
|
+
const target = toggleChecklistItem(ck, params.item, params.checked);
|
|
4243
|
+
if (!target) {
|
|
4244
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4245
|
+
return phase;
|
|
4246
|
+
}
|
|
4247
|
+
task.updatedAt = nowISO();
|
|
4248
|
+
phase.updatedAt = nowISO();
|
|
4249
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
4250
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
4251
|
+
return phase;
|
|
4252
|
+
});
|
|
4253
|
+
await st.writeGenerated();
|
|
4254
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4255
|
+
},
|
|
4256
|
+
});
|
|
4257
|
+
pi.registerTool({
|
|
4258
|
+
name: "task_checklist_add",
|
|
4259
|
+
label: "Task Checklist Add",
|
|
4260
|
+
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.",
|
|
4261
|
+
parameters: Type.Object({
|
|
4262
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4263
|
+
title: Type.String({ description: "Checklist item text (a single step)" }),
|
|
4264
|
+
}),
|
|
4265
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4266
|
+
const st = await requirePlan(ctx);
|
|
4267
|
+
if (!st)
|
|
4268
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4269
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4270
|
+
if (!found)
|
|
4271
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4272
|
+
let result = "";
|
|
4273
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4274
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4275
|
+
if (!task) {
|
|
4276
|
+
result = `Task not found: ${params.taskId}`;
|
|
4277
|
+
return phase;
|
|
4278
|
+
}
|
|
4279
|
+
const ck = task.checklist ?? [];
|
|
4280
|
+
const item = addChecklistItem(ck, task.id, params.title);
|
|
4281
|
+
ck.push(item);
|
|
4282
|
+
task.checklist = ck;
|
|
4283
|
+
task.updatedAt = nowISO();
|
|
4284
|
+
phase.updatedAt = nowISO();
|
|
4285
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
4286
|
+
return phase;
|
|
4287
|
+
});
|
|
4288
|
+
await st.writeGenerated();
|
|
4289
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4290
|
+
},
|
|
4291
|
+
});
|
|
4292
|
+
pi.registerTool({
|
|
4293
|
+
name: "task_checklist_remove",
|
|
4294
|
+
label: "Task Checklist Remove",
|
|
4295
|
+
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.",
|
|
4296
|
+
parameters: Type.Object({
|
|
4297
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4298
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4299
|
+
}),
|
|
4300
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4301
|
+
const st = await requirePlan(ctx);
|
|
4302
|
+
if (!st)
|
|
4303
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4304
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4305
|
+
if (!found)
|
|
4306
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4307
|
+
let result = "";
|
|
4308
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4309
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4310
|
+
if (!task) {
|
|
4311
|
+
result = `Task not found: ${params.taskId}`;
|
|
4312
|
+
return phase;
|
|
4313
|
+
}
|
|
4314
|
+
const ck = task.checklist ?? [];
|
|
4315
|
+
if (ck.length === 0) {
|
|
4316
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4317
|
+
return phase;
|
|
4318
|
+
}
|
|
4319
|
+
const removed = removeChecklistItem(ck, params.item);
|
|
4320
|
+
if (!removed) {
|
|
4321
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4322
|
+
return phase;
|
|
4323
|
+
}
|
|
4324
|
+
task.updatedAt = nowISO();
|
|
4325
|
+
phase.updatedAt = nowISO();
|
|
4326
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
4327
|
+
return phase;
|
|
4328
|
+
});
|
|
4329
|
+
await st.writeGenerated();
|
|
4330
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
3583
4331
|
},
|
|
3584
4332
|
});
|
|
3585
4333
|
// ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
|
|
@@ -3656,7 +4404,7 @@ export default function planPiExtension(pi) {
|
|
|
3656
4404
|
description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
|
|
3657
4405
|
parameters: Type.Object({}),
|
|
3658
4406
|
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
3659
|
-
|
|
4407
|
+
disablePlannerSession();
|
|
3660
4408
|
await stopServer().catch(() => { });
|
|
3661
4409
|
try {
|
|
3662
4410
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -3673,15 +4421,9 @@ export default function planPiExtension(pi) {
|
|
|
3673
4421
|
if (!(await st.exists()))
|
|
3674
4422
|
return;
|
|
3675
4423
|
try {
|
|
3676
|
-
//
|
|
3677
|
-
//
|
|
3678
|
-
// add seconds of latency on every turn if run unconditionally.
|
|
4424
|
+
// Session initialization must not mutate canonical planner entities.
|
|
4425
|
+
// Migrations/backfills/repairs are explicit tools; resume is `.local`.
|
|
3679
4426
|
if (!plannerHeavyInitDone) {
|
|
3680
|
-
await migrateToUuids(st);
|
|
3681
|
-
await migrateToGlobalSequence(st).catch(() => null);
|
|
3682
|
-
await st.ensureShortIdsAndPriority().catch(() => null);
|
|
3683
|
-
await ensureProjectLanguagePreferences(st).catch(() => null);
|
|
3684
|
-
await maybeHealStatuses(st, ctx);
|
|
3685
4427
|
await st.refreshResume();
|
|
3686
4428
|
plannerHeavyInitDone = true;
|
|
3687
4429
|
}
|
|
@@ -3711,8 +4453,9 @@ export default function planPiExtension(pi) {
|
|
|
3711
4453
|
const profile = await st.loadCodebaseProfile();
|
|
3712
4454
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3713
4455
|
const activity = await st.loadActivityLog();
|
|
3714
|
-
const recentActivity = activity.entries.slice(-
|
|
4456
|
+
const recentActivity = activity.entries.slice(-3).reverse();
|
|
3715
4457
|
const handoffs = await st.listHandoffs();
|
|
4458
|
+
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
3716
4459
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3717
4460
|
const orderedPhases = [
|
|
3718
4461
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3725,21 +4468,9 @@ export default function planPiExtension(pi) {
|
|
|
3725
4468
|
...plan.phases.filter((phase) => !phase.featureId)
|
|
3726
4469
|
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3727
4470
|
];
|
|
3728
|
-
const
|
|
3729
|
-
.
|
|
3730
|
-
.
|
|
3731
|
-
const active = orderedPhases
|
|
3732
|
-
.filter((p) => p.status === "in-progress")
|
|
3733
|
-
.map((p) => `\`${p.id}\``)
|
|
3734
|
-
.join(", ");
|
|
3735
|
-
const doneFeaturesCount = plan.features.features.filter((f) => f.status === "done").length;
|
|
3736
|
-
const activeFeaturesCount = plan.features.features.filter((f) => f.status === "in-progress").length;
|
|
3737
|
-
const donePhasesCount = plan.phases.filter((p) => p.status === "done").length;
|
|
3738
|
-
const activePhasesCount = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
|
|
3739
|
-
const totalTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
3740
|
-
const doneTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "done").length, 0);
|
|
3741
|
-
const activeTasksCount = orderedPhases.reduce((total, phase) => total + phase.tasks.filter((task) => task.status === "in-progress").length, 0);
|
|
3742
|
-
const hasActiveWork = activeFeaturesCount > 0 || activePhasesCount > 0 || activeTasksCount > 0;
|
|
4471
|
+
const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
|
|
4472
|
+
|| plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
|
|
4473
|
+
|| orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
3743
4474
|
const currentPhase = hasActiveWork
|
|
3744
4475
|
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3745
4476
|
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
@@ -3756,16 +4487,51 @@ export default function planPiExtension(pi) {
|
|
|
3756
4487
|
?? null)
|
|
3757
4488
|
: null;
|
|
3758
4489
|
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3759
|
-
?
|
|
4490
|
+
? featureById.get(currentPhase.featureId) ?? null
|
|
3760
4491
|
: hasActiveWork
|
|
3761
4492
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3762
4493
|
: null;
|
|
3763
|
-
const
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
4494
|
+
const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
|
|
4495
|
+
const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
|
|
4496
|
+
const currentTaskRef = currentTask && currentPhase
|
|
4497
|
+
? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
|
|
4498
|
+
: "";
|
|
4499
|
+
const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
|
|
4500
|
+
const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
|
|
4501
|
+
for (const phase of plan.phases) {
|
|
4502
|
+
phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
|
|
4503
|
+
}
|
|
4504
|
+
const phaseCountsSummary = phaseStatusOrder
|
|
4505
|
+
.map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
|
|
4506
|
+
.join(" ");
|
|
4507
|
+
const activeTaskMap = new Map();
|
|
4508
|
+
for (const phase of orderedPhases) {
|
|
4509
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4510
|
+
for (const task of phase.tasks) {
|
|
4511
|
+
if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
|
|
4512
|
+
continue;
|
|
4513
|
+
activeTaskMap.set(task.id, { phase, task, featureNumber });
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
const inProgressTaskLines = [...activeTaskMap.values()]
|
|
4517
|
+
.sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
|
|
4518
|
+
.map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
|
|
4519
|
+
const otherActiveOrBlockedPhases = orderedPhases
|
|
4520
|
+
.filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
|
|
4521
|
+
.slice(0, 6)
|
|
4522
|
+
.map((phase) => {
|
|
4523
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4524
|
+
return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
|
|
4525
|
+
});
|
|
4526
|
+
const openQuestions = plan.phases
|
|
4527
|
+
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
4528
|
+
.slice(0, 8);
|
|
4529
|
+
const acceptedDecisions = [
|
|
4530
|
+
...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
|
|
4531
|
+
...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
|
|
4532
|
+
...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
|
|
4533
|
+
...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}`))),
|
|
4534
|
+
].slice(0, 20);
|
|
3769
4535
|
const webServer = server;
|
|
3770
4536
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3771
4537
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3778,15 +4544,6 @@ export default function planPiExtension(pi) {
|
|
|
3778
4544
|
catch { }
|
|
3779
4545
|
const webUrl = webLocalUrl;
|
|
3780
4546
|
const webUrlFull = webLanUrl ? `${webLocalUrl} (LAN: ${webLanUrl})` : webLocalUrl;
|
|
3781
|
-
const openQuestions = plan.phases
|
|
3782
|
-
.flatMap((p) => p.openQuestions.map((q) => `[${p.id}] ${q}`))
|
|
3783
|
-
.slice(0, 12);
|
|
3784
|
-
const acceptedDecisions = [
|
|
3785
|
-
...project.acceptedDecisions.map((d) => `[project] ${d.title}`),
|
|
3786
|
-
...plan.features.features.flatMap((f) => f.acceptedDecisions.map((d) => `[feature ${f.id}] ${d.title}`)),
|
|
3787
|
-
...plan.phases.flatMap((p) => p.acceptedDecisions.map((d) => `[phase ${p.id}] ${d.title}`)),
|
|
3788
|
-
...plan.phases.flatMap((p) => p.tasks.flatMap((t) => t.acceptedDecisions.map((d) => `[task ${t.id}] ${d.title}`))),
|
|
3789
|
-
].slice(0, 20);
|
|
3790
4547
|
const pkgSummary = profile?.packageJson
|
|
3791
4548
|
? `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}`
|
|
3792
4549
|
: "(no package.json)";
|
|
@@ -3808,96 +4565,63 @@ export default function planPiExtension(pi) {
|
|
|
3808
4565
|
// Build/refresh the context block (slow path: cache miss).
|
|
3809
4566
|
const contextBlock = [
|
|
3810
4567
|
`[Plan Context — ${project.name}]`,
|
|
3811
|
-
// ── Web UI status (prominent — agent must surface URL in its summary) ──
|
|
3812
4568
|
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'.`,
|
|
3813
4569
|
"",
|
|
3814
|
-
|
|
3815
|
-
"",
|
|
3816
|
-
"
|
|
3817
|
-
"
|
|
3818
|
-
"
|
|
3819
|
-
"
|
|
3820
|
-
"
|
|
3821
|
-
"
|
|
3822
|
-
"4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
|
|
3823
|
-
"5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
|
|
3824
|
-
"6. ENTITY REFERENCES & FAST LOOKUP: Feature/Phase/Task numbers are GLOBALLY unique (one P003, one T007 in the whole plan) — bare P00x/T00x is a valid unambiguous ref. PREFER the 5-char shortId (e.g. UUXD1) or composite F00x/P00x/T00x in chat; NEVER a raw UUID. To FIND an entity: call feature_list / phase_list / task_list (compact, ~2K tokens for the whole plan) — do NOT read .planner/*.json files or plan_get full=true (~32K tokens) to locate an entity. To ACT: pass the ref directly to the action tool (task_start/complete/update/phase_update accept F00x/P00x/T00x, bare P00x/T00x, shortId, UUID, title). To read a description: call task_get / phase_get / feature_get with full=true for that ONE entity. Tool responses already show shortId + compositeRef; reuse them.",
|
|
3825
|
-
"═══════════════════════════════════════════════════════════════",
|
|
4570
|
+
"Operational rules:",
|
|
4571
|
+
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4572
|
+
"- 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.",
|
|
4573
|
+
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4574
|
+
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4575
|
+
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4576
|
+
"- 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).",
|
|
4577
|
+
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
3826
4578
|
"",
|
|
3827
|
-
// ── Project details ──
|
|
3828
4579
|
`Goal: ${project.goal || "(not set)"}`,
|
|
3829
4580
|
project.description ? `Description: ${project.description}` : "",
|
|
3830
4581
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
3831
4582
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
3832
4583
|
`Ambient: ${ambientSummary}`,
|
|
3833
4584
|
"",
|
|
3834
|
-
"Global rules
|
|
3835
|
-
project.globalRules.length ? project.globalRules.map((
|
|
3836
|
-
"",
|
|
3837
|
-
"Workflow rules:",
|
|
3838
|
-
` before phase start: ${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}`,
|
|
3839
|
-
` before task start: ${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}`,
|
|
3840
|
-
` after phase complete: ${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
4585
|
+
"Global rules:",
|
|
4586
|
+
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
3841
4587
|
"",
|
|
4588
|
+
`Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
3842
4589
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
3843
4590
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
3844
4591
|
"",
|
|
3845
4592
|
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
3846
|
-
...acceptedDecisions.map((
|
|
4593
|
+
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
3847
4594
|
openQuestions.length ? "" : "",
|
|
3848
4595
|
openQuestions.length ? "Open questions:" : "",
|
|
3849
|
-
...openQuestions.map((
|
|
4596
|
+
...openQuestions.map((question) => `- ${question}`),
|
|
3850
4597
|
"",
|
|
3851
|
-
"Resume focus (
|
|
3852
|
-
|
|
3853
|
-
|
|
4598
|
+
"Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
|
|
4599
|
+
currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
|
|
4600
|
+
currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
|
|
4601
|
+
currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
|
|
4602
|
+
inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
|
|
3854
4603
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3855
|
-
|
|
3856
|
-
|
|
4604
|
+
handoffs.length === 0 && resume.nextSteps.length
|
|
4605
|
+
? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
|
|
4606
|
+
: "",
|
|
4607
|
+
"",
|
|
4608
|
+
"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.",
|
|
3857
4609
|
"",
|
|
3858
|
-
|
|
3859
|
-
|
|
4610
|
+
"Phase overview:",
|
|
4611
|
+
` counts: ${phaseCountsSummary}`,
|
|
4612
|
+
currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
|
|
4613
|
+
otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
|
|
4614
|
+
...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
|
|
3860
4615
|
"",
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
phaseLines || " _none_",
|
|
3864
|
-
active ? `Active: ${active}` : "",
|
|
4616
|
+
recentActivity.length ? "Recent activity:" : "",
|
|
4617
|
+
...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
|
|
3865
4618
|
"",
|
|
3866
|
-
"
|
|
3867
|
-
|
|
3868
|
-
"
|
|
3869
|
-
"- Feature commands: planner feature list|add|show|update|delete.",
|
|
3870
|
-
"- Phase commands: planner phase add|show|discuss|update|delete.",
|
|
3871
|
-
"- Task commands: planner task add|show|discuss|update|delete|start|complete.",
|
|
3872
|
-
"- Handoff commands: planner handoff prepare|show|write|clear.",
|
|
3873
|
-
"- Web/session commands: planner web start|stop|status, planner export|export-full, planner load, planner disable, planner repair.",
|
|
3874
|
-
"Legacy flat aliases may also exist for backward compatibility, but prefer grouped `/planner ...` forms.",
|
|
3875
|
-
"Planner discuss mode is Agent Plan only: ignore GSD workflows/skills/commands unless the user explicitly asks for GSD.",
|
|
3876
|
-
`Language preferences: plan content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}.`,
|
|
3877
|
-
"If these language preferences are already set, follow them automatically and do NOT ask again in later sessions.",
|
|
3878
|
-
"Ask for language only if BOTH preferences are unset.",
|
|
3879
|
-
"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.",
|
|
3880
|
-
"Persist accepted decisions into project data, not only chat. Use acceptedDecisions on project/feature/phase/task when the user accepts a decision or solution.",
|
|
3881
|
-
"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.",
|
|
3882
|
-
"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.",
|
|
3883
|
-
"Before starting any phase/task, emit a recap of current state and ask the user if requirements/dependencies changed before planning tasks.",
|
|
4619
|
+
handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
|
|
4620
|
+
...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
|
|
4621
|
+
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." : "",
|
|
3884
4622
|
"",
|
|
3885
|
-
|
|
3886
|
-
"
|
|
3887
|
-
"- AFTER you finish implementation work on ANY task, you MUST call task_complete to set it to done. This sets completedAt.",
|
|
3888
|
-
"- Phase and feature statuses are DERIVED from task statuses. They are not the primary source of truth.",
|
|
3889
|
-
"- 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.",
|
|
3890
|
-
"- 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.",
|
|
3891
|
-
"- 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.",
|
|
3892
|
-
"- 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.",
|
|
3893
|
-
"- Use task_complete with force=true only if you have a good reason to skip the checklist check.",
|
|
3894
|
-
"- To reopen a completed task, use task_start (recommended) or task_update.",
|
|
3895
|
-
handoffs.length > 0 ? `Pending phase handoffs (${handoffs.length}):` : "",
|
|
3896
|
-
...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
|
|
3897
|
-
handoffs.length > 0 ? "Read the relevant one with handoff show <ref> (or /planner handoff show <ref>) as previous-session context, but validate it against the current planner state, ordering, and dependencies before treating any target as the next task." : "",
|
|
3898
|
-
handoffs.length > 0 ? "If planner state shows no task/phase in-progress, do NOT present a handoff target as the current focus; present it only as a candidate to validate." : "",
|
|
3899
|
-
handoffs.length > 0 ? "Handoffs are KEPT until a task in the phase starts (auto-archived to .planner/handoff-archive/) or the phase completes — read with handoff show <ref> for context; no manual clear needed." : "",
|
|
3900
|
-
"Plan tools available: project_set_language_preferences, plan_init, project_update, requirement_list, requirement_create, requirement_update, requirement_delete, plan_get, feature_list, feature_get, feature_create, feature_update, feature_delete, phase_list, phase_get, phase_create, phase_update, phase_delete, task_list, task_get, task_create, task_update, task_delete, task_start, task_complete, plan_render, plan_get_handoff, plan_write_handoff, plan_delete_handoff, handoff_list, handoff_show, handoff_write, handoff_clear, plan_authorize_bypass, plan_clear_bypass",
|
|
4623
|
+
`Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
|
|
4624
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
|
|
3901
4625
|
].filter(Boolean).join("\n");
|
|
3902
4626
|
contextBlockCache = contextBlock;
|
|
3903
4627
|
contextBlockDirty = false;
|