@agent-plan/pi-adapter 0.2.20 → 0.2.22-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/README.md +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +488 -67
- 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-B2Nv9Ykv.css +1 -0
- package/web-ui-dist/assets/index-MvITvgH7.js +298 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-CTAfas5X.css +0 -1
- package/web-ui-dist/assets/index-CyrzGuWc.js +0 -293
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, hasReadParents, parentReadAdvisory, hasReadRequirements, requirementReadAdvisory, 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";
|
|
@@ -21,6 +22,9 @@ import { serve } from "@agent-plan/server/serve";
|
|
|
21
22
|
// ─── State (module-level) ───────────────────────────────────────────────
|
|
22
23
|
const PI_CONFIG_DIR_NAME = ".pi";
|
|
23
24
|
const PLAN_DIR_NAME = ".planner";
|
|
25
|
+
const PI_ADAPTER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/pi-adapter");
|
|
26
|
+
const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
|
|
27
|
+
const SERVER_PACKAGE = resolvedPackageVersion("@agent-plan/server", import.meta.url);
|
|
24
28
|
let capturedPi = null;
|
|
25
29
|
let store = null;
|
|
26
30
|
let server = null;
|
|
@@ -104,6 +108,7 @@ const healedStatusRoots = new Set();
|
|
|
104
108
|
const PLANNER_COMMAND_COMPLETIONS = [
|
|
105
109
|
{ value: "init", label: "init", description: "Initialize planner in this project" },
|
|
106
110
|
{ value: "show", label: "show", description: "Show planner overview" },
|
|
111
|
+
{ value: "version", label: "version", description: "Show loaded Agent Plan package versions" },
|
|
107
112
|
{ value: "repair", label: "repair", description: "Repair planner integrity" },
|
|
108
113
|
{ value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
|
|
109
114
|
{ value: "project discuss", label: "project discuss", description: "Run project discovery" },
|
|
@@ -239,10 +244,11 @@ async function getPlannerExecutionGuard(st) {
|
|
|
239
244
|
? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
|
|
240
245
|
: undefined;
|
|
241
246
|
const focusFromCurrentPhase = resume?.currentPhaseId
|
|
242
|
-
? 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")
|
|
243
248
|
: undefined;
|
|
244
|
-
const fallbackFocus = allTasks.find(({ task }) => task.
|
|
245
|
-
?? 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");
|
|
246
252
|
const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
|
|
247
253
|
return {
|
|
248
254
|
totalTasks,
|
|
@@ -313,7 +319,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
|
|
|
313
319
|
?? null;
|
|
314
320
|
const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
|
|
315
321
|
?? currentPhase?.tasks.find((task) => task.status === "in-progress")
|
|
316
|
-
??
|
|
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]
|
|
317
324
|
?? latestTaskUpdate?.task
|
|
318
325
|
?? null;
|
|
319
326
|
const currentFeature = currentPhase?.featureId
|
|
@@ -551,6 +558,7 @@ function statusIcon(status) {
|
|
|
551
558
|
const icons = {
|
|
552
559
|
draft: "📄", discovery: "🔍", planned: "📋",
|
|
553
560
|
"in-progress": "🚧", done: "✅", blocked: "🚫", canceled: "❌",
|
|
561
|
+
rejected: "❌", deferred: "⏳", waiting: "⏱️",
|
|
554
562
|
};
|
|
555
563
|
return icons[status] ?? "❓";
|
|
556
564
|
}
|
|
@@ -622,10 +630,32 @@ function resolveStaticDir() {
|
|
|
622
630
|
const adapterDir = dirname(adapterFile);
|
|
623
631
|
// When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
|
|
624
632
|
const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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];
|
|
629
659
|
}
|
|
630
660
|
catch {
|
|
631
661
|
return undefined;
|
|
@@ -1041,6 +1071,7 @@ export default function planPiExtension(pi) {
|
|
|
1041
1071
|
const PLANNER_MENU_ACTIONS = [
|
|
1042
1072
|
"init",
|
|
1043
1073
|
"show",
|
|
1074
|
+
"version",
|
|
1044
1075
|
"repair",
|
|
1045
1076
|
"cleanup-orphans",
|
|
1046
1077
|
"project discuss",
|
|
@@ -1076,7 +1107,7 @@ export default function planPiExtension(pi) {
|
|
|
1076
1107
|
"load",
|
|
1077
1108
|
"stop",
|
|
1078
1109
|
];
|
|
1079
|
-
const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1110
|
+
const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
|
|
1080
1111
|
"Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
|
|
1081
1112
|
"Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
|
|
1082
1113
|
if (!a) {
|
|
@@ -1088,6 +1119,15 @@ export default function planPiExtension(pi) {
|
|
|
1088
1119
|
await runPlanner(action, ctx);
|
|
1089
1120
|
return;
|
|
1090
1121
|
}
|
|
1122
|
+
if (a === "version") {
|
|
1123
|
+
ctx.ui.notify([
|
|
1124
|
+
"Agent Plan runtime versions",
|
|
1125
|
+
`${PI_ADAPTER_PACKAGE.name}: ${PI_ADAPTER_PACKAGE.version}`,
|
|
1126
|
+
`${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
|
|
1127
|
+
`${SERVER_PACKAGE.name}: ${SERVER_PACKAGE.version}`,
|
|
1128
|
+
].join("\n"), "info");
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1091
1131
|
const st = ensureStore(ctx);
|
|
1092
1132
|
// ── init ──
|
|
1093
1133
|
if (a === "init") {
|
|
@@ -1168,12 +1208,12 @@ export default function planPiExtension(pi) {
|
|
|
1168
1208
|
.sort((a, b) => a.number - b.number);
|
|
1169
1209
|
if (features.length === 0)
|
|
1170
1210
|
return null;
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1211
|
+
return paginatedSelect(ctx, {
|
|
1212
|
+
title: "Pick a feature",
|
|
1213
|
+
items: features,
|
|
1214
|
+
render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`,
|
|
1215
|
+
pageSize: 10,
|
|
1216
|
+
});
|
|
1177
1217
|
}
|
|
1178
1218
|
// ═══════════════════════════════════════════════════════════════
|
|
1179
1219
|
// Helper: pick a phase interactively
|
|
@@ -1184,12 +1224,12 @@ export default function planPiExtension(pi) {
|
|
|
1184
1224
|
return null;
|
|
1185
1225
|
}
|
|
1186
1226
|
const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1227
|
+
return paginatedSelect(ctx, {
|
|
1228
|
+
title: "Pick a phase",
|
|
1229
|
+
items: phases,
|
|
1230
|
+
render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`,
|
|
1231
|
+
pageSize: 10,
|
|
1232
|
+
});
|
|
1193
1233
|
}
|
|
1194
1234
|
// ═══════════════════════════════════════════════════════════════
|
|
1195
1235
|
// Helper: pick a task interactively from a phase
|
|
@@ -1198,12 +1238,12 @@ export default function planPiExtension(pi) {
|
|
|
1198
1238
|
if (phase.tasks.length === 0)
|
|
1199
1239
|
return null;
|
|
1200
1240
|
const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1241
|
+
return paginatedSelect(ctx, {
|
|
1242
|
+
title: `Pick a task from "${phase.title}"`,
|
|
1243
|
+
items: tasks,
|
|
1244
|
+
render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`,
|
|
1245
|
+
pageSize: 10,
|
|
1246
|
+
});
|
|
1207
1247
|
}
|
|
1208
1248
|
function parseMultilineList(value) {
|
|
1209
1249
|
return (value ?? "")
|
|
@@ -1447,11 +1487,12 @@ export default function planPiExtension(pi) {
|
|
|
1447
1487
|
return;
|
|
1448
1488
|
}
|
|
1449
1489
|
const phases = await st.loadAllPhases();
|
|
1450
|
-
|
|
1490
|
+
const lines = features.map((feature) => {
|
|
1451
1491
|
const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
|
|
1452
1492
|
const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
|
|
1453
1493
|
return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${featurePhases.length} phases, ${taskCount} tasks`;
|
|
1454
|
-
})
|
|
1494
|
+
});
|
|
1495
|
+
await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
|
|
1455
1496
|
return;
|
|
1456
1497
|
}
|
|
1457
1498
|
if (b === "add") {
|
|
@@ -1957,6 +1998,7 @@ export default function planPiExtension(pi) {
|
|
|
1957
1998
|
acceptedDecisions: [],
|
|
1958
1999
|
checklist: [], subtasks: [],
|
|
1959
2000
|
dependsOn: [],
|
|
2001
|
+
pauseSnapshot: null, pauseHistory: [],
|
|
1960
2002
|
startedAt: "", completedAt: "",
|
|
1961
2003
|
createdAt: now, updatedAt: now,
|
|
1962
2004
|
};
|
|
@@ -2153,6 +2195,18 @@ export default function planPiExtension(pi) {
|
|
|
2153
2195
|
return;
|
|
2154
2196
|
}
|
|
2155
2197
|
}
|
|
2198
|
+
if (phase && !hasReadParents(phase.featureId, phase.id)) {
|
|
2199
|
+
ctx.ui.notify("⚠️ READ REQUIRED before proceeding: read the parent feature and phase (full=true) for this task before starting it.", "warning");
|
|
2200
|
+
}
|
|
2201
|
+
if (phase) {
|
|
2202
|
+
const linkedRequirementIds = [
|
|
2203
|
+
...(await st.linkedRequirementsForPhase(phase.id)).map((r) => r.id),
|
|
2204
|
+
...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
|
|
2205
|
+
];
|
|
2206
|
+
if (linkedRequirementIds.length > 0 && !hasReadRequirements(linkedRequirementIds)) {
|
|
2207
|
+
ctx.ui.notify("⚠️ REQUIREMENTS READ REQUIRED: read the requirements linked to this phase (and feature) before starting the task.", "warning");
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2156
2210
|
if (task.status === "in-progress") {
|
|
2157
2211
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
2158
2212
|
return;
|
|
@@ -2326,7 +2380,11 @@ export default function planPiExtension(pi) {
|
|
|
2326
2380
|
ctx.ui.notify("No phase handoffs set.", "info");
|
|
2327
2381
|
return;
|
|
2328
2382
|
}
|
|
2329
|
-
|
|
2383
|
+
await paginatedNotify(ctx, {
|
|
2384
|
+
title: "phase handoffs",
|
|
2385
|
+
lines: list.map((e) => `• ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`),
|
|
2386
|
+
pageSize: 10,
|
|
2387
|
+
});
|
|
2330
2388
|
return;
|
|
2331
2389
|
}
|
|
2332
2390
|
if (action === "show") {
|
|
@@ -2732,6 +2790,7 @@ export default function planPiExtension(pi) {
|
|
|
2732
2790
|
if (!st)
|
|
2733
2791
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2734
2792
|
const requirements = await st.loadRequirements();
|
|
2793
|
+
requirements.requirements.forEach((req) => markRequirementRead(req.id));
|
|
2735
2794
|
return {
|
|
2736
2795
|
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2737
2796
|
details: requirements,
|
|
@@ -2752,15 +2811,21 @@ export default function planPiExtension(pi) {
|
|
|
2752
2811
|
const st = await requirePlan(ctx);
|
|
2753
2812
|
if (!st)
|
|
2754
2813
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2814
|
+
const title = params.title?.trim();
|
|
2815
|
+
if (!title)
|
|
2816
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2817
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2818
|
+
if (!links.ok)
|
|
2819
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2755
2820
|
const requirements = await st.loadRequirements();
|
|
2756
2821
|
const now = nowISO();
|
|
2757
2822
|
const requirement = {
|
|
2758
2823
|
id: createRequirementId(),
|
|
2759
|
-
title
|
|
2824
|
+
title,
|
|
2760
2825
|
description: params.description?.trim() ?? "",
|
|
2761
2826
|
status: params.status?.trim() ?? "planned",
|
|
2762
2827
|
macroTasks: [],
|
|
2763
|
-
linkedPhaseIds:
|
|
2828
|
+
linkedPhaseIds: links.linkedPhaseIds,
|
|
2764
2829
|
createdAt: now,
|
|
2765
2830
|
updatedAt: now,
|
|
2766
2831
|
};
|
|
@@ -2789,14 +2854,22 @@ export default function planPiExtension(pi) {
|
|
|
2789
2854
|
const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
|
|
2790
2855
|
if (!requirement)
|
|
2791
2856
|
return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
|
|
2792
|
-
if (params.title !== undefined)
|
|
2793
|
-
|
|
2857
|
+
if (params.title !== undefined) {
|
|
2858
|
+
const title = params.title.trim();
|
|
2859
|
+
if (!title)
|
|
2860
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
2861
|
+
requirement.title = title;
|
|
2862
|
+
}
|
|
2794
2863
|
if (params.description !== undefined)
|
|
2795
2864
|
requirement.description = params.description.trim();
|
|
2796
2865
|
if (params.status !== undefined)
|
|
2797
2866
|
requirement.status = params.status.trim();
|
|
2798
|
-
if (params.linkedPhaseIds !== undefined)
|
|
2799
|
-
|
|
2867
|
+
if (params.linkedPhaseIds !== undefined) {
|
|
2868
|
+
const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
|
|
2869
|
+
if (!links.ok)
|
|
2870
|
+
return { content: [{ type: "text", text: links.error }], details: {} };
|
|
2871
|
+
requirement.linkedPhaseIds = links.linkedPhaseIds;
|
|
2872
|
+
}
|
|
2800
2873
|
requirement.updatedAt = nowISO();
|
|
2801
2874
|
await st.saveRequirements(requirements);
|
|
2802
2875
|
await st.writeGenerated();
|
|
@@ -3006,6 +3079,23 @@ export default function planPiExtension(pi) {
|
|
|
3006
3079
|
const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
|
|
3007
3080
|
return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
|
|
3008
3081
|
}
|
|
3082
|
+
async function resolveRequirementPhaseRefs(st, refs) {
|
|
3083
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
3084
|
+
return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
|
|
3085
|
+
}
|
|
3086
|
+
const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
|
|
3087
|
+
if (trimmed.length === 0) {
|
|
3088
|
+
return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
|
|
3089
|
+
}
|
|
3090
|
+
const phases = await st.loadAllPhases();
|
|
3091
|
+
const features = (await st.loadFeatures()).features;
|
|
3092
|
+
const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
|
|
3093
|
+
const missingIndex = resolved.findIndex((phase) => !phase);
|
|
3094
|
+
if (missingIndex !== -1) {
|
|
3095
|
+
return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
|
|
3096
|
+
}
|
|
3097
|
+
return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
|
|
3098
|
+
}
|
|
3009
3099
|
pi.registerTool({
|
|
3010
3100
|
name: "handoff_list",
|
|
3011
3101
|
label: "Handoff List",
|
|
@@ -3062,11 +3152,13 @@ export default function planPiExtension(pi) {
|
|
|
3062
3152
|
// Derive the current first non-empty line (H1) of the supplied body.
|
|
3063
3153
|
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3064
3154
|
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3065
|
-
const
|
|
3066
|
-
|
|
3155
|
+
const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
|
|
3156
|
+
const effectiveHeadingText = (title && title.length > 0) ? title : firstHeadingText;
|
|
3157
|
+
const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
|
|
3158
|
+
if (isGeneric) {
|
|
3067
3159
|
return {
|
|
3068
3160
|
content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted." }],
|
|
3069
|
-
details: { error: "generic title", firstHeadingText },
|
|
3161
|
+
details: { error: "generic title", firstHeadingText: effectiveHeadingText || firstHeadingText },
|
|
3070
3162
|
};
|
|
3071
3163
|
}
|
|
3072
3164
|
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
@@ -3176,6 +3268,7 @@ export default function planPiExtension(pi) {
|
|
|
3176
3268
|
if (!resolvedFeature.ok)
|
|
3177
3269
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3178
3270
|
const feature = resolvedFeature.feature;
|
|
3271
|
+
markFeatureRead(feature.id);
|
|
3179
3272
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3180
3273
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3181
3274
|
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"}` : ""})`;
|
|
@@ -3474,6 +3567,7 @@ export default function planPiExtension(pi) {
|
|
|
3474
3567
|
const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
|
|
3475
3568
|
if (!phase)
|
|
3476
3569
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3570
|
+
markPhaseRead(phase.id, phase.featureId);
|
|
3477
3571
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3478
3572
|
const reqCount = linkedRequirements.length;
|
|
3479
3573
|
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"}` : ""})`;
|
|
@@ -3498,6 +3592,9 @@ export default function planPiExtension(pi) {
|
|
|
3498
3592
|
const st = await requirePlan(ctx);
|
|
3499
3593
|
if (!st)
|
|
3500
3594
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3595
|
+
const title = params.title?.trim();
|
|
3596
|
+
if (!title)
|
|
3597
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3501
3598
|
if (!params.featureId?.trim())
|
|
3502
3599
|
return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
|
|
3503
3600
|
const createPhaseFeatures = (await st.loadFeatures()).features;
|
|
@@ -3518,7 +3615,7 @@ export default function planPiExtension(pi) {
|
|
|
3518
3615
|
const priority = await st.nextPriority("phase", featureId);
|
|
3519
3616
|
const now = nowISO();
|
|
3520
3617
|
phase = {
|
|
3521
|
-
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(
|
|
3618
|
+
id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
|
|
3522
3619
|
featureId,
|
|
3523
3620
|
status: params.status ?? "draft",
|
|
3524
3621
|
discussedAt: "",
|
|
@@ -3808,10 +3905,10 @@ export default function planPiExtension(pi) {
|
|
|
3808
3905
|
pi.registerTool({
|
|
3809
3906
|
name: "task_get",
|
|
3810
3907
|
label: "Task Get",
|
|
3811
|
-
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description
|
|
3908
|
+
description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
|
|
3812
3909
|
parameters: Type.Object({
|
|
3813
3910
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
|
|
3814
|
-
full: Type.Optional(Type.Boolean({ description: "If true, include the task description
|
|
3911
|
+
full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
|
|
3815
3912
|
}),
|
|
3816
3913
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3817
3914
|
const st = await requirePlan(ctx);
|
|
@@ -3821,11 +3918,36 @@ export default function planPiExtension(pi) {
|
|
|
3821
3918
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
3822
3919
|
if (!found)
|
|
3823
3920
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3921
|
+
markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
|
|
3824
3922
|
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})`;
|
|
3825
3923
|
if (!params.full)
|
|
3826
3924
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
3925
|
+
const project = await st.loadProject();
|
|
3926
|
+
const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
|
|
3927
|
+
? undefined
|
|
3928
|
+
: project.workDeviations
|
|
3929
|
+
.filter((deviation) => deviation.resumeTaskId === found.task.id
|
|
3930
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
3931
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
3932
|
+
const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
|
|
3827
3933
|
const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
|
|
3828
|
-
|
|
3934
|
+
const sections = [summary];
|
|
3935
|
+
if (found.task.description?.trim())
|
|
3936
|
+
sections.push(found.task.description.trim());
|
|
3937
|
+
if (snapshot || pendingDeviation) {
|
|
3938
|
+
sections.push([
|
|
3939
|
+
pendingDeviation
|
|
3940
|
+
? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
|
|
3941
|
+
: "Resume checkpoint:",
|
|
3942
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
|
|
3943
|
+
snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
|
|
3944
|
+
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
3945
|
+
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
3946
|
+
].filter(Boolean).join("\n"));
|
|
3947
|
+
}
|
|
3948
|
+
if (log)
|
|
3949
|
+
sections.push(`Status log:\n${log}`);
|
|
3950
|
+
return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
|
|
3829
3951
|
},
|
|
3830
3952
|
});
|
|
3831
3953
|
pi.registerTool({
|
|
@@ -3845,6 +3967,9 @@ export default function planPiExtension(pi) {
|
|
|
3845
3967
|
const st = await requirePlan(ctx);
|
|
3846
3968
|
if (!st)
|
|
3847
3969
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3970
|
+
const title = params.title?.trim();
|
|
3971
|
+
if (!title)
|
|
3972
|
+
return { content: [{ type: "text", text: "title required" }], details: {} };
|
|
3848
3973
|
if (!params.featureId?.trim())
|
|
3849
3974
|
return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
|
|
3850
3975
|
const features = (await st.loadFeatures()).features;
|
|
@@ -3871,15 +3996,18 @@ export default function planPiExtension(pi) {
|
|
|
3871
3996
|
const existingPhase = await st.loadPhase(phaseId);
|
|
3872
3997
|
if (!existingPhase)
|
|
3873
3998
|
return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
|
|
3874
|
-
const shortName = clampSlug(params.shortName ??
|
|
3999
|
+
const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
|
|
3875
4000
|
const taskId = createTaskId();
|
|
3876
4001
|
const identity = await st.allocateEntityIdentity("task", taskId);
|
|
3877
4002
|
const priority = await st.nextPriority("task", phaseId);
|
|
3878
4003
|
const now = nowISO();
|
|
3879
4004
|
const initialStatus = params.status ?? "planned";
|
|
4005
|
+
if (initialStatus === "paused") {
|
|
4006
|
+
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: {} };
|
|
4007
|
+
}
|
|
3880
4008
|
const task = {
|
|
3881
4009
|
id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
|
|
3882
|
-
title
|
|
4010
|
+
title,
|
|
3883
4011
|
status: initialStatus,
|
|
3884
4012
|
description: params.description ?? "",
|
|
3885
4013
|
descriptionUpdatedAt: now,
|
|
@@ -3892,6 +4020,8 @@ export default function planPiExtension(pi) {
|
|
|
3892
4020
|
: [],
|
|
3893
4021
|
subtasks: [],
|
|
3894
4022
|
dependsOn: [],
|
|
4023
|
+
pauseSnapshot: null,
|
|
4024
|
+
pauseHistory: [],
|
|
3895
4025
|
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
3896
4026
|
completedAt: initialStatus === "done" ? now : "",
|
|
3897
4027
|
createdAt: now, updatedAt: now,
|
|
@@ -3947,6 +4077,12 @@ export default function planPiExtension(pi) {
|
|
|
3947
4077
|
// Validate the motivation requirement for restrictive status transitions,
|
|
3948
4078
|
// mirroring the MCP adapter and the /planner task update command
|
|
3949
4079
|
// (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
|
|
4080
|
+
if (found.task.pauseSnapshot && params.status === "in-progress" && params.status !== found.task.status) {
|
|
4081
|
+
return {
|
|
4082
|
+
content: [{ type: "text", text: "Resume checkpoint lifecycle transitions require task_start so the checkpoint and return stack remain consistent." }],
|
|
4083
|
+
details: {},
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
3950
4086
|
if (params.status !== undefined &&
|
|
3951
4087
|
params.status !== found.task.status &&
|
|
3952
4088
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4085,7 +4221,12 @@ export default function planPiExtension(pi) {
|
|
|
4085
4221
|
if (temporary.task.status !== "planned")
|
|
4086
4222
|
return { content: [{ type: "text", text: `Temporary task is not startable: ${temporary.task.status}.` }], details: {} };
|
|
4087
4223
|
const timestamp = nowISO();
|
|
4088
|
-
const record = {
|
|
4224
|
+
const record = {
|
|
4225
|
+
id: crypto.randomUUID(), recommendedTaskId: selected.candidate?.task.id ?? resume.task.id,
|
|
4226
|
+
temporaryTaskId: temporary.task.id, resumeTaskId: resume.task.id, reason: params.reason, snapshot: null,
|
|
4227
|
+
requestedBy: "user", approvedBy: "user", state: "approved",
|
|
4228
|
+
createdAt: timestamp, activatedAt: "", resumeRequiredAt: "", resolvedAt: "", resumedAt: "",
|
|
4229
|
+
};
|
|
4089
4230
|
await st.addWorkDeviation(record);
|
|
4090
4231
|
await st.writeGenerated();
|
|
4091
4232
|
const tempRef = `${formatPhaseRef(temporary.phase.number, featureNumberOfPhase(temporary.phase, features.features))}/T${String(temporary.task.number).padStart(3, "0")}`;
|
|
@@ -4093,10 +4234,206 @@ export default function planPiExtension(pi) {
|
|
|
4093
4234
|
return { content: [{ type: "text", text: `✅ Approved deviation: ${tempRef} temporarily overrides ${resumeRef}. Resume target retained.` }], details: { deviation: record } };
|
|
4094
4235
|
},
|
|
4095
4236
|
});
|
|
4237
|
+
pi.registerTool({
|
|
4238
|
+
name: "task_pause",
|
|
4239
|
+
label: "Task Pause",
|
|
4240
|
+
description: "Pause an in-progress task with a mandatory durable checkpoint: why work stopped, what was underway, the exact resume location, and how to continue.",
|
|
4241
|
+
parameters: Type.Object({
|
|
4242
|
+
taskId: Type.String({ description: "Task ref to pause" }),
|
|
4243
|
+
reason: Type.String({ description: "Why work is being paused" }),
|
|
4244
|
+
what_was_being_done: Type.String({ description: "Concrete work underway at the checkpoint" }),
|
|
4245
|
+
resume_location: Type.String({ description: "Exact file, symbol, command, or location from which to continue" }),
|
|
4246
|
+
how_to_resume: Type.String({ description: "Actionable instructions for resuming" }),
|
|
4247
|
+
paused_by: Type.Optional(Type.String({ description: "Agent/session identifier" })),
|
|
4248
|
+
}),
|
|
4249
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4250
|
+
const st = await requirePlan(ctx);
|
|
4251
|
+
if (!st)
|
|
4252
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4253
|
+
const features = (await st.loadFeatures()).features;
|
|
4254
|
+
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4255
|
+
if (!found)
|
|
4256
|
+
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4257
|
+
if (found.task.status !== "in-progress")
|
|
4258
|
+
return { content: [{ type: "text", text: `Task pause denied: task is ${found.task.status}, not in-progress.` }], details: found.task };
|
|
4259
|
+
const snapshot = {
|
|
4260
|
+
id: crypto.randomUUID(), reason: params.reason.trim(), whatWasBeingDone: params.what_was_being_done.trim(),
|
|
4261
|
+
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
|
|
4262
|
+
pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
|
|
4263
|
+
};
|
|
4264
|
+
invalidateReads();
|
|
4265
|
+
const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
|
|
4266
|
+
await st.syncTaskStatusRollup(found.phase.id);
|
|
4267
|
+
const activeDeviation = (await st.loadProject()).workDeviations
|
|
4268
|
+
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
|
|
4269
|
+
&& deviation.temporaryTaskId === found.task.id)
|
|
4270
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4271
|
+
if (activeDeviation)
|
|
4272
|
+
await st.setWorkDeviationState(activeDeviation.id, "resume-required", snapshot.pausedAt);
|
|
4273
|
+
const resume = activeDeviation
|
|
4274
|
+
? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
|
|
4275
|
+
: undefined;
|
|
4276
|
+
await st.writeGenerated();
|
|
4277
|
+
const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
|
|
4278
|
+
const resumeRef = resume
|
|
4279
|
+
? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
|
|
4280
|
+
: "";
|
|
4281
|
+
return {
|
|
4282
|
+
content: [{ type: "text", text: [
|
|
4283
|
+
`💾 Resume checkpoint saved: ${reference} — ${checkpointed.title}`,
|
|
4284
|
+
`Why: ${snapshot.reason}`,
|
|
4285
|
+
`Checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4286
|
+
`Resume from: ${snapshot.resumeLocation}`,
|
|
4287
|
+
`How to resume: ${snapshot.howToResume}`,
|
|
4288
|
+
resume ? `↩️ RESUME REQUIRED: ${resumeRef} — ${resume.task.title}` : "",
|
|
4289
|
+
].filter(Boolean).join("\n") }],
|
|
4290
|
+
details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
|
|
4291
|
+
};
|
|
4292
|
+
},
|
|
4293
|
+
});
|
|
4294
|
+
pi.registerTool({
|
|
4295
|
+
name: "task_switch",
|
|
4296
|
+
label: "Task Switch",
|
|
4297
|
+
description: "Switch from active work to a temporary task, even outside priority order. Atomically snapshots/pauses the source, starts the target, and pushes a durable LIFO return target.",
|
|
4298
|
+
parameters: Type.Object({
|
|
4299
|
+
from_task: Type.String({ description: "Currently in-progress task ref" }),
|
|
4300
|
+
to_task: Type.String({ description: "Temporary task ref to start" }),
|
|
4301
|
+
reason: Type.String({ description: "Why this temporary switch is necessary" }),
|
|
4302
|
+
what_was_being_done: Type.String({ description: "Concrete work underway in the source task" }),
|
|
4303
|
+
resume_location: Type.String({ description: "Exact source resume location" }),
|
|
4304
|
+
how_to_resume: Type.String({ description: "Actionable source resume instructions" }),
|
|
4305
|
+
switched_by: Type.Optional(Type.String({ description: "Agent/session identifier" })),
|
|
4306
|
+
}),
|
|
4307
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4308
|
+
const st = await requirePlan(ctx);
|
|
4309
|
+
if (!st)
|
|
4310
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
4311
|
+
const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
|
|
4312
|
+
const features = featuresDoc.features;
|
|
4313
|
+
const source = findTaskByRef(phases, features, params.from_task.trim());
|
|
4314
|
+
const target = findTaskByRef(phases, features, params.to_task.trim());
|
|
4315
|
+
if (!source)
|
|
4316
|
+
return { content: [{ type: "text", text: `Task not found: ${params.from_task}` }], details: {} };
|
|
4317
|
+
if (!target)
|
|
4318
|
+
return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
|
|
4319
|
+
if (source.task.id === target.task.id)
|
|
4320
|
+
return { content: [{ type: "text", text: "Source and temporary task must differ." }], details: {} };
|
|
4321
|
+
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4322
|
+
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4323
|
+
}
|
|
4324
|
+
const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
|
|
4325
|
+
if (!eligibility.eligible)
|
|
4326
|
+
return { content: [{ type: "text", text: `Task switch denied: ${eligibility.reason}` }], details: eligibility };
|
|
4327
|
+
const timestamp = nowISO();
|
|
4328
|
+
const snapshot = {
|
|
4329
|
+
id: crypto.randomUUID(), reason: params.reason.trim(), whatWasBeingDone: params.what_was_being_done.trim(),
|
|
4330
|
+
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
|
|
4331
|
+
relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
|
|
4332
|
+
};
|
|
4333
|
+
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4334
|
+
const record = {
|
|
4335
|
+
id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
|
|
4336
|
+
temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
|
|
4337
|
+
requestedBy: "agent", approvedBy: params.switched_by?.trim() || "explicit task_switch",
|
|
4338
|
+
state: "active", createdAt: timestamp, activatedAt: timestamp,
|
|
4339
|
+
resumeRequiredAt: "", resolvedAt: "", resumedAt: "",
|
|
4340
|
+
};
|
|
4341
|
+
const sourceWasActive = source.task.status === "in-progress";
|
|
4342
|
+
let sourceCheckpointed = false;
|
|
4343
|
+
let targetStarted = false;
|
|
4344
|
+
try {
|
|
4345
|
+
if (sourceWasActive) {
|
|
4346
|
+
await st.pauseTask(source.phase.id, source.task.id, snapshot);
|
|
4347
|
+
}
|
|
4348
|
+
else {
|
|
4349
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
4350
|
+
const task = phase.tasks.find((entry) => entry.id === source.task.id);
|
|
4351
|
+
if (!task)
|
|
4352
|
+
throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
|
|
4353
|
+
task.pauseSnapshot = snapshot;
|
|
4354
|
+
task.pauseHistory = [...task.pauseHistory, snapshot];
|
|
4355
|
+
task.updatedAt = timestamp;
|
|
4356
|
+
return phase;
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
sourceCheckpointed = true;
|
|
4360
|
+
if (target.task.pauseSnapshot) {
|
|
4361
|
+
await st.resumeTask(target.phase.id, target.task.id, timestamp);
|
|
4362
|
+
}
|
|
4363
|
+
else {
|
|
4364
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
4365
|
+
const task = phase.tasks.find((entry) => entry.id === target.task.id);
|
|
4366
|
+
if (!task)
|
|
4367
|
+
throw new Error(`Temporary task disappeared: ${params.to_task}`);
|
|
4368
|
+
const previousStatus = task.status;
|
|
4369
|
+
applyTaskLifecycleDates(task, "in-progress", timestamp);
|
|
4370
|
+
task.statusLog = [...task.statusLog, {
|
|
4371
|
+
id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
4372
|
+
date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
|
|
4373
|
+
title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
|
|
4374
|
+
}];
|
|
4375
|
+
task.updatedAt = timestamp;
|
|
4376
|
+
phase.updatedAt = timestamp;
|
|
4377
|
+
return phase;
|
|
4378
|
+
});
|
|
4379
|
+
}
|
|
4380
|
+
targetStarted = true;
|
|
4381
|
+
await st.addWorkDeviation(record);
|
|
4382
|
+
if (target.task.pauseSnapshot) {
|
|
4383
|
+
const resumedDeviation = project.workDeviations
|
|
4384
|
+
.filter((deviation) => deviation.resumeTaskId === target.task.id
|
|
4385
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
4386
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4387
|
+
if (resumedDeviation)
|
|
4388
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
catch (error) {
|
|
4392
|
+
if (targetStarted) {
|
|
4393
|
+
await st.updatePhase(target.phase.id, (phase) => {
|
|
4394
|
+
phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
|
|
4395
|
+
return phase;
|
|
4396
|
+
}).catch(() => { });
|
|
4397
|
+
}
|
|
4398
|
+
if (sourceCheckpointed) {
|
|
4399
|
+
if (sourceWasActive)
|
|
4400
|
+
await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
|
|
4401
|
+
else
|
|
4402
|
+
await st.updatePhase(source.phase.id, (phase) => {
|
|
4403
|
+
phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
|
|
4404
|
+
return phase;
|
|
4405
|
+
}).catch(() => { });
|
|
4406
|
+
}
|
|
4407
|
+
throw error;
|
|
4408
|
+
}
|
|
4409
|
+
await st.syncTaskStatusRollup(source.phase.id);
|
|
4410
|
+
if (target.phase.id !== source.phase.id)
|
|
4411
|
+
await st.syncTaskStatusRollup(target.phase.id);
|
|
4412
|
+
await st.writeGenerated();
|
|
4413
|
+
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
4414
|
+
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4415
|
+
const targetReadAdvisory = parentReadAdvisory(target.phase.featureId, target.phase.id);
|
|
4416
|
+
const targetRequirementAdvisory = requirementReadAdvisory([
|
|
4417
|
+
...(await st.linkedRequirementsForPhase(target.phase.id)).map((r) => r.id),
|
|
4418
|
+
...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((r) => r.id) : []),
|
|
4419
|
+
]);
|
|
4420
|
+
return {
|
|
4421
|
+
content: [{ type: "text", text: [
|
|
4422
|
+
`🔀 Task switched: ${sourceRef} → ${targetRef}`,
|
|
4423
|
+
`Resume checkpoint: ${snapshot.whatWasBeingDone}`,
|
|
4424
|
+
`Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
|
|
4425
|
+
"Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
|
|
4426
|
+
...(targetReadAdvisory ? [targetReadAdvisory.trimStart()] : []),
|
|
4427
|
+
...(targetRequirementAdvisory ? [targetRequirementAdvisory.trimStart()] : []),
|
|
4428
|
+
].join("\n") }],
|
|
4429
|
+
details: { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id, ...(targetReadAdvisory || targetRequirementAdvisory ? { readRequired: true } : {}) },
|
|
4430
|
+
};
|
|
4431
|
+
},
|
|
4432
|
+
});
|
|
4096
4433
|
pi.registerTool({
|
|
4097
4434
|
name: "task_start",
|
|
4098
4435
|
label: "Task Start",
|
|
4099
|
-
description: "Set a task to in-progress. BEFORE calling, read feature_get(full=true)
|
|
4436
|
+
description: "Set a task to in-progress or resume checkpointed work. BEFORE calling, read feature_get(full=true), phase_get(full=true), then task_get(full=true). A different active task must first be suspended through task_switch. Otherwise follow active/pending-resume/feature → phase → task priority guidance.",
|
|
4100
4437
|
parameters: Type.Object({
|
|
4101
4438
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4102
4439
|
}),
|
|
@@ -4119,6 +4456,35 @@ export default function planPiExtension(pi) {
|
|
|
4119
4456
|
if (!eligibility.eligible)
|
|
4120
4457
|
return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
|
|
4121
4458
|
const selection = recommendNextTask(features, phases, project.workDeviations);
|
|
4459
|
+
if (selection.kind === "conflict") {
|
|
4460
|
+
return { content: [{ type: "text", text: `${selection.reason} Pause/reconcile active tasks before starting another task.` }], details: selection };
|
|
4461
|
+
}
|
|
4462
|
+
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
4463
|
+
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
4464
|
+
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 };
|
|
4465
|
+
}
|
|
4466
|
+
let resumeProposal;
|
|
4467
|
+
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
4468
|
+
// Advisory only: a pending resume must be surfaced loudly, but it must
|
|
4469
|
+
// not hard-block an explicit start of a different task. Capture a
|
|
4470
|
+
// structured proposal so the resume-first guidance is explicit and
|
|
4471
|
+
// machine-readable for the host/agent.
|
|
4472
|
+
const resumeTask = selection.candidate;
|
|
4473
|
+
const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
|
|
4474
|
+
const resumeSnapshot = resumeTask.task.pauseSnapshot
|
|
4475
|
+
?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
|
|
4476
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
|
|
4477
|
+
?? null;
|
|
4478
|
+
resumeProposal = buildResumeRequiredProposal({
|
|
4479
|
+
ref: resumeRef,
|
|
4480
|
+
title: resumeTask.task.title,
|
|
4481
|
+
taskId: resumeTask.task.id,
|
|
4482
|
+
phaseId: resumeTask.phase.id,
|
|
4483
|
+
snapshot: resumeSnapshot
|
|
4484
|
+
? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
|
|
4485
|
+
: null,
|
|
4486
|
+
});
|
|
4487
|
+
}
|
|
4122
4488
|
// Assemble the mandatory parent context before changing task lifecycle state.
|
|
4123
4489
|
// The output order is feature + its requirements, then phase + its requirements.
|
|
4124
4490
|
const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
|
|
@@ -4127,33 +4493,58 @@ export default function planPiExtension(pi) {
|
|
|
4127
4493
|
parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
|
|
4128
4494
|
]);
|
|
4129
4495
|
const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
|
|
4130
|
-
const advisory = selection.
|
|
4131
|
-
?
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
: `\n\n⚠️ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
|
|
4136
|
-
: "";
|
|
4496
|
+
const advisory = selection.candidate && selection.candidate.task.id !== task.id
|
|
4497
|
+
? selection.kind === "resume"
|
|
4498
|
+
? `\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.`}`
|
|
4499
|
+
: `\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.`
|
|
4500
|
+
: "";
|
|
4137
4501
|
const now = nowISO();
|
|
4138
4502
|
let startedTask;
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4503
|
+
if (task.pauseSnapshot) {
|
|
4504
|
+
startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
|
|
4505
|
+
}
|
|
4506
|
+
else {
|
|
4507
|
+
await st.updatePhase(found.phase.id, (phase) => {
|
|
4508
|
+
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
4509
|
+
if (!t)
|
|
4510
|
+
return phase;
|
|
4511
|
+
const previousStatus = t.status;
|
|
4512
|
+
applyTaskLifecycleDates(t, "in-progress", now);
|
|
4513
|
+
t.statusLog = [...t.statusLog, {
|
|
4514
|
+
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
|
|
4515
|
+
date: now, fromStatus: previousStatus, toStatus: "in-progress",
|
|
4516
|
+
title: `${previousStatus} → in-progress`, description: "",
|
|
4517
|
+
}];
|
|
4518
|
+
t.updatedAt = now;
|
|
4519
|
+
phase.updatedAt = now;
|
|
4520
|
+
startedTask = t;
|
|
4142
4521
|
return phase;
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
phase.updatedAt = now;
|
|
4146
|
-
startedTask = t;
|
|
4147
|
-
return phase;
|
|
4148
|
-
});
|
|
4522
|
+
});
|
|
4523
|
+
}
|
|
4149
4524
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4150
4525
|
const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
|
|
4151
4526
|
if (approvedDeviation)
|
|
4152
4527
|
await st.setWorkDeviationState(approvedDeviation.id, "active", now);
|
|
4528
|
+
const resumedDeviation = project.workDeviations
|
|
4529
|
+
.filter((deviation) => deviation.resumeTaskId === task.id
|
|
4530
|
+
&& (deviation.state === "resume-required" || deviation.state === "resolved"))
|
|
4531
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4532
|
+
if (resumedDeviation)
|
|
4533
|
+
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
4153
4534
|
await st.writeGenerated();
|
|
4154
4535
|
if (!startedTask)
|
|
4155
4536
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4156
|
-
|
|
4537
|
+
const readAdvisory = parentReadAdvisory(parentFeature?.id, found.phase.id);
|
|
4538
|
+
const requirementAdvisory = requirementReadAdvisory([
|
|
4539
|
+
...(phaseWithRequirements.linkedRequirements ?? []).map((r) => r.id),
|
|
4540
|
+
...featureRequirements.map((r) => r.id),
|
|
4541
|
+
]);
|
|
4542
|
+
return {
|
|
4543
|
+
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}${readAdvisory}${requirementAdvisory}` }],
|
|
4544
|
+
details: startedTask,
|
|
4545
|
+
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4546
|
+
...(readAdvisory || requirementAdvisory ? { readRequired: true } : {}),
|
|
4547
|
+
};
|
|
4157
4548
|
},
|
|
4158
4549
|
});
|
|
4159
4550
|
pi.registerTool({
|
|
@@ -4176,6 +4567,8 @@ export default function planPiExtension(pi) {
|
|
|
4176
4567
|
const task = found.task;
|
|
4177
4568
|
if (task.status === "done")
|
|
4178
4569
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
4570
|
+
if (task.pauseSnapshot)
|
|
4571
|
+
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 };
|
|
4179
4572
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
4180
4573
|
if (unchecked.length > 0 && !params.force) {
|
|
4181
4574
|
return {
|
|
@@ -4189,7 +4582,13 @@ export default function planPiExtension(pi) {
|
|
|
4189
4582
|
const t = phase.tasks.find((x) => x.id === found.task.id);
|
|
4190
4583
|
if (!t)
|
|
4191
4584
|
return phase;
|
|
4585
|
+
const previousStatus = t.status;
|
|
4192
4586
|
applyTaskLifecycleDates(t, "done", now);
|
|
4587
|
+
t.statusLog = [...t.statusLog, {
|
|
4588
|
+
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
4589
|
+
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
4590
|
+
title: `${previousStatus} → done`, description: "",
|
|
4591
|
+
}];
|
|
4193
4592
|
if (params.description_update) {
|
|
4194
4593
|
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
4195
4594
|
t.description = t.description + sep + params.description_update;
|
|
@@ -4204,11 +4603,33 @@ export default function planPiExtension(pi) {
|
|
|
4204
4603
|
.filter((deviation) => (deviation.state === "approved" || deviation.state === "active") && deviation.temporaryTaskId === task.id)
|
|
4205
4604
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
|
|
4206
4605
|
if (completedDeviation)
|
|
4207
|
-
await st.setWorkDeviationState(completedDeviation.id, "
|
|
4606
|
+
await st.setWorkDeviationState(completedDeviation.id, "resume-required", now);
|
|
4208
4607
|
await st.writeGenerated();
|
|
4209
4608
|
if (!completedTask)
|
|
4210
4609
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
4211
|
-
|
|
4610
|
+
let resumeNotice = "";
|
|
4611
|
+
let resumeRequired;
|
|
4612
|
+
if (completedDeviation) {
|
|
4613
|
+
const resume = findTaskByRef(await st.loadAllPhases(), features, completedDeviation.resumeTaskId);
|
|
4614
|
+
if (resume) {
|
|
4615
|
+
const snapshot = resume.task.pauseSnapshot ?? completedDeviation.snapshot;
|
|
4616
|
+
const resumeRef = `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`;
|
|
4617
|
+
resumeNotice = [
|
|
4618
|
+
"",
|
|
4619
|
+
`↩️ RESUME REQUIRED: ${resumeRef} — ${resume.task.title}`,
|
|
4620
|
+
snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
|
|
4621
|
+
snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
|
|
4622
|
+
snapshot ? `How to resume: ${snapshot.howToResume}` : "",
|
|
4623
|
+
`Next action: task_start ${resumeRef}`,
|
|
4624
|
+
].filter(Boolean).join("\n");
|
|
4625
|
+
resumeRequired = { taskId: resume.task.id, phaseId: resume.phase.id, snapshot };
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
const completedRef = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(completedTask.number).padStart(3, "0")}`;
|
|
4629
|
+
return {
|
|
4630
|
+
content: [{ type: "text", text: `✅ Task completed: ${completedRef} — ${completedTask.title} (done)${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}${resumeNotice}` }],
|
|
4631
|
+
details: resumeRequired ? { task: completedTask, resumeRequired } : completedTask,
|
|
4632
|
+
};
|
|
4212
4633
|
},
|
|
4213
4634
|
});
|
|
4214
4635
|
pi.registerTool({
|