@agent-plan/pi-adapter 0.2.22 โ†’ 0.2.23-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,12 +9,14 @@
9
9
  * - inject context into prompts
10
10
  */
11
11
  import { Type } from "typebox";
12
- import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, packageVersionFromModule, resolvedPackageVersion } from "@agent-plan/core";
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, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
13
14
  import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
14
15
  import { join, dirname } from "node:path";
15
16
  import { homedir } from "node:os";
16
17
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
18
  import { writeFile, rm } from "node:fs/promises";
19
+ import { randomUUID } from "node:crypto";
18
20
  import { createServer } from "node:net";
19
21
  import { fileURLToPath } from "node:url";
20
22
  import { serve } from "@agent-plan/server/serve";
@@ -28,6 +30,7 @@ let capturedPi = null;
28
30
  let store = null;
29
31
  let server = null;
30
32
  let lastKnownWebPort = null;
33
+ let plannerSessionId = randomUUID();
31
34
  // True while the agent is mutating .planner/ files. The web server returns 503-busy
32
35
  // during this window so the UI doesn't render inconsistent data.
33
36
  let planBusy = false;
@@ -38,6 +41,20 @@ function withPlanBusy(fn) {
38
41
  .then(() => fn())
39
42
  .finally(() => { planBusy = false; });
40
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
+ }
41
58
  // Helper to notify a running server in another process via HTTP.
