@agent-plan/pi-adapter 0.2.23 → 0.2.25
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 +344 -132
- package/package.json +3 -3
- package/web-ui-dist/assets/index-CYtuauJC.css +1 -0
- package/web-ui-dist/assets/index-DubXBudL.js +318 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-COpkshJD.css +0 -1
- package/web-ui-dist/assets/index-IyxH71Dr.js +0 -298
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Type } from "typebox";
|
|
12
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,
|
|
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";
|
|
14
14
|
import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
|
|
15
15
|
import { join, dirname } from "node:path";
|
|
16
16
|
import { homedir } from "node:os";
|
|
@@ -29,6 +29,7 @@ let capturedPi = null;
|
|
|
29
29
|
let store = null;
|
|
30
30
|
let server = null;
|
|
31
31
|
let lastKnownWebPort = null;
|
|
32
|
+
let plannerSessionId = "pi:uninitialized";
|
|
32
33
|
// True while the agent is mutating .planner/ files. The web server returns 503-busy
|
|
33
34
|
// during this window so the UI doesn't render inconsistent data.
|
|
34
35
|
let planBusy = false;
|
|
@@ -39,6 +40,64 @@ function withPlanBusy(fn) {
|
|
|
39
40
|
.then(() => fn())
|
|
40
41
|
.finally(() => { planBusy = false; });
|
|
41
42
|
}
|
|
43
|
+
function taskStartFailure(outcome, details = {}) {
|
|
44
|
+
return {
|
|
45
|
+
isError: true,
|
|
46
|
+
content: [{ type: "text", text: [
|
|
47
|
+
`❌ TASK START FAILED [${outcome.errorCode}]`,
|
|
48
|
+
"started: false",
|
|
49
|
+
outcome.message,
|
|
50
|
+
"No task lifecycle transition was applied.",
|
|
51
|
+
"Next required actions:",
|
|
52
|
+
...outcome.nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
53
|
+
].join("\n") }],
|
|
54
|
+
details: { ...outcome, ...details },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
|
|
58
|
+
const childKind = entity === "feature" ? "phases" : "tasks";
|
|
59
|
+
const retryAction = entity === "feature"
|
|
60
|
+
? `Retry feature_update ${ref} without the status field if you need to update metadata.`
|
|
61
|
+
: `Retry phase_update ${ref} without the status field if you need to update metadata.`;
|
|
62
|
+
const nextActions = [
|
|
63
|
+
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
|
|
64
|
+
retryAction,
|
|
65
|
+
];
|
|
66
|
+
return {
|
|
67
|
+
isError: true,
|
|
68
|
+
content: [{ type: "text", text: [
|
|
69
|
+
`❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
|
|
70
|
+
`${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
|
|
71
|
+
`Attempted status: ${attemptedStatus}`,
|
|
72
|
+
`Effective derived status: ${effectiveStatus}`,
|
|
73
|
+
"No planner data was changed.",
|
|
74
|
+
"Next required actions:",
|
|
75
|
+
...nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
76
|
+
].join("\n") }],
|
|
77
|
+
details: {
|
|
78
|
+
updated: false,
|
|
79
|
+
errorCode: "DERIVED_STATUS_READ_ONLY",
|
|
80
|
+
entity,
|
|
81
|
+
ref,
|
|
82
|
+
attemptedStatus,
|
|
83
|
+
effectiveStatus,
|
|
84
|
+
nextActions,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function piContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
|
|
89
|
+
const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
|
|
90
|
+
if (read.kind === "task")
|
|
91
|
+
return [`task_get ${refs.task} with full=true`];
|
|
92
|
+
if (read.kind === "phase")
|
|
93
|
+
return [`phase_get ${refs.phase} with full=true`];
|
|
94
|
+
return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
|
|
95
|
+
});
|
|
96
|
+
if (requirementReadRequired)
|
|
97
|
+
actions.push("requirement_list");
|
|
98
|
+
actions.push(retryAction);
|
|
99
|
+
return actions;
|
|
100
|
+
}
|
|
42
101
|
// Helper to notify a running server in another process via HTTP.
|
|
43
102
|
async function notifyExternalServer() {
|
|
44
103
|
try {
|
|
@@ -135,7 +194,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
135
194
|
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
136
195
|
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
137
196
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
138
|
-
{ value: "handoff prepare", label: "handoff prepare", description: "
|
|
197
|
+
{ value: "handoff prepare", label: "handoff prepare", description: "Audit and reconcile the active phase handoff with durable entity context" },
|
|
139
198
|
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
|
|
140
199
|
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
|
|
141
200
|
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
|
|
@@ -786,7 +845,9 @@ export default function planPiExtension(pi) {
|
|
|
786
845
|
capturedPi = pi;
|
|
787
846
|
// ── Restore on session start/reload ─────────────────────────────────
|
|
788
847
|
pi.on("session_start", async (_event, ctx) => {
|
|
789
|
-
|
|
848
|
+
plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
|
|
849
|
+
invalidateReads(plannerSessionId);
|
|
850
|
+
startReadSession(plannerSessionId);
|
|
790
851
|
try {
|
|
791
852
|
ctx.ui.addAutocompleteProvider((current) => ({
|
|
792
853
|
triggerCharacters: ["/", " "],
|
|
@@ -1537,6 +1598,7 @@ export default function planPiExtension(pi) {
|
|
|
1537
1598
|
phaseIds: [],
|
|
1538
1599
|
dependsOn: [],
|
|
1539
1600
|
statusLog: [],
|
|
1601
|
+
sessionInfo: [],
|
|
1540
1602
|
createdAt: now,
|
|
1541
1603
|
updatedAt: now,
|
|
1542
1604
|
};
|
|
@@ -1758,6 +1820,7 @@ export default function planPiExtension(pi) {
|
|
|
1758
1820
|
handoffReadAt: "",
|
|
1759
1821
|
handoffHistory: [],
|
|
1760
1822
|
statusLog: [],
|
|
1823
|
+
sessionInfo: [],
|
|
1761
1824
|
};
|
|
1762
1825
|
await st.savePhase(phase);
|
|
1763
1826
|
await st.updateFeatures((doc) => {
|
|
@@ -1998,6 +2061,7 @@ export default function planPiExtension(pi) {
|
|
|
1998
2061
|
descriptionUpdatedAt: "",
|
|
1999
2062
|
notes: "",
|
|
2000
2063
|
statusLog: [],
|
|
2064
|
+
sessionInfo: [],
|
|
2001
2065
|
decisions: [],
|
|
2002
2066
|
acceptedDecisions: [],
|
|
2003
2067
|
checklist: [], subtasks: [],
|
|
@@ -2164,6 +2228,10 @@ export default function planPiExtension(pi) {
|
|
|
2164
2228
|
ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
|
|
2165
2229
|
return;
|
|
2166
2230
|
}
|
|
2231
|
+
if (normalizedStatus === "done" && normalizedStatus !== task.status) {
|
|
2232
|
+
ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2167
2235
|
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2168
2236
|
}
|
|
2169
2237
|
if (desc?.trim())
|
|
@@ -2201,19 +2269,41 @@ export default function planPiExtension(pi) {
|
|
|
2201
2269
|
}
|
|
2202
2270
|
}
|
|
2203
2271
|
if (phase) {
|
|
2204
|
-
const
|
|
2272
|
+
const features = (await st.loadFeatures()).features;
|
|
2273
|
+
const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
|
|
2274
|
+
const linkedRequirements = [
|
|
2275
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2276
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2277
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2278
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2279
|
+
const contextInput = {
|
|
2280
|
+
sessionId: plannerSessionId,
|
|
2281
|
+
taskId: task.id,
|
|
2282
|
+
phaseId: phase.id,
|
|
2283
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
2284
|
+
task,
|
|
2285
|
+
phase,
|
|
2286
|
+
...(feature ? { feature } : {}),
|
|
2287
|
+
requirements: linkedRequirements,
|
|
2288
|
+
requirementIds: linkedRequirementIds,
|
|
2289
|
+
};
|
|
2290
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2291
|
+
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
2292
|
+
const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
|
|
2293
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
2294
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
2205
2295
|
if (!contextEligibility.eligible) {
|
|
2206
|
-
|
|
2296
|
+
const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `/planner task start ${taskRef}`);
|
|
2297
|
+
ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
|
|
2207
2298
|
return;
|
|
2208
2299
|
}
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
|
|
2212
|
-
];
|
|
2213
|
-
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
2214
|
-
ctx.ui.notify("Task start denied: read the requirements linked to this phase and feature first.", "warning");
|
|
2300
|
+
if (!requirementsReady) {
|
|
2301
|
+
ctx.ui.notify("❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
|
|
2215
2302
|
return;
|
|
2216
2303
|
}
|
|
2304
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2305
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2306
|
+
}
|
|
2217
2307
|
}
|
|
2218
2308
|
if (task.status === "in-progress") {
|
|
2219
2309
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
@@ -2279,6 +2369,11 @@ export default function planPiExtension(pi) {
|
|
|
2279
2369
|
return;
|
|
2280
2370
|
}
|
|
2281
2371
|
}
|
|
2372
|
+
const completionSummary = await ctx.ui.input("Completion summary (shipped work, verification, remaining/unverified, files/decisions)");
|
|
2373
|
+
if (!completionSummary?.trim() || completionSummary.trim().length < 10) {
|
|
2374
|
+
ctx.ui.notify("Completion cancelled — provide a durable summary of at least 10 characters.", "warning");
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2282
2377
|
const now = nowISO();
|
|
2283
2378
|
// Record status change in the incremental statusLog.
|
|
2284
2379
|
const entry = {
|
|
@@ -2287,12 +2382,16 @@ export default function planPiExtension(pi) {
|
|
|
2287
2382
|
fromStatus: task.status,
|
|
2288
2383
|
toStatus: "done",
|
|
2289
2384
|
title: `→ done`,
|
|
2290
|
-
description:
|
|
2385
|
+
description: completionSummary.trim(),
|
|
2291
2386
|
};
|
|
2292
2387
|
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2293
2388
|
applyTaskLifecycleDates(task, "done", now);
|
|
2389
|
+
const separator = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2390
|
+
task.description = `${task.description}${separator}${completionSummary.trim()}`;
|
|
2391
|
+
task.descriptionUpdatedAt = now;
|
|
2294
2392
|
task.updatedAt = now;
|
|
2295
2393
|
phase.updatedAt = now;
|
|
2394
|
+
await st.savePhase(phase);
|
|
2296
2395
|
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2297
2396
|
await st.writeGenerated();
|
|
2298
2397
|
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");
|
|
@@ -2406,23 +2505,7 @@ export default function planPiExtension(pi) {
|
|
|
2406
2505
|
return;
|
|
2407
2506
|
}
|
|
2408
2507
|
if (action === "write") {
|
|
2409
|
-
|
|
2410
|
-
if (!r.ok) {
|
|
2411
|
-
ctx.ui.notify(r.error, "warning");
|
|
2412
|
-
return;
|
|
2413
|
-
}
|
|
2414
|
-
const text = await ctx.ui.input(`Handoff text for ${r.compositeRef}`);
|
|
2415
|
-
if (!text?.trim()) {
|
|
2416
|
-
ctx.ui.notify("No text provided; nothing written.", "info");
|
|
2417
|
-
return;
|
|
2418
|
-
}
|
|
2419
|
-
const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
|
|
2420
|
-
if (!confirmed) {
|
|
2421
|
-
ctx.ui.notify("Handoff write cancelled.", "info");
|
|
2422
|
-
return;
|
|
2423
|
-
}
|
|
2424
|
-
await st.setPhaseHandoff(r.phase.id, text);
|
|
2425
|
-
ctx.ui.notify(`Wrote handoff on ${r.compositeRef}`, "info");
|
|
2508
|
+
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");
|
|
2426
2509
|
return;
|
|
2427
2510
|
}
|
|
2428
2511
|
if (action === "prepare") {
|
|
@@ -2432,15 +2515,16 @@ export default function planPiExtension(pi) {
|
|
|
2432
2515
|
}
|
|
2433
2516
|
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" +
|
|
2434
2517
|
"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" +
|
|
2435
|
-
"Before
|
|
2436
|
-
"After confirmation, the handoff
|
|
2518
|
+
"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" +
|
|
2519
|
+
"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" +
|
|
2520
|
+
"The reconciled handoff MUST contain at minimum:\n" +
|
|
2437
2521
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2438
2522
|
"- `Reason:` why this handoff is being written\n" +
|
|
2439
2523
|
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2440
2524
|
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2441
2525
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2442
2526
|
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2443
|
-
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected.
|
|
2527
|
+
"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.");
|
|
2444
2528
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2445
2529
|
return;
|
|
2446
2530
|
}
|
|
@@ -2733,6 +2817,7 @@ export default function planPiExtension(pi) {
|
|
|
2733
2817
|
status: "planned",
|
|
2734
2818
|
macroTasks: [],
|
|
2735
2819
|
linkedPhaseIds: [],
|
|
2820
|
+
sessionInfo: [],
|
|
2736
2821
|
createdAt: now,
|
|
2737
2822
|
updatedAt: now,
|
|
2738
2823
|
})),
|
|
@@ -2798,7 +2883,7 @@ export default function planPiExtension(pi) {
|
|
|
2798
2883
|
if (!st)
|
|
2799
2884
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2800
2885
|
const requirements = await st.loadRequirements();
|
|
2801
|
-
requirements.requirements.forEach((req) =>
|
|
2886
|
+
requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
|
|
2802
2887
|
return {
|
|
2803
2888
|
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2804
2889
|
details: requirements,
|
|
@@ -2834,6 +2919,7 @@ export default function planPiExtension(pi) {
|
|
|
2834
2919
|
status: params.status?.trim() ?? "planned",
|
|
2835
2920
|
macroTasks: [],
|
|
2836
2921
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
2922
|
+
sessionInfo: [],
|
|
2837
2923
|
createdAt: now,
|
|
2838
2924
|
updatedAt: now,
|
|
2839
2925
|
};
|
|
@@ -3011,7 +3097,7 @@ export default function planPiExtension(pi) {
|
|
|
3011
3097
|
pi.registerTool({
|
|
3012
3098
|
name: "plan_write_handoff",
|
|
3013
3099
|
label: "Plan Write Handoff",
|
|
3014
|
-
description: "DEPRECATED
|
|
3100
|
+
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.",
|
|
3015
3101
|
parameters: Type.Object({
|
|
3016
3102
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3017
3103
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
@@ -3033,21 +3119,10 @@ export default function planPiExtension(pi) {
|
|
|
3033
3119
|
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 } };
|
|
3034
3120
|
if (!params.confirmed)
|
|
3035
3121
|
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 } };
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
|
|
3041
|
-
...(params.extraSections && params.extraSections.length > 0
|
|
3042
|
-
? {
|
|
3043
|
-
extraSections: params.extraSections
|
|
3044
|
-
.map((section) => ({ heading: section.heading?.trim() ?? "", body: section.body ?? "" }))
|
|
3045
|
-
.filter((section) => section.heading),
|
|
3046
|
-
}
|
|
3047
|
-
: {}),
|
|
3048
|
-
});
|
|
3049
|
-
await st.setPhaseHandoff(resolved.phase.id, markdown);
|
|
3050
|
-
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 } };
|
|
3122
|
+
return {
|
|
3123
|
+
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.` }],
|
|
3124
|
+
details: { deprecated: true, writeDisabled: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id },
|
|
3125
|
+
};
|
|
3051
3126
|
},
|
|
3052
3127
|
});
|
|
3053
3128
|
pi.registerTool({
|
|
@@ -3138,16 +3213,69 @@ export default function planPiExtension(pi) {
|
|
|
3138
3213
|
return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3139
3214
|
},
|
|
3140
3215
|
});
|
|
3216
|
+
pi.registerTool({
|
|
3217
|
+
name: "handoff_prepare",
|
|
3218
|
+
label: "Handoff Prepare",
|
|
3219
|
+
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.",
|
|
3220
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3221
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3222
|
+
const st = await requirePlan(ctx);
|
|
3223
|
+
if (!st)
|
|
3224
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3225
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3226
|
+
if (!r.ok)
|
|
3227
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3228
|
+
try {
|
|
3229
|
+
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3230
|
+
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
3231
|
+
const existing = audit.handoff.trim() || "(none)";
|
|
3232
|
+
return {
|
|
3233
|
+
content: [{ type: "text", text: [
|
|
3234
|
+
`Handoff preparation audit for ${r.compositeRef}`,
|
|
3235
|
+
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3236
|
+
"Done tasks missing durable completion/verification evidence:",
|
|
3237
|
+
missing,
|
|
3238
|
+
"",
|
|
3239
|
+
"Existing active handoff (reconcile all still-relevant content):",
|
|
3240
|
+
existing,
|
|
3241
|
+
].join("\n") }],
|
|
3242
|
+
details: { phaseRef: r.compositeRef, ...audit },
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
catch (error) {
|
|
3246
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3247
|
+
return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
|
|
3248
|
+
}
|
|
3249
|
+
},
|
|
3250
|
+
});
|
|
3141
3251
|
pi.registerTool({
|
|
3142
3252
|
name: "handoff_write",
|
|
3143
3253
|
label: "Handoff Write",
|
|
3144
|
-
description: "
|
|
3254
|
+
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.",
|
|
3145
3255
|
parameters: Type.Object({
|
|
3146
3256
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3147
|
-
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists).
|
|
3257
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3148
3258
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3149
3259
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3150
3260
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
3261
|
+
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3262
|
+
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3263
|
+
taskUpdates: Type.Optional(Type.Array(Type.Object({
|
|
3264
|
+
taskRef: Type.String(),
|
|
3265
|
+
completionSummary: Type.String(),
|
|
3266
|
+
verification: Type.String(),
|
|
3267
|
+
remainingWork: Type.String(),
|
|
3268
|
+
filesTouched: Type.Optional(Type.Array(Type.String())),
|
|
3269
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3270
|
+
}))),
|
|
3271
|
+
phaseUpdate: Type.Optional(Type.Object({
|
|
3272
|
+
progressSummary: Type.String(),
|
|
3273
|
+
remainingWork: Type.String(),
|
|
3274
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3275
|
+
})),
|
|
3276
|
+
phaseNoUpdateReason: Type.Optional(Type.String({ description: "Required when phaseUpdate is omitted." })),
|
|
3277
|
+
featureUpdate: Type.Optional(Type.Object({ workDone: Type.String(), workRemaining: Type.String() })),
|
|
3278
|
+
featureNoUpdateReason: Type.Optional(Type.String({ description: "Required when featureUpdate is omitted." })),
|
|
3151
3279
|
}),
|
|
3152
3280
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3153
3281
|
const st = await requirePlan(ctx);
|
|
@@ -3157,38 +3285,68 @@ export default function planPiExtension(pi) {
|
|
|
3157
3285
|
if (!body)
|
|
3158
3286
|
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3159
3287
|
const title = params.title?.trim();
|
|
3160
|
-
|
|
3161
|
-
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3288
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
3162
3289
|
const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
|
|
3163
|
-
const
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
if (isGeneric) {
|
|
3167
|
-
return {
|
|
3168
|
-
content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted." }],
|
|
3169
|
-
details: { error: "generic title", firstHeadingText: effectiveHeadingText || firstHeadingText },
|
|
3170
|
-
};
|
|
3290
|
+
const effectiveHeadingText = title || firstHeadingText;
|
|
3291
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
3292
|
+
return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
|
|
3171
3293
|
}
|
|
3172
|
-
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3173
3294
|
if (title) {
|
|
3174
3295
|
const lines = body.split(/\r?\n/);
|
|
3175
|
-
const firstIdx = lines.findIndex((
|
|
3176
|
-
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3296
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
3297
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3177
3298
|
lines[firstIdx] = `# ${title}`;
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
body = `# ${title}\n\n${body}`;
|
|
3182
|
-
}
|
|
3299
|
+
else
|
|
3300
|
+
lines.unshift(`# ${title}`, "");
|
|
3301
|
+
body = lines.join("\n");
|
|
3183
3302
|
}
|
|
3184
3303
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3185
3304
|
if (!r.ok)
|
|
3186
3305
|
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3187
|
-
if (!params.confirmed)
|
|
3188
|
-
return { content: [{ type: "text", text: `Proposal only: I would
|
|
3306
|
+
if (!params.confirmed)
|
|
3307
|
+
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 } };
|
|
3308
|
+
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3309
|
+
return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { error: "preparation required" } };
|
|
3310
|
+
const phases = await st.loadAllPhases();
|
|
3311
|
+
const features = (await st.loadFeatures()).features;
|
|
3312
|
+
const taskUpdates = [];
|
|
3313
|
+
for (const update of params.taskUpdates ?? []) {
|
|
3314
|
+
const found = findTaskByRef(phases, features, update.taskRef.trim());
|
|
3315
|
+
if (!found || found.phase.id !== r.phase.id)
|
|
3316
|
+
return { content: [{ type: "text", text: `❌ Task ${update.taskRef} does not belong to ${r.compositeRef}.` }], details: { error: "invalid task update" } };
|
|
3317
|
+
taskUpdates.push({
|
|
3318
|
+
taskId: found.task.id,
|
|
3319
|
+
completionSummary: update.completionSummary,
|
|
3320
|
+
verification: update.verification,
|
|
3321
|
+
remainingWork: update.remainingWork,
|
|
3322
|
+
...(update.filesTouched ? { filesTouched: update.filesTouched } : {}),
|
|
3323
|
+
...(update.decisions ? { decisions: update.decisions } : {}),
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
try {
|
|
3327
|
+
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
3328
|
+
content: body,
|
|
3329
|
+
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3330
|
+
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3331
|
+
contextSync: {
|
|
3332
|
+
taskUpdates,
|
|
3333
|
+
...(params.phaseUpdate ? { phaseUpdate: {
|
|
3334
|
+
progressSummary: params.phaseUpdate.progressSummary,
|
|
3335
|
+
remainingWork: params.phaseUpdate.remainingWork,
|
|
3336
|
+
...(params.phaseUpdate.decisions ? { decisions: params.phaseUpdate.decisions } : {}),
|
|
3337
|
+
} } : {}),
|
|
3338
|
+
...(params.phaseNoUpdateReason ? { phaseNoUpdateReason: params.phaseNoUpdateReason } : {}),
|
|
3339
|
+
...(params.featureUpdate ? { featureUpdate: params.featureUpdate } : {}),
|
|
3340
|
+
...(params.featureNoUpdateReason ? { featureNoUpdateReason: params.featureNoUpdateReason } : {}),
|
|
3341
|
+
},
|
|
3342
|
+
});
|
|
3343
|
+
await st.writeGenerated();
|
|
3344
|
+
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 } };
|
|
3345
|
+
}
|
|
3346
|
+
catch (error) {
|
|
3347
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3348
|
+
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
|
|
3189
3349
|
}
|
|
3190
|
-
await st.setPhaseHandoff(r.phase.id, body);
|
|
3191
|
-
return { content: [{ type: "text", text: `✅ Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3192
3350
|
},
|
|
3193
3351
|
});
|
|
3194
3352
|
pi.registerTool({
|
|
@@ -3277,7 +3435,7 @@ export default function planPiExtension(pi) {
|
|
|
3277
3435
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3278
3436
|
const feature = resolvedFeature.feature;
|
|
3279
3437
|
if (params.full)
|
|
3280
|
-
|
|
3438
|
+
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3281
3439
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3282
3440
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3283
3441
|
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"}` : ""})`;
|
|
@@ -3326,6 +3484,7 @@ export default function planPiExtension(pi) {
|
|
|
3326
3484
|
phaseIds: [],
|
|
3327
3485
|
dependsOn: [],
|
|
3328
3486
|
statusLog: [],
|
|
3487
|
+
sessionInfo: [],
|
|
3329
3488
|
createdAt: now,
|
|
3330
3489
|
updatedAt: now,
|
|
3331
3490
|
};
|
|
@@ -3416,10 +3575,12 @@ export default function planPiExtension(pi) {
|
|
|
3416
3575
|
if (!resolvedFeature.ok)
|
|
3417
3576
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3418
3577
|
const featureId = resolvedFeature.feature.id;
|
|
3578
|
+
if (params.status !== undefined) {
|
|
3579
|
+
return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
|
|
3580
|
+
}
|
|
3419
3581
|
const mutableFields = [
|
|
3420
3582
|
"name",
|
|
3421
3583
|
"description",
|
|
3422
|
-
"status",
|
|
3423
3584
|
"startDate",
|
|
3424
3585
|
"endDate",
|
|
3425
3586
|
"workDone",
|
|
@@ -3459,18 +3620,6 @@ export default function planPiExtension(pi) {
|
|
|
3459
3620
|
feature.priority = params.priority;
|
|
3460
3621
|
if (params.acceptedDecisions !== undefined)
|
|
3461
3622
|
feature.acceptedDecisions = params.acceptedDecisions;
|
|
3462
|
-
if (params.status !== undefined) {
|
|
3463
|
-
const status = params.status;
|
|
3464
|
-
if (feature.status !== status) {
|
|
3465
|
-
if (status === "in-progress" && !feature.startDate) {
|
|
3466
|
-
feature.startDate = new Date().toISOString().slice(0, 10);
|
|
3467
|
-
}
|
|
3468
|
-
if (status === "done" && !feature.endDate) {
|
|
3469
|
-
feature.endDate = new Date().toISOString().slice(0, 10);
|
|
3470
|
-
}
|
|
3471
|
-
}
|
|
3472
|
-
feature.status = status;
|
|
3473
|
-
}
|
|
3474
3623
|
feature.updatedAt = nowISO();
|
|
3475
3624
|
return doc;
|
|
3476
3625
|
});
|
|
@@ -3577,7 +3726,7 @@ export default function planPiExtension(pi) {
|
|
|
3577
3726
|
if (!phase)
|
|
3578
3727
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3579
3728
|
if (params.full)
|
|
3580
|
-
|
|
3729
|
+
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
3581
3730
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3582
3731
|
const reqCount = linkedRequirements.length;
|
|
3583
3732
|
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"}` : ""})`;
|
|
@@ -3639,6 +3788,7 @@ export default function planPiExtension(pi) {
|
|
|
3639
3788
|
handoffReadAt: "",
|
|
3640
3789
|
handoffHistory: [],
|
|
3641
3790
|
statusLog: [],
|
|
3791
|
+
sessionInfo: [],
|
|
3642
3792
|
};
|
|
3643
3793
|
await st.savePhase(phase);
|
|
3644
3794
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
@@ -3702,14 +3852,15 @@ export default function planPiExtension(pi) {
|
|
|
3702
3852
|
catch {
|
|
3703
3853
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3704
3854
|
}
|
|
3855
|
+
if (params.status !== undefined) {
|
|
3856
|
+
return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
|
|
3857
|
+
}
|
|
3705
3858
|
const prevFeatureId = phase.featureId;
|
|
3706
3859
|
let nextFeatureIdForUpdate = prevFeatureId;
|
|
3707
3860
|
if (params.title !== undefined) {
|
|
3708
3861
|
phase.title = params.title;
|
|
3709
3862
|
phase.slug = normalizeSlug(params.title);
|
|
3710
3863
|
}
|
|
3711
|
-
if (params.status !== undefined)
|
|
3712
|
-
phase.status = params.status;
|
|
3713
3864
|
if (params.summary !== undefined)
|
|
3714
3865
|
phase.summary = params.summary;
|
|
3715
3866
|
if (params.description !== undefined)
|
|
@@ -3929,7 +4080,7 @@ export default function planPiExtension(pi) {
|
|
|
3929
4080
|
if (!found)
|
|
3930
4081
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3931
4082
|
if (params.full)
|
|
3932
|
-
|
|
4083
|
+
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
3933
4084
|
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})`;
|
|
3934
4085
|
if (!params.full)
|
|
3935
4086
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
@@ -4024,6 +4175,7 @@ export default function planPiExtension(pi) {
|
|
|
4024
4175
|
descriptionUpdatedAt: now,
|
|
4025
4176
|
notes: "",
|
|
4026
4177
|
statusLog: [],
|
|
4178
|
+
sessionInfo: [],
|
|
4027
4179
|
decisions: [],
|
|
4028
4180
|
acceptedDecisions: [],
|
|
4029
4181
|
checklist: params.checklist
|
|
@@ -4094,6 +4246,12 @@ export default function planPiExtension(pi) {
|
|
|
4094
4246
|
details: {},
|
|
4095
4247
|
};
|
|
4096
4248
|
}
|
|
4249
|
+
if (params.status === "done" && params.status !== found.task.status) {
|
|
4250
|
+
return {
|
|
4251
|
+
content: [{ type: "text", text: "Task completion transitions require task_complete with durable completion and verification evidence." }],
|
|
4252
|
+
details: {},
|
|
4253
|
+
};
|
|
4254
|
+
}
|
|
4097
4255
|
if (params.status !== undefined &&
|
|
4098
4256
|
params.status !== found.task.status &&
|
|
4099
4257
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4272,7 +4430,7 @@ export default function planPiExtension(pi) {
|
|
|
4272
4430
|
resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
|
|
4273
4431
|
pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
|
|
4274
4432
|
};
|
|
4275
|
-
invalidateReads();
|
|
4433
|
+
invalidateReads(plannerSessionId);
|
|
4276
4434
|
const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
|
|
4277
4435
|
await st.syncTaskStatusRollup(found.phase.id);
|
|
4278
4436
|
const activeDeviation = (await st.loadProject()).workDeviations
|
|
@@ -4305,7 +4463,7 @@ export default function planPiExtension(pi) {
|
|
|
4305
4463
|
pi.registerTool({
|
|
4306
4464
|
name: "task_switch",
|
|
4307
4465
|
label: "Task Switch",
|
|
4308
|
-
description: "Switch from active work to a temporary task, even outside priority order. Atomically
|
|
4466
|
+
description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
|
|
4309
4467
|
parameters: Type.Object({
|
|
4310
4468
|
from_task: Type.String({ description: "Currently in-progress task ref" }),
|
|
4311
4469
|
to_task: Type.String({ description: "Temporary task ref to start" }),
|
|
@@ -4332,16 +4490,37 @@ export default function planPiExtension(pi) {
|
|
|
4332
4490
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4333
4491
|
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4334
4492
|
}
|
|
4335
|
-
const
|
|
4493
|
+
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
4494
|
+
const linkedRequirements = [
|
|
4495
|
+
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
4496
|
+
...(target.phase.featureId ? await st.linkedRequirementsForFeature(target.phase.featureId) : []),
|
|
4497
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4498
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4499
|
+
const contextInput = {
|
|
4500
|
+
sessionId: plannerSessionId,
|
|
4501
|
+
taskId: target.task.id,
|
|
4502
|
+
phaseId: target.phase.id,
|
|
4503
|
+
...(target.phase.featureId ? { featureId: target.phase.featureId } : {}),
|
|
4504
|
+
task: target.task,
|
|
4505
|
+
phase: target.phase,
|
|
4506
|
+
...(targetFeature ? { feature: targetFeature } : {}),
|
|
4507
|
+
requirements: linkedRequirements,
|
|
4508
|
+
requirementIds: linkedRequirementIds,
|
|
4509
|
+
};
|
|
4510
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4511
|
+
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
4512
|
+
const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
|
|
4513
|
+
const targetTaskRef = `${targetPhaseRef}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4514
|
+
const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
|
|
4336
4515
|
if (!contextEligibility.eligible) {
|
|
4337
|
-
|
|
4516
|
+
const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
|
|
4517
|
+
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
|
|
4338
4518
|
}
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
|
|
4519
|
+
if (!requirementsReady) {
|
|
4520
|
+
return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds, nextActions: ["requirement_list", `Retry task_switch to ${targetTaskRef}`] } };
|
|
4521
|
+
}
|
|
4522
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4523
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
4345
4524
|
}
|
|
4346
4525
|
const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
|
|
4347
4526
|
if (!eligibility.eligible)
|
|
@@ -4432,7 +4611,7 @@ export default function planPiExtension(pi) {
|
|
|
4432
4611
|
if (target.phase.id !== source.phase.id)
|
|
4433
4612
|
await st.syncTaskStatusRollup(target.phase.id);
|
|
4434
4613
|
await st.writeGenerated();
|
|
4435
|
-
invalidateReads();
|
|
4614
|
+
invalidateReads(plannerSessionId);
|
|
4436
4615
|
const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
|
|
4437
4616
|
const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
|
|
4438
4617
|
return {
|
|
@@ -4449,45 +4628,73 @@ export default function planPiExtension(pi) {
|
|
|
4449
4628
|
pi.registerTool({
|
|
4450
4629
|
name: "task_start",
|
|
4451
4630
|
label: "Task Start",
|
|
4452
|
-
description: "Set a task to in-progress or resume checkpointed work.
|
|
4631
|
+
description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. A denied start is an isError result with started=false; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
|
|
4453
4632
|
parameters: Type.Object({
|
|
4454
4633
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4455
4634
|
}),
|
|
4456
4635
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4457
4636
|
const st = await requirePlan(ctx);
|
|
4458
4637
|
if (!st)
|
|
4459
|
-
return
|
|
4638
|
+
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
4460
4639
|
const features = (await st.loadFeatures()).features;
|
|
4461
4640
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4462
4641
|
if (!found)
|
|
4463
|
-
return
|
|
4642
|
+
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
4464
4643
|
const task = found.task;
|
|
4465
|
-
|
|
4466
|
-
|
|
4644
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
4645
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
4646
|
+
const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
4647
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
4467
4648
|
if (task.status === "done")
|
|
4468
|
-
return
|
|
4469
|
-
const
|
|
4649
|
+
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 }));
|
|
4650
|
+
const linkedRequirements = [
|
|
4651
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
4652
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
4653
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4654
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4655
|
+
const contextInput = {
|
|
4656
|
+
sessionId: plannerSessionId,
|
|
4657
|
+
taskId: task.id,
|
|
4658
|
+
phaseId: found.phase.id,
|
|
4659
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4660
|
+
task,
|
|
4661
|
+
phase: found.phase,
|
|
4662
|
+
...(feature ? { feature } : {}),
|
|
4663
|
+
requirements: linkedRequirements,
|
|
4664
|
+
requirementIds: linkedRequirementIds,
|
|
4665
|
+
};
|
|
4666
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4667
|
+
const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
|
|
4470
4668
|
if (!contextEligibility.eligible) {
|
|
4471
|
-
return {
|
|
4669
|
+
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
|
|
4670
|
+
}
|
|
4671
|
+
if (!requirementsReady) {
|
|
4672
|
+
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 }));
|
|
4673
|
+
}
|
|
4674
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4675
|
+
await st.recordContextRead({
|
|
4676
|
+
sessionId: plannerSessionId,
|
|
4677
|
+
phaseId: found.phase.id,
|
|
4678
|
+
taskId: task.id,
|
|
4679
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4680
|
+
requirementIds: linkedRequirementIds,
|
|
4681
|
+
});
|
|
4472
4682
|
}
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
];
|
|
4477
|
-
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
4478
|
-
return { content: [{ type: "text", text: "Task start denied: read the requirements linked to the task's phase and feature first." }], details: { requirementIds: linkedRequirementIds } };
|
|
4683
|
+
if (task.status === "in-progress") {
|
|
4684
|
+
const outcome = taskStartSucceeded(task.id, true);
|
|
4685
|
+
return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
|
|
4479
4686
|
}
|
|
4480
4687
|
const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
|
|
4481
4688
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4482
4689
|
if (!eligibility.eligible)
|
|
4483
|
-
return
|
|
4690
|
+
return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
|
|
4484
4691
|
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4485
4692
|
if (selection.kind === "conflict") {
|
|
4486
|
-
return
|
|
4693
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
4487
4694
|
}
|
|
4488
4695
|
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
4489
4696
|
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
4490
|
-
return
|
|
4697
|
+
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 });
|
|
4491
4698
|
}
|
|
4492
4699
|
let resumeProposal;
|
|
4493
4700
|
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
@@ -4558,11 +4765,14 @@ export default function planPiExtension(pi) {
|
|
|
4558
4765
|
if (resumedDeviation)
|
|
4559
4766
|
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
4560
4767
|
await st.writeGenerated();
|
|
4561
|
-
|
|
4562
|
-
|
|
4768
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
4769
|
+
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
4770
|
+
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 }));
|
|
4771
|
+
}
|
|
4772
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
4563
4773
|
return {
|
|
4564
|
-
content: [{ type: "text", text: `✅ Task started: ${
|
|
4565
|
-
details:
|
|
4774
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
4775
|
+
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
4566
4776
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4567
4777
|
};
|
|
4568
4778
|
},
|
|
@@ -4570,11 +4780,11 @@ export default function planPiExtension(pi) {
|
|
|
4570
4780
|
pi.registerTool({
|
|
4571
4781
|
name: "task_complete",
|
|
4572
4782
|
label: "Task Complete",
|
|
4573
|
-
description: "Mark a task as done. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
4783
|
+
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.",
|
|
4574
4784
|
parameters: Type.Object({
|
|
4575
4785
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
4576
4786
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
4577
|
-
description_update: Type.
|
|
4787
|
+
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." }),
|
|
4578
4788
|
}),
|
|
4579
4789
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4580
4790
|
const st = await requirePlan(ctx);
|
|
@@ -4587,6 +4797,9 @@ export default function planPiExtension(pi) {
|
|
|
4587
4797
|
const task = found.task;
|
|
4588
4798
|
if (task.status === "done")
|
|
4589
4799
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
4800
|
+
const completionSummary = params.description_update?.trim() ?? "";
|
|
4801
|
+
if (completionSummary.length < 10)
|
|
4802
|
+
return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
|
|
4590
4803
|
if (task.pauseSnapshot)
|
|
4591
4804
|
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 };
|
|
4592
4805
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -4607,12 +4820,11 @@ export default function planPiExtension(pi) {
|
|
|
4607
4820
|
t.statusLog = [...t.statusLog, {
|
|
4608
4821
|
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
4609
4822
|
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
4610
|
-
title: `${previousStatus} → done`, description:
|
|
4823
|
+
title: `${previousStatus} → done`, description: completionSummary,
|
|
4611
4824
|
}];
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
}
|
|
4825
|
+
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
4826
|
+
t.description = t.description + sep + completionSummary;
|
|
4827
|
+
t.descriptionUpdatedAt = now;
|
|
4616
4828
|
t.updatedAt = now;
|
|
4617
4829
|
phase.updatedAt = now;
|
|
4618
4830
|
completedTask = t;
|
|
@@ -5002,7 +5214,7 @@ export default function planPiExtension(pi) {
|
|
|
5002
5214
|
"STARTUP RESUME PROTOCOL (mandatory):",
|
|
5003
5215
|
"- 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.",
|
|
5004
5216
|
`- Write in ${project.chatLanguage || "English"}.`,
|
|
5005
|
-
"- 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).",
|
|
5217
|
+
"- 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).",
|
|
5006
5218
|
].filter(Boolean).join("\n") : "";
|
|
5007
5219
|
// Build/refresh the context block (slow path: cache miss).
|
|
5008
5220
|
const contextBlock = [
|
|
@@ -5011,7 +5223,7 @@ export default function planPiExtension(pi) {
|
|
|
5011
5223
|
"",
|
|
5012
5224
|
"Operational rules:",
|
|
5013
5225
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
5014
|
-
"- BEFORE work:
|
|
5226
|
+
"- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale full reads listed in nextActions, in that order, then retry. A denial is an error with started=false; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
|
|
5015
5227
|
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
5016
5228
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
5017
5229
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|