@agent-plan/pi-adapter 0.2.22 โ 0.2.23-next.0
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 +523 -168
- 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,12 +9,14 @@
|
|
|
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, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } 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";
|
|
16
17
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
17
18
|
import { writeFile, rm } from "node:fs/promises";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
18
20
|
import { createServer } from "node:net";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
import { serve } from "@agent-plan/server/serve";
|
|
@@ -28,6 +30,7 @@ let capturedPi = null;
|
|
|
28
30
|
let store = null;
|
|
29
31
|
let server = null;
|
|
30
32
|
let lastKnownWebPort = null;
|
|
33
|
+
let plannerSessionId = randomUUID();
|
|
31
34
|
// True while the agent is mutating .planner/ files. The web server returns 503-busy
|
|
32
35
|
// during this window so the UI doesn't render inconsistent data.
|
|
33
36
|
let planBusy = false;
|
|
@@ -38,6 +41,20 @@ function withPlanBusy(fn) {
|
|
|
38
41
|
.then(() => fn())
|
|
39
42
|
.finally(() => { planBusy = false; });
|
|
40
43
|
}
|
|
44
|
+
function taskStartFailure(outcome, details = {}) {
|
|
45
|
+
return {
|
|
46
|
+
isError: true,
|
|
47
|
+
content: [{ type: "text", text: [
|
|
48
|
+
`โ TASK START FAILED [${outcome.errorCode}]`,
|
|
49
|
+
"started: false",
|
|
50
|
+
outcome.message,
|
|
51
|
+
"No task lifecycle transition was applied.",
|
|
52
|
+
"Next required actions:",
|
|
53
|
+
...outcome.nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
54
|
+
].join("\n") }],
|
|
55
|
+
details: { ...outcome, ...details },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
41
58
|
// Helper to notify a running server in another process via HTTP.
|
|
42
59
|
async function notifyExternalServer() {
|
|
43
60
|
try {
|
|
@@ -134,7 +151,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
134
151
|
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
135
152
|
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
136
153
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
137
|
-
{ value: "handoff prepare", label: "handoff prepare", description: "
|
|
154
|
+
{ value: "handoff prepare", label: "handoff prepare", description: "Audit and reconcile the active phase handoff with durable entity context" },
|
|
138
155
|
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
|
|
139
156
|
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
|
|
140
157
|
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
|
|
@@ -243,10 +260,11 @@ async function getPlannerExecutionGuard(st) {
|
|
|
243
260
|
? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
|
|
244
261
|
: undefined;
|
|
245
262
|
const focusFromCurrentPhase = resume?.currentPhaseId
|
|
246
|
-
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled")
|
|
263
|
+
? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
|
|
247
264
|
: undefined;
|
|
248
|
-
const fallbackFocus = allTasks.find(({ task }) => task.
|
|
249
|
-
?? allTasks.find(({ task }) => task.status
|
|
265
|
+
const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
|
|
266
|
+
?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
|
|
267
|
+
?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
|
|
250
268
|
const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
|
|
251
269
|
return {
|
|
252
270
|
totalTasks,
|
|
@@ -317,7 +335,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
317
335
|
?? null;
|
|
318
336
|
const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
319
337
|
?? currentPhase?.tasks.find((task) => task.status === "in-progress")
|
|
320
|
-
??
|
|
338
|
+
?? currentPhase?.tasks.find((task) => task.pauseSnapshot)
|
|
339
|
+
?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
|
|
321
340
|
?? latestTaskUpdate?.task
|
|
322
341
|
?? null;
|
|
323
342
|
const currentFeature = currentPhase?.featureId
|
|
@@ -555,6 +574,7 @@ function statusIcon(status) {
|
|
|
555
574
|
const icons = {
|
|
556
575
|
draft: "๐", discovery: "๐", planned: "๐",
|
|
557
576
|
"in-progress": "๐ง", done: "โ
", blocked: "๐ซ", canceled: "โ",
|
|
577
|
+
rejected: "โ", deferred: "โณ", waiting: "โฑ๏ธ",
|
|
558
578
|
};
|
|
559
579
|
return icons[status] ?? "โ";
|
|
560
580
|
}
|
|
@@ -626,20 +646,40 @@ function resolveStaticDir() {
|
|
|
626
646
|
const adapterDir = dirname(adapterFile);
|
|
627
647
|
// When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
|
|
628
648
|
const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
649
|
+
// Monorepo dev: packages/plan-web-ui/dist/ (rebuilt by `pnpm build:web-ui`)
|
|
650
|
+
const devWebUi = join(adapterDir, "..", "..", "plan-web-ui", "dist");
|
|
651
|
+
const candidates = [pkgWebUi, devWebUi].filter((d) => {
|
|
652
|
+
try {
|
|
653
|
+
return existsSync(join(d, "index.html"));
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
if (candidates.length === 0)
|
|
660
|
+
return undefined;
|
|
661
|
+
if (candidates.length === 1)
|
|
662
|
+
return candidates[0];
|
|
663
|
+
// Prefer the most recently built bundle so a fresh `pnpm build:web-ui`
|
|
664
|
+
// (or copy-web-ui.sh) is picked up in dev instead of a stale vendored
|
|
665
|
+
// snapshot. Deterministic: compare index.html mtimes, newest wins.
|
|
666
|
+
const mtime = (d) => {
|
|
667
|
+
try {
|
|
668
|
+
return statSync(join(d, "index.html")).mtimeMs;
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
return [...candidates].sort((a, b) => mtime(b) - mtime(a))[0];
|
|
633
675
|
}
|
|
634
676
|
catch {
|
|
635
677
|
return undefined;
|
|
636
678
|
}
|
|
637
679
|
}
|
|
638
680
|
async function maybeStartWeb(ctx) {
|
|
639
|
-
if (server)
|
|
640
|
-
plannerSessionEnabled = true;
|
|
681
|
+
if (server)
|
|
641
682
|
return;
|
|
642
|
-
}
|
|
643
683
|
ctx.ui.notify("Starting web server (LAN)โฆ", "info");
|
|
644
684
|
try {
|
|
645
685
|
await startServer(ctx, undefined, "lan");
|
|
@@ -661,7 +701,6 @@ function normalizeVisibility(input) {
|
|
|
661
701
|
return undefined;
|
|
662
702
|
}
|
|
663
703
|
async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
664
|
-
plannerSessionEnabled = true;
|
|
665
704
|
if (server)
|
|
666
705
|
return;
|
|
667
706
|
const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
|
|
@@ -733,6 +772,10 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
|
|
|
733
772
|
catch { }
|
|
734
773
|
}
|
|
735
774
|
}
|
|
775
|
+
function enablePlannerSession() {
|
|
776
|
+
plannerSessionEnabled = true;
|
|
777
|
+
contextBlockDirty = true;
|
|
778
|
+
}
|
|
736
779
|
function disablePlannerSession() {
|
|
737
780
|
plannerSessionEnabled = false;
|
|
738
781
|
startupResumePromptPending = false;
|
|
@@ -759,6 +802,9 @@ export default function planPiExtension(pi) {
|
|
|
759
802
|
capturedPi = pi;
|
|
760
803
|
// โโ Restore on session start/reload โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
761
804
|
pi.on("session_start", async (_event, ctx) => {
|
|
805
|
+
plannerSessionId = randomUUID();
|
|
806
|
+
invalidateReads();
|
|
807
|
+
startReadSession(plannerSessionId);
|
|
762
808
|
try {
|
|
763
809
|
ctx.ui.addAutocompleteProvider((current) => ({
|
|
764
810
|
triggerCharacters: ["/", " "],
|
|
@@ -1134,6 +1180,7 @@ export default function planPiExtension(pi) {
|
|
|
1134
1180
|
await st.writeGenerated();
|
|
1135
1181
|
await scanCodebase();
|
|
1136
1182
|
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโฆ`, "info");
|
|
1183
|
+
enablePlannerSession();
|
|
1137
1184
|
await maybeStartWeb(ctx);
|
|
1138
1185
|
await handlePlanner("project discuss", ctx);
|
|
1139
1186
|
return;
|
|
@@ -1151,6 +1198,7 @@ export default function planPiExtension(pi) {
|
|
|
1151
1198
|
await st.writeGenerated();
|
|
1152
1199
|
await scanCodebase();
|
|
1153
1200
|
ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโฆ`, "info");
|
|
1201
|
+
enablePlannerSession();
|
|
1154
1202
|
await maybeStartWeb(ctx);
|
|
1155
1203
|
await handlePlanner("project discuss", ctx);
|
|
1156
1204
|
return;
|
|
@@ -1182,12 +1230,12 @@ export default function planPiExtension(pi) {
|
|
|
1182
1230
|
.sort((a, b) => a.number - b.number);
|
|
1183
1231
|
if (features.length === 0)
|
|
1184
1232
|
return null;
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1233
|
+
return paginatedSelect(ctx, {
|
|
1234
|
+
title: "Pick a feature",
|
|
1235
|
+
items: features,
|
|
1236
|
+
render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`,
|
|
1237
|
+
pageSize: 10,
|
|
1238
|
+
});
|
|
1191
1239
|
}
|
|
1192
1240
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1193
1241
|
// Helper: pick a phase interactively
|
|
@@ -1198,12 +1246,12 @@ export default function planPiExtension(pi) {
|
|
|
1198
1246
|
return null;
|
|
1199
1247
|
}
|
|
1200
1248
|
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1249
|
+
return paginatedSelect(ctx, {
|
|
1250
|
+
title: "Pick a phase",
|
|
1251
|
+
items: phases,
|
|
1252
|
+
render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`,
|
|
1253
|
+
pageSize: 10,
|
|
1254
|
+
});
|
|
1207
1255
|
}
|
|
1208
1256
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1209
1257
|
// Helper: pick a task interactively from a phase
|
|
@@ -1212,12 +1260,12 @@ export default function planPiExtension(pi) {
|
|
|
1212
1260
|
if (phase.tasks.length === 0)
|
|
1213
1261
|
return null;
|
|
1214
1262
|
const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1263
|
+
return paginatedSelect(ctx, {
|
|
1264
|
+
title: `Pick a task from "${phase.title}"`,
|
|
1265
|
+
items: tasks,
|
|
1266
|
+
render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`,
|
|
1267
|
+
pageSize: 10,
|
|
1268
|
+
});
|
|
1221
1269
|
}
|
|
1222
1270
|
function parseMultilineList(value) {
|
|
1223
1271
|
return (value ?? "")
|
|
@@ -1435,9 +1483,9 @@ export default function planPiExtension(pi) {
|
|
|
1435
1483
|
if (b === "language") {
|
|
1436
1484
|
const project = await st.loadProject();
|
|
1437
1485
|
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 || "
|
|
1486
|
+
const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
|
|
1439
1487
|
project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
|
|
1440
|
-
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "
|
|
1488
|
+
project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
|
|
1441
1489
|
await st.saveProject(project);
|
|
1442
1490
|
await st.writeGenerated();
|
|
1443
1491
|
ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
|
|
@@ -1461,11 +1509,12 @@ export default function planPiExtension(pi) {
|
|
|
1461
1509
|
return;
|
|
1462
1510
|
}
|
|
1463
1511
|
const phases = await st.loadAllPhases();
|
|
1464
|
-
|
|
1512
|
+
const lines = features.map((feature) => {
|
|
1465
1513
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
1466
1514
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
1467
1515
|
return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) โ ${featurePhases.length} phases, ${taskCount} tasks`;
|
|
1468
|
-
})
|
|
1516
|
+
});
|
|
1517
|
+
await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
|
|
1469
1518
|
return;
|
|
1470
1519
|
}
|
|
1471
1520
|
if (b === "add") {
|
|
@@ -1506,6 +1555,7 @@ export default function planPiExtension(pi) {
|
|
|
1506
1555
|
phaseIds: [],
|
|
1507
1556
|
dependsOn: [],
|
|
1508
1557
|
statusLog: [],
|
|
1558
|
+
sessionInfo: [],
|
|
1509
1559
|
createdAt: now,
|
|
1510
1560
|
updatedAt: now,
|
|
1511
1561
|
};
|
|
@@ -1727,6 +1777,7 @@ export default function planPiExtension(pi) {
|
|
|
1727
1777
|
handoffReadAt: "",
|
|
1728
1778
|
handoffHistory: [],
|
|
1729
1779
|
statusLog: [],
|
|
1780
|
+
sessionInfo: [],
|
|
1730
1781
|
};
|
|
1731
1782
|
await st.savePhase(phase);
|
|
1732
1783
|
await st.updateFeatures((doc) => {
|
|
@@ -1967,6 +2018,7 @@ export default function planPiExtension(pi) {
|
|
|
1967
2018
|
descriptionUpdatedAt: "",
|
|
1968
2019
|
notes: "",
|
|
1969
2020
|
statusLog: [],
|
|
2021
|
+
sessionInfo: [],
|
|
1970
2022
|
decisions: [],
|
|
1971
2023
|
acceptedDecisions: [],
|
|
1972
2024
|
checklist: [], subtasks: [],
|
|
@@ -2129,8 +2181,13 @@ export default function planPiExtension(pi) {
|
|
|
2129
2181
|
ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
|
|
2130
2182
|
return;
|
|
2131
2183
|
}
|
|
2132
|
-
if (normalizedStatus === "in-progress" &&
|
|
2133
|
-
ctx.ui.notify("
|
|
2184
|
+
if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
|
|
2185
|
+
ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (normalizedStatus === "done" && normalizedStatus !== task.status) {
|
|
2189
|
+
ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
|
|
2190
|
+
return;
|
|
2134
2191
|
}
|
|
2135
2192
|
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2136
2193
|
}
|
|
@@ -2168,6 +2225,38 @@ export default function planPiExtension(pi) {
|
|
|
2168
2225
|
return;
|
|
2169
2226
|
}
|
|
2170
2227
|
}
|
|
2228
|
+
if (phase) {
|
|
2229
|
+
const features = (await st.loadFeatures()).features;
|
|
2230
|
+
const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
|
|
2231
|
+
const linkedRequirements = [
|
|
2232
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2233
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2234
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2235
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2236
|
+
const contextInput = {
|
|
2237
|
+
sessionId: plannerSessionId,
|
|
2238
|
+
taskId: task.id,
|
|
2239
|
+
phaseId: phase.id,
|
|
2240
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
2241
|
+
task,
|
|
2242
|
+
phase,
|
|
2243
|
+
...(feature ? { feature } : {}),
|
|
2244
|
+
requirements: linkedRequirements,
|
|
2245
|
+
requirementIds: linkedRequirementIds,
|
|
2246
|
+
};
|
|
2247
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2248
|
+
if (!contextEligibility.eligible) {
|
|
2249
|
+
ctx.ui.notify(`โ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nRead task(full), phase(full), feature(full) in order, then retry.`, "error");
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
2253
|
+
ctx.ui.notify("โ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2257
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2171
2260
|
if (task.status === "in-progress") {
|
|
2172
2261
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2173
2262
|
return;
|
|
@@ -2232,6 +2321,11 @@ export default function planPiExtension(pi) {
|
|
|
2232
2321
|
return;
|
|
2233
2322
|
}
|
|
2234
2323
|
}
|
|
2324
|
+
const completionSummary = await ctx.ui.input("Completion summary (shipped work, verification, remaining/unverified, files/decisions)");
|
|
2325
|
+
if (!completionSummary?.trim() || completionSummary.trim().length < 10) {
|
|
2326
|
+
ctx.ui.notify("Completion cancelled โ provide a durable summary of at least 10 characters.", "warning");
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2235
2329
|
const now = nowISO();
|
|
2236
2330
|
// Record status change in the incremental statusLog.
|
|
2237
2331
|
const entry = {
|
|
@@ -2240,12 +2334,16 @@ export default function planPiExtension(pi) {
|
|
|
2240
2334
|
fromStatus: task.status,
|
|
2241
2335
|
toStatus: "done",
|
|
2242
2336
|
title: `โ done`,
|
|
2243
|
-
description:
|
|
2337
|
+
description: completionSummary.trim(),
|
|
2244
2338
|
};
|
|
2245
2339
|
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2246
2340
|
applyTaskLifecycleDates(task, "done", now);
|
|
2341
|
+
const separator = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2342
|
+
task.description = `${task.description}${separator}${completionSummary.trim()}`;
|
|
2343
|
+
task.descriptionUpdatedAt = now;
|
|
2247
2344
|
task.updatedAt = now;
|
|
2248
2345
|
phase.updatedAt = now;
|
|
2346
|
+
await st.savePhase(phase);
|
|
2249
2347
|
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2250
2348
|
await st.writeGenerated();
|
|
2251
2349
|
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");
|
|
@@ -2341,7 +2439,11 @@ export default function planPiExtension(pi) {
|
|
|
2341
2439
|
ctx.ui.notify("No phase handoffs set.", "info");
|
|
2342
2440
|
return;
|
|
2343
2441
|
}
|
|
2344
|
-
|
|
2442
|
+
await paginatedNotify(ctx, {
|
|
2443
|
+
title: "phase handoffs",
|
|
2444
|
+
lines: list.map((e) => `โข ${e.compositeRef} โ ${e.firstLine} (updated ${e.updatedAt})`),
|
|
2445
|
+
pageSize: 10,
|
|
2446
|
+
});
|
|
2345
2447
|
return;
|
|
2346
2448
|
}
|
|
2347
2449
|
if (action === "show") {
|
|
@@ -2355,23 +2457,7 @@ export default function planPiExtension(pi) {
|
|
|
2355
2457
|
return;
|
|
2356
2458
|
}
|
|
2357
2459
|
if (action === "write") {
|
|
2358
|
-
|
|
2359
|
-
if (!r.ok) {
|
|
2360
|
-
ctx.ui.notify(r.error, "warning");
|
|
2361
|
-
return;
|
|
2362
|
-
}
|
|
2363
|
-
const text = await ctx.ui.input(`Handoff text for ${r.compositeRef}`);
|
|
2364
|
-
if (!text?.trim()) {
|
|
2365
|
-
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2366
|
-
return;
|
|
2367
|
-
}
|
|
2368
|
-
const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
|
|
2369
|
-
if (!confirmed) {
|
|
2370
|
-
ctx.ui.notify("Handoff write cancelled.", "info");
|
|
2371
|
-
return;
|
|
2372
|
-
}
|
|
2373
|
-
await st.setPhaseHandoff(r.phase.id, text);
|
|
2374
|
-
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2460
|
+
ctx.ui.notify("Direct handoff writes are disabled because they bypass durable task/phase/feature context synchronization. Use /planner handoff prepare, confirm the exact phase, then let the agent run handoff_prepare and handoff_write.", "warning");
|
|
2375
2461
|
return;
|
|
2376
2462
|
}
|
|
2377
2463
|
if (action === "prepare") {
|
|
@@ -2381,15 +2467,16 @@ export default function planPiExtension(pi) {
|
|
|
2381
2467
|
}
|
|
2382
2468
|
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" +
|
|
2383
2469
|
"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" +
|
|
2384
|
-
"Before
|
|
2385
|
-
"After confirmation, the handoff
|
|
2470
|
+
"Before writing, tell the user exactly: `I propose writing this handoff on P00x(F00x) โ <phase title>. Confirm?` Wait for an explicit confirmation. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
|
|
2471
|
+
"After confirmation, call `handoff_prepare` with that exact phaseRef. Read the entire existing handoff and its handoffUpdatedAt token. Audit every listed done task that lacks durable completion/verification evidence. Then call `handoff_write` with the exact token, one reconciled handoff body, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps.\n\n" +
|
|
2472
|
+
"The reconciled handoff MUST contain at minimum:\n" +
|
|
2386
2473
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2387
2474
|
"- `Reason:` why this handoff is being written\n" +
|
|
2388
2475
|
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2389
2476
|
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2390
2477
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2391
2478
|
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2392
|
-
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected.
|
|
2479
|
+
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoff refresh synchronizes durable task/phase/feature context and updates the single active phase.handoff; `.planner/HANDOFF.md` is deprecated.");
|
|
2393
2480
|
ctx.ui.notify("Instructing the agent to prepare the phase handoffโฆ", "info");
|
|
2394
2481
|
return;
|
|
2395
2482
|
}
|
|
@@ -2514,7 +2601,7 @@ export default function planPiExtension(pi) {
|
|
|
2514
2601
|
return;
|
|
2515
2602
|
}
|
|
2516
2603
|
if (a === "load") {
|
|
2517
|
-
|
|
2604
|
+
enablePlannerSession();
|
|
2518
2605
|
if (!server) {
|
|
2519
2606
|
ctx.ui.notify("Starting web server (LAN) โฆ", "info");
|
|
2520
2607
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
@@ -2682,6 +2769,7 @@ export default function planPiExtension(pi) {
|
|
|
2682
2769
|
status: "planned",
|
|
2683
2770
|
macroTasks: [],
|
|
2684
2771
|
linkedPhaseIds: [],
|
|
2772
|
+
sessionInfo: [],
|
|
2685
2773
|
createdAt: now,
|
|
2686
2774
|
updatedAt: now,
|
|
2687
2775
|
})),
|
|
@@ -2747,6 +2835,7 @@ export default function planPiExtension(pi) {
|
|
|
2747
2835
|
if (!st)
|
|
2748
2836
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2749
2837
|
const requirements = await st.loadRequirements();
|
|
2838
|
+
requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
|
|
2750
2839
|
return {
|
|
2751
2840
|
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} โ ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2752
2841
|
details: requirements,
|
|
@@ -2767,15 +2856,22 @@ export default function planPiExtension(pi) {
|
|
|
2767
2856
|
const st = await requirePlan(ctx);
|
|
2768
2857
|
if (!st)
|
|
2769
2858
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2859
|
+
const title = params.title?.trim();
|
|
2860
|
+
if (!title)
|
|
2861
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2862
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2863
|
+
if (!links.ok)
|
|
2864
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2770
2865
|
const requirements = await st.loadRequirements();
|
|
2771
2866
|
const now = nowISO();
|
|
2772
2867
|
const requirement = {
|
|
2773
2868
|
id: createRequirementId(),
|
|
2774
|
-
title
|
|
2869
|
+
title,
|
|
2775
2870
|
description: params.description?.trim() ?? "",
|
|
2776
2871
|
status: params.status?.trim() ?? "planned",
|
|
2777
2872
|
macroTasks: [],
|
|
2778
|
-
linkedPhaseIds:
|
|
2873
|
+
linkedPhaseIds: links.linkedPhaseIds,
|
|
2874
|
+
sessionInfo: [],
|
|
2779
2875
|
createdAt: now,
|
|
2780
2876
|
updatedAt: now,
|
|
2781
2877
|
};
|
|
@@ -2804,14 +2900,22 @@ export default function planPiExtension(pi) {
|
|
|
2804
2900
|
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
2805
2901
|
if (!requirement)
|
|
2806
2902
|
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2807
|
-
if (params.title !== undefined)
|
|
2808
|
-
|
|
2903
|
+
if (params.title !== undefined) {
|
|
2904
|
+
const title = params.title.trim();
|
|
2905
|
+
if (!title)
|
|
2906
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2907
|
+
requirement.title = title;
|
|
2908
|
+
}
|
|
2809
2909
|
if (params.description !== undefined)
|
|
2810
2910
|
requirement.description = params.description.trim();
|
|
2811
2911
|
if (params.status !== undefined)
|
|
2812
2912
|
requirement.status = params.status.trim();
|
|
2813
|
-
if (params.linkedPhaseIds !== undefined)
|
|
2814
|
-
|
|
2913
|
+
if (params.linkedPhaseIds !== undefined) {
|
|
2914
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2915
|
+
if (!links.ok)
|
|
2916
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2917
|
+
requirement.linkedPhaseIds = links.linkedPhaseIds;
|
|
2918
|
+
}
|
|
2815
2919
|
requirement.updatedAt = nowISO();
|
|
2816
2920
|
await st.saveRequirements(requirements);
|
|
2817
2921
|
await st.writeGenerated();
|
|
@@ -2945,7 +3049,7 @@ export default function planPiExtension(pi) {
|
|
|
2945
3049
|
pi.registerTool({
|
|
2946
3050
|
name: "plan_write_handoff",
|
|
2947
3051
|
label: "Plan Write Handoff",
|
|
2948
|
-
description: "DEPRECATED
|
|
3052
|
+
description: "DEPRECATED AND WRITE-DISABLED โ retained only for proposal compatibility. Use handoff_prepare followed by handoff_write so durable task/phase/feature context is synchronized and existing handoff content is reconciled.",
|
|
2949
3053
|
parameters: Type.Object({
|
|
2950
3054
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
2951
3055
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
@@ -2967,21 +3071,10 @@ export default function planPiExtension(pi) {
|
|
|
2967
3071
|
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 } };
|
|
2968
3072
|
if (!params.confirmed)
|
|
2969
3073
|
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 } };
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
2975
|
-
...(params.extraSections && params.extraSections.length > 0
|
|
2976
|
-
? {
|
|
2977
|
-
extraSections: params.extraSections
|
|
2978
|
-
.map((section) => ({ heading: section.heading?.trim() ?? "", body: section.body ?? "" }))
|
|
2979
|
-
.filter((section) => section.heading),
|
|
2980
|
-
}
|
|
2981
|
-
: {}),
|
|
2982
|
-
});
|
|
2983
|
-
await st.setPhaseHandoff(resolved.phase.id, markdown);
|
|
2984
|
-
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 } };
|
|
3074
|
+
return {
|
|
3075
|
+
content: [{ type: "text", text: `โ ๏ธ plan_write_handoff is deprecated and write-disabled because it bypasses durable context synchronization. Run handoff_prepare for ${resolved.compositeRef}, then call handoff_write with the audit token and entity updates.` }],
|
|
3076
|
+
details: { deprecated: true, writeDisabled: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id },
|
|
3077
|
+
};
|
|
2985
3078
|
},
|
|
2986
3079
|
});
|
|
2987
3080
|
pi.registerTool({
|
|
@@ -3021,6 +3114,23 @@ export default function planPiExtension(pi) {
|
|
|
3021
3114
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
3022
3115
|
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
3023
3116
|
}
|
|
3117
|
+
async function resolveRequirementPhaseRefs(st, refs) {
|
|
3118
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
3119
|
+
return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
|
|
3120
|
+
}
|
|
3121
|
+
const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
|
|
3122
|
+
if (trimmed.length === 0) {
|
|
3123
|
+
return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
|
|
3124
|
+
}
|
|
3125
|
+
const phases = await st.loadAllPhases();
|
|
3126
|
+
const features = (await st.loadFeatures()).features;
|
|
3127
|
+
const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
|
|
3128
|
+
const missingIndex = resolved.findIndex((phase) => !phase);
|
|
3129
|
+
if (missingIndex !== -1) {
|
|
3130
|
+
return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
|
|
3131
|
+
}
|
|
3132
|
+
return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
|
|
3133
|
+
}
|
|
3024
3134
|
pi.registerTool({
|
|
3025
3135
|
name: "handoff_list",
|
|
3026
3136
|
label: "Handoff List",
|
|
@@ -3055,16 +3165,69 @@ export default function planPiExtension(pi) {
|
|
|
3055
3165
|
return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3056
3166
|
},
|
|
3057
3167
|
});
|
|
3168
|
+
pi.registerTool({
|
|
3169
|
+
name: "handoff_prepare",
|
|
3170
|
+
label: "Handoff Prepare",
|
|
3171
|
+
description: "Audit one exact phase before writing a handoff. Returns the current handoff/version and every done task missing durable completion or verification evidence. Call after the user confirms the target, then reconcile the returned context through handoff_write.",
|
|
3172
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3173
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3174
|
+
const st = await requirePlan(ctx);
|
|
3175
|
+
if (!st)
|
|
3176
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3177
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3178
|
+
if (!r.ok)
|
|
3179
|
+
return { content: [{ type: "text", text: `โ ${r.error}` }], details: { error: r.error } };
|
|
3180
|
+
try {
|
|
3181
|
+
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3182
|
+
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} โ ${task.title}`).join("\n") || "- None";
|
|
3183
|
+
const existing = audit.handoff.trim() || "(none)";
|
|
3184
|
+
return {
|
|
3185
|
+
content: [{ type: "text", text: [
|
|
3186
|
+
`Handoff preparation audit for ${r.compositeRef}`,
|
|
3187
|
+
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3188
|
+
"Done tasks missing durable completion/verification evidence:",
|
|
3189
|
+
missing,
|
|
3190
|
+
"",
|
|
3191
|
+
"Existing active handoff (reconcile all still-relevant content):",
|
|
3192
|
+
existing,
|
|
3193
|
+
].join("\n") }],
|
|
3194
|
+
details: { phaseRef: r.compositeRef, ...audit },
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
catch (error) {
|
|
3198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3199
|
+
return { content: [{ type: "text", text: `โ Handoff preparation failed: ${message}` }], details: { error: message } };
|
|
3200
|
+
}
|
|
3201
|
+
},
|
|
3202
|
+
});
|
|
3058
3203
|
pi.registerTool({
|
|
3059
3204
|
name: "handoff_write",
|
|
3060
3205
|
label: "Handoff Write",
|
|
3061
|
-
description: "
|
|
3206
|
+
description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run handoff_prepare first and pass its exact handoffUpdatedAt token. Existing context must be reconciled; refreshes do not create superseded archives.",
|
|
3062
3207
|
parameters: Type.Object({
|
|
3063
3208
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3064
|
-
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists).
|
|
3209
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3065
3210
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3066
3211
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3067
3212
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
3213
|
+
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3214
|
+
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3215
|
+
taskUpdates: Type.Optional(Type.Array(Type.Object({
|
|
3216
|
+
taskRef: Type.String(),
|
|
3217
|
+
completionSummary: Type.String(),
|
|
3218
|
+
verification: Type.String(),
|
|
3219
|
+
remainingWork: Type.String(),
|
|
3220
|
+
filesTouched: Type.Optional(Type.Array(Type.String())),
|
|
3221
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3222
|
+
}))),
|
|
3223
|
+
phaseUpdate: Type.Optional(Type.Object({
|
|
3224
|
+
progressSummary: Type.String(),
|
|
3225
|
+
remainingWork: Type.String(),
|
|
3226
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3227
|
+
})),
|
|
3228
|
+
phaseNoUpdateReason: Type.Optional(Type.String({ description: "Required when phaseUpdate is omitted." })),
|
|
3229
|
+
featureUpdate: Type.Optional(Type.Object({ workDone: Type.String(), workRemaining: Type.String() })),
|
|
3230
|
+
featureNoUpdateReason: Type.Optional(Type.String({ description: "Required when featureUpdate is omitted." })),
|
|
3068
3231
|
}),
|
|
3069
3232
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3070
3233
|
const st = await requirePlan(ctx);
|
|
@@ -3074,36 +3237,68 @@ export default function planPiExtension(pi) {
|
|
|
3074
3237
|
if (!body)
|
|
3075
3238
|
return { content: [{ type: "text", text: "โ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3076
3239
|
const title = params.title?.trim();
|
|
3077
|
-
|
|
3078
|
-
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3240
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
3079
3241
|
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3080
|
-
const
|
|
3081
|
-
if (!
|
|
3082
|
-
return {
|
|
3083
|
-
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 },
|
|
3085
|
-
};
|
|
3242
|
+
const effectiveHeadingText = title || firstHeadingText;
|
|
3243
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
3244
|
+
return { content: [{ type: "text", text: "โ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
|
|
3086
3245
|
}
|
|
3087
|
-
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3088
3246
|
if (title) {
|
|
3089
3247
|
const lines = body.split(/\r?\n/);
|
|
3090
|
-
const firstIdx = lines.findIndex((
|
|
3091
|
-
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3248
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
3249
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3092
3250
|
lines[firstIdx] = `# ${title}`;
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
body = `# ${title}\n\n${body}`;
|
|
3097
|
-
}
|
|
3251
|
+
else
|
|
3252
|
+
lines.unshift(`# ${title}`, "");
|
|
3253
|
+
body = lines.join("\n");
|
|
3098
3254
|
}
|
|
3099
3255
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3100
3256
|
if (!r.ok)
|
|
3101
3257
|
return { content: [{ type: "text", text: `โ ${r.error}` }], details: { error: r.error } };
|
|
3102
|
-
if (!params.confirmed)
|
|
3103
|
-
return { content: [{ type: "text", text: `Proposal only: I would
|
|
3258
|
+
if (!params.confirmed)
|
|
3259
|
+
return { content: [{ type: "text", text: `Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run handoff_prepare and retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
|
|
3260
|
+
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3261
|
+
return { content: [{ type: "text", text: "โ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { error: "preparation required" } };
|
|
3262
|
+
const phases = await st.loadAllPhases();
|
|
3263
|
+
const features = (await st.loadFeatures()).features;
|
|
3264
|
+
const taskUpdates = [];
|
|
3265
|
+
for (const update of params.taskUpdates ?? []) {
|
|
3266
|
+
const found = findTaskByRef(phases, features, update.taskRef.trim());
|
|
3267
|
+
if (!found || found.phase.id !== r.phase.id)
|
|
3268
|
+
return { content: [{ type: "text", text: `โ Task ${update.taskRef} does not belong to ${r.compositeRef}.` }], details: { error: "invalid task update" } };
|
|
3269
|
+
taskUpdates.push({
|
|
3270
|
+
taskId: found.task.id,
|
|
3271
|
+
completionSummary: update.completionSummary,
|
|
3272
|
+
verification: update.verification,
|
|
3273
|
+
remainingWork: update.remainingWork,
|
|
3274
|
+
...(update.filesTouched ? { filesTouched: update.filesTouched } : {}),
|
|
3275
|
+
...(update.decisions ? { decisions: update.decisions } : {}),
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
try {
|
|
3279
|
+
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
3280
|
+
content: body,
|
|
3281
|
+
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3282
|
+
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3283
|
+
contextSync: {
|
|
3284
|
+
taskUpdates,
|
|
3285
|
+
...(params.phaseUpdate ? { phaseUpdate: {
|
|
3286
|
+
progressSummary: params.phaseUpdate.progressSummary,
|
|
3287
|
+
remainingWork: params.phaseUpdate.remainingWork,
|
|
3288
|
+
...(params.phaseUpdate.decisions ? { decisions: params.phaseUpdate.decisions } : {}),
|
|
3289
|
+
} } : {}),
|
|
3290
|
+
...(params.phaseNoUpdateReason ? { phaseNoUpdateReason: params.phaseNoUpdateReason } : {}),
|
|
3291
|
+
...(params.featureUpdate ? { featureUpdate: params.featureUpdate } : {}),
|
|
3292
|
+
...(params.featureNoUpdateReason ? { featureNoUpdateReason: params.featureNoUpdateReason } : {}),
|
|
3293
|
+
},
|
|
3294
|
+
});
|
|
3295
|
+
await st.writeGenerated();
|
|
3296
|
+
return { content: [{ type: "text", text: `โ
Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result } };
|
|
3297
|
+
}
|
|
3298
|
+
catch (error) {
|
|
3299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3300
|
+
return { content: [{ type: "text", text: `โ Handoff refresh denied: ${message}` }], details: { error: message } };
|
|
3104
3301
|
}
|
|
3105
|
-
await st.setPhaseHandoff(r.phase.id, body);
|
|
3106
|
-
return { content: [{ type: "text", text: `โ
Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3107
3302
|
},
|
|
3108
3303
|
});
|
|
3109
3304
|
pi.registerTool({
|
|
@@ -3191,6 +3386,8 @@ export default function planPiExtension(pi) {
|
|
|
3191
3386
|
if (!resolvedFeature.ok)
|
|
3192
3387
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3193
3388
|
const feature = resolvedFeature.feature;
|
|
3389
|
+
if (params.full)
|
|
3390
|
+
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3194
3391
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3195
3392
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3196
3393
|
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"}` : ""})`;
|
|
@@ -3239,6 +3436,7 @@ export default function planPiExtension(pi) {
|
|
|
3239
3436
|
phaseIds: [],
|
|
3240
3437
|
dependsOn: [],
|
|
3241
3438
|
statusLog: [],
|
|
3439
|
+
sessionInfo: [],
|
|
3242
3440
|
createdAt: now,
|
|
3243
3441
|
updatedAt: now,
|
|
3244
3442
|
};
|
|
@@ -3489,6 +3687,8 @@ export default function planPiExtension(pi) {
|
|
|
3489
3687
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3490
3688
|
if (!phase)
|
|
3491
3689
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3690
|
+
if (params.full)
|
|
3691
|
+
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
3492
3692
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3493
3693
|
const reqCount = linkedRequirements.length;
|
|
3494
3694
|
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 +3713,9 @@ export default function planPiExtension(pi) {
|
|
|
3513
3713
|
const st = await requirePlan(ctx);
|
|
3514
3714
|
if (!st)
|
|
3515
3715
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3716
|
+
const title = params.title?.trim();
|
|
3717
|
+
if (!title)
|
|
3718
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3516
3719
|
if (!params.featureId?.trim())
|
|
3517
3720
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3518
3721
|
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
@@ -3533,7 +3736,7 @@ export default function planPiExtension(pi) {
|
|
|
3533
3736
|
const priority = await st.nextPriority("phase", featureId);
|
|
3534
3737
|
const now = nowISO();
|
|
3535
3738
|
phase = {
|
|
3536
|
-
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(
|
|
3739
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
|
|
3537
3740
|
featureId,
|
|
3538
3741
|
status: params.status ?? "draft",
|
|
3539
3742
|
discussedAt: "",
|
|
@@ -3547,6 +3750,7 @@ export default function planPiExtension(pi) {
|
|
|
3547
3750
|
handoffReadAt: "",
|
|
3548
3751
|
handoffHistory: [],
|
|
3549
3752
|
statusLog: [],
|
|
3753
|
+
sessionInfo: [],
|
|
3550
3754
|
};
|
|
3551
3755
|
await st.savePhase(phase);
|
|
3552
3756
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
@@ -3823,10 +4027,10 @@ export default function planPiExtension(pi) {
|
|
|
3823
4027
|
pi.registerTool({
|
|
3824
4028
|
name: "task_get",
|
|
3825
4029
|
label: "Task Get",
|
|
3826
|
-
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description
|
|
4030
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
|
|
3827
4031
|
parameters: Type.Object({
|
|
3828
4032
|
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
|
|
4033
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
3830
4034
|
}),
|
|
3831
4035
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3832
4036
|
const st = await requirePlan(ctx);
|
|
@@ -3836,11 +4040,37 @@ export default function planPiExtension(pi) {
|
|
|
3836
4040
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3837
4041
|
if (!found)
|
|
3838
4042
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4043
|
+
if (params.full)
|
|
4044
|
+
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
3839
4045
|
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
4046
|
if (!params.full)
|
|
3841
4047
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
4048
|
+
const project = await st.loadProject();
|
|
4049
|
+
const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
|
|
4050
|
+
? undefined
|
|
4051
|
+
: project.workDeviations
|
|
4052
|
+
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
4053
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
4054
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4055
|
+
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
3842
4056
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3843
|
-
|
|
4057
|
+
const sections = [summary];
|
|
4058
|
+
if (found.task.description?.trim())
|
|
4059
|
+
sections.push(found.task.description.trim());
|
|
4060
|
+
if (snapshot || pendingDeviation) {
|
|
4061
|
+
sections.push([
|
|
4062
|
+
pendingDeviation
|
|
4063
|
+
? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
|
|
4064
|
+
: "Resume checkpoint:",
|
|
4065
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
|
|
4066
|
+
snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
|
|
4067
|
+
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
4068
|
+
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
4069
|
+
].filter(Boolean).join("\n"));
|
|
4070
|
+
}
|
|
4071
|
+
if (log)
|
|
4072
|
+
sections.push(`Status log:\n${log}`);
|
|
4073
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
3844
4074
|
},
|
|
3845
4075
|
});
|
|
3846
4076
|
pi.registerTool({
|
|
@@ -3860,6 +4090,9 @@ export default function planPiExtension(pi) {
|
|
|
3860
4090
|
const st = await requirePlan(ctx);
|
|
3861
4091
|
if (!st)
|
|
3862
4092
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4093
|
+
const title = params.title?.trim();
|
|
4094
|
+
if (!title)
|
|
4095
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3863
4096
|
if (!params.featureId?.trim())
|
|
3864
4097
|
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3865
4098
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3886,23 +4119,24 @@ export default function planPiExtension(pi) {
|
|
|
3886
4119
|
const existingPhase = await st.loadPhase(phaseId);
|
|
3887
4120
|
if (!existingPhase)
|
|
3888
4121
|
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3889
|
-
const
|
|
4122
|
+
const initialStatus = params.status ?? "planned";
|
|
4123
|
+
if (initialStatus !== "planned") {
|
|
4124
|
+
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: {} };
|
|
4125
|
+
}
|
|
4126
|
+
const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3890
4127
|
const taskId = createTaskId();
|
|
3891
4128
|
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
3892
4129
|
const priority = await st.nextPriority("task", phaseId);
|
|
3893
4130
|
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
4131
|
const task = {
|
|
3899
4132
|
id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
3900
|
-
title
|
|
4133
|
+
title,
|
|
3901
4134
|
status: initialStatus,
|
|
3902
4135
|
description: params.description ?? "",
|
|
3903
4136
|
descriptionUpdatedAt: now,
|
|
3904
4137
|
notes: "",
|
|
3905
4138
|
statusLog: [],
|
|
4139
|
+
sessionInfo: [],
|
|
3906
4140
|
decisions: [],
|
|
3907
4141
|
acceptedDecisions: [],
|
|
3908
4142
|
checklist: params.checklist
|
|
@@ -3912,8 +4146,8 @@ export default function planPiExtension(pi) {
|
|
|
3912
4146
|
dependsOn: [],
|
|
3913
4147
|
pauseSnapshot: null,
|
|
3914
4148
|
pauseHistory: [],
|
|
3915
|
-
startedAt:
|
|
3916
|
-
completedAt:
|
|
4149
|
+
startedAt: "",
|
|
4150
|
+
completedAt: "",
|
|
3917
4151
|
createdAt: now, updatedAt: now,
|
|
3918
4152
|
};
|
|
3919
4153
|
// Atomic read-modify-write on the phase file: serializes concurrent
|
|
@@ -3967,6 +4201,18 @@ export default function planPiExtension(pi) {
|
|
|
3967
4201
|
// Validate the motivation requirement for restrictive status transitions,
|
|
3968
4202
|
// mirroring the MCP adapter and the /planner task update command
|
|
3969
4203
|
// (AGENTS.md rule 8 โ rejected writes must leave the files unchanged).
|
|
4204
|
+
if (params.status === "in-progress" && params.status !== found.task.status) {
|
|
4205
|
+
return {
|
|
4206
|
+
content: [{ type: "text", text: "Task start/resume transitions require task_start so lifecycle state, checkpoints, and return stacks remain consistent." }],
|
|
4207
|
+
details: {},
|
|
4208
|
+
};
|
|
4209
|
+
}
|
|
4210
|
+
if (params.status === "done" && params.status !== found.task.status) {
|
|
4211
|
+
return {
|
|
4212
|
+
content: [{ type: "text", text: "Task completion transitions require task_complete with durable completion and verification evidence." }],
|
|
4213
|
+
details: {},
|
|
4214
|
+
};
|
|
4215
|
+
}
|
|
3970
4216
|
if (params.status !== undefined &&
|
|
3971
4217
|
params.status !== found.task.status &&
|
|
3972
4218
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4061,14 +4307,14 @@ export default function planPiExtension(pi) {
|
|
|
4061
4307
|
pi.registerTool({
|
|
4062
4308
|
name: "task_recommend",
|
|
4063
4309
|
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.",
|
|
4310
|
+
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
4311
|
parameters: Type.Object({}),
|
|
4066
4312
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
4067
4313
|
const st = await requirePlan(ctx);
|
|
4068
4314
|
if (!st)
|
|
4069
4315
|
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);
|
|
4316
|
+
const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4317
|
+
const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
|
|
4072
4318
|
if (!result.candidate)
|
|
4073
4319
|
return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
|
|
4074
4320
|
const { candidate } = result;
|
|
@@ -4092,11 +4338,11 @@ export default function planPiExtension(pi) {
|
|
|
4092
4338
|
const st = await requirePlan(ctx);
|
|
4093
4339
|
if (!st)
|
|
4094
4340
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4095
|
-
const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4341
|
+
const [features, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4096
4342
|
const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
|
|
4097
4343
|
if (!temporary)
|
|
4098
4344
|
return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
|
|
4099
|
-
const selected = recommendNextTask(features.features, phases, project.workDeviations);
|
|
4345
|
+
const selected = recommendNextTask(features.features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4100
4346
|
const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
|
|
4101
4347
|
if (!resume)
|
|
4102
4348
|
return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
|
|
@@ -4145,7 +4391,8 @@ export default function planPiExtension(pi) {
|
|
|
4145
4391
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
|
|
4146
4392
|
pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
|
|
4147
4393
|
};
|
|
4148
|
-
|
|
4394
|
+
invalidateReads();
|
|
4395
|
+
const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
|
|
4149
4396
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4150
4397
|
const activeDeviation = (await st.loadProject()).workDeviations
|
|
4151
4398
|
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
|
|
@@ -4157,27 +4404,27 @@ export default function planPiExtension(pi) {
|
|
|
4157
4404
|
? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
|
|
4158
4405
|
: undefined;
|
|
4159
4406
|
await st.writeGenerated();
|
|
4160
|
-
const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(
|
|
4407
|
+
const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
|
|
4161
4408
|
const resumeRef = resume
|
|
4162
4409
|
? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
|
|
4163
4410
|
: "";
|
|
4164
4411
|
return {
|
|
4165
4412
|
content: [{ type: "text", text: [
|
|
4166
|
-
|
|
4413
|
+
`๐พ Resume checkpoint saved: ${reference} โ ${checkpointed.title}`,
|
|
4167
4414
|
`Why: ${snapshot.reason}`,
|
|
4168
4415
|
`Checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4169
4416
|
`Resume from: ${snapshot.resumeLocation}`,
|
|
4170
4417
|
`How to resume: ${snapshot.howToResume}`,
|
|
4171
4418
|
resume ? `โฉ๏ธ RESUME REQUIRED: ${resumeRef} โ ${resume.task.title}` : "",
|
|
4172
4419
|
].filter(Boolean).join("\n") }],
|
|
4173
|
-
details: { task:
|
|
4420
|
+
details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
|
|
4174
4421
|
};
|
|
4175
4422
|
},
|
|
4176
4423
|
});
|
|
4177
4424
|
pi.registerTool({
|
|
4178
4425
|
name: "task_switch",
|
|
4179
4426
|
label: "Task Switch",
|
|
4180
|
-
description: "Switch from active work to a temporary task, even outside priority order. Atomically
|
|
4427
|
+
description: "Switch from active work to a temporary task, even outside priority order. Reuse the target's valid sessionInfo context or require the exact full tree when missing or stale. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
|
|
4181
4428
|
parameters: Type.Object({
|
|
4182
4429
|
from_task: Type.String({ description: "Currently in-progress task ref" }),
|
|
4183
4430
|
to_task: Type.String({ description: "Temporary task ref to start" }),
|
|
@@ -4191,7 +4438,7 @@ export default function planPiExtension(pi) {
|
|
|
4191
4438
|
const st = await requirePlan(ctx);
|
|
4192
4439
|
if (!st)
|
|
4193
4440
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4194
|
-
const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4441
|
+
const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
|
|
4195
4442
|
const features = featuresDoc.features;
|
|
4196
4443
|
const source = findTaskByRef(phases, features, params.from_task.trim());
|
|
4197
4444
|
const target = findTaskByRef(phases, features, params.to_task.trim());
|
|
@@ -4201,8 +4448,35 @@ export default function planPiExtension(pi) {
|
|
|
4201
4448
|
return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
|
|
4202
4449
|
if (source.task.id === target.task.id)
|
|
4203
4450
|
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
|
|
4451
|
+
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4452
|
+
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4453
|
+
}
|
|
4454
|
+
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
4455
|
+
const linkedRequirements = [
|
|
4456
|
+
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
4457
|
+
...(target.phase.featureId ? await st.linkedRequirementsForFeature(target.phase.featureId) : []),
|
|
4458
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4459
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4460
|
+
const contextInput = {
|
|
4461
|
+
sessionId: plannerSessionId,
|
|
4462
|
+
taskId: target.task.id,
|
|
4463
|
+
phaseId: target.phase.id,
|
|
4464
|
+
...(target.phase.featureId ? { featureId: target.phase.featureId } : {}),
|
|
4465
|
+
task: target.task,
|
|
4466
|
+
phase: target.phase,
|
|
4467
|
+
...(targetFeature ? { feature: targetFeature } : {}),
|
|
4468
|
+
requirements: linkedRequirements,
|
|
4469
|
+
requirementIds: linkedRequirementIds,
|
|
4470
|
+
};
|
|
4471
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4472
|
+
if (!contextEligibility.eligible) {
|
|
4473
|
+
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
|
|
4474
|
+
}
|
|
4475
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
4476
|
+
return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
|
|
4477
|
+
}
|
|
4478
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4479
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
4206
4480
|
}
|
|
4207
4481
|
const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
|
|
4208
4482
|
if (!eligibility.eligible)
|
|
@@ -4213,7 +4487,7 @@ export default function planPiExtension(pi) {
|
|
|
4213
4487
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
4214
4488
|
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
4215
4489
|
};
|
|
4216
|
-
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4490
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4217
4491
|
const record = {
|
|
4218
4492
|
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
4219
4493
|
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
@@ -4232,7 +4506,7 @@ export default function planPiExtension(pi) {
|
|
|
4232
4506
|
await st.updatePhase(source.phase.id, (phase) => {
|
|
4233
4507
|
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
4234
4508
|
if (!task)
|
|
4235
|
-
throw new Error(`
|
|
4509
|
+
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
4236
4510
|
task.pauseSnapshot = snapshot;
|
|
4237
4511
|
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
4238
4512
|
task.updatedAt = timestamp;
|
|
@@ -4240,7 +4514,7 @@ export default function planPiExtension(pi) {
|
|
|
4240
4514
|
});
|
|
4241
4515
|
}
|
|
4242
4516
|
sourceCheckpointed = true;
|
|
4243
|
-
if (target.task.
|
|
4517
|
+
if (target.task.pauseSnapshot) {
|
|
4244
4518
|
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
4245
4519
|
}
|
|
4246
4520
|
else {
|
|
@@ -4262,7 +4536,7 @@ export default function planPiExtension(pi) {
|
|
|
4262
4536
|
}
|
|
4263
4537
|
targetStarted = true;
|
|
4264
4538
|
await st.addWorkDeviation(record);
|
|
4265
|
-
if (target.task.
|
|
4539
|
+
if (target.task.pauseSnapshot) {
|
|
4266
4540
|
const resumedDeviation = project.workDeviations
|
|
4267
4541
|
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
4268
4542
|
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
@@ -4293,12 +4567,13 @@ export default function planPiExtension(pi) {
|
|
|
4293
4567
|
if (target.phase.id !== source.phase.id)
|
|
4294
4568
|
await st.syncTaskStatusRollup(target.phase.id);
|
|
4295
4569
|
await st.writeGenerated();
|
|
4570
|
+
invalidateReads();
|
|
4296
4571
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
4297
4572
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4298
4573
|
return {
|
|
4299
4574
|
content: [{ type: "text", text: [
|
|
4300
4575
|
`๐ Task switched: ${sourceRef} โ ${targetRef}`,
|
|
4301
|
-
`
|
|
4576
|
+
`Resume checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4302
4577
|
`Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
|
|
4303
4578
|
"Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
|
|
4304
4579
|
].join("\n") }],
|
|
@@ -4309,39 +4584,100 @@ export default function planPiExtension(pi) {
|
|
|
4309
4584
|
pi.registerTool({
|
|
4310
4585
|
name: "task_start",
|
|
4311
4586
|
label: "Task Start",
|
|
4312
|
-
description: "Set a task to in-progress or resume
|
|
4587
|
+
description: "Set a task to in-progress or resume checkpointed work. Read the exact full task โ phase โ feature tree and linked requirements once per harness session; subsequent starts reuse persisted sessionInfo while entity updatedAt values remain unchanged. A stale or denied start is an isError result with started=false and an exact retry sequence; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
|
|
4313
4588
|
parameters: Type.Object({
|
|
4314
4589
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4315
4590
|
}),
|
|
4316
4591
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4317
4592
|
const st = await requirePlan(ctx);
|
|
4318
4593
|
if (!st)
|
|
4319
|
-
return
|
|
4594
|
+
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
4320
4595
|
const features = (await st.loadFeatures()).features;
|
|
4321
4596
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4322
4597
|
if (!found)
|
|
4323
|
-
return
|
|
4598
|
+
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
4324
4599
|
const task = found.task;
|
|
4325
|
-
|
|
4326
|
-
|
|
4600
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
4601
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
4602
|
+
const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
4603
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
4604
|
+
if (task.status === "in-progress") {
|
|
4605
|
+
const outcome = taskStartSucceeded(task.id, true);
|
|
4606
|
+
return { content: [{ type: "text", text: `โ
Task already started: ${taskRef} โ ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
|
|
4607
|
+
}
|
|
4327
4608
|
if (task.status === "done")
|
|
4328
|
-
return
|
|
4329
|
-
const
|
|
4330
|
-
|
|
4609
|
+
return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry task_start ${taskRef}.`], { taskId: task.id }));
|
|
4610
|
+
const linkedRequirements = [
|
|
4611
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
4612
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
4613
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4614
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4615
|
+
const contextInput = {
|
|
4616
|
+
sessionId: plannerSessionId,
|
|
4617
|
+
taskId: task.id,
|
|
4618
|
+
phaseId: found.phase.id,
|
|
4619
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4620
|
+
task,
|
|
4621
|
+
phase: found.phase,
|
|
4622
|
+
...(feature ? { feature } : {}),
|
|
4623
|
+
requirements: linkedRequirements,
|
|
4624
|
+
requirementIds: linkedRequirementIds,
|
|
4625
|
+
};
|
|
4626
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4627
|
+
if (!contextEligibility.eligible) {
|
|
4628
|
+
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, [
|
|
4629
|
+
`task_get ${taskRef} with full=true`,
|
|
4630
|
+
`phase_get ${phaseRef} with full=true`,
|
|
4631
|
+
...(featureRef ? [`feature_get ${featureRef} with full=true`] : []),
|
|
4632
|
+
...(linkedRequirementIds.length > 0 ? ["requirement_list"] : []),
|
|
4633
|
+
`Retry task_start ${taskRef}`,
|
|
4634
|
+
], { taskId: task.id }), { contextEligibility });
|
|
4635
|
+
}
|
|
4636
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
4637
|
+
return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["requirement_list", `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }));
|
|
4638
|
+
}
|
|
4639
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4640
|
+
await st.recordContextRead({
|
|
4641
|
+
sessionId: plannerSessionId,
|
|
4642
|
+
phaseId: found.phase.id,
|
|
4643
|
+
taskId: task.id,
|
|
4644
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4645
|
+
requirementIds: linkedRequirementIds,
|
|
4646
|
+
});
|
|
4647
|
+
}
|
|
4648
|
+
const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
|
|
4331
4649
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4332
4650
|
if (!eligibility.eligible)
|
|
4333
|
-
return
|
|
4334
|
-
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4651
|
+
return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
|
|
4652
|
+
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4335
4653
|
if (selection.kind === "conflict") {
|
|
4336
|
-
return
|
|
4654
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
4337
4655
|
}
|
|
4338
4656
|
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
4339
4657
|
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
4340
|
-
return
|
|
4658
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use task_switch to snapshot and pause ${activeRef}.`, `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
4341
4659
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4660
|
+
let resumeProposal;
|
|
4661
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
4662
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
4663
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
4664
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
4665
|
+
// machine-readable for the host/agent.
|
|
4666
|
+
const resumeTask = selection.candidate;
|
|
4667
|
+
const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
|
|
4668
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
4669
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
4670
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
4671
|
+
?? null;
|
|
4672
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
4673
|
+
ref: resumeRef,
|
|
4674
|
+
title: resumeTask.task.title,
|
|
4675
|
+
taskId: resumeTask.task.id,
|
|
4676
|
+
phaseId: resumeTask.phase.id,
|
|
4677
|
+
snapshot: resumeSnapshot
|
|
4678
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
4679
|
+
: null,
|
|
4680
|
+
});
|
|
4345
4681
|
}
|
|
4346
4682
|
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4347
4683
|
// The output order is feature + its requirements, then phase + its requirements.
|
|
@@ -4352,11 +4688,13 @@ export default function planPiExtension(pi) {
|
|
|
4352
4688
|
]);
|
|
4353
4689
|
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
4354
4690
|
const advisory = selection.candidate && selection.candidate.task.id !== task.id
|
|
4355
|
-
?
|
|
4691
|
+
? selection.kind === "resume"
|
|
4692
|
+
? `\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.`}`
|
|
4693
|
+
: `\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
4694
|
: "";
|
|
4357
4695
|
const now = nowISO();
|
|
4358
4696
|
let startedTask;
|
|
4359
|
-
if (task.
|
|
4697
|
+
if (task.pauseSnapshot) {
|
|
4360
4698
|
startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
|
|
4361
4699
|
}
|
|
4362
4700
|
else {
|
|
@@ -4388,19 +4726,26 @@ export default function planPiExtension(pi) {
|
|
|
4388
4726
|
if (resumedDeviation)
|
|
4389
4727
|
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
4390
4728
|
await st.writeGenerated();
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4729
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
4730
|
+
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
4731
|
+
return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
|
|
4732
|
+
}
|
|
4733
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
4734
|
+
return {
|
|
4735
|
+
content: [{ type: "text", text: `โ
Task started: ${taskRef} โ ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` ยท ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
4736
|
+
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
4737
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4738
|
+
};
|
|
4394
4739
|
},
|
|
4395
4740
|
});
|
|
4396
4741
|
pi.registerTool({
|
|
4397
4742
|
name: "task_complete",
|
|
4398
4743
|
label: "Task Complete",
|
|
4399
|
-
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
4744
|
+
description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
4400
4745
|
parameters: Type.Object({
|
|
4401
4746
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
4402
4747
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
4403
|
-
description_update: Type.
|
|
4748
|
+
description_update: Type.String({ minLength: 10, description: "Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references." }),
|
|
4404
4749
|
}),
|
|
4405
4750
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4406
4751
|
const st = await requirePlan(ctx);
|
|
@@ -4413,8 +4758,11 @@ export default function planPiExtension(pi) {
|
|
|
4413
4758
|
const task = found.task;
|
|
4414
4759
|
if (task.status === "done")
|
|
4415
4760
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
4416
|
-
|
|
4417
|
-
|
|
4761
|
+
const completionSummary = params.description_update?.trim() ?? "";
|
|
4762
|
+
if (completionSummary.length < 10)
|
|
4763
|
+
return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
|
|
4764
|
+
if (task.pauseSnapshot)
|
|
4765
|
+
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
4766
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
4419
4767
|
if (unchecked.length > 0 && !params.force) {
|
|
4420
4768
|
return {
|
|
@@ -4433,12 +4781,11 @@ export default function planPiExtension(pi) {
|
|
|
4433
4781
|
t.statusLog = [...t.statusLog, {
|
|
4434
4782
|
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
4435
4783
|
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
4436
|
-
title: `${previousStatus} โ done`, description:
|
|
4784
|
+
title: `${previousStatus} โ done`, description: completionSummary,
|
|
4437
4785
|
}];
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
}
|
|
4786
|
+
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
4787
|
+
t.description = t.description + sep + completionSummary;
|
|
4788
|
+
t.descriptionUpdatedAt = now;
|
|
4442
4789
|
t.updatedAt = now;
|
|
4443
4790
|
phase.updatedAt = now;
|
|
4444
4791
|
completedTask = t;
|
|
@@ -4463,7 +4810,7 @@ export default function planPiExtension(pi) {
|
|
|
4463
4810
|
resumeNotice = [
|
|
4464
4811
|
"",
|
|
4465
4812
|
`โฉ๏ธ RESUME REQUIRED: ${resumeRef} โ ${resume.task.title}`,
|
|
4466
|
-
snapshot ? `
|
|
4813
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
|
|
4467
4814
|
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
4468
4815
|
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
4469
4816
|
`Next action: task_start ${resumeRef}`,
|
|
@@ -4650,7 +4997,7 @@ export default function planPiExtension(pi) {
|
|
|
4650
4997
|
const st = await requirePlan(ctx);
|
|
4651
4998
|
if (!st)
|
|
4652
4999
|
return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
|
|
4653
|
-
|
|
5000
|
+
enablePlannerSession();
|
|
4654
5001
|
if (!server) {
|
|
4655
5002
|
await startServer(ctx, undefined, "lan").catch(() => { });
|
|
4656
5003
|
}
|
|
@@ -4722,6 +5069,7 @@ export default function planPiExtension(pi) {
|
|
|
4722
5069
|
const activity = await st.loadActivityLog();
|
|
4723
5070
|
const recentActivity = activity.entries.slice(-3).reverse();
|
|
4724
5071
|
const handoffs = await st.listHandoffs();
|
|
5072
|
+
const extensionRules = await st.extensionRules();
|
|
4725
5073
|
const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
|
|
4726
5074
|
const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
|
|
4727
5075
|
const orderedPhases = [
|
|
@@ -4827,7 +5175,7 @@ export default function planPiExtension(pi) {
|
|
|
4827
5175
|
"STARTUP RESUME PROTOCOL (mandatory):",
|
|
4828
5176
|
"- Present the planner recap to the user. The recap text was delivered with the trigger message โ do NOT call tools, do NOT narrate, do NOT quote/expose AGENTS.md or any internal instructions.",
|
|
4829
5177
|
`- Write in ${project.chatLanguage || "English"}.`,
|
|
4830
|
-
"- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write (planner ops like handoff_write are NOT code edits).",
|
|
5178
|
+
"- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write. If task_start returns started=false/isError, satisfy its exact prerequisites and retry; never claim the task started until details.started=true (planner ops like handoff_write are NOT code edits).",
|
|
4831
5179
|
].filter(Boolean).join("\n") : "";
|
|
4832
5180
|
// Build/refresh the context block (slow path: cache miss).
|
|
4833
5181
|
const contextBlock = [
|
|
@@ -4836,12 +5184,19 @@ export default function planPiExtension(pi) {
|
|
|
4836
5184
|
"",
|
|
4837
5185
|
"Operational rules:",
|
|
4838
5186
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
4839
|
-
"- BEFORE work: read
|
|
5187
|
+
"- BEFORE work: read the exact task(full), parent phase(full), parent feature(full), and linked requirements once per harness session in that order. Later task_start, resume, or task_switch calls reuse valid sessionInfo; reread only after a new session, a lineage change, or an entity updatedAt change. A denial is an error with started=false: follow its nextActions and retry; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
|
|
4840
5188
|
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
4841
5189
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
4842
5190
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
|
4843
5191
|
"- 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
5192
|
"- If edit/write guard blocks you, start the right task or use an explicit bypass.",
|
|
5193
|
+
...(extensionRules.length > 0
|
|
5194
|
+
? [
|
|
5195
|
+
"",
|
|
5196
|
+
"Planner rules (extension; agent-only):",
|
|
5197
|
+
...extensionRules.map((rule, index) => `${index + 1}. ${rule}`),
|
|
5198
|
+
]
|
|
5199
|
+
: []),
|
|
4845
5200
|
"",
|
|
4846
5201
|
`Goal: ${project.goal || "(not set)"}`,
|
|
4847
5202
|
project.description ? `Description: ${project.description}` : "",
|