42
59
  async function notifyExternalServer() {
43
60
  try {
@@ -134,7 +151,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
134
151
  { value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
135
152
  { value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
136
153
  { value: "handoff list", label: "handoff list", description: "List phases with a handoff" },
137
- { 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" },
138
155
  { value: "handoff show", label: "handoff show [P00x]", description: "Show a phase handoff (phase ref required)" },
139
156
  { value: "handoff write", label: "handoff write [P00x]", description: "Write a phase handoff after confirmation (phase ref required)" },
140
157
  { value: "handoff clear", label: "handoff clear [P00x]", description: "Archive a phase handoff (phase ref required)" },
@@ -243,10 +260,11 @@ async function getPlannerExecutionGuard(st) {
243
260
  ? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
244
261
  : undefined;
245
262
  const focusFromCurrentPhase = resume?.currentPhaseId
246
- ? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled")
263
+ ? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
247
264
  : undefined;
248
- const fallbackFocus = allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked")
249
- ?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled");
265
+ const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
266
+ ?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
267
+ ?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
250
268
  const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
251
269
  return {
252
270
  totalTasks,
@@ -317,7 +335,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
317
335
  ?? null;
318
336
  const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
319
337
  ?? currentPhase?.tasks.find((task) => task.status === "in-progress")
320
- ?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
338
+ ?? currentPhase?.tasks.find((task) => task.pauseSnapshot)
339
+ ?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
321
340
  ?? latestTaskUpdate?.task
322
341
  ?? null;
323
342
  const currentFeature = currentPhase?.featureId
@@ -555,6 +574,7 @@ function statusIcon(status) {
555
574
  const icons = {
556
575
  draft: "๐Ÿ“„", discovery: "๐Ÿ”", planned: "๐Ÿ“‹",
557
576
  "in-progress": "๐Ÿšง", done: "โœ…", blocked: "๐Ÿšซ", canceled: "โŒ",
577
+ rejected: "โŒ", deferred: "โณ", waiting: "โฑ๏ธ",
558
578
  };
559
579
  return icons[status] ?? "โ“";
560
580
  }
@@ -626,20 +646,40 @@ function resolveStaticDir() {
626
646
  const adapterDir = dirname(adapterFile);
627
647
  // When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
628
648
  const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
629
- if (existsSync(pkgWebUi))
630
- return pkgWebUi;
631
- // Fallback: monorepo dev โ€” packages/plan-web-ui/dist/
632
- return join(adapterDir, "..", "..", "plan-web-ui", "dist");
649
+ // Monorepo dev: packages/plan-web-ui/dist/ (rebuilt by `pnpm build:web-ui`)
650
+ const devWebUi = join(adapterDir, "..", "..", "plan-web-ui", "dist");
651
+ const candidates = [pkgWebUi, devWebUi].filter((d) => {
652
+ try {
653
+ return existsSync(join(d, "index.html"));
654
+ }
655
+ catch {
656
+ return false;
657
+ }
658
+ });
659
+ if (candidates.length === 0)
660
+ return undefined;
661
+ if (candidates.length === 1)
662
+ return candidates[0];
663
+ // Prefer the most recently built bundle so a fresh `pnpm build:web-ui`
664
+ // (or copy-web-ui.sh) is picked up in dev instead of a stale vendored
665
+ // snapshot. Deterministic: compare index.html mtimes, newest wins.
666
+ const mtime = (d) => {
667
+ try {
668
+ return statSync(join(d, "index.html")).mtimeMs;
669
+ }
670
+ catch {
671
+ return 0;
672
+ }
673
+ };
674
+ return [...candidates].sort((a, b) => mtime(b) - mtime(a))[0];
633
675
  }
634
676
  catch {
635
677
  return undefined;
636
678
  }
637
679
  }
638
680
  async function maybeStartWeb(ctx) {
639
- if (server) {
640
- plannerSessionEnabled = true;
681
+ if (server)
641
682
  return;
642
- }
643
683
  ctx.ui.notify("Starting web server (LAN)โ€ฆ", "info");
644
684
  try {
645
685
  await startServer(ctx, undefined, "lan");
@@ -661,7 +701,6 @@ function normalizeVisibility(input) {
661
701
  return undefined;
662
702
  }
663
703
  async function startServer(ctx, requestedPort, visibility = "lan") {
664
- plannerSessionEnabled = true;
665
704
  if (server)
666
705
  return;
667
706
  const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
@@ -733,6 +772,10 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
733
772
  catch { }
734
773
  }
735
774
  }
775
+ function enablePlannerSession() {
776
+ plannerSessionEnabled = true;
777
+ contextBlockDirty = true;
778
+ }
736
779
  function disablePlannerSession() {
737
780
  plannerSessionEnabled = false;
738
781
  startupResumePromptPending = false;
@@ -759,6 +802,9 @@ export default function planPiExtension(pi) {
759
802
  capturedPi = pi;
760
803
  // โ”€โ”€ Restore on session start/reload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
761
804
  pi.on("session_start", async (_event, ctx) => {
805
+ plannerSessionId = randomUUID();
806
+ invalidateReads();
807
+ startReadSession(plannerSessionId);
762
808
  try {
763
809
  ctx.ui.addAutocompleteProvider((current) => ({
764
810
  triggerCharacters: ["/", " "],
@@ -1134,6 +1180,7 @@ export default function planPiExtension(pi) {
1134
1180
  await st.writeGenerated();
1135
1181
  await scanCodebase();
1136
1182
  ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโ€ฆ`, "info");
1183
+ enablePlannerSession();
1137
1184
  await maybeStartWeb(ctx);
1138
1185
  await handlePlanner("project discuss", ctx);
1139
1186
  return;
@@ -1151,6 +1198,7 @@ export default function planPiExtension(pi) {
1151
1198
  await st.writeGenerated();
1152
1199
  await scanCodebase();
1153
1200
  ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโ€ฆ`, "info");
1201
+ enablePlannerSession();
1154
1202
  await maybeStartWeb(ctx);
1155
1203
  await handlePlanner("project discuss", ctx);
1156
1204
  return;
@@ -1182,12 +1230,12 @@ export default function planPiExtension(pi) {
1182
1230
  .sort((a, b) => a.number - b.number);
1183
1231
  if (features.length === 0)
1184
1232
  return null;
1185
- const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`);
1186
- const chosen = await ctx.ui.select("Pick a feature", options);
1187
- if (chosen == null)
1188
- return null;
1189
- const idx = options.indexOf(chosen);
1190
- return idx >= 0 ? features[idx] : null;
1233
+ return paginatedSelect(ctx, {
1234
+ title: "Pick a feature",
1235
+ items: features,
1236
+ render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`,
1237
+ pageSize: 10,
1238
+ });
1191
1239
  }
1192
1240
  // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1193
1241
  // Helper: pick a phase interactively
@@ -1198,12 +1246,12 @@ export default function planPiExtension(pi) {
1198
1246
  return null;
1199
1247
  }
1200
1248
  const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1201
- const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`);
1202
- const chosen = await ctx.ui.select("Pick a phase", options);
1203
- if (chosen == null)
1204
- return null;
1205
- const idx = options.indexOf(chosen);
1206
- return idx >= 0 ? phases[idx] : null;
1249
+ return paginatedSelect(ctx, {
1250
+ title: "Pick a phase",
1251
+ items: phases,
1252
+ render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`,
1253
+ pageSize: 10,
1254
+ });
1207
1255
  }
1208
1256
  // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1209
1257
  // Helper: pick a task interactively from a phase
@@ -1212,12 +1260,12 @@ export default function planPiExtension(pi) {
1212
1260
  if (phase.tasks.length === 0)
1213
1261
  return null;
1214
1262
  const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1215
- const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`);
1216
- const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1217
- if (chosen == null)
1218
- return null;
1219
- const idx = options.indexOf(chosen);
1220
- return idx >= 0 ? tasks[idx] : null;
1263
+ return paginatedSelect(ctx, {
1264
+ title: `Pick a task from "${phase.title}"`,
1265
+ items: tasks,
1266
+ render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`,
1267
+ pageSize: 10,
1268
+ });
1221
1269
  }
1222
1270
  function parseMultilineList(value) {
1223
1271
  return (value ?? "")
@@ -1435,9 +1483,9 @@ export default function planPiExtension(pi) {
1435
1483
  if (b === "language") {
1436
1484
  const project = await st.loadProject();
1437
1485
  const contentLanguage = await ctx.ui.input(`Plan content language [${project.contentLanguage || "English"}]`);
1438
- const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "Italian"}]`);
1486
+ const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
1439
1487
  project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
1440
- project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "Italian";
1488
+ project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
1441
1489
  await st.saveProject(project);
1442
1490
  await st.writeGenerated();
1443
1491
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
@@ -1461,11 +1509,12 @@ export default function planPiExtension(pi) {
1461
1509
  return;
1462
1510
  }
1463
1511
  const phases = await st.loadAllPhases();
1464
- ctx.ui.notify(features.map((feature) => {
1512
+ const lines = features.map((feature) => {
1465
1513
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
1466
1514
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
1467
1515
  return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) โ€” ${featurePhases.length} phases, ${taskCount} tasks`;
1468
- }).join("\n"), "info");
1516
+ });
1517
+ await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
1469
1518
  return;
1470
1519
  }
1471
1520
  if (b === "add") {
@@ -1506,6 +1555,7 @@ export default function planPiExtension(pi) {
1506
1555
  phaseIds: [],
1507
1556
  dependsOn: [],
1508
1557
  statusLog: [],
1558
+ sessionInfo: [],
1509
1559
  createdAt: now,
1510
1560
  updatedAt: now,
1511
1561
  };
@@ -1727,6 +1777,7 @@ export default function planPiExtension(pi) {
1727
1777
  handoffReadAt: "",
1728
1778
  handoffHistory: [],
1729
1779
  statusLog: [],
1780
+ sessionInfo: [],
1730
1781
  };
1731
1782
  await st.savePhase(phase);
1732
1783
  await st.updateFeatures((doc) => {
@@ -1967,6 +2018,7 @@ export default function planPiExtension(pi) {
1967
2018
  descriptionUpdatedAt: "",
1968
2019
  notes: "",
1969
2020
  statusLog: [],
2021
+ sessionInfo: [],
1970
2022
  decisions: [],
1971
2023
  acceptedDecisions: [],
1972
2024
  checklist: [], subtasks: [],
@@ -2129,8 +2181,13 @@ export default function planPiExtension(pi) {
2129
2181
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2130
2182
  return;
2131
2183
  }
2132
- if (normalizedStatus === "in-progress" && (await st.listHandoffs()).length > 0) {
2133
- ctx.ui.notify("โ„น๏ธ One or more phases have a pending handoff โ€” if relevant to this task, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with the status change.", "info");
2184
+ if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
2185
+ ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
2186
+ return;
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;
2134
2191
  }
2135
2192
  applyTaskLifecycleDates(task, normalizedStatus, now);
2136
2193
  }
@@ -2168,6 +2225,38 @@ export default function planPiExtension(pi) {
2168
2225
  return;
2169
2226
  }
2170
2227
  }
2228
+ if (phase) {
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);
2248
+ if (!contextEligibility.eligible) {
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");
2250
+ return;
2251
+ }
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");
2254
+ return;
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
+ }
2259
+ }
2171
2260
  if (task.status === "in-progress") {
2172
2261
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2173
2262
  return;
@@ -2232,6 +2321,11 @@ export default function planPiExtension(pi) {
2232
2321
  return;
2233
2322
  }
2234
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
+ }
2235
2329
  const now = nowISO();
2236
2330
  // Record status change in the incremental statusLog.
2237
2331
  const entry = {
@@ -2240,12 +2334,16 @@ export default function planPiExtension(pi) {
2240
2334
  fromStatus: task.status,
2241
2335
  toStatus: "done",
2242
2336
  title: `โ†’ done`,
2243
- description: "",
2337
+ description: completionSummary.trim(),
2244
2338
  };
2245
2339
  task.statusLog = [...(task.statusLog ?? []), entry];
2246
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;
2247
2344
  task.updatedAt = now;
2248
2345
  phase.updatedAt = now;
2346
+ await st.savePhase(phase);
2249
2347
  const clearedRef = await st.syncTaskStatusRollup(phase.id);
2250
2348
  await st.writeGenerated();
2251
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");
@@ -2341,7 +2439,11 @@ export default function planPiExtension(pi) {
2341
2439
  ctx.ui.notify("No phase handoffs set.", "info");
2342
2440
  return;
2343
2441
  }
2344
- ctx.ui.notify(list.map((e) => `โ€ข ${e.compositeRef} โ€” ${e.firstLine} (updated ${e.updatedAt})`).join("\n"), "info");
2442
+ await paginatedNotify(ctx, {
2443
+ title: "phase handoffs",
2444
+ lines: list.map((e) => `โ€ข ${e.compositeRef} โ€” ${e.firstLine} (updated ${e.updatedAt})`),
2445
+ pageSize: 10,
2446
+ });
2345
2447
  return;
2346
2448
  }
2347
2449
  if (action === "show") {
@@ -2355,23 +2457,7 @@ export default function planPiExtension(pi) {
2355
2457
  return;
2356
2458
  }
2357
2459
  if (action === "write") {
2358
- const r = await resolvePhaseForHandoff(st, phaseRef);
2359
- if (!r.ok) {
2360
- ctx.ui.notify(r.error, "warning");
2361
- return;
2362
- }
2363
- const text = await ctx.ui.input(`Handoff text for ${r.compositeRef}`);
2364
- if (!text?.trim()) {
2365
- ctx.ui.notify("No text provided; nothing written.", "info");
2366
- return;
2367
- }
2368
- const confirmed = await ctx.ui.confirm(`Write this handoff on ${r.compositeRef}?`, "Write handoff");
2369
- if (!confirmed) {
2370
- ctx.ui.notify("Handoff write cancelled.", "info");
2371
- return;
2372
- }
2373
- await st.setPhaseHandoff(r.phase.id, text);
2374
- 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");
2375
2461
  return;
2376
2462
  }
2377
2463
  if (action === "prepare") {
@@ -2381,15 +2467,16 @@ export default function planPiExtension(pi) {
2381
2467
  }
2382
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" +
2383
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" +
2384
- "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" +
2385
- "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" +
2386
2473
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2387
2474
  "- `Reason:` why this handoff is being written\n" +
2388
2475
  "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2389
2476
  "- `## What was being done`: the concrete decisions/work from this session\n" +
2390
2477
  "- `## How to resume`: explicit ordered steps\n" +
2391
2478
  "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2392
- "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.");
2393
2480
  ctx.ui.notify("Instructing the agent to prepare the phase handoffโ€ฆ", "info");
2394
2481
  return;
2395
2482
  }
@@ -2514,7 +2601,7 @@ export default function planPiExtension(pi) {
2514
2601
  return;
2515
2602
  }
2516
2603
  if (a === "load") {
2517
- plannerSessionEnabled = true;
2604
+ enablePlannerSession();
2518
2605
  if (!server) {
2519
2606
  ctx.ui.notify("Starting web server (LAN) โ€ฆ", "info");
2520
2607
  await startServer(ctx, undefined, "lan").catch(() => { });
@@ -2682,6 +2769,7 @@ export default function planPiExtension(pi) {
2682
2769
  status: "planned",
2683
2770
  macroTasks: [],
2684
2771
  linkedPhaseIds: [],
2772
+ sessionInfo: [],
2685
2773
  createdAt: now,
2686
2774
  updatedAt: now,
2687
2775
  })),
@@ -2747,6 +2835,7 @@ export default function planPiExtension(pi) {
2747
2835
  if (!st)
2748
2836
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2749
2837
  const requirements = await st.loadRequirements();
2838
+ requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
2750
2839
  return {
2751
2840
  content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} โ€” ${req.title} (${req.status})`).join("\n") || "No requirements" }],
2752
2841
  details: requirements,
@@ -2767,15 +2856,22 @@ export default function planPiExtension(pi) {
2767
2856
  const st = await requirePlan(ctx);
2768
2857
  if (!st)
2769
2858
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2859
+ const title = params.title?.trim();
2860
+ if (!title)
2861
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2862
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2863
+ if (!links.ok)
2864
+ return { content: [{ type: "text", text: links.error }], details: {} };
2770
2865
  const requirements = await st.loadRequirements();
2771
2866
  const now = nowISO();
2772
2867
  const requirement = {
2773
2868
  id: createRequirementId(),
2774
- title: params.title.trim(),
2869
+ title,
2775
2870
  description: params.description?.trim() ?? "",
2776
2871
  status: params.status?.trim() ?? "planned",
2777
2872
  macroTasks: [],
2778
- linkedPhaseIds: (params.linkedPhaseIds ?? []).map((entry) => entry.trim()).filter(Boolean),
2873
+ linkedPhaseIds: links.linkedPhaseIds,
2874
+ sessionInfo: [],
2779
2875
  createdAt: now,
2780
2876
  updatedAt: now,
2781
2877
  };
@@ -2804,14 +2900,22 @@ export default function planPiExtension(pi) {
2804
2900
  const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
2805
2901
  if (!requirement)
2806
2902
  return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
2807
- if (params.title !== undefined)
2808
- requirement.title = params.title.trim();
2903
+ if (params.title !== undefined) {
2904
+ const title = params.title.trim();
2905
+ if (!title)
2906
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2907
+ requirement.title = title;
2908
+ }
2809
2909
  if (params.description !== undefined)
2810
2910
  requirement.description = params.description.trim();
2811
2911
  if (params.status !== undefined)
2812
2912
  requirement.status = params.status.trim();
2813
- if (params.linkedPhaseIds !== undefined)
2814
- requirement.linkedPhaseIds = params.linkedPhaseIds.map((entry) => entry.trim()).filter(Boolean);
2913
+ if (params.linkedPhaseIds !== undefined) {
2914
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2915
+ if (!links.ok)
2916
+ return { content: [{ type: "text", text: links.error }], details: {} };
2917
+ requirement.linkedPhaseIds = links.linkedPhaseIds;
2918
+ }
2815
2919
  requirement.updatedAt = nowISO();
2816
2920
  await st.saveRequirements(requirements);
2817
2921
  await st.writeGenerated();
@@ -2945,7 +3049,7 @@ export default function planPiExtension(pi) {
2945
3049
  pi.registerTool({
2946
3050
  name: "plan_write_handoff",
2947
3051
  label: "Plan Write Handoff",
2948
- 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.",
2949
3053
  parameters: Type.Object({
2950
3054
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
2951
3055
  confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
@@ -2967,21 +3071,10 @@ export default function planPiExtension(pi) {
2967
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 } };
2968
3072
  if (!params.confirmed)
2969
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 } };
2970
- const markdown = await buildHandoffMarkdown(st, params.reason?.trim() || "manual tool handoff", {
2971
- phaseId: resolved.phase.id,
2972
- ...(params.title?.trim() ? { title: params.title.trim() } : {}),
2973
- ...(params.whatWasBeingDone?.trim() ? { whatWasBeingDone: params.whatWasBeingDone.trim() } : {}),
2974
- ...(params.howToResume?.trim() ? { howToResume: params.howToResume.trim() } : {}),
2975
- ...(params.extraSections && params.extraSections.length > 0
2976
- ? {
2977
- extraSections: params.extraSections
2978
- .map((section) => ({ heading: section.heading?.trim() ?? "", body: section.body ?? "" }))
2979
- .filter((section) => section.heading),
2980
- }
2981
- : {}),
2982
- });
2983
- await st.setPhaseHandoff(resolved.phase.id, markdown);
2984
- 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
+ };
2985
3078
  },
2986
3079
  });
2987
3080
  pi.registerTool({
@@ -3021,6 +3114,23 @@ export default function planPiExtension(pi) {
3021
3114
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
3022
3115
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
3023
3116
  }
3117
+ async function resolveRequirementPhaseRefs(st, refs) {
3118
+ if (!Array.isArray(refs) || refs.length === 0) {
3119
+ return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
3120
+ }
3121
+ const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
3122
+ if (trimmed.length === 0) {
3123
+ return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
3124
+ }
3125
+ const phases = await st.loadAllPhases();
3126
+ const features = (await st.loadFeatures()).features;
3127
+ const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
3128
+ const missingIndex = resolved.findIndex((phase) => !phase);
3129
+ if (missingIndex !== -1) {
3130
+ return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
3131
+ }
3132
+ return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
3133
+ }
3024
3134
  pi.registerTool({
3025
3135
  name: "handoff_list",
3026
3136
  label: "Handoff List",
@@ -3055,16 +3165,69 @@ export default function planPiExtension(pi) {
3055
3165
  return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3056
3166
  },
3057
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
+ });
3058
3203
  pi.registerTool({
3059
3204
  name: "handoff_write",
3060
3205
  label: "Handoff Write",
3061
- 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.",
3062
3207
  parameters: Type.Object({
3063
3208
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3064
- 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)." })),
3065
3210
  confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
3066
3211
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
3067
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." })),
3068
3231
  }),
3069
3232
  async execute(_id, params, _signal, _onUpdate, ctx) {
3070
3233
  const st = await requirePlan(ctx);
@@ -3074,36 +3237,68 @@ export default function planPiExtension(pi) {
3074
3237
  if (!body)
3075
3238
  return { content: [{ type: "text", text: "โŒ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
3076
3239
  const title = params.title?.trim();
3077
- // Derive the current first non-empty line (H1) of the supplied body.
3078
- 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) ?? "";
3079
3241
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3080
- const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3081
- if (!title && isGeneric) {
3082
- return {
3083
- 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." }],
3084
- details: { error: "generic title", firstHeadingText },
3085
- };
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" } };
3086
3245
  }
3087
- // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
3088
3246
  if (title) {
3089
3247
  const lines = body.split(/\r?\n/);
3090
- const firstIdx = lines.findIndex((l) => l.trim().length > 0);
3091
- 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] ?? ""))
3092
3250
  lines[firstIdx] = `# ${title}`;
3093
- body = lines.join("\n");
3094
- }
3095
- else {
3096
- body = `# ${title}\n\n${body}`;
3097
- }
3251
+ else
3252
+ lines.unshift(`# ${title}`, "");
3253
+ body = lines.join("\n");
3098
3254
  }
3099
3255
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
3100
3256
  if (!r.ok)
3101
3257
  return { content: [{ type: "text", text: `โŒ ${r.error}` }], details: { error: r.error } };
3102
- if (!params.confirmed) {
3103
- 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 } };
3104
3301
  }
