@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.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, markFeatureRead, markPhaseRead, markTaskRead, contextReadEligibility, hasReadRequirements, markRequirementRead, invalidateReads } from "@agent-plan/core";
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: "Tell the agent to write the handoff (legacy file-based)" },
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
- invalidateReads();
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 contextEligibility = contextReadEligibility(task.id, phase.id, phase.featureId);
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
- ctx.ui.notify(`Task start denied: context reads required. ${contextEligibility.reason}`, "warning");
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
- const linkedRequirementIds = [
2210
- ...(await st.linkedRequirementsForPhase(phase.id)).map((r) => r.id),
2211
- ...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
2212
- ];
2213
- if (!hasReadRequirements(linkedRequirementIds)) {
2214
- ctx.ui.notify("Task start denied: read the requirements linked to this phase and feature first.", "warning");
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
- const r = await resolvePhaseForHandoff(st, phaseRef);
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 calling `handoff_write`, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. Only after confirmation call `handoff_write` with that exact phaseRef. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
2436
- "After confirmation, the handoff MUST contain at minimum:\n" +
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. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
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) => markRequirementRead(req.id));
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 — writes to the entity-scoped phase.handoff (not .planner/HANDOFF.md). Prefer handoff_write. phaseRef is required and must be the exact phase confirmed with the user; never infer a target from current status or resume pointers. Completed/canceled phases reject new handoffs.",
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
- const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
3037
- phaseId: resolved.phase.id,
3038
- ...(params.title?.trim() ? { title: params.title.trim() } : {}),
3039
- ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
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: "Write/refresh the entity-scoped handoff on a phase. phaseRef is required and must be the exact phase selected with the user. Pass content (or markdown_content) AND a meaningful `title` summarizing the work (e.g. 'P049 — featureId validation: tests + adapter wiring'). Never guess the phase from in-progress status or stale context. Completed/canceled phases reject new handoffs.",
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). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'." })),
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
- // Derive the current first non-empty line (H1) of the supplied body.
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 genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
3164
- const effectiveHeadingText = (title && title.length > 0) ? title : firstHeadingText;
3165
- const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
3166
- if (isGeneric) {
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((l) => l.trim().length > 0);
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
- body = lines.join("\n");
3179
- }
3180
- else {
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 write this handoff on ${r.compositeRef}. Ask the user to confirm this exact feature/phase, then retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
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
- markFeatureRead(feature.id);
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
- markPhaseRead(phase.id, phase.featureId);
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
- markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
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 snapshots/pauses the source, starts the target, and pushes a durable LIFO return target.",
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 contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
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
- return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
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
- const linkedRequirementIds = [
4340
- ...(await st.linkedRequirementsForPhase(target.phase.id)).map((requirement) => requirement.id),
4341
- ...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((requirement) => requirement.id) : []),
4342
- ];
4343
- if (!hasReadRequirements(linkedRequirementIds)) {
4344
- return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
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. BEFORE calling, read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. A different active task must first be suspended through task_switch.",
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 { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
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 { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
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
- if (task.status === "in-progress")
4466
- return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
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 { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4469
- const contextEligibility = contextReadEligibility(task.id, found.phase.id, found.phase.featureId);
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 { content: [{ type: "text", text: `Task start denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
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
- const linkedRequirementIds = [
4474
- ...(await st.linkedRequirementsForPhase(found.phase.id)).map((requirement) => requirement.id),
4475
- ...(found.phase.featureId ? (await st.linkedRequirementsForFeature(found.phase.featureId)).map((requirement) => requirement.id) : []),
4476
- ];
4477
- if (!hasReadRequirements(linkedRequirementIds)) {
4478
- return { content: [{ type: "text", text: "Task start denied: read the requirements linked to the task's phase and feature first." }], details: { requirementIds: linkedRequirementIds } };
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 { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
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 { content: [{ type: "text", text: `${selection.reason} Pause/reconcile active tasks before starting another task.` }], details: selection };
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 { content: [{ type: "text", text: `Task start denied: ${activeRef} is active. Use task_switch to snapshot and pause it before starting another task.` }], details: selection };
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
- if (!startedTask)
4562
- return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
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: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(startedTask.number).padStart(3, "0")} — ${startedTask.title} (in-progress)${startedTask.shortId ? ` · ${startedTask.shortId}` : ""}${phaseContext}${advisory}` }],
4565
- details: startedTask,
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.Optional(Type.String({ description: "Post-hoc summary: commit hash(s), files touched, decisions made, updated code references with new line numbers. Keeps the planner alive and traceable." })),
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
- if (params.description_update) {
4613
- const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
4614
- t.description = t.description + sep + params.description_update;
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: read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. task_start and task_switch reject work until these reads are complete; only then touch code. AFTER finishing a task: task_complete.",
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.",