@agent-plan/pi-adapter 0.2.23 → 0.2.24

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.
@@ -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;AA40BtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAs6H9D"}
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, 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";
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: "Tell the agent to write the handoff (legacy file-based)" },
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 contextEligibility = contextReadEligibility(task.id, phase.id, phase.featureId);
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(`Task start denied: context reads required. ${contextEligibility.reason}`, "warning");
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
- 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");
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
- 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");
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 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" +
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. Handoffs are stored on phase.handoff; `.planner/HANDOFF.md` is deprecated.");
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) => markRequirementRead(req.id));
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 — 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.",
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
- 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 } };
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: "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.",
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). REQUIRED if your markdown's first heading is generic. Example: 'P049 — featureId validation: tests + adapter wiring'." })),
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
- // 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) ?? "";
3240
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
3162
3241
  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
- };
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((l) => l.trim().length > 0);
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
- body = lines.join("\n");
3179
- }
3180
- else {
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 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 } };
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
- markFeatureRead(feature.id);
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
- markPhaseRead(phase.id, phase.featureId);
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
- markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
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 snapshots/pauses the source, starts the target, and pushes a durable LIFO return target.",
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 contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
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
- 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)) {
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. 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.",
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 { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
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 { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
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
- if (task.status === "in-progress")
4466
- return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
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 { 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);
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 { content: [{ type: "text", text: `Task start denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
4472
- }
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 } };
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 { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
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 { content: [{ type: "text", text: `${selection.reason} Pause/reconcile active tasks before starting another task.` }], details: selection };
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 { content: [{ type: "text", text: `Task start denied: ${activeRef} is active. Use task_switch to snapshot and pause it before starting another task.` }], details: selection };
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
- if (!startedTask)
4562
- return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
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: ${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,
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.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." })),
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
- 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
- }
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 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.",
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.23",
3
+ "version": "0.2.24",
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.23",
32
- "@agent-plan/server": "0.2.23"
31
+ "@agent-plan/core": "0.2.24",
32
+ "@agent-plan/server": "0.2.24"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.74.0",