3105
- await st.setPhaseHandoff(r.phase.id, body);
3106
- return { content: [{ type: "text", text: `โœ… Wrote handoff on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3107
3302
  },
3108
3303
  });
3109
3304
  pi.registerTool({
@@ -3191,6 +3386,8 @@ export default function planPiExtension(pi) {
3191
3386
  if (!resolvedFeature.ok)
3192
3387
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3193
3388
  const feature = resolvedFeature.feature;
3389
+ if (params.full)
3390
+ markFeatureReadForSessionId(plannerSessionId, feature.id);
3194
3391
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3195
3392
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3196
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"}` : ""})`;
@@ -3239,6 +3436,7 @@ export default function planPiExtension(pi) {
3239
3436
  phaseIds: [],
3240
3437
  dependsOn: [],
3241
3438
  statusLog: [],
3439
+ sessionInfo: [],
3242
3440
  createdAt: now,
3243
3441
  updatedAt: now,
3244
3442
  };
@@ -3489,6 +3687,8 @@ export default function planPiExtension(pi) {
3489
3687
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3490
3688
  if (!phase)
3491
3689
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3690
+ if (params.full)
3691
+ markPhaseReadForSessionId(plannerSessionId, phase.id);
3492
3692
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3493
3693
  const reqCount = linkedRequirements.length;
3494
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"}` : ""})`;
@@ -3513,6 +3713,9 @@ export default function planPiExtension(pi) {
3513
3713
  const st = await requirePlan(ctx);
3514
3714
  if (!st)
3515
3715
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3716
+ const title = params.title?.trim();
3717
+ if (!title)
3718
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3516
3719
  if (!params.featureId?.trim())
3517
3720
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3518
3721
  const createPhaseFeatures = (await st.loadFeatures()).features;
@@ -3533,7 +3736,7 @@ export default function planPiExtension(pi) {
3533
3736
  const priority = await st.nextPriority("phase", featureId);
3534
3737
  const now = nowISO();
3535
3738
  phase = {
3536
- id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3739
+ id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
3537
3740
  featureId,
3538
3741
  status: params.status ?? "draft",
3539
3742
  discussedAt: "",
@@ -3547,6 +3750,7 @@ export default function planPiExtension(pi) {
3547
3750
  handoffReadAt: "",
3548
3751
  handoffHistory: [],
3549
3752
  statusLog: [],
3753
+ sessionInfo: [],
3550
3754
  };
3551
3755
  await st.savePhase(phase);
3552
3756
  // Atomic: serialize concurrent phase_create linking to the same feature.
@@ -3823,10 +4027,10 @@ export default function planPiExtension(pi) {
3823
4027
  pi.registerTool({
3824
4028
  name: "task_get",
3825
4029
  label: "Task Get",
3826
- description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
4030
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
3827
4031
  parameters: Type.Object({
3828
4032
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3829
- full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
4033
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
3830
4034
  }),
3831
4035
  async execute(_id, params, _signal, _onUpdate, ctx) {
3832
4036
  const st = await requirePlan(ctx);
@@ -3836,11 +4040,37 @@ export default function planPiExtension(pi) {
3836
4040
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3837
4041
  if (!found)
3838
4042
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4043
+ if (params.full)
4044
+ markTaskReadForSessionId(plannerSessionId, found.task.id);
3839
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})`;
3840
4046
  if (!params.full)
