@agent-plan/pi-adapter 0.2.22-next.4 → 0.2.23-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +286 -113
- package/package.json +3 -3
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA+1BtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CA0mI9D"}
|
package/dist/index.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
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";
|
|
17
17
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
18
|
import { writeFile, rm } from "node:fs/promises";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import { createServer } from "node:net";
|
|
20
21
|
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { serve } from "@agent-plan/server/serve";
|
|
@@ -29,6 +30,7 @@ let capturedPi = null;
|
|
|
29
30
|
let store = null;
|
|
30
31
|
let server = null;
|
|
31
32
|
let lastKnownWebPort = null;
|
|
33
|
+
let plannerSessionId = randomUUID();
|
|
32
34
|
// True while the agent is mutating .planner/ files. The web server returns 503-busy
|
|
33
35
|
// during this window so the UI doesn't render inconsistent data.
|
|
34
36
|
let planBusy = false;
|
|
@@ -39,6 +41,20 @@ function withPlanBusy(fn) {
|
|
|
39
41
|
.then(() => fn())
|
|
40
42
|
.finally(() => { planBusy = false; });
|
|
41
43
|
}
|
|
44
|
+
function taskStartFailure(outcome, details = {}) {
|
|
45
|
+
return {
|
|
46
|
+
isError: true,
|
|
47
|
+
content: [{ type: "text", text: [
|
|
48
|
+
`❌ TASK START FAILED [${outcome.errorCode}]`,
|
|
49
|
+
"started: false",
|
|
50
|
+
outcome.message,
|
|
51
|
+
"No task lifecycle transition was applied.",
|
|
52
|
+
"Next required actions:",
|
|
53
|
+
...outcome.nextActions.map((action, index) => `${index + 1}. ${action}`),
|
|
54
|
+
].join("\n") }],
|
|
55
|
+
details: { ...outcome, ...details },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
42
58
|
// Helper to notify a running server in another process via HTTP.
|
|
43
59
|
async function notifyExternalServer() {
|
|
44
60
|
try {
|
|
@@ -135,7 +151,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
|
|
|
135
151
|
{ value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
|
|
136
152
|
{ value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
|
|
137
153
|
{ value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
|
|
138
|
-
{ value: "handoff prepare", label: "handoff prepare", description: "
|
|
154
|
+
{ value: "handoff prepare", label: "handoff prepare", description: "Audit and reconcile the active phase handoff with durable entity context" },
|
|
139
155
|
{ value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
|
|
140
156
|
{ value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
|
|
141
157
|
{ value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
|
|
@@ -786,7 +802,9 @@ export default function planPiExtension(pi) {
|
|
|
786
802
|
capturedPi = pi;
|
|
787
803
|
// ── Restore on session start/reload ─────────────────────────────────
|
|
788
804
|
pi.on("session_start", async (_event, ctx) => {
|
|
805
|
+
plannerSessionId = randomUUID();
|
|
789
806
|
invalidateReads();
|
|
807
|
+
startReadSession(plannerSessionId);
|
|
790
808
|
try {
|
|
791
809
|
ctx.ui.addAutocompleteProvider((current) => ({
|
|
792
810
|
triggerCharacters: ["/", " "],
|
|
@@ -1537,6 +1555,7 @@ export default function planPiExtension(pi) {
|
|
|
1537
1555
|
phaseIds: [],
|
|
1538
1556
|
dependsOn: [],
|
|
1539
1557
|
statusLog: [],
|
|
1558
|
+
sessionInfo: [],
|
|
1540
1559
|
createdAt: now,
|
|
1541
1560
|
updatedAt: now,
|
|
1542
1561
|
};
|
|
@@ -1758,6 +1777,7 @@ export default function planPiExtension(pi) {
|
|
|
1758
1777
|
handoffReadAt: "",
|
|
1759
1778
|
handoffHistory: [],
|
|
1760
1779
|
statusLog: [],
|
|
1780
|
+
sessionInfo: [],
|
|
1761
1781
|
};
|
|
1762
1782
|
await st.savePhase(phase);
|
|
1763
1783
|
await st.updateFeatures((doc) => {
|
|
@@ -1998,6 +2018,7 @@ export default function planPiExtension(pi) {
|
|
|
1998
2018
|
descriptionUpdatedAt: "",
|
|
1999
2019
|
notes: "",
|
|
2000
2020
|
statusLog: [],
|
|
2021
|
+
sessionInfo: [],
|
|
2001
2022
|
decisions: [],
|
|
2002
2023
|
acceptedDecisions: [],
|
|
2003
2024
|
checklist: [], subtasks: [],
|
|
@@ -2164,6 +2185,10 @@ export default function planPiExtension(pi) {
|
|
|
2164
2185
|
ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
|
|
2165
2186
|
return;
|
|
2166
2187
|
}
|
|
2188
|
+
if (normalizedStatus === "done" && normalizedStatus !== task.status) {
|
|
2189
|
+
ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2167
2192
|
applyTaskLifecycleDates(task, normalizedStatus, now);
|
|
2168
2193
|
}
|
|
2169
2194
|
if (desc?.trim())
|
|
@@ -2201,19 +2226,36 @@ export default function planPiExtension(pi) {
|
|
|
2201
2226
|
}
|
|
2202
2227
|
}
|
|
2203
2228
|
if (phase) {
|
|
2204
|
-
const
|
|
2229
|
+
const features = (await st.loadFeatures()).features;
|
|
2230
|
+
const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
|
|
2231
|
+
const linkedRequirements = [
|
|
2232
|
+
...(await st.linkedRequirementsForPhase(phase.id)),
|
|
2233
|
+
...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
|
|
2234
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
2235
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
2236
|
+
const contextInput = {
|
|
2237
|
+
sessionId: plannerSessionId,
|
|
2238
|
+
taskId: task.id,
|
|
2239
|
+
phaseId: phase.id,
|
|
2240
|
+
...(phase.featureId ? { featureId: phase.featureId } : {}),
|
|
2241
|
+
task,
|
|
2242
|
+
phase,
|
|
2243
|
+
...(feature ? { feature } : {}),
|
|
2244
|
+
requirements: linkedRequirements,
|
|
2245
|
+
requirementIds: linkedRequirementIds,
|
|
2246
|
+
};
|
|
2247
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
2205
2248
|
if (!contextEligibility.eligible) {
|
|
2206
|
-
ctx.ui.notify(
|
|
2249
|
+
ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nRead task(full), phase(full), feature(full) in order, then retry.`, "error");
|
|
2207
2250
|
return;
|
|
2208
2251
|
}
|
|
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");
|
|
2252
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
2253
|
+
ctx.ui.notify("❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
|
|
2215
2254
|
return;
|
|
2216
2255
|
}
|
|
2256
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
2257
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
2258
|
+
}
|
|
2217
2259
|
}
|
|
2218
2260
|
if (task.status === "in-progress") {
|
|
2219
2261
|
ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
|
|
@@ -2279,6 +2321,11 @@ export default function planPiExtension(pi) {
|
|
|
2279
2321
|
return;
|
|
2280
2322
|
}
|
|
2281
2323
|
}
|
|
2324
|
+
const completionSummary = await ctx.ui.input("Completion summary (shipped work, verification, remaining/unverified, files/decisions)");
|
|
2325
|
+
if (!completionSummary?.trim() || completionSummary.trim().length < 10) {
|
|
2326
|
+
ctx.ui.notify("Completion cancelled — provide a durable summary of at least 10 characters.", "warning");
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2282
2329
|
const now = nowISO();
|
|
2283
2330
|
// Record status change in the incremental statusLog.
|
|
2284
2331
|
const entry = {
|
|
@@ -2287,12 +2334,16 @@ export default function planPiExtension(pi) {
|
|
|
2287
2334
|
fromStatus: task.status,
|
|
2288
2335
|
toStatus: "done",
|
|
2289
2336
|
title: `→ done`,
|
|
2290
|
-
description:
|
|
2337
|
+
description: completionSummary.trim(),
|
|
2291
2338
|
};
|
|
2292
2339
|
task.statusLog = [...(task.statusLog ?? []), entry];
|
|
2293
2340
|
applyTaskLifecycleDates(task, "done", now);
|
|
2341
|
+
const separator = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
2342
|
+
task.description = `${task.description}${separator}${completionSummary.trim()}`;
|
|
2343
|
+
task.descriptionUpdatedAt = now;
|
|
2294
2344
|
task.updatedAt = now;
|
|
2295
2345
|
phase.updatedAt = now;
|
|
2346
|
+
await st.savePhase(phase);
|
|
2296
2347
|
const clearedRef = await st.syncTaskStatusRollup(phase.id);
|
|
2297
2348
|
await st.writeGenerated();
|
|
2298
2349
|
ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
|
|
@@ -2406,23 +2457,7 @@ export default function planPiExtension(pi) {
|
|
|
2406
2457
|
return;
|
|
2407
2458
|
}
|
|
2408
2459
|
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");
|
|
2460
|
+
ctx.ui.notify("Direct handoff writes are disabled because they bypass durable task/phase/feature context synchronization. Use /planner handoff prepare, confirm the exact phase, then let the agent run handoff_prepare and handoff_write.", "warning");
|
|
2426
2461
|
return;
|
|
2427
2462
|
}
|
|
2428
2463
|
if (action === "prepare") {
|
|
@@ -2432,15 +2467,16 @@ export default function planPiExtension(pi) {
|
|
|
2432
2467
|
}
|
|
2433
2468
|
await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
|
|
2434
2469
|
"If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
|
|
2435
|
-
"Before
|
|
2436
|
-
"After confirmation, the handoff
|
|
2470
|
+
"Before writing, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
|
|
2471
|
+
"After confirmation, call `handoff_prepare` with that exact phaseRef. Read the entire existing handoff and its handoffUpdatedAt token. Audit every listed done task that lacks durable completion/verification evidence. Then call `handoff_write` with the exact token, one reconciled handoff body, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps.\n\n" +
|
|
2472
|
+
"The reconciled handoff MUST contain at minimum:\n" +
|
|
2437
2473
|
"- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
|
|
2438
2474
|
"- `Reason:` why this handoff is being written\n" +
|
|
2439
2475
|
"- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
|
|
2440
2476
|
"- `## What was being done`: the concrete decisions/work from this session\n" +
|
|
2441
2477
|
"- `## How to resume`: explicit ordered steps\n" +
|
|
2442
2478
|
"- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
|
|
2443
|
-
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected.
|
|
2479
|
+
"Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoff refresh synchronizes durable task/phase/feature context and updates the single active phase.handoff; `.planner/HANDOFF.md` is deprecated.");
|
|
2444
2480
|
ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
|
|
2445
2481
|
return;
|
|
2446
2482
|
}
|
|
@@ -2733,6 +2769,7 @@ export default function planPiExtension(pi) {
|
|
|
2733
2769
|
status: "planned",
|
|
2734
2770
|
macroTasks: [],
|
|
2735
2771
|
linkedPhaseIds: [],
|
|
2772
|
+
sessionInfo: [],
|
|
2736
2773
|
createdAt: now,
|
|
2737
2774
|
updatedAt: now,
|
|
2738
2775
|
})),
|
|
@@ -2798,7 +2835,7 @@ export default function planPiExtension(pi) {
|
|
|
2798
2835
|
if (!st)
|
|
2799
2836
|
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
2800
2837
|
const requirements = await st.loadRequirements();
|
|
2801
|
-
requirements.requirements.forEach((req) =>
|
|
2838
|
+
requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
|
|
2802
2839
|
return {
|
|
2803
2840
|
content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
|
|
2804
2841
|
details: requirements,
|
|
@@ -2834,6 +2871,7 @@ export default function planPiExtension(pi) {
|
|
|
2834
2871
|
status: params.status?.trim() ?? "planned",
|
|
2835
2872
|
macroTasks: [],
|
|
2836
2873
|
linkedPhaseIds: links.linkedPhaseIds,
|
|
2874
|
+
sessionInfo: [],
|
|
2837
2875
|
createdAt: now,
|
|
2838
2876
|
updatedAt: now,
|
|
2839
2877
|
};
|
|
@@ -3011,7 +3049,7 @@ export default function planPiExtension(pi) {
|
|
|
3011
3049
|
pi.registerTool({
|
|
3012
3050
|
name: "plan_write_handoff",
|
|
3013
3051
|
label: "Plan Write Handoff",
|
|
3014
|
-
description: "DEPRECATED
|
|
3052
|
+
description: "DEPRECATED AND WRITE-DISABLED — retained only for proposal compatibility. Use handoff_prepare followed by handoff_write so durable task/phase/feature context is synchronized and existing handoff content is reconciled.",
|
|
3015
3053
|
parameters: Type.Object({
|
|
3016
3054
|
phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
|
|
3017
3055
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
@@ -3033,21 +3071,10 @@ export default function planPiExtension(pi) {
|
|
|
3033
3071
|
return { content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated (writes to phase.handoff, not .planner/HANDOFF.md). ${resolved.error}` }], details: { deprecated: true, error: resolved.error } };
|
|
3034
3072
|
if (!params.confirmed)
|
|
3035
3073
|
return { content: [{ type: "text", text: `Proposal only: I would write this handoff on ${resolved.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { deprecated: true, phaseRef: resolved.compositeRef, confirmationRequired: true } };
|
|
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 } };
|
|
3074
|
+
return {
|
|
3075
|
+
content: [{ type: "text", text: `⚠️ plan_write_handoff is deprecated and write-disabled because it bypasses durable context synchronization. Run handoff_prepare for ${resolved.compositeRef}, then call handoff_write with the audit token and entity updates.` }],
|
|
3076
|
+
details: { deprecated: true, writeDisabled: true, phaseRef: resolved.compositeRef, phaseId: resolved.phase.id },
|
|
3077
|
+
};
|
|
3051
3078
|
},
|
|
3052
3079
|
});
|
|
3053
3080
|
pi.registerTool({
|
|
@@ -3138,16 +3165,69 @@ export default function planPiExtension(pi) {
|
|
|
3138
3165
|
return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
|
|
3139
3166
|
},
|
|
3140
3167
|
});
|
|
3168
|
+
pi.registerTool({
|
|
3169
|
+
name: "handoff_prepare",
|
|
3170
|
+
label: "Handoff Prepare",
|
|
3171
|
+
description: "Audit one exact phase before writing a handoff. Returns the current handoff/version and every done task missing durable completion or verification evidence. Call after the user confirms the target, then reconcile the returned context through handoff_write.",
|
|
3172
|
+
parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
|
|
3173
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3174
|
+
const st = await requirePlan(ctx);
|
|
3175
|
+
if (!st)
|
|
3176
|
+
return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
|
|
3177
|
+
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3178
|
+
if (!r.ok)
|
|
3179
|
+
return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
|
|
3180
|
+
try {
|
|
3181
|
+
const audit = await st.preparePhaseHandoff(r.phase.id);
|
|
3182
|
+
const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
|
|
3183
|
+
const existing = audit.handoff.trim() || "(none)";
|
|
3184
|
+
return {
|
|
3185
|
+
content: [{ type: "text", text: [
|
|
3186
|
+
`Handoff preparation audit for ${r.compositeRef}`,
|
|
3187
|
+
`Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
|
|
3188
|
+
"Done tasks missing durable completion/verification evidence:",
|
|
3189
|
+
missing,
|
|
3190
|
+
"",
|
|
3191
|
+
"Existing active handoff (reconcile all still-relevant content):",
|
|
3192
|
+
existing,
|
|
3193
|
+
].join("\n") }],
|
|
3194
|
+
details: { phaseRef: r.compositeRef, ...audit },
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
catch (error) {
|
|
3198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3199
|
+
return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
|
|
3200
|
+
}
|
|
3201
|
+
},
|
|
3202
|
+
});
|
|
3141
3203
|
pi.registerTool({
|
|
3142
3204
|
name: "handoff_write",
|
|
3143
3205
|
label: "Handoff Write",
|
|
3144
|
-
description: "
|
|
3206
|
+
description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run handoff_prepare first and pass its exact handoffUpdatedAt token. Existing context must be reconciled; refreshes do not create superseded archives.",
|
|
3145
3207
|
parameters: Type.Object({
|
|
3146
3208
|
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).
|
|
3209
|
+
title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
|
|
3148
3210
|
confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
|
|
3149
3211
|
content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
|
|
3150
3212
|
markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
|
|
3213
|
+
expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
|
|
3214
|
+
reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
|
|
3215
|
+
taskUpdates: Type.Optional(Type.Array(Type.Object({
|
|
3216
|
+
taskRef: Type.String(),
|
|
3217
|
+
completionSummary: Type.String(),
|
|
3218
|
+
verification: Type.String(),
|
|
3219
|
+
remainingWork: Type.String(),
|
|
3220
|
+
filesTouched: Type.Optional(Type.Array(Type.String())),
|
|
3221
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3222
|
+
}))),
|
|
3223
|
+
phaseUpdate: Type.Optional(Type.Object({
|
|
3224
|
+
progressSummary: Type.String(),
|
|
3225
|
+
remainingWork: Type.String(),
|
|
3226
|
+
decisions: Type.Optional(Type.Array(Type.String())),
|
|
3227
|
+
})),
|
|
3228
|
+
phaseNoUpdateReason: Type.Optional(Type.String({ description: "Required when phaseUpdate is omitted." })),
|
|
3229
|
+
featureUpdate: Type.Optional(Type.Object({ workDone: Type.String(), workRemaining: Type.String() })),
|
|
3230
|
+
featureNoUpdateReason: Type.Optional(Type.String({ description: "Required when featureUpdate is omitted." })),
|
|
3151
3231
|
}),
|
|
3152
3232
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
3153
3233
|
const st = await requirePlan(ctx);
|
|
@@ -3157,38 +3237,68 @@ export default function planPiExtension(pi) {
|
|
|
3157
3237
|
if (!body)
|
|
3158
3238
|
return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
|
|
3159
3239
|
const title = params.title?.trim();
|
|
3160
|
-
|
|
3161
|
-
const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
3240
|
+
const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
3162
3241
|
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
|
-
};
|
|
3242
|
+
const effectiveHeadingText = title || firstHeadingText;
|
|
3243
|
+
if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
|
|
3244
|
+
return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
|
|
3171
3245
|
}
|
|
3172
|
-
// Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
|
|
3173
3246
|
if (title) {
|
|
3174
3247
|
const lines = body.split(/\r?\n/);
|
|
3175
|
-
const firstIdx = lines.findIndex((
|
|
3176
|
-
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3248
|
+
const firstIdx = lines.findIndex((line) => line.trim().length > 0);
|
|
3249
|
+
if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
|
|
3177
3250
|
lines[firstIdx] = `# ${title}`;
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
body = `# ${title}\n\n${body}`;
|
|
3182
|
-
}
|
|
3251
|
+
else
|
|
3252
|
+
lines.unshift(`# ${title}`, "");
|
|
3253
|
+
body = lines.join("\n");
|
|
3183
3254
|
}
|
|
3184
3255
|
const r = await resolvePhaseForHandoff(st, params.phaseRef);
|
|
3185
3256
|
if (!r.ok)
|
|
3186
3257
|
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
|
|
3258
|
+
if (!params.confirmed)
|
|
3259
|
+
return { content: [{ type: "text", text: `Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run handoff_prepare and retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
|
|
3260
|
+
if (params.expectedHandoffUpdatedAt === undefined)
|
|
3261
|
+
return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { error: "preparation required" } };
|
|
3262
|
+
const phases = await st.loadAllPhases();
|
|
3263
|
+
const features = (await st.loadFeatures()).features;
|
|
3264
|
+
const taskUpdates = [];
|
|
3265
|
+
for (const update of params.taskUpdates ?? []) {
|
|
3266
|
+
const found = findTaskByRef(phases, features, update.taskRef.trim());
|
|
3267
|
+
if (!found || found.phase.id !== r.phase.id)
|
|
3268
|
+
return { content: [{ type: "text", text: `❌ Task ${update.taskRef} does not belong to ${r.compositeRef}.` }], details: { error: "invalid task update" } };
|
|
3269
|
+
taskUpdates.push({
|
|
3270
|
+
taskId: found.task.id,
|
|
3271
|
+
completionSummary: update.completionSummary,
|
|
3272
|
+
verification: update.verification,
|
|
3273
|
+
remainingWork: update.remainingWork,
|
|
3274
|
+
...(update.filesTouched ? { filesTouched: update.filesTouched } : {}),
|
|
3275
|
+
...(update.decisions ? { decisions: update.decisions } : {}),
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
try {
|
|
3279
|
+
const result = await st.refreshPhaseHandoff(r.phase.id, {
|
|
3280
|
+
content: body,
|
|
3281
|
+
expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
|
|
3282
|
+
reconciledExistingHandoff: params.reconciledExistingHandoff === true,
|
|
3283
|
+
contextSync: {
|
|
3284
|
+
taskUpdates,
|
|
3285
|
+
...(params.phaseUpdate ? { phaseUpdate: {
|
|
3286
|
+
progressSummary: params.phaseUpdate.progressSummary,
|
|
3287
|
+
remainingWork: params.phaseUpdate.remainingWork,
|
|
3288
|
+
...(params.phaseUpdate.decisions ? { decisions: params.phaseUpdate.decisions } : {}),
|
|
3289
|
+
} } : {}),
|
|
3290
|
+
...(params.phaseNoUpdateReason ? { phaseNoUpdateReason: params.phaseNoUpdateReason } : {}),
|
|
3291
|
+
...(params.featureUpdate ? { featureUpdate: params.featureUpdate } : {}),
|
|
3292
|
+
...(params.featureNoUpdateReason ? { featureNoUpdateReason: params.featureNoUpdateReason } : {}),
|
|
3293
|
+
},
|
|
3294
|
+
});
|
|
3295
|
+
await st.writeGenerated();
|
|
3296
|
+
return { content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result } };
|
|
3297
|
+
}
|
|
3298
|
+
catch (error) {
|
|
3299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3300
|
+
return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
|
|
3189
3301
|
}
|
|
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
3302
|
},
|
|
3193
3303
|
});
|
|
3194
3304
|
pi.registerTool({
|
|
@@ -3277,7 +3387,7 @@ export default function planPiExtension(pi) {
|
|
|
3277
3387
|
return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
|
|
3278
3388
|
const feature = resolvedFeature.feature;
|
|
3279
3389
|
if (params.full)
|
|
3280
|
-
|
|
3390
|
+
markFeatureReadForSessionId(plannerSessionId, feature.id);
|
|
3281
3391
|
const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
|
|
3282
3392
|
const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
|
|
3283
3393
|
const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
|
|
@@ -3326,6 +3436,7 @@ export default function planPiExtension(pi) {
|
|
|
3326
3436
|
phaseIds: [],
|
|
3327
3437
|
dependsOn: [],
|
|
3328
3438
|
statusLog: [],
|
|
3439
|
+
sessionInfo: [],
|
|
3329
3440
|
createdAt: now,
|
|
3330
3441
|
updatedAt: now,
|
|
3331
3442
|
};
|
|
@@ -3577,7 +3688,7 @@ export default function planPiExtension(pi) {
|
|
|
3577
3688
|
if (!phase)
|
|
3578
3689
|
return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
|
|
3579
3690
|
if (params.full)
|
|
3580
|
-
|
|
3691
|
+
markPhaseReadForSessionId(plannerSessionId, phase.id);
|
|
3581
3692
|
const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
|
|
3582
3693
|
const reqCount = linkedRequirements.length;
|
|
3583
3694
|
const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
|
|
@@ -3639,6 +3750,7 @@ export default function planPiExtension(pi) {
|
|
|
3639
3750
|
handoffReadAt: "",
|
|
3640
3751
|
handoffHistory: [],
|
|
3641
3752
|
statusLog: [],
|
|
3753
|
+
sessionInfo: [],
|
|
3642
3754
|
};
|
|
3643
3755
|
await st.savePhase(phase);
|
|
3644
3756
|
// Atomic: serialize concurrent phase_create linking to the same feature.
|
|
@@ -3929,7 +4041,7 @@ export default function planPiExtension(pi) {
|
|
|
3929
4041
|
if (!found)
|
|
3930
4042
|
return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
|
|
3931
4043
|
if (params.full)
|
|
3932
|
-
|
|
4044
|
+
markTaskReadForSessionId(plannerSessionId, found.task.id);
|
|
3933
4045
|
const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
|
|
3934
4046
|
if (!params.full)
|
|
3935
4047
|
return { content: [{ type: "text", text: summary }], details: {} };
|
|
@@ -4024,6 +4136,7 @@ export default function planPiExtension(pi) {
|
|
|
4024
4136
|
descriptionUpdatedAt: now,
|
|
4025
4137
|
notes: "",
|
|
4026
4138
|
statusLog: [],
|
|
4139
|
+
sessionInfo: [],
|
|
4027
4140
|
decisions: [],
|
|
4028
4141
|
acceptedDecisions: [],
|
|
4029
4142
|
checklist: params.checklist
|
|
@@ -4094,6 +4207,12 @@ export default function planPiExtension(pi) {
|
|
|
4094
4207
|
details: {},
|
|
4095
4208
|
};
|
|
4096
4209
|
}
|
|
4210
|
+
if (params.status === "done" && params.status !== found.task.status) {
|
|
4211
|
+
return {
|
|
4212
|
+
content: [{ type: "text", text: "Task completion transitions require task_complete with durable completion and verification evidence." }],
|
|
4213
|
+
details: {},
|
|
4214
|
+
};
|
|
4215
|
+
}
|
|
4097
4216
|
if (params.status !== undefined &&
|
|
4098
4217
|
params.status !== found.task.status &&
|
|
4099
4218
|
needsMotivation(found.task.status, params.status)) {
|
|
@@ -4305,7 +4424,7 @@ export default function planPiExtension(pi) {
|
|
|
4305
4424
|
pi.registerTool({
|
|
4306
4425
|
name: "task_switch",
|
|
4307
4426
|
label: "Task Switch",
|
|
4308
|
-
description: "Switch from active work to a temporary task, even outside priority order. Atomically
|
|
4427
|
+
description: "Switch from active work to a temporary task, even outside priority order. Reuse the target's valid sessionInfo context or require the exact full tree when missing or stale. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
|
|
4309
4428
|
parameters: Type.Object({
|
|
4310
4429
|
from_task: Type.String({ description: "Currently in-progress task ref" }),
|
|
4311
4430
|
to_task: Type.String({ description: "Temporary task ref to start" }),
|
|
@@ -4332,17 +4451,33 @@ export default function planPiExtension(pi) {
|
|
|
4332
4451
|
if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
|
|
4333
4452
|
return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
|
|
4334
4453
|
}
|
|
4335
|
-
const
|
|
4454
|
+
const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
|
|
4455
|
+
const linkedRequirements = [
|
|
4456
|
+
...(await st.linkedRequirementsForPhase(target.phase.id)),
|
|
4457
|
+
...(target.phase.featureId ? await st.linkedRequirementsForFeature(target.phase.featureId) : []),
|
|
4458
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4459
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4460
|
+
const contextInput = {
|
|
4461
|
+
sessionId: plannerSessionId,
|
|
4462
|
+
taskId: target.task.id,
|
|
4463
|
+
phaseId: target.phase.id,
|
|
4464
|
+
...(target.phase.featureId ? { featureId: target.phase.featureId } : {}),
|
|
4465
|
+
task: target.task,
|
|
4466
|
+
phase: target.phase,
|
|
4467
|
+
...(targetFeature ? { feature: targetFeature } : {}),
|
|
4468
|
+
requirements: linkedRequirements,
|
|
4469
|
+
requirementIds: linkedRequirementIds,
|
|
4470
|
+
};
|
|
4471
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4336
4472
|
if (!contextEligibility.eligible) {
|
|
4337
4473
|
return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
|
|
4338
4474
|
}
|
|
4339
|
-
|
|
4340
|
-
...(await st.linkedRequirementsForPhase(target.phase.id)).map((requirement) => requirement.id),
|
|
4341
|
-
...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((requirement) => requirement.id) : []),
|
|
4342
|
-
];
|
|
4343
|
-
if (!hasReadRequirements(linkedRequirementIds)) {
|
|
4475
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
4344
4476
|
return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
|
|
4345
4477
|
}
|
|
4478
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4479
|
+
await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
|
|
4480
|
+
}
|
|
4346
4481
|
const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
|
|
4347
4482
|
if (!eligibility.eligible)
|
|
4348
4483
|
return { content: [{ type: "text", text: `Task switch denied: ${eligibility.reason}` }], details: eligibility };
|
|
@@ -4449,45 +4584,78 @@ export default function planPiExtension(pi) {
|
|
|
4449
4584
|
pi.registerTool({
|
|
4450
4585
|
name: "task_start",
|
|
4451
4586
|
label: "Task Start",
|
|
4452
|
-
description: "Set a task to in-progress or resume checkpointed work.
|
|
4587
|
+
description: "Set a task to in-progress or resume checkpointed work. Read the exact full task → phase → feature tree and linked requirements once per harness session; subsequent starts reuse persisted sessionInfo while entity updatedAt values remain unchanged. A stale or denied start is an isError result with started=false and an exact retry sequence; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
|
|
4453
4588
|
parameters: Type.Object({
|
|
4454
4589
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
|
|
4455
4590
|
}),
|
|
4456
4591
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4457
4592
|
const st = await requirePlan(ctx);
|
|
4458
4593
|
if (!st)
|
|
4459
|
-
return
|
|
4594
|
+
return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
|
|
4460
4595
|
const features = (await st.loadFeatures()).features;
|
|
4461
4596
|
const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
|
|
4462
4597
|
if (!found)
|
|
4463
|
-
return
|
|
4598
|
+
return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
|
|
4464
4599
|
const task = found.task;
|
|
4465
|
-
|
|
4466
|
-
|
|
4600
|
+
const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
|
|
4601
|
+
const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
|
|
4602
|
+
const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
|
|
4603
|
+
const featureRef = feature ? formatFeatureRef(feature.number) : "";
|
|
4604
|
+
if (task.status === "in-progress") {
|
|
4605
|
+
const outcome = taskStartSucceeded(task.id, true);
|
|
4606
|
+
return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
|
|
4607
|
+
}
|
|
4467
4608
|
if (task.status === "done")
|
|
4468
|
-
return
|
|
4469
|
-
const
|
|
4609
|
+
return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry task_start ${taskRef}.`], { taskId: task.id }));
|
|
4610
|
+
const linkedRequirements = [
|
|
4611
|
+
...(await st.linkedRequirementsForPhase(found.phase.id)),
|
|
4612
|
+
...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
|
|
4613
|
+
].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
|
|
4614
|
+
const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
|
|
4615
|
+
const contextInput = {
|
|
4616
|
+
sessionId: plannerSessionId,
|
|
4617
|
+
taskId: task.id,
|
|
4618
|
+
phaseId: found.phase.id,
|
|
4619
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4620
|
+
task,
|
|
4621
|
+
phase: found.phase,
|
|
4622
|
+
...(feature ? { feature } : {}),
|
|
4623
|
+
requirements: linkedRequirements,
|
|
4624
|
+
requirementIds: linkedRequirementIds,
|
|
4625
|
+
};
|
|
4626
|
+
const contextEligibility = contextReadEligibilityForSession(contextInput);
|
|
4470
4627
|
if (!contextEligibility.eligible) {
|
|
4471
|
-
return
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4628
|
+
return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, [
|
|
4629
|
+
`task_get ${taskRef} with full=true`,
|
|
4630
|
+
`phase_get ${phaseRef} with full=true`,
|
|
4631
|
+
...(featureRef ? [`feature_get ${featureRef} with full=true`] : []),
|
|
4632
|
+
...(linkedRequirementIds.length > 0 ? ["requirement_list"] : []),
|
|
4633
|
+
`Retry task_start ${taskRef}`,
|
|
4634
|
+
], { taskId: task.id }), { contextEligibility });
|
|
4635
|
+
}
|
|
4636
|
+
if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
|
|
4637
|
+
return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["requirement_list", `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }));
|
|
4638
|
+
}
|
|
4639
|
+
if (!hasValidSessionAttestation(contextInput)) {
|
|
4640
|
+
await st.recordContextRead({
|
|
4641
|
+
sessionId: plannerSessionId,
|
|
4642
|
+
phaseId: found.phase.id,
|
|
4643
|
+
taskId: task.id,
|
|
4644
|
+
...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
|
|
4645
|
+
requirementIds: linkedRequirementIds,
|
|
4646
|
+
});
|
|
4479
4647
|
}
|
|
4480
4648
|
const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
|
|
4481
4649
|
const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
|
|
4482
4650
|
if (!eligibility.eligible)
|
|
4483
|
-
return
|
|
4651
|
+
return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
|
|
4484
4652
|
const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
|
|
4485
4653
|
if (selection.kind === "conflict") {
|
|
4486
|
-
return
|
|
4654
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
4487
4655
|
}
|
|
4488
4656
|
if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
|
|
4489
4657
|
const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
|
|
4490
|
-
return
|
|
4658
|
+
return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use task_switch to snapshot and pause ${activeRef}.`, `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
|
|
4491
4659
|
}
|
|
4492
4660
|
let resumeProposal;
|
|
4493
4661
|
if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
|
|
@@ -4558,11 +4726,14 @@ export default function planPiExtension(pi) {
|
|
|
4558
4726
|
if (resumedDeviation)
|
|
4559
4727
|
await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
|
|
4560
4728
|
await st.writeGenerated();
|
|
4561
|
-
|
|
4562
|
-
|
|
4729
|
+
const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
|
|
4730
|
+
if (!startedTask || persistedTask?.status !== "in-progress") {
|
|
4731
|
+
return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
|
|
4732
|
+
}
|
|
4733
|
+
const outcome = taskStartSucceeded(persistedTask.id);
|
|
4563
4734
|
return {
|
|
4564
|
-
content: [{ type: "text", text: `✅ Task started: ${
|
|
4565
|
-
details:
|
|
4735
|
+
content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
|
|
4736
|
+
details: { ...persistedTask, ...outcome, task: persistedTask },
|
|
4566
4737
|
...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
|
|
4567
4738
|
};
|
|
4568
4739
|
},
|
|
@@ -4570,11 +4741,11 @@ export default function planPiExtension(pi) {
|
|
|
4570
4741
|
pi.registerTool({
|
|
4571
4742
|
name: "task_complete",
|
|
4572
4743
|
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.",
|
|
4744
|
+
description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
|
|
4574
4745
|
parameters: Type.Object({
|
|
4575
4746
|
taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
|
|
4576
4747
|
force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
|
|
4577
|
-
description_update: Type.
|
|
4748
|
+
description_update: Type.String({ minLength: 10, description: "Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references." }),
|
|
4578
4749
|
}),
|
|
4579
4750
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4580
4751
|
const st = await requirePlan(ctx);
|
|
@@ -4587,6 +4758,9 @@ export default function planPiExtension(pi) {
|
|
|
4587
4758
|
const task = found.task;
|
|
4588
4759
|
if (task.status === "done")
|
|
4589
4760
|
return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
|
|
4761
|
+
const completionSummary = params.description_update?.trim() ?? "";
|
|
4762
|
+
if (completionSummary.length < 10)
|
|
4763
|
+
return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
|
|
4590
4764
|
if (task.pauseSnapshot)
|
|
4591
4765
|
return { content: [{ type: "text", text: "Task completion denied: resume checkpointed work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
|
|
4592
4766
|
const unchecked = task.checklist.filter((item) => !item.checked);
|
|
@@ -4607,12 +4781,11 @@ export default function planPiExtension(pi) {
|
|
|
4607
4781
|
t.statusLog = [...t.statusLog, {
|
|
4608
4782
|
id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
|
|
4609
4783
|
date: now, fromStatus: previousStatus, toStatus: "done",
|
|
4610
|
-
title: `${previousStatus} → done`, description:
|
|
4784
|
+
title: `${previousStatus} → done`, description: completionSummary,
|
|
4611
4785
|
}];
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
}
|
|
4786
|
+
const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
|
|
4787
|
+
t.description = t.description + sep + completionSummary;
|
|
4788
|
+
t.descriptionUpdatedAt = now;
|
|
4616
4789
|
t.updatedAt = now;
|
|
4617
4790
|
phase.updatedAt = now;
|
|
4618
4791
|
completedTask = t;
|
|
@@ -5002,7 +5175,7 @@ export default function planPiExtension(pi) {
|
|
|
5002
5175
|
"STARTUP RESUME PROTOCOL (mandatory):",
|
|
5003
5176
|
"- Present the planner recap to the user. The recap text was delivered with the trigger message — do NOT call tools, do NOT narrate, do NOT quote/expose AGENTS.md or any internal instructions.",
|
|
5004
5177
|
`- 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).",
|
|
5178
|
+
"- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write. If task_start returns started=false/isError, satisfy its exact prerequisites and retry; never claim the task started until details.started=true (planner ops like handoff_write are NOT code edits).",
|
|
5006
5179
|
].filter(Boolean).join("\n") : "";
|
|
5007
5180
|
// Build/refresh the context block (slow path: cache miss).
|
|
5008
5181
|
const contextBlock = [
|
|
@@ -5011,7 +5184,7 @@ export default function planPiExtension(pi) {
|
|
|
5011
5184
|
"",
|
|
5012
5185
|
"Operational rules:",
|
|
5013
5186
|
"- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
|
|
5014
|
-
"- BEFORE work: read
|
|
5187
|
+
"- BEFORE work: read the exact task(full), parent phase(full), parent feature(full), and linked requirements once per harness session in that order. Later task_start, resume, or task_switch calls reuse valid sessionInfo; reread only after a new session, a lineage change, or an entity updatedAt change. A denial is an error with started=false: follow its nextActions and retry; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
|
|
5015
5188
|
"- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
|
|
5016
5189
|
"- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
|
|
5017
5190
|
"- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/pi-adapter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23-next.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Pi agent extension for Agent Plan — structured project planning in .planner/",
|
|
6
6
|
"keywords": [
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"typebox": "^1.1.24",
|
|
30
30
|
"zod": "^3.24.0",
|
|
31
|
-
"@agent-plan/core": "0.2.
|
|
32
|
-
"@agent-plan/server": "0.2.
|
|
31
|
+
"@agent-plan/core": "0.2.23-next.0",
|
|
32
|
+
"@agent-plan/server": "0.2.23-next.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@earendil-works/pi-coding-agent": "^0.74.0",
|