@agent-plan/pi-adapter 0.2.22 โ 0.2.23
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 +274 -92
- package/dist/ui/paginate.d.ts +29 -0
- package/dist/ui/paginate.d.ts.map +1 -0
- package/dist/ui/paginate.js +73 -0
- package/package.json +3 -3
- package/web-ui-dist/assets/index-COpkshJD.css +1 -0
- package/web-ui-dist/assets/index-IyxH71Dr.js +298 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-B97B4wii.js +0 -298
- package/web-ui-dist/assets/index-Bi9ehfxU.css +0 -1
package/dist/index.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - inject context into prompts
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
|
-
import {
|
|
12
|
+
import { paginatedSelect, paginatedNotify } from "./ui/paginate.js";
|
|
13
|
+
import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureRead, markPhaseRead, markTaskRead, contextReadEligibility, hasReadRequirements, markRequirementRead, invalidateReads } from "@agent-plan/core";
|
|
13
14
|
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
14
15
|
import { join, dirname } from "node:path";
|
|
15
16
|
import { homedir } from "node:os";
|
|
@@ -243,10 +244,11 @@ async function getPlannerExecutionGuard(st) {
|
|
|
243
244
|
? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
|
|
244
245
|
: undefined;
|
|
245
246
|
const focusFromCurrentPhase = resume?.currentPhaseId
|
|
246
|
-
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled")
|
|
247
|
+
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
|
|
247
248
|
: undefined;
|
|
248
|
-
const fallbackFocus = allTasks.find(({ task }) => task.
|
|
249
|
-
?? allTasks.find(({ task }) => task.status
|
|
249
|
+
const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
|
|
250
|
+
?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
|
|
251
|
+
?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
|
|
250
252
|
const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
|
|
251
253
|
return {
|
|
252
254
|
totalTasks,
|
|
@@ -317,7 +319,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
317
319
|
?? null;
|
|
318
320
|
const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
319
321
|
?? currentPhase?.tasks.find((task) => task.status === "in-progress")
|
|
320
|
-
??
|
|
322
|
+
?? currentPhase?.tasks.find((task) => task.pauseSnapshot)
|
|
323
|
+
?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
|
|
321
324
|
?? latestTaskUpdate?.task
|
|
322
325
|
?? null;
|
|
323
326
|
const currentFeature = currentPhase?.featureId
|
|
@@ -555,6 +558,7 @@ function statusIcon(status) {
|
|
|
555
558
|
const icons = {
|
|
556
559
|
draft: "๐", discovery: "๐", planned: "๐",
|
|
557
560
|
"in-progress": "๐ง", done: "โ
", blocked: "๐ซ", canceled: "โ",
|
|
561
|
+
rejected: "โ", deferred: "โณ", waiting: "โฑ๏ธ",
|
|
558
562
|
};
|
|
559
563
|
return icons[status] ?? "โ";
|
|
560
564
|
}
|
|
@@ -626,20 +630,40 @@ function resolveStaticDir() {
|
|
|
626
630
|
const adapterDir = dirname(adapterFile);
|
|
627
631
|
// When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
|
|
628
632
|
const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
+
// Monorepo dev: packages/plan-web-ui/dist/ (rebuilt by `pnpm build:web-ui`)
|
|
634
|
+
const devWebUi = join(adapterDir, "..", "..", "plan-web-ui", "dist");
|
|
635
|
+
const candidates = [pkgWebUi, devWebUi].filter((d) => {
|
|
636
|
+
try {
|
|
637
|
+
return existsSync(join(d, "index.html"));
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
if (candidates.length === 0)
|
|
644
|
+
return undefined;
|
|
645
|
+
if (candidates.length === 1)
|
|
646
|
+
return candidates[0];
|
|
647
|
+
// Prefer the most recently built bundle so a fresh `pnpm build:web-ui`
|
|
648
|
+
// (or copy-web-ui.sh) is picked up in dev instead of a stale vendored
|
|
649
|
+
// snapshot. Deterministic: compare index.html mtimes, newest wins.
|
|
650
|
+
const mtime = (d) => {
|
|
651
|
+
try {
|
|
652
|
+
return statSync(join(d, "index.html")).mtimeMs;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
return [...candidates].sort((a, b) => mtime(b) - mtime(a))[0];
|
|
633
659
|
}
|
|
634
660
|
catch {
|
|
635
661
|
return undefined;
|
|
636
662
|
}
|
|
637
663
|
}
|
|
638
664
|
async function maybeStartWeb(ctx) {
|
|
639
|
-
if (server)
|
|
640
|
-
plannerSessionEnabled = true;
|
|
665
|
+
if (server)
|
|
641
666
|
return;
|
|
642
|
-
}
|
|
643
667
|
ctx.ui.notify("Starting web server (LAN)โฆ", "info");
|
|
644
668
|
try {
|
|
645
669
|
await startServer(ctx, undefined, "lan");
|
|
@@ -661,7 +685,6 @@ function normalizeVisibility(input) {
|
|
|
661
685
|
return undefined;
|
|
662
686
|
}
|
|
663
687
|
async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
664
|
-
plannerSessionEnabled = true;
|
|
665
688
|
if (server)
|
|
666
689
|
return;
|
|
667
690
|
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
@@ -733,6 +756,10 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
733
756
|
catch { }
|
|
734
757
|
}
|
|
735
758
|
}
|
|
759
|
+
function enablePlannerSession() {
|
|
760
|
+
plannerSessionEnabled = true;
|
|
761
|
+
contextBlockDirty = true;
|
|
762
|
+
}
|
|
736
763
|
function disablePlannerSession() {
|
|
737
764
|
plannerSessionEnabled = false;
|
|
738
765
|
startupResumePromptPending = false;
|
|
@@ -759,6 +786,7 @@ export default function planPiExtension(pi) {
|
|
|
759
786
|
capturedPi = pi;
|
|
760
787
|
// โโ Restore on session start/reload โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
761
788
|
pi.on("session_start", async (_event, ctx) => {
|
|
789
|
+
invalidateReads();
|
|
762
790
|
try {
|
|
763
791
|
ctx.ui.addAutocompleteProvider((current) => ({
|
|
764
792
|
triggerCharacters: ["/", " "],
|
|
@@ -1134,6 +1162,7 @@ export default function planPiExtension(pi) {
|
|
|
1134
1162
|
await st.writeGenerated();
|
|
1135
1163
|
await scanCodebase();
|
|
1136
1164
|
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโฆ`, "info");
|
|
1165
|
+
enablePlannerSession();
|
|
1137
1166
|
await maybeStartWeb(ctx);
|
|
1138
1167
|
await handlePlanner("project discuss", ctx);
|
|
1139
1168
|
return;
|
|
@@ -1151,6 +1180,7 @@ export default function planPiExtension(pi) {
|
|
|
1151
1180
|
await st.writeGenerated();
|
|
1152
1181
|
await scanCodebase();
|
|
1153
1182
|
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโฆ`, "info");
|
|
1183
|
+
enablePlannerSession();
|
|
1154
1184
|
await maybeStartWeb(ctx);
|
|
1155
1185
|
await handlePlanner("project discuss", ctx);
|
|
1156
1186
|
return;
|
|
@@ -1182,12 +1212,12 @@ export default function planPiExtension(pi) {
|
|
|
1182
1212
|
.sort((a, b) => a.number - b.number);
|
|
1183
1213
|
if (features.length === 0)
|
|
1184
1214
|
return null;
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1215
|
+
return paginatedSelect(ctx, {
|
|
1216
|
+
title: "Pick a feature",
|
|
1217
|
+
items: features,
|
|
1218
|
+
render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`,
|
|
1219
|
+
pageSize: 10,
|
|
1220
|
+
});
|
|
1191
1221
|
}
|
|
1192
1222
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1193
1223
|
// Helper: pick a phase interactively
|
|
@@ -1198,12 +1228,12 @@ export default function planPiExtension(pi) {
|
|
|
1198
1228
|
return null;
|
|
1199
1229
|
}
|
|
1200
1230
|
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1231
|
+
return paginatedSelect(ctx, {
|
|
1232
|
+
title: "Pick a phase",
|
|
1233
|
+
items: phases,
|
|
1234
|
+
render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`,
|
|
1235
|
+
pageSize: 10,
|
|
1236
|
+
});
|
|
1207
1237
|
}
|
|
1208
1238
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1209
1239
|
// Helper: pick a task interactively from a phase
|
|
@@ -1212,12 +1242,12 @@ export default function planPiExtension(pi) {
|
|
|
1212
1242
|
if (phase.tasks.length === 0)
|
|
1213
1243
|
return null;
|
|
1214
1244
|
const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1245
|
+
return paginatedSelect(ctx, {
|
|
1246
|
+
title: `Pick a task from "${phase.title}"`,
|
|
1247
|
+
items: tasks,
|
|
1248
|
+
render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`,
|
|
1249
|
+
pageSize: 10,
|
|
1250
|
+
});
|
|
1221
1251
|
}
|
|
1222
1252
|
function parseMultilineList(value) {
|
|
1223
1253
|
return (value ?? "")
|
|
@@ -1435,9 +1465,9 @@ export default function planPiExtension(pi) {
|
|
|
1435
1465
|
if (b === "language") {
|
|
1436
1466
|
const project = await st.loadProject();
|
|
1437
1467
|
const contentLanguage = await ctx.ui.input(`Plan content language [${project.contentLanguage || "English"}]`);
|
|
1438
|
-
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "
|
|
1468
|
+
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
|
|
1439
1469
|
project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
|
|
1440
|
-
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "
|
|
1470
|
+
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
|
|
1441
1471
|
await st.saveProject(project);
|
|
1442
1472
|
await st.writeGenerated();
|
|
1443
1473
|
ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
|
|
@@ -1461,11 +1491,12 @@ export default function planPiExtension(pi) {
|
|
|
1461
1491
|
return;
|
|
1462
1492
|
}
|
|
1463
1493
|
const phases = await st.loadAllPhases();
|
|
1464
|
-
|
|
1494
|
+
const lines = features.map((feature) => {
|
|
1465
1495
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
1466
1496
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
1467
1497
|
return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) โ ${featurePhases.length} phases, ${taskCount} tasks`;
|
|
1468
|
-
})
|
|
1498
|
+
});
|
|
1499
|
+
await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
|
|
1469
1500
|
return;
|
|
1470
1501
|
}
|
|
1471
1502
|
if (b === "add") {
|
|
@@ -2129,8 +2160,9 @@ export default function planPiExtension(pi) {
|
|
|
2129
2160
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2130
2161
|
return;
|
|
2131
2162
|
}
|
|
2132
|
-
if (normalizedStatus === "in-progress" &&
|
|
2133
|
-
ctx.ui.notify("
|
|
2163
|
+
if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
|
|
2164
|
+
ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
|
|
2165
|
+
return;
|
|
2134
2166
|
}
|
|
2135
2167
|
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2136
2168
|
}
|
|
@@ -2168,6 +2200,21 @@ export default function planPiExtension(pi) {
|
|
|
2168
2200
|
return;
|
|
2169
2201
|
}
|
|
2170
2202
|
}
|
|
2203
|
+
if (phase) {
|
|
2204
|
+
const contextEligibility = contextReadEligibility(task.id, phase.id, phase.featureId);
|
|
2205
|
+
if (!contextEligibility.eligible) {
|
|
2206
|
+
ctx.ui.notify(`Task start denied: context reads required. ${contextEligibility.reason}`, "warning");
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
const linkedRequirementIds = [
|
|
2210
|
+
...(await st.linkedRequirementsForPhase(phase.id)).map((r) => r.id),
|
|
2211
|
+
...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
|
|
2212
|
+
];
|
|
2213
|
+
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
2214
|
+
ctx.ui.notify("Task start denied: read the requirements linked to this phase and feature first.", "warning");
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2171
2218
|
if (task.status === "in-progress") {
|
|
2172
2219
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2173
2220
|
return;
|
|
@@ -2341,7 +2388,11 @@ export default function planPiExtension(pi) {
|
|
|
2341
2388
|
ctx.ui.notify("No phase handoffs set.", "info");
|
|
2342
2389
|
return;
|
|
2343
2390
|
}
|
|
2344
|
-
|
|
2391
|
+
await paginatedNotify(ctx, {
|
|
2392
|
+
title: "phase handoffs",
|
|
2393
|
+
lines: list.map((e) => `โข ${e.compositeRef} โ ${e.firstLine} (updated ${e.updatedAt})`),
|
|
2394
|
+
pageSize: 10,
|
|
2395
|
+
});
|
|
2345
2396
|
return;
|
|
2346
2397
|
}
|
|
2347
2398
|
if (action === "show") {
|
|
@@ -2514,7 +2565,7 @@ export default function planPiExtension(pi) {
|
|
|
2514
2565
|
return;
|
|
2515
2566
|
}
|
|
2516
2567
|
if (a === "load") {
|
|
2517
|
-
|
|
2568
|
+
enablePlannerSession();
|
|
2518
2569
|
if (!server) {
|
|
2519
2570
|
ctx.ui.notify("Starting web server (LAN) โฆ", "info");
|
|
2520
2571
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
@@ -2747,6 +2798,7 @@ export default function planPiExtension(pi) {
|
|
|
2747
2798
|
if (!st)
|
|
2748
2799
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2749
2800
|
const requirements = await st.loadRequirements();
|
|
2801
|
+
requirements.requirements.forEach((req) => markRequirementRead(req.id));
|
|
2750
2802
|
return {
|
|
2751
2803
|
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} โ ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2752
2804
|
details: requirements,
|
|
@@ -2767,15 +2819,21 @@ export default function planPiExtension(pi) {
|
|
|
2767
2819
|
const st = await requirePlan(ctx);
|
|
2768
2820
|
if (!st)
|
|
2769
2821
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2822
|
+
const title = params.title?.trim();
|
|
2823
|
+
if (!title)
|
|
2824
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2825
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2826
|
+
if (!links.ok)
|
|
2827
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2770
2828
|
const requirements = await st.loadRequirements();
|
|
2771
2829
|
const now = nowISO();
|
|
2772
2830
|
const requirement = {
|
|
2773
2831
|
id: createRequirementId(),
|
|
2774
|
-
title
|
|
2832
|
+
title,
|
|
2775
2833
|
description: params.description?.trim() ?? "",
|
|
2776
2834
|
status: params.status?.trim() ?? "planned",
|
|
2777
2835
|
macroTasks: [],
|
|
2778
|
-
linkedPhaseIds:
|
|
2836
|
+
linkedPhaseIds: links.linkedPhaseIds,
|
|
2779
2837
|
createdAt: now,
|
|
2780
2838
|
updatedAt: now,
|
|
2781
2839
|
};
|
|
@@ -2804,14 +2862,22 @@ export default function planPiExtension(pi) {
|
|
|
2804
2862
|
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
2805
2863
|
if (!requirement)
|
|
2806
2864
|
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2807
|
-
if (params.title !== undefined)
|
|
2808
|
-
|
|
2865
|
+
if (params.title !== undefined) {
|
|
2866
|
+
const title = params.title.trim();
|
|
2867
|
+
if (!title)
|
|
2868
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2869
|
+
requirement.title = title;
|
|
2870
|
+
}
|
|
2809
2871
|
if (params.description !== undefined)
|
|
2810
2872
|
requirement.description = params.description.trim();
|
|
2811
2873
|
if (params.status !== undefined)
|
|
2812
2874
|
requirement.status = params.status.trim();
|
|
2813
|
-
if (params.linkedPhaseIds !== undefined)
|
|
2814
|
-
|
|
2875
|
+
if (params.linkedPhaseIds !== undefined) {
|
|
2876
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2877
|
+
if (!links.ok)
|
|
2878
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2879
|
+
requirement.linkedPhaseIds = links.linkedPhaseIds;
|
|
2880
|
+
}
|
|
2815
2881
|
requirement.updatedAt = nowISO();
|
|
2816
2882
|
await st.saveRequirements(requirements);
|
|
2817
2883
|
await st.writeGenerated();
|
|
@@ -3021,6 +3087,23 @@ export default function planPiExtension(pi) {
|
|
|
3021
3087
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
3022
3088
|
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
3023
3089
|
}
|
|
3090
|
+
async function resolveRequirementPhaseRefs(st, refs) {
|
|
3091
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
3092
|
+
return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
|
|
3093
|
+
}
|
|
3094
|
+
const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
|
|
3095
|
+
if (trimmed.length === 0) {
|
|
3096
|
+
return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
|
|
3097
|
+
}
|
|
3098
|
+
const phases = await st.loadAllPhases();
|
|
3099
|
+
const features = (await st.loadFeatures()).features;
|
|
3100
|
+
const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
|
|
3101
|
+
const missingIndex = resolved.findIndex((phase) => !phase);
|
|
3102
|
+
if (missingIndex !== -1) {
|
|
3103
|
+
return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
|
|
3104
|
+
}
|
|
3105
|
+
return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
|
|
3106
|
+
}
|
|
3024
3107
|
pi.registerTool({
|
|
3025
3108
|
name: "handoff_list",
|
|
3026
3109
|
label: "Handoff List",
|
|
@@ -3077,11 +3160,13 @@ export default function planPiExtension(pi) {
|
|
|
3077
3160
|
// Derive the current first non-empty line (H1) of the supplied body.
|
|
3078
3161
|
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3079
3162
|
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3080
|
-
const
|
|
3081
|
-
|
|
3163
|
+
const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
|
|
3164
|
+
const effectiveHeadingText = (title && title.length > 0) ? title : firstHeadingText;
|
|
3165
|
+
const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
|
|
3166
|
+
if (isGeneric) {
|
|
3082
3167
|
return {
|
|
3083
3168
|
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." }],
|
|
3084
|
-
details: { error: "generic title", firstHeadingText },
|
|
3169
|
+
details: { error: "generic title", firstHeadingText: effectiveHeadingText || firstHeadingText },
|
|
3085
3170
|
};
|
|
3086
3171
|
}
|
|
3087
3172
|
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
@@ -3191,6 +3276,8 @@ export default function planPiExtension(pi) {
|
|
|
3191
3276
|
if (!resolvedFeature.ok)
|
|
3192
3277
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3193
3278
|
const feature = resolvedFeature.feature;
|
|
3279
|
+
if (params.full)
|
|
3280
|
+
markFeatureRead(feature.id);
|
|
3194
3281
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3195
3282
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3196
3283
|
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"}` : ""})`;
|
|
@@ -3489,6 +3576,8 @@ export default function planPiExtension(pi) {
|
|
|
3489
3576
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3490
3577
|
if (!phase)
|
|
3491
3578
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3579
|
+
if (params.full)
|
|
3580
|
+
markPhaseRead(phase.id, phase.featureId);
|
|
3492
3581
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3493
3582
|
const reqCount = linkedRequirements.length;
|
|
3494
3583
|
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"}` : ""})`;
|
|
@@ -3513,6 +3602,9 @@ export default function planPiExtension(pi) {
|
|
|
3513
3602
|
const st = await requirePlan(ctx);
|
|
3514
3603
|
if (!st)
|
|
3515
3604
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3605
|
+
const title = params.title?.trim();
|
|
3606
|
+
if (!title)
|
|
3607
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3516
3608
|
if (!params.featureId?.trim())
|
|
3517
3609
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3518
3610
|
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
@@ -3533,7 +3625,7 @@ export default function planPiExtension(pi) {
|
|
|
3533
3625
|
const priority = await st.nextPriority("phase", featureId);
|
|
3534
3626
|
const now = nowISO();
|
|
3535
3627
|
phase = {
|
|
3536
|
-
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(
|
|
3628
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
|
|
3537
3629
|
featureId,
|
|
3538
3630
|
status: params.status ?? "draft",
|
|
3539
3631
|
discussedAt: "",
|
|
@@ -3823,10 +3915,10 @@ export default function planPiExtension(pi) {
|
|
|
3823
3915
|
pi.registerTool({
|
|
3824
3916
|
name: "task_get",
|
|
3825
3917
|
label: "Task Get",
|
|
3826
|
-
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description
|
|
3918
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
|
|
3827
3919
|
parameters: Type.Object({
|
|
3828
3920
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3829
|
-
full: Type.Optional(Type.Boolean({ description: "If true, include the task description
|
|
3921
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
3830
3922
|
}),
|
|
3831
3923
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3832
3924
|
const st = await requirePlan(ctx);
|
|
@@ -3836,11 +3928,37 @@ export default function planPiExtension(pi) {
|
|
|
3836
3928
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3837
3929
|
if (!found)
|
|
3838
3930
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3931
|
+
if (params.full)
|
|
3932
|
+
markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
|
|
3839
3933
|
const summary = `${found.task.title} โ ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` ยท ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
3840
3934
|
if (!params.full)
|
|
3841
3935
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
3936
|
+
const project = await st.loadProject();
|
|
3937
|
+
const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
|
|
3938
|
+
? undefined
|
|
3939
|
+
: project.workDeviations
|
|
3940
|
+
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
3941
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
3942
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
3943
|
+
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
3842
3944
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3843
|
-
|
|
3945
|
+
const sections = [summary];
|
|
3946
|
+
if (found.task.description?.trim())
|
|
3947
|
+
sections.push(found.task.description.trim());
|
|
3948
|
+
if (snapshot || pendingDeviation) {
|
|
3949
|
+
sections.push([
|
|
3950
|
+
pendingDeviation
|
|
3951
|
+
? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
|
|
3952
|
+
: "Resume checkpoint:",
|
|
3953
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
|
|
3954
|
+
snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
|
|
3955
|
+
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
3956
|
+
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
3957
|
+
].filter(Boolean).join("\n"));
|
|
3958
|
+
}
|
|
3959
|
+
if (log)
|
|
3960
|
+
sections.push(`Status log:\n${log}`);
|
|
3961
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
3844
3962
|
},
|
|
3845
3963
|
});
|
|
3846
3964
|
pi.registerTool({
|
|
@@ -3860,6 +3978,9 @@ export default function planPiExtension(pi) {
|
|
|
3860
3978
|
const st = await requirePlan(ctx);
|
|
3861
3979
|
if (!st)
|
|
3862
3980
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3981
|
+
const title = params.title?.trim();
|
|
3982
|
+
if (!title)
|
|
3983
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3863
3984
|
if (!params.featureId?.trim())
|
|
3864
3985
|
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3865
3986
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3886,18 +4007,18 @@ export default function planPiExtension(pi) {
|
|
|
3886
4007
|
const existingPhase = await st.loadPhase(phaseId);
|
|
3887
4008
|
if (!existingPhase)
|
|
3888
4009
|
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3889
|
-
const
|
|
4010
|
+
const initialStatus = params.status ?? "planned";
|
|
4011
|
+
if (initialStatus !== "planned") {
|
|
4012
|
+
return { content: [{ type: "text", text: "Tasks must be created planned. Use task_start for in-progress work and task_update for other justified status changes." }], details: {} };
|
|
4013
|
+
}
|
|
4014
|
+
const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3890
4015
|
const taskId = createTaskId();
|
|
3891
4016
|
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
3892
4017
|
const priority = await st.nextPriority("task", phaseId);
|
|
3893
4018
|
const now = nowISO();
|
|
3894
|
-
const initialStatus = params.status ?? "planned";
|
|
3895
|
-
if (initialStatus === "paused") {
|
|
3896
|
-
return { content: [{ type: "text", text: "A task cannot be created paused without an in-progress checkpoint. Create it planned, then use task_pause/task_switch." }], details: {} };
|
|
3897
|
-
}
|
|
3898
4019
|
const task = {
|
|
3899
4020
|
id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
3900
|
-
title
|
|
4021
|
+
title,
|
|
3901
4022
|
status: initialStatus,
|
|
3902
4023
|
description: params.description ?? "",
|
|
3903
4024
|
descriptionUpdatedAt: now,
|
|
@@ -3912,8 +4033,8 @@ export default function planPiExtension(pi) {
|
|
|
3912
4033
|
dependsOn: [],
|
|
3913
4034
|
pauseSnapshot: null,
|
|
3914
4035
|
pauseHistory: [],
|
|
3915
|
-
startedAt:
|
|
3916
|
-
completedAt:
|
|
4036
|
+
startedAt: "",
|
|
4037
|
+
completedAt: "",
|
|
3917
4038
|
createdAt: now, updatedAt: now,
|
|
3918
4039
|
};
|
|
3919
4040
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
@@ -3967,6 +4088,12 @@ export default function planPiExtension(pi) {
|
|
|
3967
4088
|
// Validate the motivation requirement for restrictive status transitions,
|
|
3968
4089
|
// mirroring the MCP adapter and the /planner task update command
|
|
3969
4090
|
// (AGENTS.md rule 8 โ rejected writes must leave the files unchanged).
|
|
4091
|
+
if (params.status === "in-progress" && params.status !== found.task.status) {
|
|
4092
|
+
return {
|
|
4093
|
+
content: [{ type: "text", text: "Task start/resume transitions require task_start so lifecycle state, checkpoints, and return stacks remain consistent." }],
|
|
4094
|
+
details: {},
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
3970
4097
|
if (params.status !== undefined &&
|
|
3971
4098
|
params.status !== found.task.status &&
|
|
3972
4099
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4061,14 +4188,14 @@ export default function planPiExtension(pi) {
|
|
|
4061
4188
|
pi.registerTool({
|
|
4062
4189
|
name: "task_recommend",
|
|
4063
4190
|
label: "Task Recommend",
|
|
4064
|
-
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.",
|
|
4191
|
+
description: "Return the recommended next task without starting work: continue one active task or the current phase, otherwise choose ready work by feature โ phase โ task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
|
|
4065
4192
|
parameters: Type.Object({}),
|
|
4066
4193
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4067
4194
|
const st = await requirePlan(ctx);
|
|
4068
4195
|
if (!st)
|
|
4069
4196
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4070
|
-
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4071
|
-
const result = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4197
|
+
const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4198
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
|
|
4072
4199
|
if (!result.candidate)
|
|
4073
4200
|
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4074
4201
|
const { candidate } = result;
|
|
@@ -4092,11 +4219,11 @@ export default function planPiExtension(pi) {
|
|
|
4092
4219
|
const st = await requirePlan(ctx);
|
|
4093
4220
|
if (!st)
|
|
4094
4221
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4095
|
-
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4222
|
+
const [features, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4096
4223
|
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4097
4224
|
if (!temporary)
|
|
4098
4225
|
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4099
|
-
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4226
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4100
4227
|
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4101
4228
|
if (!resume)
|
|
4102
4229
|
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
@@ -4145,7 +4272,8 @@ export default function planPiExtension(pi) {
|
|
|
4145
4272
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
|
|
4146
4273
|
pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
|
|
4147
4274
|
};
|
|
4148
|
-
|
|
4275
|
+
invalidateReads();
|
|
4276
|
+
const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
|
|
4149
4277
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4150
4278
|
const activeDeviation = (await st.loadProject()).workDeviations
|
|
4151
4279
|
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
|
|
@@ -4157,20 +4285,20 @@ export default function planPiExtension(pi) {
|
|
|
4157
4285
|
? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
|
|
4158
4286
|
: undefined;
|
|
4159
4287
|
await st.writeGenerated();
|
|
4160
|
-
const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(
|
|
4288
|
+
const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
|
|
4161
4289
|
const resumeRef = resume
|
|
4162
4290
|
? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
|
|
4163
4291
|
: "";
|
|
4164
4292
|
return {
|
|
4165
4293
|
content: [{ type: "text", text: [
|
|
4166
|
-
|
|
4294
|
+
`๐พ Resume checkpoint saved: ${reference} โ ${checkpointed.title}`,
|
|
4167
4295
|
`Why: ${snapshot.reason}`,
|
|
4168
4296
|
`Checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4169
4297
|
`Resume from: ${snapshot.resumeLocation}`,
|
|
4170
4298
|
`How to resume: ${snapshot.howToResume}`,
|
|
4171
4299
|
resume ? `โฉ๏ธ RESUME REQUIRED: ${resumeRef} โ ${resume.task.title}` : "",
|
|
4172
4300
|
].filter(Boolean).join("\n") }],
|
|
4173
|
-
details: { task:
|
|
4301
|
+
details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
|
|
4174
4302
|
};
|
|
4175
4303
|
},
|
|
4176
4304
|
});
|
|
@@ -4191,7 +4319,7 @@ export default function planPiExtension(pi) {
|
|
|
4191
4319
|
const st = await requirePlan(ctx);
|
|
4192
4320
|
if (!st)
|
|
4193
4321
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4194
|
-
const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4322
|
+
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4195
4323
|
const features = featuresDoc.features;
|
|
4196
4324
|
const source = findTaskByRef(phases, features, params.from_task.trim());
|
|
4197
4325
|
const target = findTaskByRef(phases, features, params.to_task.trim());
|
|
@@ -4201,8 +4329,19 @@ export default function planPiExtension(pi) {
|
|
|
4201
4329
|
return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
|
|
4202
4330
|
if (source.task.id === target.task.id)
|
|
4203
4331
|
return { content: [{ type: "text", text: "Source and temporary task must differ." }], details: {} };
|
|
4204
|
-
if (source.task.status !== "in-progress" && source.task.
|
|
4205
|
-
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or
|
|
4332
|
+
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4333
|
+
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4334
|
+
}
|
|
4335
|
+
const contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
|
|
4336
|
+
if (!contextEligibility.eligible) {
|
|
4337
|
+
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
|
|
4338
|
+
}
|
|
4339
|
+
const linkedRequirementIds = [
|
|
4340
|
+
...(await st.linkedRequirementsForPhase(target.phase.id)).map((requirement) => requirement.id),
|
|
4341
|
+
...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((requirement) => requirement.id) : []),
|
|
4342
|
+
];
|
|
4343
|
+
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
4344
|
+
return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
|
|
4206
4345
|
}
|
|
4207
4346
|
const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
|
|
4208
4347
|
if (!eligibility.eligible)
|
|
@@ -4213,7 +4352,7 @@ export default function planPiExtension(pi) {
|
|
|
4213
4352
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
4214
4353
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
4215
4354
|
};
|
|
4216
|
-
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4355
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4217
4356
|
const record = {
|
|
4218
4357
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
4219
4358
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
@@ -4232,7 +4371,7 @@ export default function planPiExtension(pi) {
|
|
|
4232
4371
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
4233
4372
|
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
4234
4373
|
if (!task)
|
|
4235
|
-
throw new Error(`
|
|
4374
|
+
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
4236
4375
|
task.pauseSnapshot = snapshot;
|
|
4237
4376
|
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
4238
4377
|
task.updatedAt = timestamp;
|
|
@@ -4240,7 +4379,7 @@ export default function planPiExtension(pi) {
|
|
|
4240
4379
|
});
|
|
4241
4380
|
}
|
|
4242
4381
|
sourceCheckpointed = true;
|
|
4243
|
-
if (target.task.
|
|
4382
|
+
if (target.task.pauseSnapshot) {
|
|
4244
4383
|
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
4245
4384
|
}
|
|
4246
4385
|
else {
|
|
@@ -4262,7 +4401,7 @@ export default function planPiExtension(pi) {
|
|
|
4262
4401
|
}
|
|
4263
4402
|
targetStarted = true;
|
|
4264
4403
|
await st.addWorkDeviation(record);
|
|
4265
|
-
if (target.task.
|
|
4404
|
+
if (target.task.pauseSnapshot) {
|
|
4266
4405
|
const resumedDeviation = project.workDeviations
|
|
4267
4406
|
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
4268
4407
|
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
@@ -4293,12 +4432,13 @@ export default function planPiExtension(pi) {
|
|
|
4293
4432
|
if (target.phase.id !== source.phase.id)
|
|
4294
4433
|
await st.syncTaskStatusRollup(target.phase.id);
|
|
4295
4434
|
await st.writeGenerated();
|
|
4435
|
+
invalidateReads();
|
|
4296
4436
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
4297
4437
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4298
4438
|
return {
|
|
4299
4439
|
content: [{ type: "text", text: [
|
|
4300
4440
|
`๐ Task switched: ${sourceRef} โ ${targetRef}`,
|
|
4301
|
-
`
|
|
4441
|
+
`Resume checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4302
4442
|
`Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
|
|
4303
4443
|
"Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
|
|
4304
4444
|
].join("\n") }],
|
|
@@ -4309,7 +4449,7 @@ export default function planPiExtension(pi) {
|
|
|
4309
4449
|
pi.registerTool({
|
|
4310
4450
|
name: "task_start",
|
|
4311
4451
|
label: "Task Start",
|
|
4312
|
-
description: "Set a task to in-progress or resume
|
|
4452
|
+
description: "Set a task to in-progress or resume checkpointed work. BEFORE calling, read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. A different active task must first be suspended through task_switch.",
|
|
4313
4453
|
parameters: Type.Object({
|
|
4314
4454
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4315
4455
|
}),
|
|
@@ -4326,12 +4466,22 @@ export default function planPiExtension(pi) {
|
|
|
4326
4466
|
return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
|
|
4327
4467
|
if (task.status === "done")
|
|
4328
4468
|
return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
|
|
4329
|
-
const
|
|
4330
|
-
|
|
4469
|
+
const contextEligibility = contextReadEligibility(task.id, found.phase.id, found.phase.featureId);
|
|
4470
|
+
if (!contextEligibility.eligible) {
|
|
4471
|
+
return { content: [{ type: "text", text: `Task start denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
|
|
4472
|
+
}
|
|
4473
|
+
const linkedRequirementIds = [
|
|
4474
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)).map((requirement) => requirement.id),
|
|
4475
|
+
...(found.phase.featureId ? (await st.linkedRequirementsForFeature(found.phase.featureId)).map((requirement) => requirement.id) : []),
|
|
4476
|
+
];
|
|
4477
|
+
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
4478
|
+
return { content: [{ type: "text", text: "Task start denied: read the requirements linked to the task's phase and feature first." }], details: { requirementIds: linkedRequirementIds } };
|
|
4479
|
+
}
|
|
4480
|
+
const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
|
|
4331
4481
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4332
4482
|
if (!eligibility.eligible)
|
|
4333
4483
|
return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
|
|
4334
|
-
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4484
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4335
4485
|
if (selection.kind === "conflict") {
|
|
4336
4486
|
return { content: [{ type: "text", text: `${selection.reason} Pause/reconcile active tasks before starting another task.` }], details: selection };
|
|
4337
4487
|
}
|
|
@@ -4339,9 +4489,27 @@ export default function planPiExtension(pi) {
|
|
|
4339
4489
|
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
4340
4490
|
return { content: [{ type: "text", text: `Task start denied: ${activeRef} is active. Use task_switch to snapshot and pause it before starting another task.` }], details: selection };
|
|
4341
4491
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4492
|
+
let resumeProposal;
|
|
4493
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
4494
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
4495
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
4496
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
4497
|
+
// machine-readable for the host/agent.
|
|
4498
|
+
const resumeTask = selection.candidate;
|
|
4499
|
+
const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
|
|
4500
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
4501
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
4502
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
4503
|
+
?? null;
|
|
4504
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
4505
|
+
ref: resumeRef,
|
|
4506
|
+
title: resumeTask.task.title,
|
|
4507
|
+
taskId: resumeTask.task.id,
|
|
4508
|
+
phaseId: resumeTask.phase.id,
|
|
4509
|
+
snapshot: resumeSnapshot
|
|
4510
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
4511
|
+
: null,
|
|
4512
|
+
});
|
|
4345
4513
|
}
|
|
4346
4514
|
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4347
4515
|
// The output order is feature + its requirements, then phase + its requirements.
|
|
@@ -4352,11 +4520,13 @@ export default function planPiExtension(pi) {
|
|
|
4352
4520
|
]);
|
|
4353
4521
|
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
4354
4522
|
const advisory = selection.candidate && selection.candidate.task.id !== task.id
|
|
4355
|
-
?
|
|
4523
|
+
? selection.kind === "resume"
|
|
4524
|
+
? `\n\n${resumeProposal ? resumeProposal.text : `โ ๏ธ Resume advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`}`
|
|
4525
|
+
: `\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.`
|
|
4356
4526
|
: "";
|
|
4357
4527
|
const now = nowISO();
|
|
4358
4528
|
let startedTask;
|
|
4359
|
-
if (task.
|
|
4529
|
+
if (task.pauseSnapshot) {
|
|
4360
4530
|
startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
|
|
4361
4531
|
}
|
|
4362
4532
|
else {
|
|
@@ -4390,7 +4560,11 @@ export default function planPiExtension(pi) {
|
|
|
4390
4560
|
await st.writeGenerated();
|
|
4391
4561
|
if (!startedTask)
|
|
4392
4562
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4393
|
-
return {
|
|
4563
|
+
return {
|
|
4564
|
+
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}` }],
|
|
4565
|
+
details: startedTask,
|
|
4566
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4567
|
+
};
|
|
4394
4568
|
},
|
|
4395
4569
|
});
|
|
4396
4570
|
pi.registerTool({
|
|
@@ -4413,8 +4587,8 @@ export default function planPiExtension(pi) {
|
|
|
4413
4587
|
const task = found.task;
|
|
4414
4588
|
if (task.status === "done")
|
|
4415
4589
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
4416
|
-
if (task.
|
|
4417
|
-
return { content: [{ type: "text", text: "Task completion denied: resume
|
|
4590
|
+
if (task.pauseSnapshot)
|
|
4591
|
+
return { content: [{ type: "text", text: "Task completion denied: resume checkpointed work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
|
|
4418
4592
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
4419
4593
|
if (unchecked.length > 0 && !params.force) {
|
|
4420
4594
|
return {
|
|
@@ -4463,7 +4637,7 @@ export default function planPiExtension(pi) {
|
|
|
4463
4637
|
resumeNotice = [
|
|
4464
4638
|
"",
|
|
4465
4639
|
`โฉ๏ธ RESUME REQUIRED: ${resumeRef} โ ${resume.task.title}`,
|
|
4466
|
-
snapshot ? `
|
|
4640
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
|
|
4467
4641
|
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
4468
4642
|
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
4469
4643
|
`Next action: task_start ${resumeRef}`,
|
|
@@ -4650,7 +4824,7 @@ export default function planPiExtension(pi) {
|
|
|
4650
4824
|
const st = await requirePlan(ctx);
|
|
4651
4825
|
if (!st)
|
|
4652
4826
|
return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
|
|
4653
|
-
|
|
4827
|
+
enablePlannerSession();
|
|
4654
4828
|
if (!server) {
|
|
4655
4829
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
4656
4830
|
}
|
|
@@ -4722,6 +4896,7 @@ export default function planPiExtension(pi) {
|
|
|
4722
4896
|
const activity = await st.loadActivityLog();
|
|
4723
4897
|
const recentActivity = activity.entries.slice(-3).reverse();
|
|
4724
4898
|
const handoffs = await st.listHandoffs();
|
|
4899
|
+
const extensionRules = await st.extensionRules();
|
|
4725
4900
|
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
4726
4901
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
4727
4902
|
const orderedPhases = [
|
|
@@ -4836,12 +5011,19 @@ export default function planPiExtension(pi) {
|
|
|
4836
5011
|
"",
|
|
4837
5012
|
"Operational rules:",
|
|
4838
5013
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4839
|
-
"- BEFORE work: read
|
|
5014
|
+
"- BEFORE work: read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. task_start and task_switch reject work until these reads are complete; only then touch code. AFTER finishing a task: task_complete.",
|
|
4840
5015
|
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4841
5016
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4842
5017
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4843
5018
|
"- 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).",
|
|
4844
5019
|
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
5020
|
+
...(extensionRules.length > 0
|
|
5021
|
+
? [
|
|
5022
|
+
"",
|
|
5023
|
+
"Planner rules (extension; agent-only):",
|
|
5024
|
+
...extensionRules.map((rule, index) => `${index + 1}. ${rule}`),
|
|
5025
|
+
]
|
|
5026
|
+
: []),
|
|
4845
5027
|
"",
|
|
4846
5028
|
`Goal: ${project.goal || "(not set)"}`,
|
|
4847
5029
|
project.description ? `Description: ${project.description}` : "",
|