3841
4047
  return { content: [{ type: "text", text: summary }], details: {} };
4048
+ const project = await st.loadProject();
4049
+ const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
4050
+ ? undefined
4051
+ : project.workDeviations
4052
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
4053
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
4054
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4055
+ const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
3842
4056
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3843
- return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
4057
+ const sections = [summary];
4058
+ if (found.task.description?.trim())
4059
+ sections.push(found.task.description.trim());
4060
+ if (snapshot || pendingDeviation) {
4061
+ sections.push([
4062
+ pendingDeviation
4063
+ ? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
4064
+ : "Resume checkpoint:",
4065
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
4066
+ snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
4067
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
4068
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
4069
+ ].filter(Boolean).join("\n"));
4070
+ }
4071
+ if (log)
4072
+ sections.push(`Status log:\n${log}`);
4073
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
3844
4074
  },
3845
4075
  });
3846
4076
  pi.registerTool({
@@ -3860,6 +4090,9 @@ export default function planPiExtension(pi) {
3860
4090
  const st = await requirePlan(ctx);
3861
4091
  if (!st)
3862
4092
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4093
+ const title = params.title?.trim();
4094
+ if (!title)
4095
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3863
4096
  if (!params.featureId?.trim())
3864
4097
  return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3865
4098
  const features = (await st.loadFeatures()).features;
@@ -3886,23 +4119,24 @@ export default function planPiExtension(pi) {
3886
4119
  const existingPhase = await st.loadPhase(phaseId);
3887
4120
  if (!existingPhase)
3888
4121
  return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3889
- const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
4122
+ const initialStatus = params.status ?? "planned";
4123
+ if (initialStatus !== "planned") {
4124
+ return { content: [{ type: "text", text: "Tasks must be created planned. Use task_start for in-progress work and task_update for other justified status changes." }], details: {} };
4125
+ }
4126
+ const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3890
4127
  const taskId = createTaskId();
3891
4128
  const identity = await st.allocateEntityIdentity("task", taskId);
3892
4129
  const priority = await st.nextPriority("task", phaseId);
3893
4130
  const now = nowISO();
3894
- const initialStatus = params.status ?? "planned";
3895
- if (initialStatus === "paused") {
3896
- return { content: [{ type: "text", text: "A task cannot be created paused without an in-progress checkpoint. Create it planned, then use task_pause/task_switch." }], details: {} };
3897
- }
3898
4131
  const task = {
3899
4132
  id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
3900
- title: params.title,
4133
+ title,
3901
4134
  status: initialStatus,
3902
4135
  description: params.description ?? "",
3903
4136
  descriptionUpdatedAt: now,
3904
4137
  notes: "",
3905
4138
  statusLog: [],
4139
+ sessionInfo: [],
3906
4140
  decisions: [],
3907
4141
  acceptedDecisions: [],
3908
4142
  checklist: params.checklist
@@ -3912,8 +4146,8 @@ export default function planPiExtension(pi) {
3912
4146
  dependsOn: [],
3913
4147
  pauseSnapshot: null,
3914
4148
  pauseHistory: [],
3915
- startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3916
- completedAt: initialStatus === "done" ? now : "",
4149
+ startedAt: "",
4150
+ completedAt: "",
3917
4151
  createdAt: now, updatedAt: now,
3918
4152
  };
3919
4153
  // Atomic read-modify-write on the phase file: serializes concurrent
@@ -3967,6 +4201,18 @@ export default function planPiExtension(pi) {
3967
4201
  // Validate the motivation requirement for restrictive status transitions,
3968
4202
  // mirroring the MCP adapter and the /planner task update command
3969
4203
  // (AGENTS.md rule 8 โ€” rejected writes must leave the files unchanged).
4204
+ if (params.status === "in-progress" && params.status !== found.task.status) {
4205
+ return {
4206
+ content: [{ type: "text", text: "Task start/resume transitions require task_start so lifecycle state, checkpoints, and return stacks remain consistent." }],
4207
+ details: {},
4208
+ };
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
+ }
3970
4216
  if (params.status !== undefined &&
3971
4217
  params.status !== found.task.status &&
3972
4218
  needsMotivation(found.task.status, params.status)) {
@@ -4061,14 +4307,14 @@ export default function planPiExtension(pi) {
4061
4307
  pi.registerTool({
4062
4308
  name: "task_recommend",
4063
4309
  label: "Task Recommend",
4064
- description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature โ†’ phase โ†’ task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4310
+ description: "Return the recommended next task without starting work: continue one active task or the current phase, otherwise choose ready work by feature โ†’ phase โ†’ task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4065
4311
  parameters: Type.Object({}),
4066
4312
  async execute(_id, _params, _signal, _onUpdate, ctx) {
4067
4313
  const st = await requirePlan(ctx);
4068
4314
  if (!st)
4069
4315
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4070
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4071
- const result = recommendNextTask(features.features, phases, project.workDeviations);
4316
+ const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4317
+ const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
4072
4318
  if (!result.candidate)
4073
4319
  return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4074
4320
  const { candidate } = result;
@@ -4092,11 +4338,11 @@ export default function planPiExtension(pi) {
4092
4338
  const st = await requirePlan(ctx);
4093
4339
  if (!st)
4094
4340
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4095
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4341
+ const [features, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4096
4342
  const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4097
4343
  if (!temporary)
4098
4344
  return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4099
- const selected = recommendNextTask(features.features, phases, project.workDeviations);
4345
+ const selected = recommendNextTask(features.features, phases, project.workDeviations, focus?.currentPhaseId);
4100
4346
  const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4101
4347
  if (!resume)
4102
4348
  return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
@@ -4145,7 +4391,8 @@ export default function planPiExtension(pi) {
4145
4391
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
4146
4392
  pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
4147
4393
  };
4148
- const paused = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4394
+ invalidateReads();
4395
+ const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4149
4396
  await st.syncTaskStatusRollup(found.phase.id);
4150
4397
  const activeDeviation = (await st.loadProject()).workDeviations
4151
4398
  .filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
@@ -4157,27 +4404,27 @@ export default function planPiExtension(pi) {
4157
4404
  ? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
4158
4405
  : undefined;
4159
4406
  await st.writeGenerated();
4160
- const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(paused.number).padStart(3, "0")}`;
4407
+ const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
4161
4408
  const resumeRef = resume
4162
4409
  ? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
4163
4410
  : "";
4164
4411
  return {
4165
4412
  content: [{ type: "text", text: [
4166
- `โธ๏ธ Task paused: ${reference} โ€” ${paused.title}`,
4413
+ `๐Ÿ’พ Resume checkpoint saved: ${reference} โ€” ${checkpointed.title}`,
4167
4414
  `Why: ${snapshot.reason}`,
4168
4415
  `Checkpoint: ${snapshot.whatWasBeingDone}`,
4169
4416
  `Resume from: ${snapshot.resumeLocation}`,
4170
4417
  `How to resume: ${snapshot.howToResume}`,
4171
4418
  resume ? `โ†ฉ๏ธ RESUME REQUIRED: ${resumeRef} โ€” ${resume.task.title}` : "",
4172
4419
  ].filter(Boolean).join("\n") }],
4173
- details: { task: paused, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4420
+ details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4174
4421
  };
4175
4422
  },
4176
4423
  });
4177
4424
  pi.registerTool({
4178
4425
  name: "task_switch",
4179
4426
  label: "Task Switch",
4180
- 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.",
4181
4428
  parameters: Type.Object({
4182
4429
  from_task: Type.String({ description: "Currently in-progress task ref" }),
4183
4430
  to_task: Type.String({ description: "Temporary task ref to start" }),
@@ -4191,7 +4438,7 @@ export default function planPiExtension(pi) {
4191
4438
  const st = await requirePlan(ctx);
4192
4439
  if (!st)
4193
4440
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4194
- const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4441
+ const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4195
4442
  const features = featuresDoc.features;
4196
4443
  const source = findTaskByRef(phases, features, params.from_task.trim());
4197
4444
  const target = findTaskByRef(phases, features, params.to_task.trim());
@@ -4201,8 +4448,35 @@ export default function planPiExtension(pi) {
4201
4448
  return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
4202
4449
  if (source.task.id === target.task.id)
4203
4450
  return { content: [{ type: "text", text: "Source and temporary task must differ." }], details: {} };
4204
- if (source.task.status !== "in-progress" && source.task.status !== "paused") {
4205
- return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or pending-resume paused work can be switched.` }], details: {} };
4451
+ if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
4452
+ return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
4453
+ }
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);
4472
+ if (!contextEligibility.eligible) {
4473
+ return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
4474
+ }
4475
+ if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
4476
+ return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
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 });
4206
4480
  }
4207
4481
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
4208
4482
  if (!eligibility.eligible)
@@ -4213,7 +4487,7 @@ export default function planPiExtension(pi) {
4213
4487
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
4214
4488
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
4215
4489
  };
4216
- const selection = recommendNextTask(features, phases, project.workDeviations);
4490
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
4217
4491
  const record = {
4218
4492
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
4219
4493
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
@@ -4232,7 +4506,7 @@ export default function planPiExtension(pi) {
4232
4506
  await st.updatePhase(source.phase.id, (phase) => {
4233
4507
  const task = phase.tasks.find((entry) => entry.id === source.task.id);
4234
4508
  if (!task)
4235
- throw new Error(`Paused source task disappeared: ${params.from_task}`);
4509
+ throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
4236
4510
  task.pauseSnapshot = snapshot;
4237
4511
  task.pauseHistory = [...task.pauseHistory, snapshot];
4238
4512
  task.updatedAt = timestamp;
@@ -4240,7 +4514,7 @@ export default function planPiExtension(pi) {
4240
4514
  });
4241
4515
  }
4242
4516
  sourceCheckpointed = true;
4243
- if (target.task.status === "paused") {
4517
+ if (target.task.pauseSnapshot) {
4244
4518
  await st.resumeTask(target.phase.id, target.task.id, timestamp);
4245
4519
  }
4246
4520
  else {
@@ -4262,7 +4536,7 @@ export default function planPiExtension(pi) {
4262
4536
  }
4263
4537
  targetStarted = true;
4264
4538
  await st.addWorkDeviation(record);
4265
- if (target.task.status === "paused") {
4539
+ if (target.task.pauseSnapshot) {
4266
4540
  const resumedDeviation = project.workDeviations
4267
4541
  .filter((deviation) => deviation.resumeTaskId === target.task.id
4268
4542
  && (deviation.state === "resume-required" || deviation.state === "resolved"))
@@ -4293,12 +4567,13 @@ export default function planPiExtension(pi) {
4293
4567
  if (target.phase.id !== source.phase.id)
4294
4568
  await st.syncTaskStatusRollup(target.phase.id);
4295
4569
  await st.writeGenerated();
4570
+ invalidateReads();
4296
4571
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
4297
4572
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
4298
4573
  return {
4299
4574
  content: [{ type: "text", text: [
4300
4575
  `๐Ÿ”€ Task switched: ${sourceRef} โ†’ ${targetRef}`,
4301
- `Paused snapshot: ${snapshot.whatWasBeingDone}`,
4576
+ `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
4302
4577
  `Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
4303
4578
  "Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
4304
4579
  ].join("\n") }],
@@ -4309,39 +4584,100 @@ export default function planPiExtension(pi) {
4309
4584
  pi.registerTool({
4310
4585
  name: "task_start",
4311
4586
  label: "Task Start",
4312
- description: "Set a task to in-progress or resume paused work. BEFORE calling, read feature_get(full=true), phase_get(full=true), then task_get(full=true). A different active task must first be suspended through task_switch. Otherwise follow active/pending-resume/feature โ†’ phase โ†’ task priority guidance.",
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.",
4313
4588
  parameters: Type.Object({
4314
4589
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4315
4590
  }),
4316
4591
  async execute(_id, params, _signal, _onUpdate, ctx) {
4317
4592
  const st = await requirePlan(ctx);
4318
4593
  if (!st)
4319
- 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."]));
4320
4595
  const features = (await st.loadFeatures()).features;
4321
4596
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4322
4597
  if (!found)
4323
- 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."]));
4324
4599
  const task = found.task;
4325
- if (task.status === "in-progress")
4326
- 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
+ }
4327
4608
  if (task.status === "done")
4328
- return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4329
- const project = await st.loadProject();
4330
- const phases = await st.loadAllPhases();
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);
4627
+ if (!contextEligibility.eligible) {
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
+ });
4647
+ }
4648
+ const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
4331
4649
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4332
4650
  if (!eligibility.eligible)
4333
- return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
4334
- const selection = recommendNextTask(features, phases, project.workDeviations);
4651
+ return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
4652
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
4335
4653
  if (selection.kind === "conflict") {
4336
- 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 });
4337
4655
  }
4338
4656
  if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
4339
4657
  const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4340
- 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 });
4341
4659
  }
4342
- if (selection.kind === "resume" && selection.candidate?.task.id !== task.id) {
4343
- const resumeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4344
- return { content: [{ type: "text", text: `Task start denied: ${resumeRef} is pending resume. Resume it now, or use task_switch from that paused task with a new checkpoint and reason.` }], details: selection };
4660
+ let resumeProposal;
4661
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
4662
+ // Advisory only: a pending resume must be surfaced loudly, but it must
4663
+ // not hard-block an explicit start of a different task. Capture a
4664
+ // structured proposal so the resume-first guidance is explicit and
4665
+ // machine-readable for the host/agent.
4666
+ const resumeTask = selection.candidate;
4667
+ const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
4668
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
4669
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
4670
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
4671
+ ?? null;
4672
+ resumeProposal = buildResumeRequiredProposal({
4673
+ ref: resumeRef,
4674
+ title: resumeTask.task.title,
4675
+ taskId: resumeTask.task.id,
4676
+ phaseId: resumeTask.phase.id,
4677
+ snapshot: resumeSnapshot
4678
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
4679
+ : null,
4680
+ });
4345
4681
  }
4346
4682
  // Assemble the mandatory parent context before changing task lifecycle state.
4347
4683
  // The output order is feature + its requirements, then phase + its requirements.
@@ -4352,11 +4688,13 @@ export default function planPiExtension(pi) {
4352
4688
  ]);
4353
4689
  const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4354
4690
  const advisory = selection.candidate && selection.candidate.task.id !== task.id
4355
- ? `\n\nโš ๏ธ ${selection.kind === "resume" ? "Resume" : "Priority"} advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
4691
+ ? selection.kind === "resume"
4692
+ ? `\n\n${resumeProposal ? resumeProposal.text : `โš ๏ธ Resume advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`}`
4693
+ : `\n\nโš ๏ธ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
4356
4694
  : "";
4357
4695
  const now = nowISO();
4358
4696
  let startedTask;
4359
- if (task.status === "paused") {
4697
+ if (task.pauseSnapshot) {
4360
4698
  startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
4361
4699
  }
4362
4700
  else {
@@ -4388,19 +4726,26 @@ export default function planPiExtension(pi) {
4388
4726
  if (resumedDeviation)
4389
4727
  await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
4390
4728
  await st.writeGenerated();
4391
- if (!startedTask)
4392
- return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4393
- return { 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}` }], details: startedTask };
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);
4734
+ return {
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 },
4737
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
4738
+ };
4394
4739
  },
4395
4740
  });
4396
4741
  pi.registerTool({
4397
4742
  name: "task_complete",
4398
4743
  label: "Task Complete",
4399
- 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.",
4400
4745
  parameters: Type.Object({
4401
4746
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
4402
4747
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
4403
- 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." }),
4404
4749
  }),
4405
4750
  async execute(_id, params, _signal, _onUpdate, ctx) {
4406
4751
  const st = await requirePlan(ctx);
@@ -4413,8 +4758,11 @@ export default function planPiExtension(pi) {
4413
4758
  const task = found.task;
4414
4759
  if (task.status === "done")
4415
4760
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
4416
- if (task.status === "paused")
4417
- return { content: [{ type: "text", text: "Task completion denied: resume paused work with task_start before completing it, or cancel it explicitly with motivation." }], 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" } };
4764
+ if (task.pauseSnapshot)
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 };
4418
4766
  const unchecked = task.checklist.filter((item) => !item.checked);
4419
4767
  if (unchecked.length > 0 && !params.force) {
4420
4768
  return {
@@ -4433,12 +4781,11 @@ export default function planPiExtension(pi) {
4433
4781
  t.statusLog = [...t.statusLog, {
4434
4782
  id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
4435
4783
  date: now, fromStatus: previousStatus, toStatus: "done",
4436
- title: `${previousStatus} โ†’ done`, description: "",
4784
+ title: `${previousStatus} โ†’ done`, description: completionSummary,
4437
4785
  }];
4438
- if (params.description_update) {
4439
- const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
4440
- t.description = t.description + sep + params.description_update;
4441
- }
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;
4442
4789
  t.updatedAt = now;
4443
4790
  phase.updatedAt = now;
4444
4791
  completedTask = t;
@@ -4463,7 +4810,7 @@ export default function planPiExtension(pi) {
4463
4810
  resumeNotice = [
4464
4811
  "",
4465
4812
  `โ†ฉ๏ธ RESUME REQUIRED: ${resumeRef} โ€” ${resume.task.title}`,
4466
- snapshot ? `Paused because: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4813
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4467
4814
  snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
4468
4815
  snapshot ? `How to resume: ${snapshot.howToResume}` : "",
4469
4816
  `Next action: task_start ${resumeRef}`,
@@ -4650,7 +4997,7 @@ export default function planPiExtension(pi) {
4650
4997
  const st = await requirePlan(ctx);
4651
4998
  if (!st)
4652
4999
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
4653
- plannerSessionEnabled = true;
5000
+ enablePlannerSession();
4654
5001
  if (!server) {
4655
5002
  await startServer(ctx, undefined, "lan").catch(() => { });
4656
5003
  }
@@ -4722,6 +5069,7 @@ export default function planPiExtension(pi) {
4722
5069
  const activity = await st.loadActivityLog();
4723
5070
  const recentActivity = activity.entries.slice(-3).reverse();
4724
5071
  const handoffs = await st.listHandoffs();
5072
+ const extensionRules = await st.extensionRules();
4725
5073
  const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
4726
5074
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
4727
5075
  const orderedPhases = [
@@ -4827,7 +5175,7 @@ export default function planPiExtension(pi) {
4827
5175
  "STARTUP RESUME PROTOCOL (mandatory):",
4828
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.",
4829
5177
  `- Write in ${project.chatLanguage || "English"}.`,
4830
- "- 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).",
4831
5179
  ].filter(Boolean).join("\n") : "";
4832
5180
  // Build/refresh the context block (slow path: cache miss).
4833
5181
  const contextBlock = [
@@ -4836,12 +5184,19 @@ export default function planPiExtension(pi) {
4836
5184
  "",
4837
5185
  "Operational rules:",
4838
5186
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4839
- "- BEFORE work: read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true); only then call task_start before any code edit/write. 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.",
4840
5188
  "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4841
5189
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4842
5190
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4843
5191
  "- Prioritize work: continue an in-progress task; otherwise choose ready work feature โ†’ phase โ†’ task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4844
5192
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
5193
+ ...(extensionRules.length > 0
5194
+ ? [
5195
+ "",
5196
+ "Planner rules (extension; agent-only):",
5197
+ ...extensionRules.map((rule, index) => `${index + 1}. ${rule}`),
5198
+ ]
5199
+ : []),
4845
5200
  "",
4846
5201
  `Goal: ${project.goal || "(not set)"}`,
4847
5202
  project.description ? `Description: ${project.description}` : "",