@agent-plan/pi-adapter 0.2.19-next.8 → 0.2.19
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 +1055 -335
- package/package.json +3 -3
- package/web-ui-dist/assets/index-CTAfas5X.css +1 -0
- package/web-ui-dist/assets/index-rFM0JSW_.js +293 -0
- package/web-ui-dist/index.html +3 -2
- package/web-ui-dist/assets/index-Bp9VqCa6.css +0 -1
- package/web-ui-dist/assets/index-CWwCyVYf.js +0 -211
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook,
|
|
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,13 +1469,13 @@ 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() ?? "",
|
|
@@ -1374,6 +1490,7 @@ export default function planPiExtension(pi) {
|
|
|
1374
1490
|
acceptedDecisions: [],
|
|
1375
1491
|
phaseIds: [],
|
|
1376
1492
|
dependsOn: [],
|
|
1493
|
+
statusLog: [],
|
|
1377
1494
|
createdAt: now,
|
|
1378
1495
|
updatedAt: now,
|
|
1379
1496
|
};
|
|
@@ -1411,6 +1528,44 @@ export default function planPiExtension(pi) {
|
|
|
1411
1528
|
].filter(Boolean).join("\n"), "info");
|
|
1412
1529
|
return;
|
|
1413
1530
|
}
|
|
1531
|
+
if (b === "discuss") {
|
|
1532
|
+
const featuresDoc = await st.loadFeatures();
|
|
1533
|
+
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
1534
|
+
if (!feature) {
|
|
1535
|
+
feature = await pickFeature();
|
|
1536
|
+
if (!feature) {
|
|
1537
|
+
ctx.ui.notify("No features to discuss. Create one first.", "warning");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const description = await ctx.ui.editor("Feature description / scope / current implementation state", feature.description || "");
|
|
1542
|
+
const workDone = await ctx.ui.editor("Work done / already decided", feature.workDone || "");
|
|
1543
|
+
const workRemaining = await ctx.ui.editor("Work remaining / next work", feature.workRemaining || "");
|
|
1544
|
+
const dependencies = await ctx.ui.input(`Dependencies (comma-separated) [${feature.dependsOn.join(", ") || ""}]`);
|
|
1545
|
+
const featureId = feature.id;
|
|
1546
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
1547
|
+
const target = doc.features.find((entry) => entry.id === featureId);
|
|
1548
|
+
if (!target)
|
|
1549
|
+
return doc;
|
|
1550
|
+
if (description !== undefined)
|
|
1551
|
+
target.description = description.trim();
|
|
1552
|
+
if (workDone !== undefined)
|
|
1553
|
+
target.workDone = workDone.trim();
|
|
1554
|
+
if (workRemaining !== undefined)
|
|
1555
|
+
target.workRemaining = workRemaining.trim();
|
|
1556
|
+
if (dependencies !== undefined)
|
|
1557
|
+
target.dependsOn = splitCsv(dependencies);
|
|
1558
|
+
target.discussedAt = nowISO();
|
|
1559
|
+
target.contextReady = true;
|
|
1560
|
+
target.contextReadyReason = "Updated through /planner feature discuss.";
|
|
1561
|
+
target.updatedAt = nowISO();
|
|
1562
|
+
return doc;
|
|
1563
|
+
});
|
|
1564
|
+
feature = updatedDoc.features.find((entry) => entry.id === featureId) ?? feature;
|
|
1565
|
+
await st.writeGenerated();
|
|
1566
|
+
ctx.ui.notify(`Feature discussed/updated: ${formatFeatureRef(feature.number)}${feature.shortId ? ` +MI+ ${feature.shortId}` : ""} +EM+ ${feature.name}`, "info");
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1414
1569
|
if (b === "update") {
|
|
1415
1570
|
const featuresDoc = await st.loadFeatures();
|
|
1416
1571
|
let feature = subArgs.trim() ? findFeatureByRef(featuresDoc.features, subArgs.trim()) : null;
|
|
@@ -1509,7 +1664,7 @@ export default function planPiExtension(pi) {
|
|
|
1509
1664
|
ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
|
|
1510
1665
|
return;
|
|
1511
1666
|
}
|
|
1512
|
-
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, update, delete`, "warning");
|
|
1667
|
+
ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
|
|
1513
1668
|
return;
|
|
1514
1669
|
}
|
|
1515
1670
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1536,23 +1691,27 @@ export default function planPiExtension(pi) {
|
|
|
1536
1691
|
ctx.ui.notify("Phase creation cancelled: no feature selected.", "warning");
|
|
1537
1692
|
return;
|
|
1538
1693
|
}
|
|
1694
|
+
// This belongs to the creation transaction: Escape before the phase
|
|
1695
|
+
// is persisted cancels the whole command rather than creating a phase
|
|
1696
|
+
// and only then abandoning the follow-up question.
|
|
1697
|
+
const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
|
|
1539
1698
|
let phase;
|
|
1540
1699
|
await withFeatureLock(feature.id, async () => {
|
|
1541
1700
|
const phases = await st.loadAllPhases();
|
|
1542
1701
|
const featurePhases = phases.filter((p) => p.featureId === feature.id);
|
|
1543
|
-
const number = await st.allocPhaseNumber();
|
|
1544
1702
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1545
1703
|
const id = createPhaseId();
|
|
1546
|
-
const
|
|
1704
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
1547
1705
|
const priority = await st.nextPriority("phase", feature.id);
|
|
1548
1706
|
phase = {
|
|
1549
|
-
id, number, shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1707
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug, title, featureId: feature.id, status: "draft", discussedAt: "", contextReady: false, contextReadyReason: "", summary: "", description: "", notes: "",
|
|
1550
1708
|
goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
|
|
1551
1709
|
openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
|
|
1552
1710
|
createdAt: nowISO(), updatedAt: nowISO(),
|
|
1553
1711
|
handoff: "", handoffUpdatedAt: "",
|
|
1554
1712
|
handoffReadAt: "",
|
|
1555
1713
|
handoffHistory: [],
|
|
1714
|
+
statusLog: [],
|
|
1556
1715
|
};
|
|
1557
1716
|
await st.savePhase(phase);
|
|
1558
1717
|
await st.updateFeatures((doc) => {
|
|
@@ -1568,7 +1727,6 @@ export default function planPiExtension(pi) {
|
|
|
1568
1727
|
if (!phase)
|
|
1569
1728
|
return;
|
|
1570
1729
|
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
1730
|
if (isYes(startDiscuss)) {
|
|
1573
1731
|
await handlePlanner(`phase discuss ${phase.id}`, ctx);
|
|
1574
1732
|
}
|
|
@@ -1577,7 +1735,7 @@ export default function planPiExtension(pi) {
|
|
|
1577
1735
|
if (b === "discuss") {
|
|
1578
1736
|
let phase = null;
|
|
1579
1737
|
if (subArgs.trim()) {
|
|
1580
|
-
phase = findPhaseByRef(await st.loadAllPhases(), subArgs.trim());
|
|
1738
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1581
1739
|
}
|
|
1582
1740
|
if (!phase) {
|
|
1583
1741
|
phase = await pickPhase();
|
|
@@ -1589,9 +1747,6 @@ export default function planPiExtension(pi) {
|
|
|
1589
1747
|
if (phase.status !== "draft" && phase.status !== "discovery" && phase.status !== "planned") {
|
|
1590
1748
|
ctx.ui.notify(`Phase is already "${phase.status}". Re-discuss works best on draft/discovery/planned phases.`, "info");
|
|
1591
1749
|
}
|
|
1592
|
-
phase.status = "discovery";
|
|
1593
|
-
phase.updatedAt = nowISO();
|
|
1594
|
-
await st.savePhase(phase);
|
|
1595
1750
|
ctx.ui.notify(`Discovery for ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}…`, "info");
|
|
1596
1751
|
// ── Recap: give full context before planning tasks ──
|
|
1597
1752
|
const recapPlan = await st.loadAll();
|
|
@@ -1663,7 +1818,7 @@ export default function planPiExtension(pi) {
|
|
|
1663
1818
|
if (b === "show") {
|
|
1664
1819
|
let phase = null;
|
|
1665
1820
|
if (subArgs.trim()) {
|
|
1666
|
-
phase = await st.
|
|
1821
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1667
1822
|
}
|
|
1668
1823
|
if (!phase) {
|
|
1669
1824
|
phase = await pickPhase();
|
|
@@ -1689,7 +1844,7 @@ export default function planPiExtension(pi) {
|
|
|
1689
1844
|
if (b === "delete") {
|
|
1690
1845
|
let phase = null;
|
|
1691
1846
|
if (subArgs.trim()) {
|
|
1692
|
-
phase = await st.
|
|
1847
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1693
1848
|
}
|
|
1694
1849
|
if (!phase) {
|
|
1695
1850
|
phase = await pickPhase();
|
|
@@ -1711,7 +1866,7 @@ export default function planPiExtension(pi) {
|
|
|
1711
1866
|
if (b === "update") {
|
|
1712
1867
|
let phase = null;
|
|
1713
1868
|
if (subArgs.trim()) {
|
|
1714
|
-
phase = await st.
|
|
1869
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1715
1870
|
}
|
|
1716
1871
|
if (!phase) {
|
|
1717
1872
|
phase = await pickPhase();
|
|
@@ -1768,7 +1923,7 @@ export default function planPiExtension(pi) {
|
|
|
1768
1923
|
// ═══════════════════════════════════════════════════════════════
|
|
1769
1924
|
if (a === "task") {
|
|
1770
1925
|
if (!b) {
|
|
1771
|
-
ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id]", "info");
|
|
1926
|
+
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
1927
|
return;
|
|
1773
1928
|
}
|
|
1774
1929
|
if (b === "add") {
|
|
@@ -1782,14 +1937,16 @@ export default function planPiExtension(pi) {
|
|
|
1782
1937
|
ctx.ui.notify("Aborted", "warning");
|
|
1783
1938
|
return;
|
|
1784
1939
|
}
|
|
1940
|
+
// Keep the optional follow-up inside the creation transaction so
|
|
1941
|
+
// Escape can still cancel before a task is persisted.
|
|
1942
|
+
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1785
1943
|
const shortName = clampSlug(title, 30, `task-${Date.now().toString(36)}`);
|
|
1786
|
-
const taskNum = await st.allocTaskNumber();
|
|
1787
1944
|
const taskId = createTaskId();
|
|
1788
|
-
const
|
|
1945
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
1789
1946
|
const priority = await st.nextPriority("task", phase.id);
|
|
1790
1947
|
const now = nowISO();
|
|
1791
1948
|
const task = {
|
|
1792
|
-
id: taskId, phaseId: phase.id, number:
|
|
1949
|
+
id: taskId, phaseId: phase.id, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
1793
1950
|
title: title.trim(), status: "planned",
|
|
1794
1951
|
description: "",
|
|
1795
1952
|
notes: "",
|
|
@@ -1807,7 +1964,6 @@ export default function planPiExtension(pi) {
|
|
|
1807
1964
|
await st.savePhase(phase);
|
|
1808
1965
|
await st.writeGenerated();
|
|
1809
1966
|
ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
|
|
1810
|
-
const startDiscuss = await ctx.ui.input("Start task discuss now? Type yes to continue");
|
|
1811
1967
|
if (isYes(startDiscuss)) {
|
|
1812
1968
|
await handlePlanner(`task discuss ${taskId}`, ctx);
|
|
1813
1969
|
}
|
|
@@ -1839,7 +1995,7 @@ export default function planPiExtension(pi) {
|
|
|
1839
1995
|
if (description?.trim())
|
|
1840
1996
|
task.description = description.trim();
|
|
1841
1997
|
if (checklistSeed?.trim()) {
|
|
1842
|
-
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({
|
|
1998
|
+
task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
|
|
1843
1999
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
1844
2000
|
title: itemTitle,
|
|
1845
2001
|
checked: false,
|
|
@@ -1890,7 +2046,7 @@ export default function planPiExtension(pi) {
|
|
|
1890
2046
|
if (b === "delete") {
|
|
1891
2047
|
let phase = null;
|
|
1892
2048
|
if (subArgs.trim()) {
|
|
1893
|
-
phase = await st.
|
|
2049
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1894
2050
|
}
|
|
1895
2051
|
if (!phase) {
|
|
1896
2052
|
phase = await pickPhase();
|
|
@@ -1924,7 +2080,7 @@ export default function planPiExtension(pi) {
|
|
|
1924
2080
|
if (b === "update") {
|
|
1925
2081
|
let phase = null;
|
|
1926
2082
|
if (subArgs.trim()) {
|
|
1927
|
-
phase = await st.
|
|
2083
|
+
phase = findPhaseByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim()) ?? null;
|
|
1928
2084
|
}
|
|
1929
2085
|
if (!phase) {
|
|
1930
2086
|
phase = await pickPhase();
|
|
@@ -1966,6 +2122,7 @@ export default function planPiExtension(pi) {
|
|
|
1966
2122
|
task.updatedAt = now;
|
|
1967
2123
|
phase.updatedAt = now;
|
|
1968
2124
|
await st.savePhase(phase);
|
|
2125
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
1969
2126
|
await st.writeGenerated();
|
|
1970
2127
|
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
2128
|
return;
|
|
@@ -1998,17 +2155,9 @@ export default function planPiExtension(pi) {
|
|
|
1998
2155
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
1999
2156
|
return;
|
|
2000
2157
|
}
|
|
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
|
-
}
|
|
2158
|
+
// A pending handoff is context, not a lock: task start RETAINS it.
|
|
2159
|
+
// It is archived only when the phase completes (auto) or the user
|
|
2160
|
+
// explicitly clears it.
|
|
2012
2161
|
const now = nowISO();
|
|
2013
2162
|
// Record status change in the incremental statusLog.
|
|
2014
2163
|
const prevStatus = task.status;
|
|
@@ -2025,6 +2174,7 @@ export default function planPiExtension(pi) {
|
|
|
2025
2174
|
task.updatedAt = now;
|
|
2026
2175
|
phase.updatedAt = now;
|
|
2027
2176
|
await st.savePhase(phase);
|
|
2177
|
+
await st.syncTaskStatusRollup(phase.id);
|
|
2028
2178
|
await st.writeGenerated();
|
|
2029
2179
|
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
2180
|
return;
|
|
@@ -2079,12 +2229,80 @@ export default function planPiExtension(pi) {
|
|
|
2079
2229
|
applyTaskLifecycleDates(task, "done", now);
|
|
2080
2230
|
task.updatedAt = now;
|
|
2081
2231
|
phase.updatedAt = now;
|
|
2082
|
-
await st.
|
|
2232
|
+
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2233
|
+
await st.writeGenerated();
|
|
2234
|
+
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");
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
// checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
|
|
2238
|
+
if (b === "checklist-add" || b === "checklist-remove" || b === "checklist-toggle") {
|
|
2239
|
+
const [taskRef, ...itemParts] = rest;
|
|
2240
|
+
const itemText = itemParts.join(" ").trim();
|
|
2241
|
+
if (!taskRef || !itemText) {
|
|
2242
|
+
ctx.ui.notify(`Usage: /planner task ${b} <T00x> <${b === "checklist-add" ? "title" : "C{n}|title"}>${b === "checklist-toggle" ? " [on|off]" : ""}`, "warning");
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, taskRef.trim());
|
|
2246
|
+
if (!resolved) {
|
|
2247
|
+
ctx.ui.notify(`Task not found: ${taskRef}`, "warning");
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
let checked;
|
|
2251
|
+
if (b === "checklist-toggle") {
|
|
2252
|
+
const m = itemText.match(/\s+(on|off)$/i);
|
|
2253
|
+
if (m) {
|
|
2254
|
+
checked = m[1].toLowerCase() === "on";
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const selector = b === "checklist-toggle" ? itemText.replace(/\s+(on|off)$/i, "").trim() : itemText;
|
|
2258
|
+
let msg = "";
|
|
2259
|
+
await st.updatePhase(resolved.phase.id, (phase) => {
|
|
2260
|
+
const task = phase.tasks.find((t) => t.id === resolved.task.id);
|
|
2261
|
+
if (!task) {
|
|
2262
|
+
msg = `Task not found: ${taskRef}`;
|
|
2263
|
+
return phase;
|
|
2264
|
+
}
|
|
2265
|
+
const ck = task.checklist ?? [];
|
|
2266
|
+
if (b === "checklist-add") {
|
|
2267
|
+
const item = addChecklistItem(ck, task.id, selector);
|
|
2268
|
+
ck.push(item);
|
|
2269
|
+
task.checklist = ck;
|
|
2270
|
+
msg = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
2271
|
+
}
|
|
2272
|
+
else if (b === "checklist-remove") {
|
|
2273
|
+
if (ck.length === 0) {
|
|
2274
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2275
|
+
return phase;
|
|
2276
|
+
}
|
|
2277
|
+
const removed = removeChecklistItem(ck, selector);
|
|
2278
|
+
if (!removed) {
|
|
2279
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2280
|
+
return phase;
|
|
2281
|
+
}
|
|
2282
|
+
msg = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
2283
|
+
}
|
|
2284
|
+
else {
|
|
2285
|
+
if (ck.length === 0) {
|
|
2286
|
+
msg = `Task "${task.title}" has no checklist.`;
|
|
2287
|
+
return phase;
|
|
2288
|
+
}
|
|
2289
|
+
const target = toggleChecklistItem(ck, selector, checked);
|
|
2290
|
+
if (!target) {
|
|
2291
|
+
msg = `No checklist item matching "${selector}".`;
|
|
2292
|
+
return phase;
|
|
2293
|
+
}
|
|
2294
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
2295
|
+
msg = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
2296
|
+
}
|
|
2297
|
+
task.updatedAt = nowISO();
|
|
2298
|
+
phase.updatedAt = nowISO();
|
|
2299
|
+
return phase;
|
|
2300
|
+
});
|
|
2083
2301
|
await st.writeGenerated();
|
|
2084
|
-
ctx.ui.notify(`✅
|
|
2302
|
+
ctx.ui.notify(`✅ ${msg}`, "info");
|
|
2085
2303
|
return;
|
|
2086
2304
|
}
|
|
2087
|
-
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete`, "warning");
|
|
2305
|
+
ctx.ui.notify(`Unknown task action "${b}". Try: add, show, discuss, delete, update, start, complete, checklist-add, checklist-remove, checklist-toggle`, "warning");
|
|
2088
2306
|
return;
|
|
2089
2307
|
}
|
|
2090
2308
|
// ── discuss (legacy alias) ──
|
|
@@ -2130,6 +2348,11 @@ export default function planPiExtension(pi) {
|
|
|
2130
2348
|
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2131
2349
|
return;
|
|
2132
2350
|
}
|
|
2351
|
+
const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
|
|
2352
|
+
if (!confirmed) {
|
|
2353
|
+
ctx.ui.notify("Handoff write cancelled.", "info");
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2133
2356
|
await st.setPhaseHandoff(r.phase.id, text);
|
|
2134
2357
|
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2135
2358
|
return;
|
|
@@ -2139,22 +2362,17 @@ export default function planPiExtension(pi) {
|
|
|
2139
2362
|
ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
|
|
2140
2363
|
return;
|
|
2141
2364
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
"
|
|
2365
|
+
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" +
|
|
2366
|
+
"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" +
|
|
2367
|
+
"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" +
|
|
2368
|
+
"After confirmation, the handoff MUST contain at minimum:\n" +
|
|
2145
2369
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2146
2370
|
"- `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.");
|
|
2371
|
+
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2372
|
+
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2373
|
+
"- `## How to resume`: explicit ordered steps\n" +
|
|
2374
|
+
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2375
|
+
"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
2376
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2159
2377
|
return;
|
|
2160
2378
|
}
|
|
@@ -2223,7 +2441,26 @@ export default function planPiExtension(pi) {
|
|
|
2223
2441
|
const m = report.migrated;
|
|
2224
2442
|
const dup = report.integrity.duplicatePhaseIds.length;
|
|
2225
2443
|
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");
|
|
2444
|
+
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");
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
if (a === "cleanup-orphans") {
|
|
2448
|
+
const found = await st.listOrphanPhases();
|
|
2449
|
+
if (found.length === 0) {
|
|
2450
|
+
ctx.ui.notify("No orphan phases found.", "info");
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
const lines = [
|
|
2454
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}:`,
|
|
2455
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2456
|
+
];
|
|
2457
|
+
const confirmed = await ctx.ui.confirm(lines.join("\n\n"), "Delete orphan phase files?");
|
|
2458
|
+
if (!confirmed) {
|
|
2459
|
+
ctx.ui.notify("Cleanup aborted.", "warning");
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
const report = await st.cleanupOrphanPhases();
|
|
2463
|
+
ctx.ui.notify(`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`, "info");
|
|
2227
2464
|
return;
|
|
2228
2465
|
}
|
|
2229
2466
|
// ── export ──
|
|
@@ -2279,6 +2516,10 @@ export default function planPiExtension(pi) {
|
|
|
2279
2516
|
startupResumeSummaryPending = false;
|
|
2280
2517
|
startupResumeSummaryTimer = null;
|
|
2281
2518
|
}, 60000);
|
|
2519
|
+
// Safety timeout must not keep the Node process alive on its own
|
|
2520
|
+
// (relevant for test hosts that load this entrypoint). Inside Pi the
|
|
2521
|
+
// process is long-lived, so unref is a no-op there.
|
|
2522
|
+
startupResumeSummaryTimer.unref?.();
|
|
2282
2523
|
// Build the recap and embed it in the trigger content. The trigger
|
|
2283
2524
|
// message is the channel that reliably reaches the agent on a triggered
|
|
2284
2525
|
// turn (before_agent_start's systemPrompt injection is NOT applied to
|
|
@@ -2303,7 +2544,7 @@ export default function planPiExtension(pi) {
|
|
|
2303
2544
|
return;
|
|
2304
2545
|
}
|
|
2305
2546
|
if (a === "stop" || a === "disable") {
|
|
2306
|
-
|
|
2547
|
+
disablePlannerSession();
|
|
2307
2548
|
await stopServer().catch(() => { });
|
|
2308
2549
|
try {
|
|
2309
2550
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -2319,6 +2560,11 @@ export default function planPiExtension(pi) {
|
|
|
2319
2560
|
description: "Grouped planner command. Use /planner <TAB> for subcommands or /planner to open the menu.",
|
|
2320
2561
|
getArgumentCompletions: (prefix) => {
|
|
2321
2562
|
const normalized = prefix.trim().toLowerCase();
|
|
2563
|
+
// Once a complete command path is followed by whitespace, the remaining
|
|
2564
|
+
// text is a free-form human ref/title. Returning the fallback menu here
|
|
2565
|
+
// lets Pi select `init` and replace input such as `feature update F001`.
|
|
2566
|
+
if (hasPlannerCommandArgument(prefix))
|
|
2567
|
+
return null;
|
|
2322
2568
|
const filtered = PLANNER_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
2323
2569
|
return filtered.length > 0 ? filtered : PLANNER_COMMAND_COMPLETIONS;
|
|
2324
2570
|
},
|
|
@@ -2595,7 +2841,7 @@ export default function planPiExtension(pi) {
|
|
|
2595
2841
|
pi.registerTool({
|
|
2596
2842
|
name: "plan_render",
|
|
2597
2843
|
label: "Plan Render",
|
|
2598
|
-
description: "Regenerate all generated markdown views in .planner/generated/. Call after any data change to keep docs in sync.",
|
|
2844
|
+
description: "Regenerate all generated markdown views in .planner/.local/generated/. Call after any data change to keep docs in sync.",
|
|
2599
2845
|
parameters: Type.Object({}),
|
|
2600
2846
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
2601
2847
|
const st = await requirePlan(ctx);
|
|
@@ -2621,6 +2867,7 @@ export default function planPiExtension(pi) {
|
|
|
2621
2867
|
`Repair complete.`,
|
|
2622
2868
|
`Migration: renamed=${m.renamed}, repaired=${m.repaired} refs, inferred=${m.inferred}.`,
|
|
2623
2869
|
`Containment: ${c.changed} phase file${c.changed === 1 ? "" : "s"} rewritten (tasks regrouped by their own phaseId), ${c.tasks} tasks scanned, ${c.orphan} orphan.`,
|
|
2870
|
+
`Handoffs: archived=${report.handoffs.archived} stale completed/canceled handoff(s).`,
|
|
2624
2871
|
`Integrity: duplicatePhaseIds=${report.integrity.duplicatePhaseIds.length}, danglingPhaseIds=${report.integrity.danglingPhaseIds.length}.`,
|
|
2625
2872
|
];
|
|
2626
2873
|
if (report.integrity.danglingPhaseIds.length)
|
|
@@ -2630,6 +2877,38 @@ export default function planPiExtension(pi) {
|
|
|
2630
2877
|
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2631
2878
|
},
|
|
2632
2879
|
});
|
|
2880
|
+
pi.registerTool({
|
|
2881
|
+
name: "plan_cleanup_orphan_phases",
|
|
2882
|
+
label: "Plan Cleanup Orphan Phases",
|
|
2883
|
+
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.",
|
|
2884
|
+
parameters: Type.Object({
|
|
2885
|
+
confirm: Type.Optional(Type.Boolean({ description: "Set true to actually delete the discovered orphan phase files. Default: false (dry-run/list only)." })),
|
|
2886
|
+
}),
|
|
2887
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2888
|
+
const st = await requirePlan(ctx);
|
|
2889
|
+
if (!st)
|
|
2890
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2891
|
+
const found = await st.listOrphanPhases();
|
|
2892
|
+
if (!params.confirm) {
|
|
2893
|
+
if (found.length === 0)
|
|
2894
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: { found: [] } };
|
|
2895
|
+
const lines = [
|
|
2896
|
+
`Found ${found.length} orphan phase${found.length === 1 ? "" : "s"}.`,
|
|
2897
|
+
...found.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title} (${phase.reason})`),
|
|
2898
|
+
"Rerun with confirm=true to delete these orphan phase files.",
|
|
2899
|
+
];
|
|
2900
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { found, confirmRequired: true } };
|
|
2901
|
+
}
|
|
2902
|
+
const report = await st.cleanupOrphanPhases();
|
|
2903
|
+
if (report.removed.length === 0)
|
|
2904
|
+
return { content: [{ type: "text", text: "No orphan phases found." }], details: report };
|
|
2905
|
+
const lines = [
|
|
2906
|
+
`Removed ${report.removed.length} orphan phase${report.removed.length === 1 ? "" : "s"}.`,
|
|
2907
|
+
...report.removed.map((phase) => `- ${phase.compositeRef}${phase.shortId ? ` · ${phase.shortId}` : ""} — ${phase.title}`),
|
|
2908
|
+
];
|
|
2909
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: report };
|
|
2910
|
+
},
|
|
2911
|
+
});
|
|
2633
2912
|
pi.registerTool({
|
|
2634
2913
|
name: "plan_get_handoff",
|
|
2635
2914
|
label: "Plan Get Handoff",
|
|
@@ -2649,9 +2928,12 @@ export default function planPiExtension(pi) {
|
|
|
2649
2928
|
pi.registerTool({
|
|
2650
2929
|
name: "plan_write_handoff",
|
|
2651
2930
|
label: "Plan Write Handoff",
|
|
2652
|
-
description: "DEPRECATED — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write.
|
|
2931
|
+
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
2932
|
parameters: Type.Object({
|
|
2933
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
2934
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2654
2935
|
reason: Type.Optional(Type.String({ description: "Why the handoff is being written" })),
|
|
2936
|
+
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
2937
|
whatWasBeingDone: Type.Optional(Type.String({ description: "Optional override for the current work summary" })),
|
|
2656
2938
|
howToResume: Type.Optional(Type.String({ description: "Optional override for resume instructions" })),
|
|
2657
2939
|
extraSections: Type.Optional(Type.Array(Type.Object({
|
|
@@ -2663,7 +2945,14 @@ export default function planPiExtension(pi) {
|
|
|
2663
2945
|
const st = await requirePlan(ctx);
|
|
2664
2946
|
if (!st)
|
|
2665
2947
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2948
|
+
const resolved = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2949
|
+
if (!resolved.ok)
|
|
2950
|
+
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 } };
|
|
2951
|
+
if (!params.confirmed)
|
|
2952
|
+
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
2953
|
const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
|
|
2954
|
+
phaseId: resolved.phase.id,
|
|
2955
|
+
...(params.title?.trim() ? { title: params.title.trim() } : {}),
|
|
2667
2956
|
...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
|
|
2668
2957
|
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2669
2958
|
...(params.extraSections && params.extraSections.length > 0
|
|
@@ -2674,23 +2963,20 @@ export default function planPiExtension(pi) {
|
|
|
2674
2963
|
}
|
|
2675
2964
|
: {}),
|
|
2676
2965
|
});
|
|
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 } };
|
|
2966
|
+
await st.setPhaseHandoff(resolved.phase.id, markdown);
|
|
2967
|
+
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
2968
|
},
|
|
2683
2969
|
});
|
|
2684
2970
|
pi.registerTool({
|
|
2685
2971
|
name: "plan_delete_handoff",
|
|
2686
2972
|
label: "Plan Delete Handoff",
|
|
2687
|
-
description: "DEPRECATED — clears the entity-scoped handoff of
|
|
2688
|
-
parameters: Type.Object({}),
|
|
2689
|
-
async execute(_id,
|
|
2973
|
+
description: "DEPRECATED — clears/archives the entity-scoped handoff of a phase. phaseRef is required. Prefer handoff_clear.",
|
|
2974
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
2975
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2690
2976
|
const st = await requirePlan(ctx);
|
|
2691
2977
|
if (!st)
|
|
2692
2978
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2693
|
-
const r = await resolvePhaseForHandoff(st,
|
|
2979
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2694
2980
|
if (!r.ok)
|
|
2695
2981
|
return { content: [{ type: "text", text: `⚠️ plan_delete_handoff is deprecated — use handoff_clear. ${r.error}` }], details: { deprecated: true, error: r.error } };
|
|
2696
2982
|
await st.clearPhaseHandoff(r.phase.id, "manual");
|
|
@@ -2698,8 +2984,8 @@ export default function planPiExtension(pi) {
|
|
|
2698
2984
|
},
|
|
2699
2985
|
});
|
|
2700
2986
|
// Resolve a phase for entity-scoped handoff tools. ref = P00x | P00x(F00x) |
|
|
2701
|
-
// UUID | title
|
|
2702
|
-
//
|
|
2987
|
+
// UUID | title. A write target is always explicit: never guess from the
|
|
2988
|
+
// first in-progress phase or a stale resume pointer.
|
|
2703
2989
|
async function resolvePhaseForHandoff(st, ref) {
|
|
2704
2990
|
const phases = await st.loadAllPhases();
|
|
2705
2991
|
const features = (await st.loadFeatures()).features;
|
|
@@ -2707,16 +2993,12 @@ export default function planPiExtension(pi) {
|
|
|
2707
2993
|
if (ref && ref.trim()) {
|
|
2708
2994
|
phase = findPhaseByRef(phases, features, ref.trim());
|
|
2709
2995
|
}
|
|
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
2996
|
if (!phase) {
|
|
2715
2997
|
return {
|
|
2716
2998
|
ok: false,
|
|
2717
2999
|
error: ref && ref.trim()
|
|
2718
3000
|
? `Phase not found: "${ref.trim()}". Use P00x, P00x(F00x), UUID, or title.`
|
|
2719
|
-
: "
|
|
3001
|
+
: "A phaseRef is required. First identify the exact feature and phase with the user, then pass P00x or P00x(F00x).",
|
|
2720
3002
|
};
|
|
2721
3003
|
}
|
|
2722
3004
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
@@ -2741,8 +3023,8 @@ export default function planPiExtension(pi) {
|
|
|
2741
3023
|
pi.registerTool({
|
|
2742
3024
|
name: "handoff_show",
|
|
2743
3025
|
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.
|
|
3026
|
+
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.",
|
|
3027
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
|
|
2746
3028
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2747
3029
|
const st = await requirePlan(ctx);
|
|
2748
3030
|
if (!st)
|
|
@@ -2759,9 +3041,11 @@ export default function planPiExtension(pi) {
|
|
|
2759
3041
|
pi.registerTool({
|
|
2760
3042
|
name: "handoff_write",
|
|
2761
3043
|
label: "Handoff Write",
|
|
2762
|
-
description: "Write/refresh the entity-scoped handoff on a phase. Pass content (or markdown_content).
|
|
3044
|
+
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
3045
|
parameters: Type.Object({
|
|
2764
|
-
phaseRef: Type.
|
|
3046
|
+
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3047
|
+
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'." })),
|
|
3048
|
+
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
2765
3049
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
2766
3050
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
2767
3051
|
}),
|
|
@@ -2769,12 +3053,38 @@ export default function planPiExtension(pi) {
|
|
|
2769
3053
|
const st = await requirePlan(ctx);
|
|
2770
3054
|
if (!st)
|
|
2771
3055
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2772
|
-
|
|
3056
|
+
let body = (params.markdown_content ?? params.content ?? "").trim();
|
|
2773
3057
|
if (!body)
|
|
2774
3058
|
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3059
|
+
const title = params.title?.trim();
|
|
3060
|
+
// Derive the current first non-empty line (H1) of the supplied body.
|
|
3061
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3062
|
+
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3063
|
+
const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
|
|
3064
|
+
if (!title && isGeneric) {
|
|
3065
|
+
return {
|
|
3066
|
+
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." }],
|
|
3067
|
+
details: { error: "generic title", firstHeadingText },
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3071
|
+
if (title) {
|
|
3072
|
+
const lines = body.split(/\r?\n/);
|
|
3073
|
+
const firstIdx = lines.findIndex((l) => l.trim().length > 0);
|
|
3074
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? "")) {
|
|
3075
|
+
lines[firstIdx] = `# ${title}`;
|
|
3076
|
+
body = lines.join("\n");
|
|
3077
|
+
}
|
|
3078
|
+
else {
|
|
3079
|
+
body = `# ${title}\n\n${body}`;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
2775
3082
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
2776
3083
|
if (!r.ok)
|
|
2777
3084
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3085
|
+
if (!params.confirmed) {
|
|
3086
|
+
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 } };
|
|
3087
|
+
}
|
|
2778
3088
|
await st.setPhaseHandoff(r.phase.id, body);
|
|
2779
3089
|
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
2780
3090
|
},
|
|
@@ -2782,8 +3092,8 @@ export default function planPiExtension(pi) {
|
|
|
2782
3092
|
pi.registerTool({
|
|
2783
3093
|
name: "handoff_clear",
|
|
2784
3094
|
label: "Handoff Clear",
|
|
2785
|
-
description: "Clear the entity-scoped handoff of a phase (
|
|
2786
|
-
parameters: Type.Object({ phaseRef: Type.
|
|
3095
|
+
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.",
|
|
3096
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
2787
3097
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
2788
3098
|
const st = await requirePlan(ctx);
|
|
2789
3099
|
if (!st)
|
|
@@ -2792,7 +3102,7 @@ export default function planPiExtension(pi) {
|
|
|
2792
3102
|
if (!r.ok)
|
|
2793
3103
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
2794
3104
|
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 } };
|
|
3105
|
+
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
3106
|
},
|
|
2797
3107
|
});
|
|
2798
3108
|
pi.registerTool({
|
|
@@ -2849,7 +3159,7 @@ export default function planPiExtension(pi) {
|
|
|
2849
3159
|
pi.registerTool({
|
|
2850
3160
|
name: "feature_get",
|
|
2851
3161
|
label: "Feature Get",
|
|
2852
|
-
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3162
|
+
description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
2853
3163
|
parameters: Type.Object({
|
|
2854
3164
|
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
2855
3165
|
full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
|
|
@@ -2860,12 +3170,17 @@ export default function planPiExtension(pi) {
|
|
|
2860
3170
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2861
3171
|
const features = (await st.loadFeatures()).features;
|
|
2862
3172
|
const ref = params.featureId.trim();
|
|
2863
|
-
const
|
|
2864
|
-
if (!
|
|
2865
|
-
return { content: [{ type: "text", text:
|
|
3173
|
+
const resolvedFeature = resolveFeatureRefStrict(features, ref);
|
|
3174
|
+
if (!resolvedFeature.ok)
|
|
3175
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3176
|
+
const feature = resolvedFeature.feature;
|
|
2866
3177
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
2867
|
-
const
|
|
2868
|
-
|
|
3178
|
+
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3179
|
+
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"}` : ""})`;
|
|
3180
|
+
const requirementsBlock = linkedRequirements.length > 0
|
|
3181
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3182
|
+
: "\n\nLinked requirements:\n- None linked to this feature.";
|
|
3183
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
2869
3184
|
},
|
|
2870
3185
|
});
|
|
2871
3186
|
pi.registerTool({
|
|
@@ -2884,12 +3199,13 @@ export default function planPiExtension(pi) {
|
|
|
2884
3199
|
const now = nowISO();
|
|
2885
3200
|
const status = params.status ?? "planned";
|
|
2886
3201
|
const currentFeatures = (await st.loadFeatures()).features;
|
|
2887
|
-
const
|
|
3202
|
+
const id = createFeatureId();
|
|
3203
|
+
const identity = await st.allocateEntityIdentity("feature", id);
|
|
2888
3204
|
const priority = await st.nextPriority("feature");
|
|
2889
3205
|
const feature = {
|
|
2890
|
-
id
|
|
2891
|
-
number:
|
|
2892
|
-
shortId,
|
|
3206
|
+
id,
|
|
3207
|
+
number: identity.number,
|
|
3208
|
+
shortId: identity.shortId,
|
|
2893
3209
|
priority,
|
|
2894
3210
|
name: params.name,
|
|
2895
3211
|
description: params.description ?? "",
|
|
@@ -2904,6 +3220,7 @@ export default function planPiExtension(pi) {
|
|
|
2904
3220
|
acceptedDecisions: [],
|
|
2905
3221
|
phaseIds: [],
|
|
2906
3222
|
dependsOn: [],
|
|
3223
|
+
statusLog: [],
|
|
2907
3224
|
createdAt: now,
|
|
2908
3225
|
updatedAt: now,
|
|
2909
3226
|
};
|
|
@@ -2917,6 +3234,51 @@ export default function planPiExtension(pi) {
|
|
|
2917
3234
|
return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
2918
3235
|
},
|
|
2919
3236
|
});
|
|
3237
|
+
pi.registerTool({
|
|
3238
|
+
name: "feature_discuss",
|
|
3239
|
+
label: "Feature Discuss",
|
|
3240
|
+
description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
|
|
3241
|
+
parameters: Type.Object({
|
|
3242
|
+
featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
|
|
3243
|
+
description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
|
|
3244
|
+
workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
|
|
3245
|
+
workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
|
|
3246
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
|
|
3247
|
+
}),
|
|
3248
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3249
|
+
const st = await requirePlan(ctx);
|
|
3250
|
+
if (!st)
|
|
3251
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3252
|
+
const features = (await st.loadFeatures()).features;
|
|
3253
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3254
|
+
if (!resolvedFeature.ok)
|
|
3255
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3256
|
+
const featureId = resolvedFeature.feature.id;
|
|
3257
|
+
const updatedDoc = await st.updateFeatures((doc) => {
|
|
3258
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3259
|
+
if (!feature)
|
|
3260
|
+
return doc;
|
|
3261
|
+
if (params.description !== undefined)
|
|
3262
|
+
feature.description = params.description;
|
|
3263
|
+
if (params.workDone !== undefined)
|
|
3264
|
+
feature.workDone = params.workDone;
|
|
3265
|
+
if (params.workRemaining !== undefined)
|
|
3266
|
+
feature.workRemaining = params.workRemaining;
|
|
3267
|
+
if (params.dependencies !== undefined)
|
|
3268
|
+
feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
|
|
3269
|
+
feature.discussedAt = nowISO();
|
|
3270
|
+
feature.contextReady = true;
|
|
3271
|
+
feature.contextReadyReason = "Updated through feature_discuss tool.";
|
|
3272
|
+
feature.updatedAt = nowISO();
|
|
3273
|
+
return doc;
|
|
3274
|
+
});
|
|
3275
|
+
const feature = updatedDoc.features.find((f) => f.id === featureId);
|
|
3276
|
+
if (!feature)
|
|
3277
|
+
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3278
|
+
await st.writeGenerated();
|
|
3279
|
+
return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
|
|
3280
|
+
},
|
|
3281
|
+
});
|
|
2920
3282
|
pi.registerTool({
|
|
2921
3283
|
name: "feature_update",
|
|
2922
3284
|
label: "Feature Update",
|
|
@@ -2944,10 +3306,36 @@ export default function planPiExtension(pi) {
|
|
|
2944
3306
|
const st = await requirePlan(ctx);
|
|
2945
3307
|
if (!st)
|
|
2946
3308
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3309
|
+
const features = (await st.loadFeatures()).features;
|
|
3310
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3311
|
+
if (!resolvedFeature.ok)
|
|
3312
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3313
|
+
const featureId = resolvedFeature.feature.id;
|
|
3314
|
+
const mutableFields = [
|
|
3315
|
+
"name",
|
|
3316
|
+
"description",
|
|
3317
|
+
"status",
|
|
3318
|
+
"startDate",
|
|
3319
|
+
"endDate",
|
|
3320
|
+
"workDone",
|
|
3321
|
+
"workRemaining",
|
|
3322
|
+
"priority",
|
|
3323
|
+
"acceptedDecisions",
|
|
3324
|
+
];
|
|
3325
|
+
const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
|
|
3326
|
+
if (receivedFields.length === 0) {
|
|
3327
|
+
return {
|
|
3328
|
+
content: [{
|
|
3329
|
+
type: "text",
|
|
3330
|
+
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.",
|
|
3331
|
+
}],
|
|
3332
|
+
details: { updated: false, reason: "no-mutable-fields" },
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
2947
3335
|
let foundDoc;
|
|
2948
3336
|
try {
|
|
2949
3337
|
foundDoc = await st.updateFeatures((doc) => {
|
|
2950
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3338
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
2951
3339
|
if (!feature)
|
|
2952
3340
|
return doc; // not found: no-op, handled below
|
|
2953
3341
|
if (params.name !== undefined)
|
|
@@ -2985,11 +3373,17 @@ export default function planPiExtension(pi) {
|
|
|
2985
3373
|
catch (e) {
|
|
2986
3374
|
return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
|
|
2987
3375
|
}
|
|
2988
|
-
const feature = foundDoc?.features.find((f) => f.id ===
|
|
3376
|
+
const feature = foundDoc?.features.find((f) => f.id === featureId);
|
|
2989
3377
|
if (!feature)
|
|
2990
3378
|
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
2991
3379
|
await st.writeGenerated();
|
|
2992
|
-
return {
|
|
3380
|
+
return {
|
|
3381
|
+
content: [{
|
|
3382
|
+
type: "text",
|
|
3383
|
+
text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
|
|
3384
|
+
}],
|
|
3385
|
+
details: { ...feature, updated: true, updatedFields: receivedFields },
|
|
3386
|
+
};
|
|
2993
3387
|
},
|
|
2994
3388
|
});
|
|
2995
3389
|
pi.registerTool({
|
|
@@ -3004,21 +3398,23 @@ export default function planPiExtension(pi) {
|
|
|
3004
3398
|
const st = await requirePlan(ctx);
|
|
3005
3399
|
if (!st)
|
|
3006
3400
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3401
|
+
const features = (await st.loadFeatures()).features;
|
|
3402
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3403
|
+
if (!resolvedFeature.ok)
|
|
3404
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3405
|
+
const featureId = resolvedFeature.feature.id;
|
|
3007
3406
|
let deleted = false;
|
|
3008
3407
|
await st.updateFeatures((doc) => {
|
|
3009
3408
|
const before = doc.features.length;
|
|
3010
|
-
doc.features = doc.features.filter((f) => f.id !==
|
|
3409
|
+
doc.features = doc.features.filter((f) => f.id !== featureId);
|
|
3011
3410
|
if (doc.features.length !== before)
|
|
3012
3411
|
deleted = true;
|
|
3013
3412
|
return doc;
|
|
3014
3413
|
});
|
|
3015
|
-
if (!deleted) {
|
|
3016
|
-
return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
|
|
3017
|
-
}
|
|
3018
3414
|
let cascadeCount = 0;
|
|
3019
3415
|
if (params.cascade) {
|
|
3020
3416
|
const phases = await st.loadAllPhases();
|
|
3021
|
-
for (const phase of phases.filter((p) => p.featureId ===
|
|
3417
|
+
for (const phase of phases.filter((p) => p.featureId === featureId)) {
|
|
3022
3418
|
await st.deletePhase(phase.id);
|
|
3023
3419
|
cascadeCount += 1;
|
|
3024
3420
|
}
|
|
@@ -3062,7 +3458,7 @@ export default function planPiExtension(pi) {
|
|
|
3062
3458
|
pi.registerTool({
|
|
3063
3459
|
name: "phase_get",
|
|
3064
3460
|
label: "Phase Get",
|
|
3065
|
-
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description.",
|
|
3461
|
+
description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
|
|
3066
3462
|
parameters: Type.Object({
|
|
3067
3463
|
phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
|
|
3068
3464
|
full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
|
|
@@ -3075,8 +3471,13 @@ export default function planPiExtension(pi) {
|
|
|
3075
3471
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3076
3472
|
if (!phase)
|
|
3077
3473
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3078
|
-
const
|
|
3079
|
-
|
|
3474
|
+
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3475
|
+
const reqCount = linkedRequirements.length;
|
|
3476
|
+
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"}` : ""})`;
|
|
3477
|
+
const requirementsBlock = reqCount > 0
|
|
3478
|
+
? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
|
|
3479
|
+
: "";
|
|
3480
|
+
return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
|
|
3080
3481
|
},
|
|
3081
3482
|
});
|
|
3082
3483
|
pi.registerTool({
|
|
@@ -3096,18 +3497,26 @@ export default function planPiExtension(pi) {
|
|
|
3096
3497
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3097
3498
|
if (!params.featureId?.trim())
|
|
3098
3499
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3500
|
+
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
3501
|
+
const resolvedFeature = resolveFeatureRefStrict(createPhaseFeatures, params.featureId);
|
|
3502
|
+
if (!resolvedFeature.ok)
|
|
3503
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3504
|
+
const featureId = resolvedFeature.feature.id;
|
|
3505
|
+
const featureValidation = await validateResolvedTarget("feature", featureId, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === featureId)).catch(() => undefined));
|
|
3506
|
+
if (!featureValidation.ok)
|
|
3507
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3508
|
+
const existingFeature = (await st.loadFeatures()).features.find((f) => f.id === featureId);
|
|
3509
|
+
if (!existingFeature)
|
|
3510
|
+
return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
|
|
3099
3511
|
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();
|
|
3512
|
+
await withFeatureLock(featureId, async () => {
|
|
3104
3513
|
const id = createPhaseId();
|
|
3105
|
-
const
|
|
3106
|
-
const priority = await st.nextPriority("phase",
|
|
3514
|
+
const identity = await st.allocateEntityIdentity("phase", id);
|
|
3515
|
+
const priority = await st.nextPriority("phase", featureId);
|
|
3107
3516
|
const now = nowISO();
|
|
3108
3517
|
phase = {
|
|
3109
|
-
id, number, shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3110
|
-
featureId
|
|
3518
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
|
|
3519
|
+
featureId,
|
|
3111
3520
|
status: params.status ?? "draft",
|
|
3112
3521
|
discussedAt: "",
|
|
3113
3522
|
contextReady: false,
|
|
@@ -3119,11 +3528,12 @@ export default function planPiExtension(pi) {
|
|
|
3119
3528
|
handoff: "", handoffUpdatedAt: "",
|
|
3120
3529
|
handoffReadAt: "",
|
|
3121
3530
|
handoffHistory: [],
|
|
3531
|
+
statusLog: [],
|
|
3122
3532
|
};
|
|
3123
3533
|
await st.savePhase(phase);
|
|
3124
3534
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
3125
3535
|
await st.updateFeatures((doc) => {
|
|
3126
|
-
const feature = doc.features.find((f) => f.id ===
|
|
3536
|
+
const feature = doc.features.find((f) => f.id === featureId);
|
|
3127
3537
|
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
3128
3538
|
feature.phaseIds.push(phase.id);
|
|
3129
3539
|
feature.updatedAt = now;
|
|
@@ -3170,14 +3580,20 @@ export default function planPiExtension(pi) {
|
|
|
3170
3580
|
const st = await requirePlan(ctx);
|
|
3171
3581
|
if (!st)
|
|
3172
3582
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3583
|
+
const features = (await st.loadFeatures()).features;
|
|
3584
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3585
|
+
if (!resolvedPhase)
|
|
3586
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3587
|
+
const phaseId = resolvedPhase.id;
|
|
3173
3588
|
let phase;
|
|
3174
3589
|
try {
|
|
3175
|
-
phase = await st.loadPhase(
|
|
3590
|
+
phase = await st.loadPhase(phaseId);
|
|
3176
3591
|
}
|
|
3177
3592
|
catch {
|
|
3178
3593
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3179
3594
|
}
|
|
3180
3595
|
const prevFeatureId = phase.featureId;
|
|
3596
|
+
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3181
3597
|
if (params.title !== undefined) {
|
|
3182
3598
|
phase.title = params.title;
|
|
3183
3599
|
phase.slug = normalizeSlug(params.title);
|
|
@@ -3207,14 +3623,22 @@ export default function planPiExtension(pi) {
|
|
|
3207
3623
|
if (params.completionCriteria !== undefined)
|
|
3208
3624
|
phase.completionCriteria = params.completionCriteria;
|
|
3209
3625
|
if (params.featureId !== undefined) {
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3626
|
+
if (params.featureId === "") {
|
|
3627
|
+
nextFeatureIdForUpdate = undefined;
|
|
3628
|
+
}
|
|
3629
|
+
else {
|
|
3630
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3631
|
+
if (!resolvedFeature.ok)
|
|
3632
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3633
|
+
nextFeatureIdForUpdate = resolvedFeature.feature.id;
|
|
3634
|
+
}
|
|
3635
|
+
phase.featureId = nextFeatureIdForUpdate;
|
|
3636
|
+
if (nextFeatureIdForUpdate !== prevFeatureId) {
|
|
3213
3637
|
await st.updateFeatures((doc) => {
|
|
3214
3638
|
for (const f of doc.features) {
|
|
3215
3639
|
if (f.id === prevFeatureId)
|
|
3216
3640
|
f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
|
|
3217
|
-
if (f.id ===
|
|
3641
|
+
if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
|
|
3218
3642
|
f.phaseIds.push(phase.id);
|
|
3219
3643
|
}
|
|
3220
3644
|
return doc;
|
|
@@ -3228,6 +3652,77 @@ export default function planPiExtension(pi) {
|
|
|
3228
3652
|
return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
|
|
3229
3653
|
},
|
|
3230
3654
|
});
|
|
3655
|
+
pi.registerTool({
|
|
3656
|
+
name: "decision_record",
|
|
3657
|
+
label: "Decision Record",
|
|
3658
|
+
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.",
|
|
3659
|
+
parameters: Type.Object({
|
|
3660
|
+
featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
|
|
3661
|
+
phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
|
|
3662
|
+
title: Type.String({ minLength: 1, description: "Short human-readable decision title" }),
|
|
3663
|
+
decision: Type.String({ minLength: 1, description: "The agreed decision" }),
|
|
3664
|
+
rationale: Type.String({ minLength: 1, description: "Why the decision was made" }),
|
|
3665
|
+
implementationNotes: Type.String({ minLength: 1, description: "Concrete follow-up or preservation notes" }),
|
|
3666
|
+
}),
|
|
3667
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3668
|
+
const st = await requirePlan(ctx);
|
|
3669
|
+
if (!st)
|
|
3670
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3671
|
+
const features = (await st.loadFeatures()).features;
|
|
3672
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
|
|
3673
|
+
if (!resolvedFeature.ok)
|
|
3674
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3675
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId);
|
|
3676
|
+
if (!resolvedPhase)
|
|
3677
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3678
|
+
if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
|
|
3679
|
+
return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
|
|
3680
|
+
}
|
|
3681
|
+
const acceptedAt = nowISO();
|
|
3682
|
+
const acceptedDecision = {
|
|
3683
|
+
id: crypto.randomUUID(),
|
|
3684
|
+
title: params.title.trim(),
|
|
3685
|
+
decision: params.decision.trim(),
|
|
3686
|
+
rationale: params.rationale.trim(),
|
|
3687
|
+
implementationNotes: params.implementationNotes.trim(),
|
|
3688
|
+
acceptedAt,
|
|
3689
|
+
};
|
|
3690
|
+
await withFeatureLock(resolvedFeature.feature.id, async () => {
|
|
3691
|
+
let featureWritten = false;
|
|
3692
|
+
try {
|
|
3693
|
+
await st.updateFeatures((doc) => {
|
|
3694
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3695
|
+
if (!feature)
|
|
3696
|
+
throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
|
|
3697
|
+
feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
|
|
3698
|
+
feature.updatedAt = acceptedAt;
|
|
3699
|
+
return doc;
|
|
3700
|
+
});
|
|
3701
|
+
featureWritten = true;
|
|
3702
|
+
await st.updatePhase(resolvedPhase.id, (phase) => {
|
|
3703
|
+
phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
|
|
3704
|
+
phase.updatedAt = acceptedAt;
|
|
3705
|
+
return phase;
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
catch (error) {
|
|
3709
|
+
// Best-effort compensation keeps a failed dual-write from reporting
|
|
3710
|
+
// success with a decision persisted only on the feature.
|
|
3711
|
+
if (featureWritten) {
|
|
3712
|
+
await st.updateFeatures((doc) => {
|
|
3713
|
+
const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
|
|
3714
|
+
if (feature)
|
|
3715
|
+
feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
|
|
3716
|
+
return doc;
|
|
3717
|
+
}).catch(() => { });
|
|
3718
|
+
}
|
|
3719
|
+
throw error;
|
|
3720
|
+
}
|
|
3721
|
+
});
|
|
3722
|
+
await st.writeGenerated();
|
|
3723
|
+
return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
|
|
3724
|
+
},
|
|
3725
|
+
});
|
|
3231
3726
|
pi.registerTool({
|
|
3232
3727
|
name: "phase_delete",
|
|
3233
3728
|
label: "Phase Delete",
|
|
@@ -3239,19 +3734,24 @@ export default function planPiExtension(pi) {
|
|
|
3239
3734
|
const st = await requirePlan(ctx);
|
|
3240
3735
|
if (!st)
|
|
3241
3736
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3737
|
+
const features = (await st.loadFeatures()).features;
|
|
3738
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3739
|
+
if (!resolvedPhase)
|
|
3740
|
+
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3741
|
+
const phaseId = resolvedPhase.id;
|
|
3242
3742
|
let phase;
|
|
3243
3743
|
try {
|
|
3244
|
-
phase = await st.loadPhase(
|
|
3744
|
+
phase = await st.loadPhase(phaseId);
|
|
3245
3745
|
}
|
|
3246
3746
|
catch {
|
|
3247
3747
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3248
3748
|
}
|
|
3249
|
-
await st.deletePhase(
|
|
3749
|
+
await st.deletePhase(phaseId);
|
|
3250
3750
|
if (phase.featureId) {
|
|
3251
3751
|
await st.updateFeatures((doc) => {
|
|
3252
3752
|
const feature = doc.features.find((f) => f.id === phase.featureId);
|
|
3253
3753
|
if (feature) {
|
|
3254
|
-
feature.phaseIds = feature.phaseIds.filter((pid) => pid !==
|
|
3754
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
|
|
3255
3755
|
feature.updatedAt = nowISO();
|
|
3256
3756
|
}
|
|
3257
3757
|
return doc;
|
|
@@ -3330,32 +3830,52 @@ export default function planPiExtension(pi) {
|
|
|
3330
3830
|
label: "Task Create",
|
|
3331
3831
|
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
3832
|
parameters: Type.Object({
|
|
3833
|
+
featureId: Type.String({ description: "Feature ID the phase belongs to (required)" }),
|
|
3333
3834
|
phaseId: Type.String({ description: "Phase ID the task belongs to" }),
|
|
3334
3835
|
title: Type.String({ description: "Task title" }),
|
|
3335
3836
|
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
3837
|
status: Type.Optional(Type.String({ description: "Initial status. Default: planned" })),
|
|
3337
3838
|
shortName: Type.Optional(Type.String({ description: "Short slug for the task id. Auto-derived from title if omitted." })),
|
|
3839
|
+
checklist: Type.Optional(Type.Array(Type.String(), { description: "Initial checklist items (plain strings). Seeded as C1, C2, … (unchecked)." })),
|
|
3338
3840
|
}),
|
|
3339
3841
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3340
3842
|
const st = await requirePlan(ctx);
|
|
3341
3843
|
if (!st)
|
|
3342
3844
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3845
|
+
if (!params.featureId?.trim())
|
|
3846
|
+
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3847
|
+
const features = (await st.loadFeatures()).features;
|
|
3848
|
+
const resolvedFeature = resolveFeatureRefStrict(features, params.featureId.trim());
|
|
3849
|
+
if (!resolvedFeature.ok)
|
|
3850
|
+
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3851
|
+
const featureValidation = await validateResolvedTarget("feature", resolvedFeature.feature.id, () => st.loadFeatures().then((doc) => doc.features.find((f) => f.id === resolvedFeature.feature.id)).catch(() => undefined));
|
|
3852
|
+
if (!featureValidation.ok)
|
|
3853
|
+
return { content: [{ type: "text", text: featureValidation.error }], details: {} };
|
|
3854
|
+
const feature = resolvedFeature.feature;
|
|
3855
|
+
const existingFeature = features.find((f) => f.id === feature.id);
|
|
3856
|
+
if (!existingFeature)
|
|
3857
|
+
return { content: [{ type: "text", text: `Resolved feature ${feature.id} no longer exists. Refusing to create task.` }], details: {} };
|
|
3858
|
+
// Resolve the phase ref (accepts F00x/P00x, bare P00x, shortId, UUID, title).
|
|
3859
|
+
const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3860
|
+
if (!resolvedPhase)
|
|
3348
3861
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3349
|
-
|
|
3862
|
+
if (resolvedPhase.featureId !== feature.id)
|
|
3863
|
+
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: {} };
|
|
3864
|
+
const phaseId = resolvedPhase.id;
|
|
3865
|
+
const phaseValidation = await validateResolvedTarget("phase", phaseId, () => st.loadPhase(phaseId).catch(() => undefined));
|
|
3866
|
+
if (!phaseValidation.ok)
|
|
3867
|
+
return { content: [{ type: "text", text: phaseValidation.error }], details: {} };
|
|
3868
|
+
const existingPhase = await st.loadPhase(phaseId);
|
|
3869
|
+
if (!existingPhase)
|
|
3870
|
+
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3350
3871
|
const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3351
3872
|
const taskId = createTaskId();
|
|
3352
|
-
const
|
|
3353
|
-
const priority = await st.nextPriority("task",
|
|
3873
|
+
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
3874
|
+
const priority = await st.nextPriority("task", phaseId);
|
|
3354
3875
|
const now = nowISO();
|
|
3355
3876
|
const initialStatus = params.status ?? "planned";
|
|
3356
|
-
const existingPhase = await st.loadPhase(params.phaseId);
|
|
3357
3877
|
const task = {
|
|
3358
|
-
id: taskId, phaseId
|
|
3878
|
+
id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
3359
3879
|
title: params.title,
|
|
3360
3880
|
status: initialStatus,
|
|
3361
3881
|
description: params.description ?? "",
|
|
@@ -3363,7 +3883,10 @@ export default function planPiExtension(pi) {
|
|
|
3363
3883
|
statusLog: [],
|
|
3364
3884
|
decisions: [],
|
|
3365
3885
|
acceptedDecisions: [],
|
|
3366
|
-
checklist:
|
|
3886
|
+
checklist: params.checklist
|
|
3887
|
+
? params.checklist.map((itemTitle, index) => ({ number: index + 1, id: createChecklistItemId(taskId, index + 1, itemTitle), title: itemTitle, checked: false }))
|
|
3888
|
+
: [],
|
|
3889
|
+
subtasks: [],
|
|
3367
3890
|
dependsOn: [],
|
|
3368
3891
|
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3369
3892
|
completedAt: initialStatus === "done" ? now : "",
|
|
@@ -3371,14 +3894,15 @@ export default function planPiExtension(pi) {
|
|
|
3371
3894
|
};
|
|
3372
3895
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
3373
3896
|
// task_create calls on the SAME phaseId (batch) so tasks don't get lost.
|
|
3374
|
-
await st.updatePhase(
|
|
3897
|
+
await st.updatePhase(phaseId, (phase) => {
|
|
3375
3898
|
phase.tasks.push(task);
|
|
3376
3899
|
phase.taskIds.push(taskId);
|
|
3377
3900
|
phase.updatedAt = now;
|
|
3378
3901
|
return phase;
|
|
3379
3902
|
});
|
|
3380
3903
|
await st.writeGenerated();
|
|
3381
|
-
|
|
3904
|
+
const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
|
|
3905
|
+
return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
|
|
3382
3906
|
},
|
|
3383
3907
|
});
|
|
3384
3908
|
pi.registerTool({
|
|
@@ -3409,15 +3933,29 @@ export default function planPiExtension(pi) {
|
|
|
3409
3933
|
if (!st)
|
|
3410
3934
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3411
3935
|
// Find the phase hosting this task first.
|
|
3412
|
-
const
|
|
3413
|
-
const
|
|
3414
|
-
if (!
|
|
3936
|
+
const features = (await st.loadFeatures()).features;
|
|
3937
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3938
|
+
if (!found)
|
|
3415
3939
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3940
|
+
const hostPhase = found.phase;
|
|
3416
3941
|
const now = nowISO();
|
|
3417
3942
|
let updatedTask;
|
|
3943
|
+
// Validate the motivation requirement for restrictive status transitions,
|
|
3944
|
+
// mirroring the MCP adapter and the /planner task update command
|
|
3945
|
+
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
3946
|
+
if (params.status !== undefined &&
|
|
3947
|
+
params.status !== found.task.status &&
|
|
3948
|
+
needsMotivation(found.task.status, params.status)) {
|
|
3949
|
+
if (!params.motivation || !params.motivation.trim()) {
|
|
3950
|
+
return {
|
|
3951
|
+
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.` }],
|
|
3952
|
+
details: {},
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3418
3956
|
try {
|
|
3419
3957
|
const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
|
|
3420
|
-
const task = phase.tasks.find((t) => t.id ===
|
|
3958
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
3421
3959
|
if (!task)
|
|
3422
3960
|
return phase;
|
|
3423
3961
|
if (params.title !== undefined)
|
|
@@ -3433,7 +3971,7 @@ export default function planPiExtension(pi) {
|
|
|
3433
3971
|
if (params.acceptedDecisions !== undefined)
|
|
3434
3972
|
task.acceptedDecisions = params.acceptedDecisions;
|
|
3435
3973
|
if (params.checklist !== undefined) {
|
|
3436
|
-
task.checklist = params.checklist.map((itemTitle, index) => ({
|
|
3974
|
+
task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
|
|
3437
3975
|
id: createChecklistItemId(task.id, index + 1, itemTitle),
|
|
3438
3976
|
title: itemTitle,
|
|
3439
3977
|
checked: false,
|
|
@@ -3465,6 +4003,7 @@ export default function planPiExtension(pi) {
|
|
|
3465
4003
|
}
|
|
3466
4004
|
if (!updatedTask)
|
|
3467
4005
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4006
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
3468
4007
|
await st.writeGenerated();
|
|
3469
4008
|
return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
|
|
3470
4009
|
},
|
|
@@ -3480,13 +4019,13 @@ export default function planPiExtension(pi) {
|
|
|
3480
4019
|
const st = await requirePlan(ctx);
|
|
3481
4020
|
if (!st)
|
|
3482
4021
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3483
|
-
const
|
|
3484
|
-
const
|
|
3485
|
-
if (!
|
|
4022
|
+
const features = (await st.loadFeatures()).features;
|
|
4023
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4024
|
+
if (!found)
|
|
3486
4025
|
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 !==
|
|
4026
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4027
|
+
phase.tasks = phase.tasks.filter((t) => t.id !== found.task.id);
|
|
4028
|
+
phase.taskIds = phase.taskIds.filter((id) => id !== found.task.id);
|
|
3490
4029
|
phase.updatedAt = nowISO();
|
|
3491
4030
|
return phase;
|
|
3492
4031
|
});
|
|
@@ -3495,10 +4034,65 @@ export default function planPiExtension(pi) {
|
|
|
3495
4034
|
},
|
|
3496
4035
|
});
|
|
3497
4036
|
// ── task start / complete tools ──────────────────────────────────
|
|
4037
|
+
pi.registerTool({
|
|
4038
|
+
name: "task_recommend",
|
|
4039
|
+
label: "Task Recommend",
|
|
4040
|
+
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.",
|
|
4041
|
+
parameters: Type.Object({}),
|
|
4042
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4043
|
+
const st = await requirePlan(ctx);
|
|
4044
|
+
if (!st)
|
|
4045
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4046
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4047
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4048
|
+
if (!result.candidate)
|
|
4049
|
+
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4050
|
+
const { candidate } = result;
|
|
4051
|
+
const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
|
|
4052
|
+
return {
|
|
4053
|
+
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}.` : ""}` }],
|
|
4054
|
+
details: result,
|
|
4055
|
+
};
|
|
4056
|
+
},
|
|
4057
|
+
});
|
|
4058
|
+
pi.registerTool({
|
|
4059
|
+
name: "task_deviation",
|
|
4060
|
+
label: "Task Deviation",
|
|
4061
|
+
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.",
|
|
4062
|
+
parameters: Type.Object({
|
|
4063
|
+
temporary_task: Type.String({ description: "Temporary task ref" }),
|
|
4064
|
+
resume_task: Type.Optional(Type.String({ description: "Task to resume; defaults to the current recommendation" })),
|
|
4065
|
+
reason: Type.String({ description: "Why this approved temporary deviation is needed" }),
|
|
4066
|
+
}),
|
|
4067
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4068
|
+
const st = await requirePlan(ctx);
|
|
4069
|
+
if (!st)
|
|
4070
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4071
|
+
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4072
|
+
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4073
|
+
if (!temporary)
|
|
4074
|
+
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4075
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4076
|
+
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4077
|
+
if (!resume)
|
|
4078
|
+
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
4079
|
+
if (temporary.task.id === resume.task.id)
|
|
4080
|
+
return { content: [{ type: "text", text: "A temporary task must differ from its resume target." }], details: {} };
|
|
4081
|
+
if (temporary.task.status !== "planned")
|
|
4082
|
+
return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
|
|
4083
|
+
const timestamp = nowISO();
|
|
4084
|
+
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: "" };
|
|
4085
|
+
await st.addWorkDeviation(record);
|
|
4086
|
+
await st.writeGenerated();
|
|
4087
|
+
const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
|
|
4088
|
+
const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features.features))}/T${String(resume.task.number).padStart(3, "0")}`;
|
|
4089
|
+
return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
|
|
4090
|
+
},
|
|
4091
|
+
});
|
|
3498
4092
|
pi.registerTool({
|
|
3499
4093
|
name: "task_start",
|
|
3500
4094
|
label: "Task Start",
|
|
3501
|
-
description: "Set a task to in-progress.
|
|
4095
|
+
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
4096
|
parameters: Type.Object({
|
|
3503
4097
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
3504
4098
|
}),
|
|
@@ -3506,19 +4100,40 @@ export default function planPiExtension(pi) {
|
|
|
3506
4100
|
const st = await requirePlan(ctx);
|
|
3507
4101
|
if (!st)
|
|
3508
4102
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3509
|
-
const
|
|
3510
|
-
const
|
|
3511
|
-
|
|
3512
|
-
if (!task || !hostPhase)
|
|
4103
|
+
const features = (await st.loadFeatures()).features;
|
|
4104
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4105
|
+
if (!found)
|
|
3513
4106
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4107
|
+
const task = found.task;
|
|
3514
4108
|
if (task.status === "in-progress")
|
|
3515
4109
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
3516
4110
|
if (task.status === "done")
|
|
3517
4111
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
4112
|
+
const project = await st.loadProject();
|
|
4113
|
+
const phases = await st.loadAllPhases();
|
|
4114
|
+
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4115
|
+
if (!eligibility.eligible)
|
|
4116
|
+
return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
|
|
4117
|
+
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4118
|
+
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4119
|
+
// The output order is feature + its requirements, then phase + its requirements.
|
|
4120
|
+
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
4121
|
+
const [phaseWithRequirements, featureRequirements] = await Promise.all([
|
|
4122
|
+
st.loadPhaseWithRequirements(found.phase.id),
|
|
4123
|
+
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
4124
|
+
]);
|
|
4125
|
+
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
4126
|
+
const advisory = selection.kind === "conflict"
|
|
4127
|
+
? `\n\n⚠️ ${selection.reason} Explicit task request honored; reconcile active work before relying on automatic recommendations.`
|
|
4128
|
+
: selection.candidate && selection.candidate.task.id !== task.id
|
|
4129
|
+
? selection.kind === "active"
|
|
4130
|
+
? `\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.`
|
|
4131
|
+
: `\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.`
|
|
4132
|
+
: "";
|
|
3518
4133
|
const now = nowISO();
|
|
3519
4134
|
let startedTask;
|
|
3520
|
-
await st.updatePhase(
|
|
3521
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4135
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4136
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3522
4137
|
if (!t)
|
|
3523
4138
|
return phase;
|
|
3524
4139
|
applyTaskLifecycleDates(t, "in-progress", now);
|
|
@@ -3527,10 +4142,14 @@ export default function planPiExtension(pi) {
|
|
|
3527
4142
|
startedTask = t;
|
|
3528
4143
|
return phase;
|
|
3529
4144
|
});
|
|
4145
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
4146
|
+
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
|
|
4147
|
+
if (approvedDeviation)
|
|
4148
|
+
await st.setWorkDeviationState(approvedDeviation.id, "active", now);
|
|
3530
4149
|
await st.writeGenerated();
|
|
3531
4150
|
if (!startedTask)
|
|
3532
4151
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3533
|
-
return { content: [{ type: "text", text: `✅ Task started: ${startedTask.
|
|
4152
|
+
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
4153
|
},
|
|
3535
4154
|
});
|
|
3536
4155
|
pi.registerTool({
|
|
@@ -3546,11 +4165,11 @@ export default function planPiExtension(pi) {
|
|
|
3546
4165
|
const st = await requirePlan(ctx);
|
|
3547
4166
|
if (!st)
|
|
3548
4167
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3549
|
-
const
|
|
3550
|
-
const
|
|
3551
|
-
|
|
3552
|
-
if (!task || !hostPhase)
|
|
4168
|
+
const features = (await st.loadFeatures()).features;
|
|
4169
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4170
|
+
if (!found)
|
|
3553
4171
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4172
|
+
const task = found.task;
|
|
3554
4173
|
if (task.status === "done")
|
|
3555
4174
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
3556
4175
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -3562,8 +4181,8 @@ export default function planPiExtension(pi) {
|
|
|
3562
4181
|
}
|
|
3563
4182
|
const now = nowISO();
|
|
3564
4183
|
let completedTask;
|
|
3565
|
-
await st.updatePhase(
|
|
3566
|
-
const t = phase.tasks.find((x) => x.id ===
|
|
4184
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4185
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
3567
4186
|
if (!t)
|
|
3568
4187
|
return phase;
|
|
3569
4188
|
applyTaskLifecycleDates(t, "done", now);
|
|
@@ -3576,10 +4195,135 @@ export default function planPiExtension(pi) {
|
|
|
3576
4195
|
completedTask = t;
|
|
3577
4196
|
return phase;
|
|
3578
4197
|
});
|
|
4198
|
+
const clearedRef = await st.syncTaskStatusRollup(found.phase.id);
|
|
4199
|
+
const completedDeviation = (await st.loadProject()).workDeviations
|
|
4200
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
|
|
4201
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4202
|
+
if (completedDeviation)
|
|
4203
|
+
await st.setWorkDeviationState(completedDeviation.id, "resolved", now);
|
|
3579
4204
|
await st.writeGenerated();
|
|
3580
4205
|
if (!completedTask)
|
|
3581
4206
|
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 };
|
|
4207
|
+
return { content: [{ type: "text", text: `✅ Task completed: ${completedTask.id} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}` }], details: completedTask };
|
|
4208
|
+
},
|
|
4209
|
+
});
|
|
4210
|
+
pi.registerTool({
|
|
4211
|
+
name: "task_checklist_toggle",
|
|
4212
|
+
label: "Task Checklist Toggle",
|
|
4213
|
+
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.",
|
|
4214
|
+
parameters: Type.Object({
|
|
4215
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4216
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4217
|
+
checked: Type.Optional(Type.Boolean({ description: "Set explicitly: true=done, false=open. Omit to toggle." })),
|
|
4218
|
+
}),
|
|
4219
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4220
|
+
const st = await requirePlan(ctx);
|
|
4221
|
+
if (!st)
|
|
4222
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4223
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4224
|
+
if (!found)
|
|
4225
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4226
|
+
let result = "";
|
|
4227
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4228
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4229
|
+
if (!task) {
|
|
4230
|
+
result = `Task not found: ${params.taskId}`;
|
|
4231
|
+
return phase;
|
|
4232
|
+
}
|
|
4233
|
+
const ck = task.checklist ?? [];
|
|
4234
|
+
if (ck.length === 0) {
|
|
4235
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4236
|
+
return phase;
|
|
4237
|
+
}
|
|
4238
|
+
const target = toggleChecklistItem(ck, params.item, params.checked);
|
|
4239
|
+
if (!target) {
|
|
4240
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4241
|
+
return phase;
|
|
4242
|
+
}
|
|
4243
|
+
task.updatedAt = nowISO();
|
|
4244
|
+
phase.updatedAt = nowISO();
|
|
4245
|
+
const doneCount = ck.filter((i) => i.checked).length;
|
|
4246
|
+
result = `C${target.number} "${target.title}" → ${target.checked ? "done" : "open"} (${doneCount}/${ck.length} checked)`;
|
|
4247
|
+
return phase;
|
|
4248
|
+
});
|
|
4249
|
+
await st.writeGenerated();
|
|
4250
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4251
|
+
},
|
|
4252
|
+
});
|
|
4253
|
+
pi.registerTool({
|
|
4254
|
+
name: "task_checklist_add",
|
|
4255
|
+
label: "Task Checklist Add",
|
|
4256
|
+
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.",
|
|
4257
|
+
parameters: Type.Object({
|
|
4258
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4259
|
+
title: Type.String({ description: "Checklist item text (a single step)" }),
|
|
4260
|
+
}),
|
|
4261
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4262
|
+
const st = await requirePlan(ctx);
|
|
4263
|
+
if (!st)
|
|
4264
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4265
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4266
|
+
if (!found)
|
|
4267
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4268
|
+
let result = "";
|
|
4269
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4270
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4271
|
+
if (!task) {
|
|
4272
|
+
result = `Task not found: ${params.taskId}`;
|
|
4273
|
+
return phase;
|
|
4274
|
+
}
|
|
4275
|
+
const ck = task.checklist ?? [];
|
|
4276
|
+
const item = addChecklistItem(ck, task.id, params.title);
|
|
4277
|
+
ck.push(item);
|
|
4278
|
+
task.checklist = ck;
|
|
4279
|
+
task.updatedAt = nowISO();
|
|
4280
|
+
phase.updatedAt = nowISO();
|
|
4281
|
+
result = `Added C${item.number} "${item.title}" (${ck.length} items)`;
|
|
4282
|
+
return phase;
|
|
4283
|
+
});
|
|
4284
|
+
await st.writeGenerated();
|
|
4285
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
4286
|
+
},
|
|
4287
|
+
});
|
|
4288
|
+
pi.registerTool({
|
|
4289
|
+
name: "task_checklist_remove",
|
|
4290
|
+
label: "Task Checklist Remove",
|
|
4291
|
+
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.",
|
|
4292
|
+
parameters: Type.Object({
|
|
4293
|
+
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
4294
|
+
item: Type.String({ description: "Checklist item selector: C{n} (e.g. C2), item id, or title (case-insensitive)" }),
|
|
4295
|
+
}),
|
|
4296
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4297
|
+
const st = await requirePlan(ctx);
|
|
4298
|
+
if (!st)
|
|
4299
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4300
|
+
const found = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, params.taskId.trim());
|
|
4301
|
+
if (!found)
|
|
4302
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4303
|
+
let result = "";
|
|
4304
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4305
|
+
const task = phase.tasks.find((t) => t.id === found.task.id);
|
|
4306
|
+
if (!task) {
|
|
4307
|
+
result = `Task not found: ${params.taskId}`;
|
|
4308
|
+
return phase;
|
|
4309
|
+
}
|
|
4310
|
+
const ck = task.checklist ?? [];
|
|
4311
|
+
if (ck.length === 0) {
|
|
4312
|
+
result = `Task "${found.task.title}" has no checklist.`;
|
|
4313
|
+
return phase;
|
|
4314
|
+
}
|
|
4315
|
+
const removed = removeChecklistItem(ck, params.item);
|
|
4316
|
+
if (!removed) {
|
|
4317
|
+
result = `No checklist item matching "${params.item}".`;
|
|
4318
|
+
return phase;
|
|
4319
|
+
}
|
|
4320
|
+
task.updatedAt = nowISO();
|
|
4321
|
+
phase.updatedAt = nowISO();
|
|
4322
|
+
result = `Removed C${removed.number} "${removed.title}" (${ck.length} items left)`;
|
|
4323
|
+
return phase;
|
|
4324
|
+
});
|
|
4325
|
+
await st.writeGenerated();
|
|
4326
|
+
return { content: [{ type: "text", text: `✅ ${result}` }], details: {} };
|
|
3583
4327
|
},
|
|
3584
4328
|
});
|
|
3585
4329
|
// ── Web lifecycle tools (agent-callable; parity with @agent-plan/mcp planner-web) ──
|
|
@@ -3656,7 +4400,7 @@ export default function planPiExtension(pi) {
|
|
|
3656
4400
|
description: "Disable the planner for this project and stop the web dashboard. Alias: planner-disable. Planner operations are NOT code edits.",
|
|
3657
4401
|
parameters: Type.Object({}),
|
|
3658
4402
|
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
3659
|
-
|
|
4403
|
+
disablePlannerSession();
|
|
3660
4404
|
await stopServer().catch(() => { });
|
|
3661
4405
|
try {
|
|
3662
4406
|
capturedPi?.appendEntry("plan-web-state", { running: false });
|
|
@@ -3673,15 +4417,9 @@ export default function planPiExtension(pi) {
|
|
|
3673
4417
|
if (!(await st.exists()))
|
|
3674
4418
|
return;
|
|
3675
4419
|
try {
|
|
3676
|
-
//
|
|
3677
|
-
//
|
|
3678
|
-
// add seconds of latency on every turn if run unconditionally.
|
|
4420
|
+
// Session initialization must not mutate canonical planner entities.
|
|
4421
|
+
// Migrations/backfills/repairs are explicit tools; resume is `.local`.
|
|
3679
4422
|
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
4423
|
await st.refreshResume();
|
|
3686
4424
|
plannerHeavyInitDone = true;
|
|
3687
4425
|
}
|
|
@@ -3711,8 +4449,9 @@ export default function planPiExtension(pi) {
|
|
|
3711
4449
|
const profile = await st.loadCodebaseProfile();
|
|
3712
4450
|
const resume = await st.loadResume().catch(() => null) ?? await st.refreshResume();
|
|
3713
4451
|
const activity = await st.loadActivityLog();
|
|
3714
|
-
const recentActivity = activity.entries.slice(-
|
|
4452
|
+
const recentActivity = activity.entries.slice(-3).reverse();
|
|
3715
4453
|
const handoffs = await st.listHandoffs();
|
|
4454
|
+
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
3716
4455
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
3717
4456
|
const orderedPhases = [
|
|
3718
4457
|
...plan.features.features.flatMap((feature) => {
|
|
@@ -3725,21 +4464,9 @@ export default function planPiExtension(pi) {
|
|
|
3725
4464
|
...plan.phases.filter((phase) => !phase.featureId)
|
|
3726
4465
|
.sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
3727
4466
|
];
|
|
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;
|
|
4467
|
+
const hasActiveWork = plan.features.features.some((feature) => feature.status === "in-progress")
|
|
4468
|
+
|| plan.phases.some((phase) => phase.status === "in-progress" || phase.status === "discovery")
|
|
4469
|
+
|| orderedPhases.some((phase) => phase.tasks.some((task) => task.status === "in-progress"));
|
|
3743
4470
|
const currentPhase = hasActiveWork
|
|
3744
4471
|
? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
|
|
3745
4472
|
?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
|
|
@@ -3756,16 +4483,51 @@ export default function planPiExtension(pi) {
|
|
|
3756
4483
|
?? null)
|
|
3757
4484
|
: null;
|
|
3758
4485
|
const currentFeature = hasActiveWork && currentPhase?.featureId
|
|
3759
|
-
?
|
|
4486
|
+
? featureById.get(currentPhase.featureId) ?? null
|
|
3760
4487
|
: hasActiveWork
|
|
3761
4488
|
? plan.features.features.find((feature) => feature.status === "in-progress") ?? null
|
|
3762
4489
|
: null;
|
|
3763
|
-
const
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
4490
|
+
const currentFeatureRef = currentFeature ? formatFeatureRef(currentFeature.number) : "";
|
|
4491
|
+
const currentPhaseRef = currentPhase ? formatPhaseRef(currentPhase.number, currentFeature?.number) : "";
|
|
4492
|
+
const currentTaskRef = currentTask && currentPhase
|
|
4493
|
+
? `${formatPhaseRef(currentPhase.number, currentFeature?.number)}/T${pad(currentTask.number)}`
|
|
4494
|
+
: "";
|
|
4495
|
+
const phaseStatusOrder = ["draft", "discovery", "planned", "in-progress", "blocked", "done", "canceled"];
|
|
4496
|
+
const phaseStatusCounts = new Map(phaseStatusOrder.map((status) => [status, 0]));
|
|
4497
|
+
for (const phase of plan.phases) {
|
|
4498
|
+
phaseStatusCounts.set(phase.status, (phaseStatusCounts.get(phase.status) ?? 0) + 1);
|
|
4499
|
+
}
|
|
4500
|
+
const phaseCountsSummary = phaseStatusOrder
|
|
4501
|
+
.map((status) => `${status}=${phaseStatusCounts.get(status) ?? 0}`)
|
|
4502
|
+
.join(" ");
|
|
4503
|
+
const activeTaskMap = new Map();
|
|
4504
|
+
for (const phase of orderedPhases) {
|
|
4505
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4506
|
+
for (const task of phase.tasks) {
|
|
4507
|
+
if (!resume.inProgressTaskIds.includes(task.id) && task.status !== "in-progress")
|
|
4508
|
+
continue;
|
|
4509
|
+
activeTaskMap.set(task.id, { phase, task, featureNumber });
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
const inProgressTaskLines = [...activeTaskMap.values()]
|
|
4513
|
+
.sort((left, right) => left.task.number - right.task.number || left.task.createdAt.localeCompare(right.task.createdAt))
|
|
4514
|
+
.map(({ phase, task, featureNumber }) => `${formatPhaseRef(phase.number, featureNumber)}/T${pad(task.number)} — ${task.title}`);
|
|
4515
|
+
const otherActiveOrBlockedPhases = orderedPhases
|
|
4516
|
+
.filter((phase) => phase.id !== currentPhase?.id && (phase.status === "in-progress" || phase.status === "discovery" || phase.status === "blocked"))
|
|
4517
|
+
.slice(0, 6)
|
|
4518
|
+
.map((phase) => {
|
|
4519
|
+
const featureNumber = phase.featureId ? featureById.get(phase.featureId)?.number : undefined;
|
|
4520
|
+
return `${statusIcon(phase.status)} ${formatPhaseRef(phase.number, featureNumber)} — ${phase.title} (${phase.status})`;
|
|
4521
|
+
});
|
|
4522
|
+
const openQuestions = plan.phases
|
|
4523
|
+
.flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
|
|
4524
|
+
.slice(0, 8);
|
|
4525
|
+
const acceptedDecisions = [
|
|
4526
|
+
...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
|
|
4527
|
+
...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
|
|
4528
|
+
...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
|
|
4529
|
+
...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}`))),
|
|
4530
|
+
].slice(0, 20);
|
|
3769
4531
|
const webServer = server;
|
|
3770
4532
|
const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
|
|
3771
4533
|
const webLanUrl = webServer?.lanUrl ?? "";
|
|
@@ -3778,15 +4540,6 @@ export default function planPiExtension(pi) {
|
|
|
3778
4540
|
catch { }
|
|
3779
4541
|
const webUrl = webLocalUrl;
|
|
3780
4542
|
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
4543
|
const pkgSummary = profile?.packageJson
|
|
3791
4544
|
? `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
4545
|
: "(no package.json)";
|
|
@@ -3808,96 +4561,63 @@ export default function planPiExtension(pi) {
|
|
|
3808
4561
|
// Build/refresh the context block (slow path: cache miss).
|
|
3809
4562
|
const contextBlock = [
|
|
3810
4563
|
`[Plan Context — ${project.name}]`,
|
|
3811
|
-
// ── Web UI status (prominent — agent must surface URL in its summary) ──
|
|
3812
4564
|
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
4565
|
"",
|
|
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
|
-
"═══════════════════════════════════════════════════════════════",
|
|
4566
|
+
"Operational rules:",
|
|
4567
|
+
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4568
|
+
"- 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.",
|
|
4569
|
+
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4570
|
+
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4571
|
+
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4572
|
+
"- 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).",
|
|
4573
|
+
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
3826
4574
|
"",
|
|
3827
|
-
// ── Project details ──
|
|
3828
4575
|
`Goal: ${project.goal || "(not set)"}`,
|
|
3829
4576
|
project.description ? `Description: ${project.description}` : "",
|
|
3830
4577
|
`Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
|
|
3831
4578
|
`Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
|
|
3832
4579
|
`Ambient: ${ambientSummary}`,
|
|
3833
4580
|
"",
|
|
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)"}`,
|
|
4581
|
+
"Global rules:",
|
|
4582
|
+
project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
|
|
3841
4583
|
"",
|
|
4584
|
+
`Workflow: before phase=${project.workflowRules.beforePhaseStart.join(" | ") || "(none)"}; before task=${project.workflowRules.beforeTaskStart.join(" | ") || "(none)"}; after phase=${project.workflowRules.afterPhaseComplete.join(" | ") || "(none)"}`,
|
|
3842
4585
|
`Scope: ${project.scope.join(", ") || "(none)"}`,
|
|
3843
4586
|
`Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
|
|
3844
4587
|
"",
|
|
3845
4588
|
acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
|
|
3846
|
-
...acceptedDecisions.map((
|
|
4589
|
+
...acceptedDecisions.map((decision) => `- ${decision}`),
|
|
3847
4590
|
openQuestions.length ? "" : "",
|
|
3848
4591
|
openQuestions.length ? "Open questions:" : "",
|
|
3849
|
-
...openQuestions.map((
|
|
4592
|
+
...openQuestions.map((question) => `- ${question}`),
|
|
3850
4593
|
"",
|
|
3851
|
-
"Resume focus (
|
|
3852
|
-
|
|
3853
|
-
|
|
4594
|
+
"Resume focus (stale/fallback pointer — may not match the phase you actually worked on in this session):",
|
|
4595
|
+
currentFeatureRef ? ` current feature pointer: ${currentFeatureRef} — ${currentFeature?.name}` : " current feature pointer: (none)",
|
|
4596
|
+
currentPhaseRef ? ` current phase pointer: ${currentPhaseRef} — ${currentPhase?.title}` : " current phase pointer: (none)",
|
|
4597
|
+
currentTaskRef ? ` current task pointer: ${currentTaskRef} — ${currentTask?.title}` : " current task pointer: (none)",
|
|
4598
|
+
inProgressTaskLines.length ? ` in-progress tasks: ${inProgressTaskLines.join("; ")}` : " in-progress tasks: (none)",
|
|
3854
4599
|
resume.blockers.length ? ` blockers: ${resume.blockers.join("; ")}` : " blockers: (none)",
|
|
3855
|
-
|
|
3856
|
-
|
|
4600
|
+
handoffs.length === 0 && resume.nextSteps.length
|
|
4601
|
+
? ` next step: ${resume.nextSteps[0]}${resume.nextStepsUpdatedAt ? ` (updated ${resume.nextStepsUpdatedAt})` : ""}`
|
|
4602
|
+
: "",
|
|
4603
|
+
"",
|
|
4604
|
+
"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
4605
|
"",
|
|
3858
|
-
|
|
3859
|
-
|
|
4606
|
+
"Phase overview:",
|
|
4607
|
+
` counts: ${phaseCountsSummary}`,
|
|
4608
|
+
currentPhaseRef ? ` current: ${currentPhaseRef} — ${currentPhase?.title} (${currentPhase?.status})` : " current: (none)",
|
|
4609
|
+
otherActiveOrBlockedPhases.length ? " other active/blocked:" : " other active/blocked: (none)",
|
|
4610
|
+
...otherActiveOrBlockedPhases.map((line) => ` - ${line}`),
|
|
3860
4611
|
"",
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
phaseLines || " _none_",
|
|
3864
|
-
active ? `Active: ${active}` : "",
|
|
4612
|
+
recentActivity.length ? "Recent activity:" : "",
|
|
4613
|
+
...recentActivity.map((entry) => `- ${entry.at} [${entry.type}] ${entry.ref}: ${entry.summary}`),
|
|
3865
4614
|
"",
|
|
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.",
|
|
4615
|
+
handoffs.length > 0 ? "Pending phase handoffs (entity-scoped; may be on a stale phase — validate before treating as current focus):" : "",
|
|
4616
|
+
...handoffs.map((handoff, index) => ` [${index + 1}] ${handoff.compositeRef} — ${handoff.updatedAt} — "${handoff.firstLine}"`),
|
|
4617
|
+
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
4618
|
"",
|
|
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",
|
|
4619
|
+
`Language: content=${project.contentLanguage || "(not set)"}; chat=${project.chatLanguage || "(not set)"}`,
|
|
4620
|
+
"Planner discuss mode is Agent Plan only: ignore GSD workflows unless the user explicitly asks for GSD.",
|
|
3901
4621
|
].filter(Boolean).join("\n");
|
|
3902
4622
|
contextBlockCache = contextBlock;
|
|
3903
4623
|
contextBlockDirty = false;
|