@agent-plan/pi-adapter 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,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, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, HandoffContractError } from "@agent-plan/core";
13
+ import { ExportService, PlanStore, PlanStoreError, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_SOURCE_REVIEWS, HANDOFF_COLD_START_INVENTORY_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, HandoffContractError } from "@agent-plan/core";
14
14
  import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, 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";
@@ -93,6 +94,15 @@ function mutationNoFieldsFailure(input) {
93
94
  details: outcome,
94
95
  };
95
96
  }
97
+ function acceptedDecisionMutationFailure(error, outcome) {
98
+ if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
99
+ throw error;
100
+ return {
101
+ isError: true,
102
+ content: [{ type: "text", text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}` }],
103
+ details: { [outcome]: false, ...error.details },
104
+ };
105
+ }
96
106
  function legacyContextMigrationSummary(preview) {
97
107
  return [
98
108
  `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
@@ -138,10 +148,12 @@ function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired,
138
148
  return [`task_get ${refs.task} with full=true`];
139
149
  if (read.kind === "phase")
140
150
  return [`phase_get ${refs.phase} with full=true`];
141
- return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
151
+ if (read.kind === "feature")
152
+ return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
153
+ return [];
142
154
  }));
143
155
  if (requirementReadRequired)
144
- actions.push("requirement_list");
156
+ actions.push(`requirement_list with phaseRef=${refs.phase}`);
145
157
  actions.push(retryAction);
146
158
  return actions;
147
159
  }
@@ -221,6 +233,12 @@ const PLANNER_COMMAND_COMPLETIONS = [
221
233
  { value: "project language", label: "project language", description: "Set persistent language preferences" },
222
234
  { value: "project guidelines", label: "project guidelines", description: "Edit the canonical Project Guidelines section" },
223
235
  { value: "project migrate-context", label: "project migrate-context", description: "Preview and explicitly migrate legacy project rules and decisions" },
236
+ { value: "idea list", label: "idea list", description: "List Ideas Inbox entries" },
237
+ { value: "idea add", label: "idea add", description: "Create an Ideas Inbox entry" },
238
+ { value: "idea show", label: "idea show", description: "Show an idea" },
239
+ { value: "idea update", label: "idea update", description: "Update an idea" },
240
+ { value: "idea delete", label: "idea delete", description: "Delete an idea after confirmation" },
241
+ { value: "idea promote", label: "idea promote", description: "Begin guided idea promotion" },
224
242
  { value: "feature list", label: "feature list", description: "List features" },
225
243
  { value: "feature add", label: "feature add", description: "Create a feature" },
226
244
  { value: "feature show", label: "feature show", description: "Show a feature" },
@@ -329,7 +347,7 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
329
347
  project.contentLanguage = contentLanguage;
330
348
  project.chatLanguage = chatLanguage;
331
349
  if (persist)
332
- await st.saveProject(project);
350
+ await st.updateProject((current) => ({ ...current, contentLanguage, chatLanguage }));
333
351
  }
334
352
  return project;
335
353
  }
@@ -338,7 +356,11 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
338
356
  project.contentLanguage = contentLanguage || fallback;
339
357
  project.chatLanguage = chatLanguage || fallback;
340
358
  if (persist)
341
- await st.saveProject(project);
359
+ await st.updateProject((current) => ({
360
+ ...current,
361
+ contentLanguage: project.contentLanguage,
362
+ chatLanguage: project.chatLanguage,
363
+ }));
342
364
  return project;
343
365
  }
344
366
  return project;
@@ -673,6 +695,33 @@ function statusIcon(status) {
673
695
  function pad(n) {
674
696
  return String(n).padStart(3, "0");
675
697
  }
698
+ async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
699
+ if (targetType === "project") {
700
+ const project = await st.loadProject();
701
+ return { ok: true, owner: { kind: "project" }, ref: project.name };
702
+ }
703
+ const ref = targetRef?.trim();
704
+ if (!ref)
705
+ return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
706
+ const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
707
+ const features = featuresDoc.features;
708
+ if (targetType === "feature") {
709
+ const resolved = resolveFeatureRefStrict(features, ref);
710
+ if (!resolved.ok)
711
+ return { ok: false, error: resolved.error };
712
+ return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
713
+ }
714
+ if (targetType === "phase") {
715
+ const phase = findPhaseByRef(phases, features, ref);
716
+ if (!phase)
717
+ return { ok: false, error: `Phase not found: ${ref}` };
718
+ return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
719
+ }
720
+ const found = findTaskByRef(phases, features, ref);
721
+ if (!found)
722
+ return { ok: false, error: `Task not found: ${ref}` };
723
+ return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` };
724
+ }
676
725
  function applyTaskLifecycleDates(task, nextStatus, now) {
677
726
  const previousStatus = task.status;
678
727
  if (nextStatus === "in-progress" && !task.startedAt) {
@@ -705,8 +754,7 @@ async function pickProjectPort(ctx, explicitPort) {
705
754
  if (!(await isPortAvailable(explicitPort)) && project.webPort !== explicitPort) {
706
755
  throw new Error(`Port ${explicitPort} is already in use`);
707
756
  }
708
- project.webPort = explicitPort;
709
- await st.saveProject(project);
757
+ await st.updateProject((current) => ({ ...current, webPort: explicitPort }));
710
758
  return explicitPort;
711
759
  }
712
760
  // Reuse the persisted port if it's still free (stable port across restarts,
@@ -723,8 +771,7 @@ async function pickProjectPort(ctx, explicitPort) {
723
771
  continue;
724
772
  if (await isPortAvailable(port)) {
725
773
  if (project.webPort <= 0) {
726
- project.webPort = port;
727
- await st.saveProject(project);
774
+ await st.updateProject((current) => ({ ...current, webPort: port }));
728
775
  }
729
776
  return port;
730
777
  }
@@ -856,8 +903,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
856
903
  // existing canonical). Never persist a transient fallback port — that
857
904
  // hijacks the primary session's port when two instances share a .planner.
858
905
  if (project.webPort <= 0 || project.webPort === realPort) {
859
- project.webPort = realPort;
860
- await st.saveProject(project);
906
+ await st.updateProject((current) => ({ ...current, webPort: realPort }));
861
907
  }
862
908
  }
863
909
  }
@@ -1239,11 +1285,16 @@ export default function planPiExtension(pi) {
1239
1285
  return;
1240
1286
  }
1241
1287
  if (a === "version") {
1288
+ const packages = runtimePackagesDiagnostic([PI_ADAPTER_PACKAGE, CORE_PACKAGE, SERVER_PACKAGE]);
1289
+ const capabilities = runtimeCapabilities();
1242
1290
  ctx.ui.notify([
1243
- "Agent Plan runtime versions",
1244
- `${PI_ADAPTER_PACKAGE.name}: ${PI_ADAPTER_PACKAGE.version}`,
1245
- `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
1246
- `${SERVER_PACKAGE.name}: ${SERVER_PACKAGE.version}`,
1291
+ "Agent Plan runtime diagnostics",
1292
+ "Runtime packages (loaded package manifests):",
1293
+ `- ${PI_ADAPTER_PACKAGE.name}: loaded ${packages[PI_ADAPTER_PACKAGE.name]?.loadedVersion ?? PI_ADAPTER_PACKAGE.version}`,
1294
+ `- ${CORE_PACKAGE.name}: loaded ${packages[CORE_PACKAGE.name]?.loadedVersion ?? CORE_PACKAGE.version}`,
1295
+ `- ${SERVER_PACKAGE.name}: loaded ${packages[SERVER_PACKAGE.name]?.loadedVersion ?? SERVER_PACKAGE.version}`,
1296
+ `Plan schema: manifest schemaVersion ${capabilities.planSchema.manifestSchemaVersion}`,
1297
+ `Allocation registry: v${capabilities.allocationRegistry.version}; supported kinds: ${capabilities.allocationRegistry.supportedKinds.join(", ")}`,
1247
1298
  ].join("\n"), "info");
1248
1299
  return;
1249
1300
  }
@@ -1310,12 +1361,18 @@ export default function planPiExtension(pi) {
1310
1361
  // ── show ──
1311
1362
  if (a === "show") {
1312
1363
  const plan = await st.loadAll();
1364
+ const activeTasks = plan.phases.flatMap((phase) => phase.tasks
1365
+ .filter((task) => task.status === "in-progress")
1366
+ .map((task) => `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)} — ${task.title} (in-progress)`));
1313
1367
  ctx.ui.notify([
1314
1368
  `📋 **${plan.project.name}**`,
1315
1369
  ` Description: ${plan.project.description || "*not set*"}`,
1316
1370
  ` Goal: ${plan.project.goal || "*not set*"}`,
1317
1371
  ` Phases: ${plan.phases.length}`,
1318
1372
  ` Requirements: ${plan.requirements.requirements.length}`,
1373
+ ...(activeTasks.length === 0
1374
+ ? [" Active tasks: none (verified from all persisted phase task statuses)"]
1375
+ : [` Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT: multiple tasks are in progress" : ""}:`, ...activeTasks.map((task) => ` - ${task}`)]),
1319
1376
  ` Updated: ${plan.manifest.updatedAt}`,
1320
1377
  ].join("\n"), "info");
1321
1378
  return;
@@ -1559,7 +1616,17 @@ export default function planPiExtension(pi) {
1559
1616
  }))
1560
1617
  : project.acceptedDecisions,
1561
1618
  };
1562
- await st.saveProject(draftProject);
1619
+ await st.updateProject((current) => ({
1620
+ ...current,
1621
+ goal: draftProject.goal,
1622
+ technologies: draftProject.technologies,
1623
+ tools: draftProject.tools,
1624
+ globalRules: draftProject.globalRules,
1625
+ decisions: draftProject.decisions,
1626
+ acceptedDecisions: draftProject.acceptedDecisions,
1627
+ contentLanguage: draftProject.contentLanguage,
1628
+ chatLanguage: draftProject.chatLanguage,
1629
+ }));
1563
1630
  await st.saveCodebaseProfile(profile);
1564
1631
  await st.writeGenerated();
1565
1632
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
@@ -1585,7 +1652,11 @@ export default function planPiExtension(pi) {
1585
1652
  const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
1586
1653
  project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
1587
1654
  project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
1588
- await st.saveProject(project);
1655
+ await st.updateProject((current) => ({
1656
+ ...current,
1657
+ contentLanguage: project.contentLanguage,
1658
+ chatLanguage: project.chatLanguage,
1659
+ }));
1589
1660
  await st.writeGenerated();
1590
1661
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
1591
1662
  return;
@@ -1593,12 +1664,12 @@ export default function planPiExtension(pi) {
1593
1664
  if (b === "guidelines") {
1594
1665
  const project = await st.loadProject();
1595
1666
  const nextContent = await ctx.ui.editor("Project Guidelines / Coding Standard (Markdown or plain text). Agents must read this before task work.", project.projectGuidelines.content || "");
1596
- project.projectGuidelines = {
1597
- content: nextContent?.trim() || "",
1598
- updatedAt: project.projectGuidelines.updatedAt,
1599
- sessionInfo: project.projectGuidelines.sessionInfo,
1600
- };
1601
- await st.saveProject(project);
1667
+ const guidelinesContent = nextContent?.trim() || "";
1668
+ project.projectGuidelines = { ...project.projectGuidelines, content: guidelinesContent };
1669
+ await st.updateProject((current) => ({
1670
+ ...current,
1671
+ projectGuidelines: { ...current.projectGuidelines, content: guidelinesContent },
1672
+ }), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
1602
1673
  await st.writeGenerated();
1603
1674
  ctx.ui.notify(project.projectGuidelines.content ? "Project Guidelines updated." : "Project Guidelines cleared.", "info");
1604
1675
  return;
@@ -1926,9 +1997,7 @@ export default function planPiExtension(pi) {
1926
1997
  }
1927
1998
  else {
1928
1999
  for (const phase of phases) {
1929
- phase.featureId = undefined;
1930
- phase.updatedAt = nowISO();
1931
- await st.savePhase(phase);
2000
+ await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
1932
2001
  affectedPhases += 1;
1933
2002
  }
1934
2003
  }
@@ -1968,7 +2037,7 @@ export default function planPiExtension(pi) {
1968
2037
  // and only then abandoning the follow-up question.
1969
2038
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1970
2039
  let phase;
1971
- await withFeatureLock(feature.id, async () => {
2040
+ await st.runBatch(() => withFeatureLock(feature.id, async () => {
1972
2041
  const phases = await st.loadAllPhases();
1973
2042
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1974
2043
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1996,7 +2065,7 @@ export default function planPiExtension(pi) {
1996
2065
  return doc;
1997
2066
  });
1998
2067
  await st.writeGenerated();
1999
- });
2068
+ }));
2000
2069
  if (!phase)
2001
2070
  return;
2002
2071
  ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
@@ -2044,8 +2113,12 @@ export default function planPiExtension(pi) {
2044
2113
  ctx.ui.notify(recapLines.join("\n"), "info");
2045
2114
  const changeNote = await ctx.ui.input("Anything changed? (free text, or leave empty to keep current plan)");
2046
2115
  if (changeNote?.trim()) {
2047
- phase.notes = `${phase.notes ? phase.notes + "\n" : ""}[discuss ${nowISO()}] ${changeNote.trim()}`;
2048
- await st.savePhase(phase);
2116
+ const note = changeNote.trim();
2117
+ phase = await st.updatePhase(phase.id, (current) => ({
2118
+ ...current,
2119
+ notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
2120
+ updatedAt: nowISO(),
2121
+ }));
2049
2122
  if (capturedPi) {
2050
2123
  await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
2051
2124
  "Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
@@ -2067,25 +2140,22 @@ export default function planPiExtension(pi) {
2067
2140
  if (ans?.trim())
2068
2141
  answers[q.key] = ans.trim();
2069
2142
  }
2070
- if (answers.goal)
2071
- phase.goals = [answers.goal];
2072
- if (answers.summary)
2073
- phase.summary = answers.summary;
2074
- if (answers.scope)
2075
- phase.description = answers.scope;
2076
- if (answers.non_scope)
2077
- phase.nonGoals = [answers.non_scope];
2078
- if (answers.deps)
2079
- phase.dependencies = splitCsv(answers.deps);
2080
- if (answers.risks)
2081
- phase.risks = splitCsv(answers.risks);
2082
- if (answers.completion)
2083
- phase.completionCriteria = splitCsv(answers.completion);
2084
- phase.status = "planned";
2085
- phase.updatedAt = nowISO();
2086
- await st.savePhase(phase);
2143
+ phase = await st.updatePhase(phase.id, (current) => ({
2144
+ ...current,
2145
+ ...(answers.goal ? { goals: [answers.goal] } : {}),
2146
+ ...(answers.summary ? { summary: answers.summary } : {}),
2147
+ ...(answers.scope ? { description: answers.scope } : {}),
2148
+ ...(answers.non_scope ? { nonGoals: [answers.non_scope] } : {}),
2149
+ ...(answers.deps ? { dependencies: splitCsv(answers.deps) } : {}),
2150
+ ...(answers.risks ? { risks: splitCsv(answers.risks) } : {}),
2151
+ ...(answers.completion ? { completionCriteria: splitCsv(answers.completion) } : {}),
2152
+ discussedAt: nowISO(),
2153
+ contextReady: true,
2154
+ contextReadyReason: "Updated through /planner phase discuss.",
2155
+ updatedAt: nowISO(),
2156
+ }));
2087
2157
  await st.writeGenerated();
2088
- ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **planned**.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
2158
+ ctx.ui.notify(`Phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} is now **context ready**; derived status remains ${phase.status}.${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
2089
2159
  return;
2090
2160
  }
2091
2161
  if (b === "show") {
@@ -2157,33 +2227,25 @@ export default function planPiExtension(pi) {
2157
2227
  const deps = await ctx.ui.input(`Dependencies (comma-separated) [${phase.dependencies.join(", ") || ""}]`);
2158
2228
  const risks = await ctx.ui.input(`Risks (comma-separated) [${phase.risks.join(", ") || ""}]`);
2159
2229
  const completion = await ctx.ui.input(`Completion criteria (comma-separated) [${phase.completionCriteria.join(", ") || ""}]`);
2160
- // Update only changed fields
2161
- if (title?.trim())
2162
- phase.title = title.trim();
2163
2230
  if (statusInput?.trim()) {
2164
2231
  const valid = ["draft", "discovery", "planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
2165
2232
  if (!valid.includes(statusInput.trim())) {
2166
2233
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2167
2234
  return;
2168
2235
  }
2169
- phase.status = statusInput.trim();
2170
2236
  }
2171
- if (summary !== undefined)
2172
- phase.summary = summary;
2173
- if (desc?.trim())
2174
- phase.description = desc.trim();
2175
- if (goals?.trim())
2176
- phase.goals = goals.split(",").map((g) => g.trim()).filter(Boolean);
2177
- if (nonGoals?.trim())
2178
- phase.nonGoals = nonGoals.split(",").map((g) => g.trim()).filter(Boolean);
2179
- if (deps?.trim())
2180
- phase.dependencies = deps.split(",").map((g) => g.trim()).filter(Boolean);
2181
- if (risks?.trim())
2182
- phase.risks = risks.split(",").map((g) => g.trim()).filter(Boolean);
2183
- if (completion?.trim())
2184
- phase.completionCriteria = completion.split(",").map((g) => g.trim()).filter(Boolean);
2185
- phase.updatedAt = nowISO();
2186
- await st.savePhase(phase);
2237
+ phase = await st.updatePhase(phase.id, (current) => ({
2238
+ ...current,
2239
+ ...(title?.trim() ? { title: title.trim(), slug: normalizeSlug(title) } : {}),
2240
+ ...(summary !== undefined ? { summary } : {}),
2241
+ ...(desc?.trim() ? { description: desc.trim() } : {}),
2242
+ ...(goals?.trim() ? { goals: splitCsv(goals) } : {}),
2243
+ ...(nonGoals?.trim() ? { nonGoals: splitCsv(nonGoals) } : {}),
2244
+ ...(deps?.trim() ? { dependencies: splitCsv(deps) } : {}),
2245
+ ...(risks?.trim() ? { risks: splitCsv(risks) } : {}),
2246
+ ...(completion?.trim() ? { completionCriteria: splitCsv(completion) } : {}),
2247
+ updatedAt: nowISO(),
2248
+ }));
2187
2249
  await st.writeGenerated();
2188
2250
  ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
2189
2251
  return;
@@ -2232,12 +2294,15 @@ export default function planPiExtension(pi) {
2232
2294
  dependsOn: [],
2233
2295
  pauseSnapshot: null, pauseHistory: [],
2234
2296
  startedAt: "", completedAt: "",
2297
+ activeOwnerSession: "",
2235
2298
  createdAt: now, updatedAt: now,
2236
2299
  };
2237
- phase.tasks.push(task);
2238
- phase.taskIds.push(taskId);
2239
- phase.updatedAt = nowISO();
2240
- await st.savePhase(phase);
2300
+ await st.updatePhase(phase.id, (current) => ({
2301
+ ...current,
2302
+ tasks: [...current.tasks, task],
2303
+ taskIds: [...current.taskIds, taskId],
2304
+ updatedAt: nowISO(),
2305
+ }));
2241
2306
  await st.writeGenerated();
2242
2307
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
2243
2308
  if (isYes(startDiscuss)) {
@@ -2268,18 +2333,20 @@ export default function planPiExtension(pi) {
2268
2333
  const { phase, task } = resolved;
2269
2334
  const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
2270
2335
  const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
2271
- if (description?.trim())
2272
- task.description = description.trim();
2273
- if (checklistSeed?.trim()) {
2274
- task.checklist = splitCsv(checklistSeed).map((itemTitle, index) => ({ number: index + 1,
2336
+ const checklist = checklistSeed?.trim()
2337
+ ? splitCsv(checklistSeed).map((itemTitle, index) => ({
2338
+ number: index + 1,
2275
2339
  id: createChecklistItemId(task.id, index + 1, itemTitle),
2276
2340
  title: itemTitle,
2277
2341
  checked: false,
2278
- }));
2279
- }
2280
- task.updatedAt = nowISO();
2281
- phase.updatedAt = nowISO();
2282
- await st.savePhase(phase);
2342
+ }))
2343
+ : undefined;
2344
+ await st.updateTask(phase.id, task.id, (current) => ({
2345
+ ...current,
2346
+ ...(description?.trim() ? { description: description.trim() } : {}),
2347
+ ...(checklist ? { checklist } : {}),
2348
+ updatedAt: nowISO(),
2349
+ }));
2283
2350
  await st.writeGenerated();
2284
2351
  ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
2285
2352
  return;
@@ -2345,10 +2412,12 @@ export default function planPiExtension(pi) {
2345
2412
  ctx.ui.notify("Aborted", "warning");
2346
2413
  return;
2347
2414
  }
2348
- phase.tasks = phase.tasks.filter((t) => t.id !== task.id);
2349
- phase.taskIds = phase.taskIds.filter((id) => id !== task.id);
2350
- phase.updatedAt = nowISO();
2351
- await st.savePhase(phase);
2415
+ await st.updatePhase(phase.id, (current) => ({
2416
+ ...current,
2417
+ tasks: current.tasks.filter((candidate) => candidate.id !== task.id),
2418
+ taskIds: current.taskIds.filter((id) => id !== task.id),
2419
+ updatedAt: nowISO(),
2420
+ }));
2352
2421
  await st.writeGenerated();
2353
2422
  ctx.ui.notify(`Task deleted: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")}${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2354
2423
  return;
@@ -2377,133 +2446,137 @@ export default function planPiExtension(pi) {
2377
2446
  const title = await ctx.ui.input(`Title [${task.title}]`);
2378
2447
  const statusInput = await ctx.ui.input(`Status [${task.status}] (planned|in-progress|done|blocked|canceled)`);
2379
2448
  const desc = await ctx.ui.input(`Description [leave empty to keep]`);
2380
- // Update only changed fields
2381
- if (title?.trim())
2382
- task.title = title.trim();
2383
2449
  const now = nowISO();
2384
- if (statusInput?.trim()) {
2385
- const normalizedStatus = statusInput.trim();
2450
+ const normalizedStatus = statusInput?.trim();
2451
+ if (normalizedStatus) {
2386
2452
  const valid = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
2387
2453
  if (!valid.includes(normalizedStatus)) {
2388
2454
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2389
2455
  return;
2390
2456
  }
2391
2457
  if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
2392
- ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
2458
+ ctx.ui.notify("Task start/resume transitions require /planner task start so lifecycle state remains consistent.", "warning");
2393
2459
  return;
2394
2460
  }
2395
2461
  if (normalizedStatus === "done" && normalizedStatus !== task.status) {
2396
2462
  ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
2397
2463
  return;
2398
2464
  }
2399
- applyTaskLifecycleDates(task, normalizedStatus, now);
2400
2465
  }
2401
- if (desc?.trim())
2402
- task.description = desc.trim();
2403
- task.updatedAt = now;
2404
- phase.updatedAt = now;
2405
- await st.savePhase(phase);
2466
+ const result = await st.updateTask(phase.id, task.id, (current) => {
2467
+ const next = {
2468
+ ...current,
2469
+ ...(title?.trim() ? { title: title.trim() } : {}),
2470
+ ...(normalizedStatus ? { status: normalizedStatus } : {}),
2471
+ ...(desc?.trim() ? { description: desc.trim() } : {}),
2472
+ updatedAt: now,
2473
+ };
2474
+ if (normalizedStatus)
2475
+ applyTaskLifecycleDates(next, normalizedStatus, now);
2476
+ return next;
2477
+ });
2406
2478
  await st.syncTaskStatusRollup(phase.id);
2407
2479
  await st.writeGenerated();
2408
- ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (${task.status})${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2480
+ ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (${result.task.status})${result.task.shortId ? ` · ${result.task.shortId}` : ""}`, "info");
2409
2481
  return;
2410
2482
  }
2411
2483
  if (b === "start") {
2412
- let phase = null;
2413
- let task = null;
2414
- if (subArgs.trim()) {
2415
- const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2416
- phase = resolved?.phase ?? null;
2417
- task = resolved?.task ?? null;
2418
- }
2419
- if (!task) {
2420
- phase = await pickPhase();
2421
- if (!phase) {
2422
- ctx.ui.notify("No phases available", "warning");
2423
- return;
2424
- }
2425
- if (phase.tasks.length === 0) {
2426
- ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
2427
- return;
2484
+ return st.runBatch(async () => {
2485
+ let phase = null;
2486
+ let task = null;
2487
+ if (subArgs.trim()) {
2488
+ const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2489
+ phase = resolved?.phase ?? null;
2490
+ task = resolved?.task ?? null;
2428
2491
  }
2429
- task = await pickTask(phase);
2430
2492
  if (!task) {
2431
- ctx.ui.notify("Aborted", "warning");
2432
- return;
2433
- }
2434
- }
2435
- if (phase) {
2436
- const features = (await st.loadFeatures()).features;
2437
- const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
2438
- const linkedRequirements = [
2439
- ...(await st.linkedRequirementsForPhase(phase.id)),
2440
- ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2441
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2442
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2443
- const contextInput = {
2444
- sessionId: plannerSessionId,
2445
- taskId: task.id,
2446
- phaseId: phase.id,
2447
- ...(phase.featureId ? { featureId: phase.featureId } : {}),
2448
- task,
2449
- phase,
2450
- ...(feature ? { feature } : {}),
2451
- requirements: linkedRequirements,
2452
- requirementIds: linkedRequirementIds,
2453
- };
2454
- const contextEligibility = contextReadEligibilityForSession(contextInput);
2455
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2456
- const project = await st.loadProject();
2457
- const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2458
- const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2459
- const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2460
- const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
2461
- const featureRef = feature ? formatFeatureRef(feature.number) : "";
2462
- if (!projectGuidelinesReady) {
2463
- const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `/planner task start ${taskRef}`);
2464
- ctx.ui.notify(`❌ TASK START FAILED [PROJECT_GUIDELINES_READ_REQUIRED]\nstarted: false\nProject Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2465
- return;
2493
+ phase = await pickPhase();
2494
+ if (!phase) {
2495
+ ctx.ui.notify("No phases available", "warning");
2496
+ return;
2497
+ }
2498
+ if (phase.tasks.length === 0) {
2499
+ ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
2500
+ return;
2501
+ }
2502
+ task = await pickTask(phase);
2503
+ if (!task) {
2504
+ ctx.ui.notify("Aborted", "warning");
2505
+ return;
2506
+ }
2466
2507
  }
2467
- if (!contextEligibility.eligible) {
2468
- const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `/planner task start ${taskRef}`);
2469
- ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2470
- return;
2508
+ if (phase) {
2509
+ const features = (await st.loadFeatures()).features;
2510
+ const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
2511
+ const linkedRequirements = [
2512
+ ...(await st.linkedRequirementsForPhase(phase.id)),
2513
+ ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2514
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2515
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2516
+ const contextInput = {
2517
+ sessionId: plannerSessionId,
2518
+ taskId: task.id,
2519
+ phaseId: phase.id,
2520
+ ...(phase.featureId ? { featureId: phase.featureId } : {}),
2521
+ task,
2522
+ phase,
2523
+ ...(feature ? { feature } : {}),
2524
+ requirements: linkedRequirements,
2525
+ requirementIds: linkedRequirementIds,
2526
+ };
2527
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
2528
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2529
+ const requirementsReady = requirementEligibility.eligible;
2530
+ const project = await st.loadProject();
2531
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2532
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2533
+ const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2534
+ const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
2535
+ const featureRef = feature ? formatFeatureRef(feature.number) : "";
2536
+ if (!projectGuidelinesReady) {
2537
+ const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `/planner task start ${taskRef}`);
2538
+ ctx.ui.notify(`❌ TASK START FAILED [PROJECT_GUIDELINES_READ_REQUIRED]\nstarted: false\nProject Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2539
+ return;
2540
+ }
2541
+ if (!contextEligibility.eligible) {
2542
+ const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `/planner task start ${taskRef}`);
2543
+ ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2544
+ return;
2545
+ }
2546
+ if (!requirementsReady) {
2547
+ ctx.ui.notify(`❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\n${requirementEligibility.reason}\nRun requirement_list with phaseRef=${phaseRef}, then retry /planner task start. The task remains unchanged.`, "error");
2548
+ return;
2549
+ }
2550
+ if (!hasValidSessionAttestation(contextInput)) {
2551
+ await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
2552
+ }
2471
2553
  }
2472
- if (!requirementsReady) {
2473
- ctx.ui.notify("❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
2554
+ if (task.status === "in-progress") {
2555
+ ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2474
2556
  return;
2475
2557
  }
2476
- if (!hasValidSessionAttestation(contextInput)) {
2477
- await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
2478
- }
2479
- }
2480
- if (task.status === "in-progress") {
2481
- ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2558
+ // A pending handoff is context, not a lock: task start RETAINS it.
2559
+ // It is archived only when the phase completes (auto) or the user
2560
+ // explicitly clears it.
2561
+ const now = nowISO();
2562
+ const result = await st.updateTask(phase.id, task.id, (current) => {
2563
+ const entry = {
2564
+ id: createChecklistItemId(current.id, current.statusLog.length + 1, `${current.status}-in-progress`),
2565
+ date: now,
2566
+ fromStatus: current.status,
2567
+ toStatus: "in-progress",
2568
+ title: current.status === "done" ? "Reopened" : "→ in-progress",
2569
+ description: current.status === "done" ? "Task reopened from done status." : "",
2570
+ };
2571
+ const next = { ...current, status: "in-progress", statusLog: [...current.statusLog, entry], updatedAt: now };
2572
+ applyTaskLifecycleDates(next, "in-progress", now);
2573
+ return next;
2574
+ });
2575
+ await st.syncTaskStatusRollup(phase.id);
2576
+ await st.writeGenerated();
2577
+ ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (in-progress)${result.task.shortId ? ` · ${result.task.shortId}` : ""}`, "info");
2482
2578
  return;
2483
- }
2484
- // A pending handoff is context, not a lock: task start RETAINS it.
2485
- // It is archived only when the phase completes (auto) or the user
2486
- // explicitly clears it.
2487
- const now = nowISO();
2488
- // Record status change in the incremental statusLog.
2489
- const prevStatus = task.status;
2490
- const entry = {
2491
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
2492
- date: now,
2493
- fromStatus: task.status,
2494
- toStatus: "in-progress",
2495
- title: task.status === "done" ? "Reopened" : `→ in-progress`,
2496
- description: task.status === "done" ? "Task reopened from done status." : "",
2497
- };
2498
- task.statusLog = [...(task.statusLog ?? []), entry];
2499
- applyTaskLifecycleDates(task, "in-progress", now);
2500
- task.updatedAt = now;
2501
- phase.updatedAt = now;
2502
- await st.savePhase(phase);
2503
- await st.syncTaskStatusRollup(phase.id);
2504
- await st.writeGenerated();
2505
- ctx.ui.notify(`✅ Task started: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (in-progress)${task.shortId ? ` · ${task.shortId}` : ""}`, "info");
2506
- return;
2579
+ });
2507
2580
  }
2508
2581
  if (b === "complete") {
2509
2582
  let phase = null;
@@ -2547,26 +2620,30 @@ export default function planPiExtension(pi) {
2547
2620
  return;
2548
2621
  }
2549
2622
  const now = nowISO();
2550
- // Record status change in the incremental statusLog.
2551
- const entry = {
2552
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-done`),
2553
- date: now,
2554
- fromStatus: task.status,
2555
- toStatus: "done",
2556
- title: `→ done`,
2557
- description: completionSummary.trim(),
2558
- };
2559
- task.statusLog = [...(task.statusLog ?? []), entry];
2560
- applyTaskLifecycleDates(task, "done", now);
2561
- const separator = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
2562
- task.description = `${task.description}${separator}${completionSummary.trim()}`;
2563
- task.descriptionUpdatedAt = now;
2564
- task.updatedAt = now;
2565
- phase.updatedAt = now;
2566
- await st.savePhase(phase);
2623
+ const result = await st.updateTask(phase.id, task.id, (current) => {
2624
+ const entry = {
2625
+ id: createChecklistItemId(current.id, current.statusLog.length + 1, `${current.status}-done`),
2626
+ date: now,
2627
+ fromStatus: current.status,
2628
+ toStatus: "done",
2629
+ title: "→ done",
2630
+ description: completionSummary.trim(),
2631
+ };
2632
+ const separator = current.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
2633
+ const next = {
2634
+ ...current,
2635
+ status: "done",
2636
+ statusLog: [...current.statusLog, entry],
2637
+ description: `${current.description}${separator}${completionSummary.trim()}`,
2638
+ descriptionUpdatedAt: now,
2639
+ updatedAt: now,
2640
+ };
2641
+ applyTaskLifecycleDates(next, "done", now);
2642
+ return next;
2643
+ });
2567
2644
  const clearedRef = await st.syncTaskStatusRollup(phase.id);
2568
2645
  await st.writeGenerated();
2569
- 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");
2646
+ ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (done)${result.task.shortId ? ` · ${result.task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
2570
2647
  return;
2571
2648
  }
2572
2649
  // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
@@ -2672,8 +2749,15 @@ export default function planPiExtension(pi) {
2672
2749
  ctx.ui.notify(r.error, "warning");
2673
2750
  return;
2674
2751
  }
2675
- const content = await st.getPhaseHandoff(r.phase.id);
2676
- ctx.ui.notify(content.trim() ? `Handoff ${r.compositeRef}:\n${content}` : `No handoff set on ${r.compositeRef}.`, "info");
2752
+ const phase = await st.loadPhase(r.phase.id);
2753
+ const content = phase.handoff;
2754
+ const contentHash = content.trim() ? handoffContentHash(content) : "";
2755
+ const resumeReady = Boolean(phase.handoffAudit?.resumeReadyAt)
2756
+ && phase.handoffAudit?.contentHash === contentHash
2757
+ && phase.handoffAudit?.contentLength === content.length;
2758
+ ctx.ui.notify(content.trim()
2759
+ ? `Handoff ${r.compositeRef} (${resumeReady ? "resume-ready" : "verification required"}; contentHash ${contentHash}):\n${content}`
2760
+ : `No handoff set on ${r.compositeRef}.`, "info");
2677
2761
  return;
2678
2762
  }
2679
2763
  if (action === "write") {
@@ -2688,14 +2772,16 @@ export default function planPiExtension(pi) {
2688
2772
  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" +
2689
2773
  "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" +
2690
2774
  "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" +
2691
- "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" +
2775
+ "After confirmation, call `handoff_prepare` with that exact phaseRef. BEFORE drafting prose, use its exact returned scaffold and cold-start inventory categories. Read the entire existing handoff and its handoffUpdatedAt token. Complete every source review by checking the conversation and approvals, planner entities and sibling tasks, current diff/implementation, verification/runtime evidence, and peer-agent or network messages (or explicitly record that no peer output exists). Inventory every exact file, symbol, working-tree ownership/completion state, commit authorization, work or deletion not yet started, command/tool path, runtime call site, preservation constraint, proof observation, related planned capability, user-visible behavior, operator action, blocker/risk, remaining item, and ordered resume step known from this session. Then call `handoff_write` once with the exact token, the completed scaffold, the structured coldStartInventory, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Every inventory item must appear verbatim in the inline handoff or a validated supporting document. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps. The write persists only a candidate and returns resumeReady=false. Immediately call `handoff_show`, read the entire persisted body, compare it again against all five sources, and either rewrite every discovered omission or call `handoff_verify` with the shown contentHash. Never tell the user the handoff is complete until verification returns resumeReady=true.\n\n" +
2692
2776
  "The reconciled handoff MUST contain at minimum:\n" +
2693
2777
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2694
2778
  "- `Reason:` why this handoff is being written\n" +
2695
2779
  "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2696
- "- `## What was being done`: the concrete decisions/work from this session\n" +
2780
+ "- `## Current and partial state`: concise completed/current/partial state (extended detail goes in .planner/docs/)\n" +
2781
+ "- `## Preservation constraints`: minimal surviving behaviors and boundaries\n" +
2782
+ "- `## Supporting documents`: ordered .planner/docs/*.md links or an explicit verified none-needed statement\n" +
2783
+ "- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
2697
2784
  "- `## How to resume`: explicit ordered steps\n" +
2698
- "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2699
2785
  "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.");
2700
2786
  ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2701
2787
  return;
@@ -2953,10 +3039,14 @@ export default function planPiExtension(pi) {
2953
3039
  project.contentLanguage = normalizedContent || fallback;
2954
3040
  project.chatLanguage = normalizedChat || fallback;
2955
3041
  }
2956
- await st.saveProject(project);
3042
+ const persisted = await st.updateProject((current) => ({
3043
+ ...current,
3044
+ contentLanguage: project.contentLanguage,
3045
+ chatLanguage: project.chatLanguage,
3046
+ }));
2957
3047
  await st.writeGenerated();
2958
3048
  return {
2959
- content: [{ type: "text", text: `Saved language preferences: content=${project.contentLanguage || "(unset)"}, chat=${project.chatLanguage || "(unset)"}` }],
3049
+ content: [{ type: "text", text: `Saved language preferences: content=${persisted.contentLanguage || "(unset)"}, chat=${persisted.chatLanguage || "(unset)"}` }],
2960
3050
  details: { contentLanguage: project.contentLanguage, chatLanguage: project.chatLanguage },
2961
3051
  };
2962
3052
  },
@@ -2969,7 +3059,7 @@ export default function planPiExtension(pi) {
2969
3059
  projectName: Type.String({ description: "Concise project name/title" }),
2970
3060
  description: Type.Optional(Type.String({ description: "Short project description" })),
2971
3061
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
2972
- requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial top-level requirements to seed requirements.json" })),
3062
+ requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial declarative product outcomes to seed as Requirements. Never include coding standards, best practices, formatting, verification process, or other Project Guidelines." })),
2973
3063
  }),
2974
3064
  async execute(_id, params, _signal, _onUpdate, ctx) {
2975
3065
  const st = loadStore(ctx);
@@ -3000,7 +3090,6 @@ export default function planPiExtension(pi) {
3000
3090
  id: createRequirementId(),
3001
3091
  title,
3002
3092
  description: "",
3003
- status: "planned",
3004
3093
  macroTasks: [],
3005
3094
  linkedPhaseIds: [],
3006
3095
  sessionInfo: [],
@@ -3079,9 +3168,30 @@ export default function planPiExtension(pi) {
3079
3168
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
3080
3169
  if (params.decisions !== undefined)
3081
3170
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
3082
- await st.saveProject(project);
3171
+ const persisted = await st.updateProject((current) => {
3172
+ const next = {
3173
+ ...current,
3174
+ ...(params.name !== undefined ? { name: params.name.trim() } : {}),
3175
+ ...(params.description !== undefined ? { description: params.description.trim() } : {}),
3176
+ ...(params.goal !== undefined ? { goal: params.goal.trim() } : {}),
3177
+ ...(params.scope !== undefined ? { scope: params.scope.map((entry) => entry.trim()).filter(Boolean) } : {}),
3178
+ ...(params.outOfScope !== undefined ? { outOfScope: params.outOfScope.map((entry) => entry.trim()).filter(Boolean) } : {}),
3179
+ ...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
3180
+ ...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
3181
+ ...(params.globalRules !== undefined ? { globalRules: params.globalRules.map((entry) => entry.trim()).filter(Boolean) } : {}),
3182
+ ...(params.decisions !== undefined ? { decisions: params.decisions.map((entry) => entry.trim()).filter(Boolean) } : {}),
3183
+ };
3184
+ if (params.descriptionRef !== undefined) {
3185
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
3186
+ if (descriptionRef)
3187
+ next.descriptionRef = descriptionRef;
3188
+ else
3189
+ delete next.descriptionRef;
3190
+ }
3191
+ return next;
3192
+ });
3083
3193
  await st.writeGenerated();
3084
- return { content: [{ type: "text", text: `Project updated: ${project.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...project, updated: true, updatedFields: receivedFields } };
3194
+ return { content: [{ type: "text", text: `Project updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...persisted, updated: true, updatedFields: receivedFields } };
3085
3195
  },
3086
3196
  });
3087
3197
  pi.registerTool({
@@ -3126,12 +3236,10 @@ export default function planPiExtension(pi) {
3126
3236
  if (!st)
3127
3237
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3128
3238
  const project = await st.loadProject();
3129
- project.projectGuidelines = {
3130
- content: params.content.trim(),
3131
- updatedAt: project.projectGuidelines.updatedAt,
3132
- sessionInfo: project.projectGuidelines.sessionInfo,
3133
- };
3134
- await st.saveProject(project);
3239
+ await st.updateProject((current) => ({
3240
+ ...current,
3241
+ projectGuidelines: { ...current.projectGuidelines, content: params.content.trim() },
3242
+ }), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
3135
3243
  await st.writeGenerated();
3136
3244
  const refreshed = await st.loadProject();
3137
3245
  return {
@@ -3175,28 +3283,57 @@ export default function planPiExtension(pi) {
3175
3283
  pi.registerTool({
3176
3284
  name: "requirement_list",
3177
3285
  label: "Requirement List",
3178
- description: "List all top-level requirements in requirements.json.",
3179
- parameters: Type.Object({}),
3180
- async execute(_id, _params, _signal, _onUpdate, ctx) {
3286
+ description: "List declarative product Requirements: user, business, or system outcomes. Requirements never contain coding standards, best practices, formatting, verification process, or other Project Guidelines. Pass phaseRef to deliver and attest only the Requirements linked to that phase or its owning feature; an unscoped inventory does not satisfy task-start read gates.",
3287
+ parameters: Type.Object({
3288
+ phaseRef: Type.Optional(Type.String({ description: "Optional target phase ref. When supplied, return full target-relevant requirements and record only those delivered requirements as read." })),
3289
+ }),
3290
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3181
3291
  const st = await requirePlan(ctx);
3292
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
3182
3293
  if (!st)
3183
3294
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3184
3295
  const requirements = await st.loadRequirements();
3185
- requirements.requirements.forEach((req) => markRequirementReadForSessionId(plannerSessionId, req.id));
3296
+ if (!params.phaseRef?.trim()) {
3297
+ return {
3298
+ content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title}`).join("\n") || "No requirements" }],
3299
+ details: { ...requirements, attestedRequirementIds: [], readScope: "inventory" },
3300
+ };
3301
+ }
3302
+ const features = (await st.loadFeatures()).features;
3303
+ const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseRef.trim());
3304
+ if (!phase)
3305
+ return { isError: true, content: [{ type: "text", text: `Phase not found: ${params.phaseRef}` }], details: { attestedRequirementIds: [] } };
3306
+ const delivered = [
3307
+ ...(await st.linkedRequirementsForPhase(phase.id)),
3308
+ ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
3309
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
3310
+ delivered.forEach((requirement) => markRequirementReadForSessionId(plannerSessionId, requirement.id));
3311
+ const targetRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
3312
+ const body = delivered.map((requirement) => [
3313
+ `- ${requirement.id} — ${requirement.title}`,
3314
+ ` Description: ${requirement.description || "None"}`,
3315
+ ` Linked phases: ${requirement.linkedPhaseIds.join(", ") || "None"}`,
3316
+ ` Macro tasks: ${requirement.macroTasks.map((item) => `${item.title} (${item.status})`).join("; ") || "None"}`,
3317
+ ].join("\n")).join("\n");
3186
3318
  return {
3187
- content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
3188
- details: requirements,
3319
+ content: [{ type: "text", text: body || `No requirements linked to ${targetRef}` }],
3320
+ details: {
3321
+ ...requirements,
3322
+ requirements: delivered,
3323
+ attestedRequirementIds: delivered.map((requirement) => requirement.id),
3324
+ readScope: "target",
3325
+ target: { phaseId: phase.id, phaseRef: targetRef, featureId: phase.featureId },
3326
+ },
3189
3327
  };
3190
3328
  },
3191
3329
  });
3192
3330
  pi.registerTool({
3193
3331
  name: "requirement_create",
3194
3332
  label: "Requirement Create",
3195
- description: "Create a new top-level requirement in requirements.json.",
3333
+ description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use project_guidelines_update instead. Requirements have no lifecycle status.",
3196
3334
  parameters: Type.Object({
3197
3335
  title: Type.String({ description: "Requirement title" }),
3198
3336
  description: Type.Optional(Type.String({ description: "Requirement description" })),
3199
- status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
3200
3337
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
3201
3338
  macroTasks: Type.Optional(Type.Array(Type.Object({
3202
3339
  title: Type.String({ description: "Macro-task title" }),
@@ -3214,7 +3351,6 @@ export default function planPiExtension(pi) {
3214
3351
  const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
3215
3352
  if (!links.ok)
3216
3353
  return { content: [{ type: "text", text: links.error }], details: {} };
3217
- const requirements = await st.loadRequirements();
3218
3354
  const now = nowISO();
3219
3355
  let macroTasks;
3220
3356
  try {
@@ -3232,34 +3368,31 @@ export default function planPiExtension(pi) {
3232
3368
  id: createRequirementId(),
3233
3369
  title,
3234
3370
  description: params.description?.trim() ?? "",
3235
- status: params.status?.trim() ?? "planned",
3236
3371
  macroTasks,
3237
3372
  linkedPhaseIds: links.linkedPhaseIds,
3238
3373
  sessionInfo: [],
3239
3374
  createdAt: now,
3240
3375
  updatedAt: now,
3241
3376
  };
3242
- requirements.requirements.push(requirement);
3243
3377
  try {
3244
- await st.saveRequirements(requirements);
3378
+ await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
3245
3379
  }
3246
3380
  catch (error) {
3247
3381
  const message = error instanceof RequirementMacroTaskError ? error.message : error instanceof Error ? error.message : "Invalid requirement.";
3248
3382
  return { content: [{ type: "text", text: message }], details: {} };
3249
3383
  }
3250
3384
  await st.writeGenerated();
3251
- return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: requirement };
3385
+ return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: { requirement, created: true } };
3252
3386
  },
3253
3387
  });
3254
3388
  pi.registerTool({
3255
3389
  name: "requirement_update",
3256
3390
  label: "Requirement Update",
3257
- description: "Update an existing top-level requirement.",
3391
+ description: "Update a Requirement’s product outcome, linked phases, or ordered macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use project_guidelines_update instead. Requirements have no lifecycle status.",
3258
3392
  parameters: Type.Object({
3259
3393
  requirementId: Type.String({ description: "Requirement ID" }),
3260
3394
  title: Type.Optional(Type.String({ description: "Requirement title" })),
3261
3395
  description: Type.Optional(Type.String({ description: "Requirement description" })),
3262
- status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
3263
3396
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
3264
3397
  macroTasks: Type.Optional(Type.Array(Type.Object({
3265
3398
  id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
@@ -3276,41 +3409,46 @@ export default function planPiExtension(pi) {
3276
3409
  const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
3277
3410
  if (!requirement)
3278
3411
  return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
3279
- if (params.title !== undefined) {
3280
- const title = params.title.trim();
3281
- if (!title)
3282
- return { content: [{ type: "text", text: "title required" }], details: {} };
3283
- requirement.title = title;
3412
+ if (params.title !== undefined && !params.title.trim()) {
3413
+ return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
3284
3414
  }
3285
- if (params.description !== undefined)
3286
- requirement.description = params.description.trim();
3287
- if (params.status !== undefined)
3288
- requirement.status = params.status.trim();
3289
- if (params.linkedPhaseIds !== undefined) {
3290
- const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
3291
- if (!links.ok)
3292
- return { content: [{ type: "text", text: links.error }], details: {} };
3293
- requirement.linkedPhaseIds = links.linkedPhaseIds;
3415
+ if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
3416
+ return mutationNoFieldsFailure({
3417
+ entity: "requirement",
3418
+ ref: params.requirementId,
3419
+ operation: "update",
3420
+ mutableFields: ["title", "description", "linkedPhaseIds", "macroTasks"],
3421
+ retryCommand: `requirement_update ${params.requirementId}`,
3422
+ });
3294
3423
  }
3424
+ const links = params.linkedPhaseIds === undefined
3425
+ ? undefined
3426
+ : await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
3427
+ if (links && !links.ok)
3428
+ return { content: [{ type: "text", text: links.error }], details: { errorCode: "REQUIREMENT_PHASE_LINK_INVALID", updated: false } };
3295
3429
  const now = nowISO();
3296
- if (params.macroTasks !== undefined) {
3297
- try {
3298
- requirement.macroTasks = reconcileRequirementMacroTasks(requirement.macroTasks, params.macroTasks.map((task) => ({
3299
- ...(task.id ? { id: task.id } : {}),
3300
- title: task.title,
3301
- ...(task.description !== undefined ? { description: task.description } : {}),
3302
- status: task.status.trim(),
3303
- })), now);
3304
- }
3305
- catch (error) {
3306
- const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
3307
- return { content: [{ type: "text", text: message }], details: {} };
3308
- }
3430
+ let persisted;
3431
+ try {
3432
+ persisted = await st.updateRequirement(requirement.id, (canonical) => ({
3433
+ ...canonical,
3434
+ ...(params.title !== undefined ? { title: params.title.trim() } : {}),
3435
+ ...(params.description !== undefined ? { description: params.description.trim() } : {}),
3436
+ ...(links?.ok ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
3437
+ ...(params.macroTasks !== undefined ? { macroTasks: reconcileRequirementMacroTasks(canonical.macroTasks, params.macroTasks.map((task) => ({
3438
+ ...(task.id ? { id: task.id } : {}),
3439
+ title: task.title,
3440
+ ...(task.description !== undefined ? { description: task.description } : {}),
3441
+ status: task.status.trim(),
3442
+ })), now) } : {}),
3443
+ updatedAt: now,
3444
+ }));
3445
+ }
3446
+ catch (error) {
3447
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
3448
+ return { content: [{ type: "text", text: message }], details: {} };
3309
3449
  }
3310
- requirement.updatedAt = now;
3311
- await st.saveRequirements(requirements);
3312
3450
  await st.writeGenerated();
3313
- return { content: [{ type: "text", text: `Requirement updated: ${requirement.id}` }], details: requirement };
3451
+ return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
3314
3452
  },
3315
3453
  });
3316
3454
  pi.registerTool({
@@ -3324,14 +3462,17 @@ export default function planPiExtension(pi) {
3324
3462
  const st = await requirePlan(ctx);
3325
3463
  if (!st)
3326
3464
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3327
- const requirements = await st.loadRequirements();
3328
- const next = requirements.requirements.filter((entry) => entry.id !== params.requirementId);
3329
- if (next.length === requirements.requirements.length) {
3330
- return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
3465
+ let deleted = false;
3466
+ await st.updateRequirements((document) => {
3467
+ const requirements = document.requirements.filter((entry) => entry.id !== params.requirementId);
3468
+ deleted = requirements.length !== document.requirements.length;
3469
+ return deleted ? { requirements } : document;
3470
+ });
3471
+ if (!deleted) {
3472
+ return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: { errorCode: "NOT_FOUND", deleted: false } };
3331
3473
  }
3332
- await st.saveRequirements({ requirements: next });
3333
3474
  await st.writeGenerated();
3334
- return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: params.requirementId } };
3475
+ return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: true, requirementId: params.requirementId } };
3335
3476
  },
3336
3477
  });
3337
3478
  pi.registerTool({
@@ -3354,7 +3495,7 @@ export default function planPiExtension(pi) {
3354
3495
  async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3355
3496
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3356
3497
  return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
3357
- return { content: [{ type: "text", text: "No mutable idea fields were received." }], details: { errorCode: "NO_MUTABLE_FIELDS_RECEIVED", updated: false } }; const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}` }], details: { idea, updated: true } }; },
3498
+ return mutationNoFieldsFailure({ entity: "idea", ref: params.idea, operation: "update", mutableFields: ["title", "description"], retryCommand: `idea_update ${params.idea}` }); const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}` }], details: { idea, updated: true } }; },
3358
3499
  });
3359
3500
  pi.registerTool({
3360
3501
  name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
@@ -3408,19 +3549,47 @@ export default function planPiExtension(pi) {
3408
3549
  pi.registerTool({
3409
3550
  name: "plan_get",
3410
3551
  label: "Plan Get",
3411
- description: "Read the full plan: manifest, project, features, phases (with their tasks), and requirements. Call this first to understand current state before planning work.",
3552
+ description: "Read the full plan plus explicit active-task evidence: manifest, project (including Accepted Decisions), features, phases (with their tasks), requirements, activeTaskState, and activeTasks. Never infer that no task is active from counts or omitted detail.",
3412
3553
  parameters: Type.Object({}),
3413
3554
  async execute(_id, _params, _signal, _onUpdate, ctx) {
3414
3555
  const st = await requirePlan(ctx);
3415
3556
  if (!st)
3416
3557
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3417
3558
  const plan = await st.loadAll();
3559
+ const activeTasks = plan.phases.flatMap((phase) => phase.tasks
3560
+ .filter((task) => task.status === "in-progress")
3561
+ .map((task) => ({
3562
+ id: task.id,
3563
+ ref: `${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
3564
+ title: task.title,
3565
+ status: task.status,
3566
+ })));
3567
+ const activeTaskEvidence = activeTasks.length === 0
3568
+ ? "Active tasks: none (verified from all persisted phase task statuses)"
3569
+ : `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
3570
+ const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
3418
3571
  return {
3419
- content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements` }],
3420
- details: plan,
3572
+ content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements. ${activeTaskEvidence}\n${projectDecisionContext.content}` }],
3573
+ details: { ...plan, activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict", activeTasks },
3421
3574
  };
3422
3575
  },
3423
3576
  });
3577
+ pi.registerTool({
3578
+ name: "description_freshness",
3579
+ label: "Description Freshness",
3580
+ description: "Preview deterministic child-to-parent description drift. Returns exact stale phase/feature refs and leaf-to-root reconciliation steps without rewriting user-authored descriptions.",
3581
+ parameters: Type.Object({}),
3582
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
3583
+ const st = await requirePlan(ctx);
3584
+ if (!st)
3585
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3586
+ const freshness = await st.previewDescriptionReconciliation();
3587
+ if (!freshness.reconciliationRequired)
3588
+ return { content: [{ type: "text", text: "Description hierarchy is fresh; no parent reconciliation is required." }], details: freshness };
3589
+ const lines = freshness.reconciliationPreview.map((step) => `- ${step.ownerRef} after ${step.causedByRef}: ${step.action}`);
3590
+ return { content: [{ type: "text", text: `Parent description reconciliation required (${freshness.staleParentRefs.length}):\n${lines.join("\n")}` }], details: freshness };
3591
+ },
3592
+ });
3424
3593
  pi.registerTool({
3425
3594
  name: "plan_render",
3426
3595
  label: "Plan Render",
@@ -3506,7 +3675,7 @@ export default function planPiExtension(pi) {
3506
3675
  return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
3507
3676
  const result = boundedPage(list, 1, 10);
3508
3677
  const { items, ...pageInfo } = result;
3509
- const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
3678
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
3510
3679
  return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Page 1/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}` }], details: { deprecated: true, count: items.length, ...pageInfo, handoffs: items } };
3511
3680
  },
3512
3681
  });
@@ -3612,14 +3781,14 @@ export default function planPiExtension(pi) {
3612
3781
  return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
3613
3782
  const result = boundedPage(list, params.page ?? 1, params.pageSize ?? 10);
3614
3783
  const { items, ...pageInfo } = result;
3615
- const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
3784
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
3616
3785
  return { content: [{ type: "text", text: `Phase handoffs — page ${pageInfo.page}/${pageInfo.totalPages} (${pageInfo.total} total):\n${lines.join("\n")}` }], details: { count: items.length, ...pageInfo, handoffs: items } };
3617
3786
  },
3618
3787
  });
3619
3788
  pi.registerTool({
3620
3789
  name: "handoff_show",
3621
3790
  label: "Handoff Show",
3622
- description: "Read the entity-scoped handoff of a phase. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
3791
+ description: "Read the active entity-scoped handoff of a phase, or its latest terminal archive, together with a bounded priority-ordered phase work map. Reread the canonical phase and relevant sibling tasks before proposing work so capability ownership is not duplicated. phaseRef is required and accepts P00x, P00x(F00x), UUID, or title. Never infer a phase automatically.",
3623
3792
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
3624
3793
  async execute(_id, params, _signal, _onUpdate, ctx) {
3625
3794
  const st = await requirePlan(ctx);
@@ -3630,18 +3799,98 @@ export default function planPiExtension(pi) {
3630
3799
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3631
3800
  const phase = await st.loadPhase(r.phase.id);
3632
3801
  const bounded = boundedHandoffForTransport(phase.handoff);
3633
- if (!bounded.content.trim())
3634
- return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, handoffAudit: phase.handoffAudit } };
3802
+ if (!bounded.content.trim()) {
3803
+ const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
3804
+ && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
3805
+ if (archived) {
3806
+ const archivedContent = boundedHandoffForTransport(archived.content);
3807
+ const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
3808
+ const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
3809
+ return {
3810
+ content: [{ type: "text", text: [
3811
+ `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
3812
+ ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
3813
+ "",
3814
+ archivedContent.content,
3815
+ ].join("\n") }],
3816
+ details: {
3817
+ phaseRef: r.compositeRef,
3818
+ phaseId: phase.id,
3819
+ active: false,
3820
+ archived: true,
3821
+ archiveReason: archived.reason,
3822
+ archivedAt: archived.archivedAt,
3823
+ archiveFile: archived.file,
3824
+ ...archivedContent,
3825
+ supportingDocuments,
3826
+ empty: false,
3827
+ resumeReady: false,
3828
+ handoffAudit: phase.handoffAudit,
3829
+ },
3830
+ };
3831
+ }
3832
+ return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit } };
3833
+ }
3834
+ const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
3835
+ const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
3836
+ const contentHash = handoffContentHash(phase.handoff);
3837
+ const persistenceVerified = Boolean(phase.handoffAudit
3838
+ && phase.handoffAudit.contentHash === contentHash
3839
+ && phase.handoffAudit.contentLength === phase.handoff.length);
3840
+ const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
3841
+ const status = resumeReady
3842
+ ? "Resume-ready: persisted compact capsule read-back completed."
3843
+ : "NOT resume-ready: after reading this persisted capsule, call handoff_verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
3635
3844
  return {
3636
- content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${bounded.content}` }],
3637
- details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit },
3845
+ content: [{ type: "text", text: `Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}` }],
3846
+ details: { phaseRef: r.compositeRef, phaseId: r.phase.id, phaseWorkMap, ...bounded, contentHash, persistenceVerified, resumeReady, verificationRequired: !resumeReady, handoffAudit: phase.handoffAudit },
3638
3847
  };
3639
3848
  },
3640
3849
  });
3850
+ pi.registerTool({
3851
+ name: "handoff_verify",
3852
+ label: "Handoff Verify",
3853
+ description: "Finalize one persisted handoff as resume-ready after handoff_show confirms the persisted compact resume capsule. Legacy sourceReviews and omissionsFound evidence remains accepted but is optional; the planner derives omitted evidence from persisted state.",
3854
+ parameters: Type.Object({
3855
+ phaseRef: Type.String(),
3856
+ expectedContentHash: Type.String({ pattern: "^[a-f0-9]{64}$" }),
3857
+ sourceReviews: Type.Optional(Type.Array(Type.Object({
3858
+ source: Type.Union([Type.Literal("conversation"), Type.Literal("planner-entities"), Type.Literal("working-tree"), Type.Literal("verification-runtime"), Type.Literal("peer-agent-output")]),
3859
+ detail: Type.String({ minLength: 12 }),
3860
+ }))),
3861
+ omissionsFound: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
3862
+ }),
3863
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3864
+ const st = await requirePlan(ctx);
3865
+ if (!st)
3866
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3867
+ const r = await resolvePhaseForHandoff(st, params.phaseRef);
3868
+ if (!r.ok)
3869
+ return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { resumeReady: false, error: r.error } };
3870
+ try {
3871
+ const result = await st.verifyPhaseHandoffReadBack(r.phase.id, {
3872
+ expectedContentHash: params.expectedContentHash,
3873
+ ...(params.sourceReviews ? { sourceReviews: params.sourceReviews } : {}),
3874
+ ...(params.omissionsFound ? { omissionsFound: params.omissionsFound } : {}),
3875
+ });
3876
+ await st.writeGenerated();
3877
+ return {
3878
+ content: [{ type: "text", text: `✅ Handoff on ${r.compositeRef} is resume-ready after persisted read-back and source reconciliation.` }],
3879
+ details: { phaseRef: r.compositeRef, ...result, resumeReady: true, verificationRequired: false },
3880
+ };
3881
+ }
3882
+ catch (error) {
3883
+ if (error instanceof HandoffContractError)
3884
+ return handoffContractFailure(error);
3885
+ const message = error instanceof Error ? error.message : String(error);
3886
+ return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
3887
+ }
3888
+ },
3889
+ });
3641
3890
  pi.registerTool({
3642
3891
  name: "handoff_prepare",
3643
3892
  label: "Handoff Prepare",
3644
- description: "Audit one exact phase before writing a handoff. Returns the current handoff token, missing task evidence, the required versioned completeness categories, and the 24,000-character inline budget. Reconcile everything through handoff_write.",
3893
+ description: "Prepare one exact phase before writing a handoff. Returns a compact resume capsule scaffold, bounded phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; do not duplicate planner evidence in Markdown.",
3645
3894
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3646
3895
  async execute(_id, params, _signal, _onUpdate, ctx) {
3647
3896
  const st = await requirePlan(ctx);
@@ -3652,7 +3901,7 @@ export default function planPiExtension(pi) {
3652
3901
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3653
3902
  try {
3654
3903
  const audit = await st.preparePhaseHandoff(r.phase.id);
3655
- const { handoff, ...auditDetails } = audit;
3904
+ const { handoff, completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...auditDetails } = audit;
3656
3905
  const boundedExisting = boundedHandoffForTransport(handoff);
3657
3906
  const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3658
3907
  const existing = boundedExisting.content.trim() || "(none)";
@@ -3660,17 +3909,30 @@ export default function planPiExtension(pi) {
3660
3909
  content: [{ type: "text", text: [
3661
3910
  `Handoff preparation audit for ${r.compositeRef}`,
3662
3911
  `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
3912
+ "Required human inputs before drafting:",
3913
+ ...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
3914
+ "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
3915
+ "Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
3916
+ audit.draftTemplate,
3917
+ "",
3663
3918
  "Done tasks missing durable completion/verification evidence:",
3664
3919
  missing,
3665
3920
  "",
3666
- `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
3667
- ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3668
- `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
3921
+ "Compact handoff contract: include only the exact focus/resume point, current or partial state, preservation constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
3922
+ "No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
3923
+ "Completeness audit and cold-start evidence are planner-owned metadata. Do not copy their categories or source reviews into Markdown; provide legacy evidence only when it already exists.",
3924
+ `Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
3669
3925
  "",
3670
3926
  "Existing active handoff (reconcile all still-relevant content):",
3671
3927
  existing,
3672
3928
  ].join("\n") }],
3673
- details: { phaseRef: r.compositeRef, ...auditDetails, existingHandoffLength: boundedExisting.fullLength, existingHandoffTruncated: boundedExisting.truncated },
3929
+ details: {
3930
+ phaseRef: r.compositeRef,
3931
+ ...auditDetails,
3932
+ evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
3933
+ existingHandoffLength: boundedExisting.fullLength,
3934
+ existingHandoffTruncated: boundedExisting.truncated,
3935
+ },
3674
3936
  };
3675
3937
  }
3676
3938
  catch (error) {
@@ -3682,10 +3944,11 @@ export default function planPiExtension(pi) {
3682
3944
  pi.registerTool({
3683
3945
  name: "handoff_write",
3684
3946
  label: "Handoff Write",
3685
- description: "Reconcile and refresh one active handoff with durable context synchronization and a mandatory versioned completeness audit. Run handoff_prepare first. Every category must be captured or substantively not-applicable; extended detail belongs in linked .planner/docs/ Markdown.",
3947
+ description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
3686
3948
  parameters: Type.Object({
3687
3949
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3688
3950
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
3951
+ reason: Type.Optional(Type.String({ description: "Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown." })),
3689
3952
  confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
3690
3953
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
3691
3954
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
@@ -3696,7 +3959,19 @@ export default function planPiExtension(pi) {
3696
3959
  entries: Type.Array(Type.Object({
3697
3960
  category: Type.String({ description: `One required category id from handoff_prepare: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
3698
3961
  status: Type.Union([Type.Literal("captured"), Type.Literal("not-applicable")]),
3699
- detail: Type.String({ description: "Substantive captured detail or a substantive reason why the category is not applicable." }),
3962
+ detail: Type.String({ description: "Legacy evidence detail; omit the entire audit for compact handoffs." }),
3963
+ })),
3964
+ })),
3965
+ coldStartInventory: Type.Optional(Type.Object({
3966
+ version: Type.Literal(HANDOFF_COLD_START_INVENTORY_VERSION),
3967
+ sourceReviews: Type.Array(Type.Object({
3968
+ source: Type.String({ description: `One required source review id: ${HANDOFF_COLD_START_SOURCE_REVIEWS.map((entry) => entry.id).join(", ")}` }),
3969
+ detail: Type.String({ description: "Substantive summary of what was reviewed and which facts were extracted before drafting." }),
3970
+ })),
3971
+ entries: Type.Array(Type.Object({
3972
+ category: Type.String({ description: `One required cold-start category id from handoff_prepare: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
3973
+ items: Type.Array(Type.String({ description: "Legacy resume evidence item; omit the entire inventory for compact handoffs." })),
3974
+ notApplicableReason: Type.Optional(Type.String({ description: "Legacy evidence explanation." })),
3700
3975
  })),
3701
3976
  })),
3702
3977
  supportingDocuments: Type.Optional(Type.Array(Type.Object({
@@ -3749,7 +4024,9 @@ export default function planPiExtension(pi) {
3749
4024
  if (!params.confirmed)
3750
4025
  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 } };
3751
4026
  if (params.expectedHandoffUpdatedAt === undefined)
3752
- return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { error: "preparation required" } };
4027
+ return { isError: true, content: [{ type: "text", text: "❌ Handoff refresh denied [HANDOFF_PREFLIGHT_REQUIRED]: Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { errorCode: "HANDOFF_PREFLIGHT_REQUIRED", persisted: false } };
4028
+ if (!params.reason?.trim())
4029
+ return { isError: true, content: [{ type: "text", text: "❌ Handoff refresh denied [HANDOFF_REASON_REQUIRED]: Handoff reason is required before persistence. Created at and Updated at are generated by the planner; do not add them to Markdown." }], details: { errorCode: "HANDOFF_REASON_REQUIRED", persisted: false, requiredInputs: ["title", "reason"], generatedMetadata: ["Created at", "Updated at"] } };
3753
4030
  const phases = await st.loadAllPhases();
3754
4031
  const features = (await st.loadFeatures()).features;
3755
4032
  const taskUpdates = [];
@@ -3768,10 +4045,12 @@ export default function planPiExtension(pi) {
3768
4045
  }
3769
4046
  try {
3770
4047
  const result = await st.refreshPhaseHandoff(r.phase.id, {
4048
+ reason: params.reason.trim(),
3771
4049
  content: body,
3772
4050
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
3773
4051
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
3774
4052
  ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
4053
+ ...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
3775
4054
  ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
3776
4055
  contextSync: {
3777
4056
  taskUpdates,
@@ -3787,13 +4066,20 @@ export default function planPiExtension(pi) {
3787
4066
  });
3788
4067
  await st.writeGenerated();
3789
4068
  return {
3790
- content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }],
4069
+ content: [{ type: "text", text: `⚠️ Handoff candidate persisted on ${r.compositeRef}, but it is NOT resume-ready yet. Required next action: call handoff_show ${r.compositeRef}, read the persisted compact capsule, then call handoff_verify with the returned contentHash. Rewrite only if resume-critical context is missing.` }],
3791
4070
  details: {
3792
4071
  phaseRef: r.compositeRef,
3793
4072
  phaseId: r.phase.id,
3794
4073
  updatedTaskIds: result.updatedTaskIds,
3795
4074
  handoffUpdatedAt: result.handoffUpdatedAt,
3796
4075
  handoffAudit: result.handoffAudit,
4076
+ persisted: true,
4077
+ resumeReady: false,
4078
+ verificationRequired: true,
4079
+ nextActions: [
4080
+ `Call handoff_show ${r.compositeRef} and read the persisted compact capsule.`,
4081
+ "Call handoff_verify with the shown contentHash; legacy sourceReviews and omissionsFound are optional because the planner derives omitted evidence from persisted state.",
4082
+ ],
3797
4083
  },
3798
4084
  };
3799
4085
  }
@@ -3875,13 +4161,14 @@ export default function planPiExtension(pi) {
3875
4161
  pi.registerTool({
3876
4162
  name: "feature_get",
3877
4163
  label: "Feature Get",
3878
- description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
4164
+ description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, and derived linked requirements.",
3879
4165
  parameters: Type.Object({
3880
4166
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3881
4167
  full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
3882
4168
  }),
3883
4169
  async execute(_id, params, _signal, _onUpdate, ctx) {
3884
4170
  const st = await requirePlan(ctx);
4171
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
3885
4172
  if (!st)
3886
4173
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3887
4174
  const features = (await st.loadFeatures()).features;
@@ -3890,19 +4177,30 @@ export default function planPiExtension(pi) {
3890
4177
  if (!resolvedFeature.ok)
3891
4178
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3892
4179
  const feature = resolvedFeature.feature;
3893
- if (params.full)
3894
- markFeatureReadForSessionId(plannerSessionId, feature.id);
3895
4180
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3896
4181
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3897
4182
  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"}` : ""})`;
3898
4183
  const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
3899
4184
  const requirementsBlock = linkedRequirements.length > 0
3900
- ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
4185
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
3901
4186
  : "\n\nLinked requirements:\n- None linked to this feature.";
3902
- return {
3903
- content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary }],
3904
- details: { feature: params.full ? { ref: formatFeatureRef(feature.number), description: feature.description, descriptionRef: feature.descriptionRef ?? "" } : undefined, linkedRequirements },
4187
+ if (!params.full)
4188
+ return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
4189
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
4190
+ const result = {
4191
+ content: [{ type: "text", text: `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
4192
+ details: {
4193
+ feature: {
4194
+ ref: formatFeatureRef(feature.number),
4195
+ description: feature.description,
4196
+ descriptionRef: feature.descriptionRef ?? "",
4197
+ acceptedDecisions: feature.acceptedDecisions,
4198
+ },
4199
+ linkedRequirements,
4200
+ },
3905
4201
  };
4202
+ markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
4203
+ return result;
3906
4204
  },
3907
4205
  });
3908
4206
  pi.registerTool({
@@ -3918,44 +4216,46 @@ export default function planPiExtension(pi) {
3918
4216
  const st = await requirePlan(ctx);
3919
4217
  if (!st)
3920
4218
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3921
- const now = nowISO();
3922
- const status = params.status ?? "planned";
3923
- const currentFeatures = (await st.loadFeatures()).features;
3924
- const id = createFeatureId();
3925
- const identity = await st.allocateEntityIdentity("feature", id);
3926
- const priority = await st.nextPriority("feature");
3927
- const feature = {
3928
- id,
3929
- number: identity.number,
3930
- shortId: identity.shortId,
3931
- priority,
3932
- name: params.name,
3933
- description: params.description ?? "",
3934
- descriptionUpdatedAt: now,
3935
- status,
3936
- discussedAt: "",
3937
- contextReady: false,
3938
- contextReadyReason: "",
3939
- startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
3940
- endDate: "",
3941
- workDone: "",
3942
- workRemaining: "",
3943
- acceptedDecisions: [],
3944
- phaseIds: [],
3945
- dependsOn: [],
3946
- statusLog: [],
3947
- sessionInfo: [],
3948
- createdAt: now,
3949
- updatedAt: now,
3950
- };
3951
- // Atomic read-modify-write: serializes concurrent feature_create calls
3952
- // (batch) so they don't overwrite each other (last-write-wins race).
3953
- await st.updateFeatures((doc) => {
3954
- doc.features.push(feature);
3955
- return doc;
4219
+ return st.runBatch(async () => {
4220
+ const now = nowISO();
4221
+ const status = params.status ?? "planned";
4222
+ const currentFeatures = (await st.loadFeatures()).features;
4223
+ const id = createFeatureId();
4224
+ const identity = await st.allocateEntityIdentity("feature", id);
4225
+ const priority = await st.nextPriority("feature");
4226
+ const feature = {
4227
+ id,
4228
+ number: identity.number,
4229
+ shortId: identity.shortId,
4230
+ priority,
4231
+ name: params.name,
4232
+ description: params.description ?? "",
4233
+ descriptionUpdatedAt: now,
4234
+ status,
4235
+ discussedAt: "",
4236
+ contextReady: false,
4237
+ contextReadyReason: "",
4238
+ startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
4239
+ endDate: "",
4240
+ workDone: "",
4241
+ workRemaining: "",
4242
+ acceptedDecisions: [],
4243
+ phaseIds: [],
4244
+ dependsOn: [],
4245
+ statusLog: [],
4246
+ sessionInfo: [],
4247
+ createdAt: now,
4248
+ updatedAt: now,
4249
+ };
4250
+ // Atomic read-modify-write: serializes concurrent feature_create calls
4251
+ // (batch) so they don't overwrite each other (last-write-wins race).
4252
+ await st.updateFeatures((doc) => {
4253
+ doc.features.push(feature);
4254
+ return doc;
4255
+ });
4256
+ await st.writeGenerated();
4257
+ return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3956
4258
  });
3957
- await st.writeGenerated();
3958
- return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3959
4259
  },
3960
4260
  });
3961
4261
  pi.registerTool({
@@ -4032,13 +4332,13 @@ export default function planPiExtension(pi) {
4032
4332
  pi.registerTool({
4033
4333
  name: "feature_update",
4034
4334
  label: "Feature Update",
4035
- description: "Update one or more fields of a feature. Only provided fields are changed. IMPORTANT: feature status is derived from child phases/tasks, so do not update feature.status directly unless truly necessary.",
4335
+ description: "Update one or more canonical feature fields. Feature status is derived from child phases/tasks and cannot be changed directly.",
4036
4336
  parameters: Type.Object({
4037
4337
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
4038
4338
  name: Type.Optional(Type.String({ description: "New name" })),
4039
4339
  description: Type.Optional(Type.String({ description: "New description" })),
4040
4340
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4041
- status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: feature status is derived from child phases/tasks." })),
4341
+ status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Feature status is derived from child phases/tasks; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
4042
4342
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
4043
4343
  endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
4044
4344
  workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
@@ -4051,7 +4351,7 @@ export default function planPiExtension(pi) {
4051
4351
  rationale: Type.String(),
4052
4352
  implementationNotes: Type.String(),
4053
4353
  acceptedAt: Type.String(),
4054
- }), { description: "Replace accepted decisions list" })),
4354
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
4055
4355
  }),
4056
4356
  async execute(_id, params, _signal, _onUpdate, ctx) {
4057
4357
  const st = await requirePlan(ctx);
@@ -4065,6 +4365,13 @@ export default function planPiExtension(pi) {
4065
4365
  if (params.status !== undefined) {
4066
4366
  return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4067
4367
  }
4368
+ if (params.acceptedDecisions !== undefined) {
4369
+ return {
4370
+ content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
4371
+ details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
4372
+ isError: true,
4373
+ };
4374
+ }
4068
4375
  const mutableFields = [
4069
4376
  "name",
4070
4377
  "description",
@@ -4074,7 +4381,6 @@ export default function planPiExtension(pi) {
4074
4381
  "workDone",
4075
4382
  "workRemaining",
4076
4383
  "priority",
4077
- "acceptedDecisions",
4078
4384
  ];
4079
4385
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4080
4386
  if (receivedFields.length === 0) {
@@ -4120,8 +4426,6 @@ export default function planPiExtension(pi) {
4120
4426
  feature.endDate = params.endDate.trim();
4121
4427
  if (params.priority !== undefined)
4122
4428
  feature.priority = params.priority;
4123
- if (params.acceptedDecisions !== undefined)
4124
- feature.acceptedDecisions = params.acceptedDecisions;
4125
4429
  feature.updatedAt = nowISO();
4126
4430
  return doc;
4127
4431
  });
@@ -4160,22 +4464,24 @@ export default function planPiExtension(pi) {
4160
4464
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
4161
4465
  const featureId = resolvedFeature.feature.id;
4162
4466
  let deleted = false;
4163
- await st.updateFeatures((doc) => {
4164
- const before = doc.features.length;
4165
- doc.features = doc.features.filter((f) => f.id !== featureId);
4166
- if (doc.features.length !== before)
4167
- deleted = true;
4168
- return doc;
4169
- });
4170
4467
  let cascadeCount = 0;
4171
- if (params.cascade) {
4172
- const phases = await st.loadAllPhases();
4173
- for (const phase of phases.filter((p) => p.featureId === featureId)) {
4174
- await st.deletePhase(phase.id);
4175
- cascadeCount += 1;
4468
+ await st.runBatch(async () => {
4469
+ await st.updateFeatures((doc) => {
4470
+ const before = doc.features.length;
4471
+ doc.features = doc.features.filter((f) => f.id !== featureId);
4472
+ if (doc.features.length !== before)
4473
+ deleted = true;
4474
+ return doc;
4475
+ });
4476
+ if (params.cascade) {
4477
+ const phases = await st.loadAllPhases();
4478
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
4479
+ await st.deletePhase(phase.id);
4480
+ cascadeCount += 1;
4481
+ }
4176
4482
  }
4177
- }
4178
- await st.writeGenerated();
4483
+ await st.writeGenerated();
4484
+ });
4179
4485
  return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
4180
4486
  },
4181
4487
  });
@@ -4214,32 +4520,44 @@ export default function planPiExtension(pi) {
4214
4520
  pi.registerTool({
4215
4521
  name: "phase_get",
4216
4522
  label: "Phase Get",
4217
- description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
4523
+ description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, and derived linked requirements.",
4218
4524
  parameters: Type.Object({
4219
4525
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
4220
4526
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
4221
4527
  }),
4222
4528
  async execute(_id, params, _signal, _onUpdate, ctx) {
4223
4529
  const st = await requirePlan(ctx);
4530
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
4224
4531
  if (!st)
4225
4532
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4226
4533
  const features = (await st.loadFeatures()).features;
4227
4534
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
4228
4535
  if (!phase)
4229
4536
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
4230
- if (params.full)
4231
- markPhaseReadForSessionId(plannerSessionId, phase.id);
4232
4537
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
4233
4538
  const reqCount = linkedRequirements.length;
4234
4539
  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"}` : ""})`;
4235
4540
  const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
4236
4541
  const requirementsBlock = reqCount > 0
4237
- ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
4542
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
4238
4543
  : "";
4239
- return {
4240
- content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary }],
4241
- details: { phase: params.full ? { ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)), description: phase.description, descriptionRef: phase.descriptionRef ?? "" } : undefined, linkedRequirements },
4544
+ if (!params.full)
4545
+ return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
4546
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
4547
+ const result = {
4548
+ content: [{ type: "text", text: `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
4549
+ details: {
4550
+ phase: {
4551
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
4552
+ description: phase.description,
4553
+ descriptionRef: phase.descriptionRef ?? "",
4554
+ acceptedDecisions: phase.acceptedDecisions,
4555
+ },
4556
+ linkedRequirements,
4557
+ },
4242
4558
  };
4559
+ markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
4560
+ return result;
4243
4561
  },
4244
4562
  });
4245
4563
  pi.registerTool({
@@ -4274,7 +4592,7 @@ export default function planPiExtension(pi) {
4274
4592
  if (!existingFeature)
4275
4593
  return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
4276
4594
  let phase;
4277
- await withFeatureLock(featureId, async () => {
4595
+ await st.runBatch(() => withFeatureLock(featureId, async () => {
4278
4596
  const id = createPhaseId();
4279
4597
  const identity = await st.allocateEntityIdentity("phase", id);
4280
4598
  const priority = await st.nextPriority("phase", featureId);
@@ -4307,7 +4625,7 @@ export default function planPiExtension(pi) {
4307
4625
  return doc;
4308
4626
  });
4309
4627
  await st.writeGenerated();
4310
- });
4628
+ }));
4311
4629
  if (!phase)
4312
4630
  return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
4313
4631
  const phaseCreateFeatures = (await st.loadFeatures()).features;
@@ -4315,17 +4633,97 @@ export default function planPiExtension(pi) {
4315
4633
  },
4316
4634
  });
4317
4635
  pi.registerTool({
4318
- name: "phase_update",
4319
- label: "Phase Update",
4320
- description: "Update one or more fields of a phase. Only provided fields are changed. Supports re-linking to a feature via featureId. IMPORTANT: phase status is derived from task statuses, so do not update phase.status directly unless truly necessary.",
4636
+ name: "phase_discuss",
4637
+ label: "Phase Discuss",
4638
+ description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
4321
4639
  parameters: Type.Object({
4322
4640
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
4323
- title: Type.Optional(Type.String({ description: "New title" })),
4324
- status: Type.Optional(Type.String({ description: "New status: draft|discovery|planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: phase status is derived from task statuses." })),
4325
- summary: Type.Optional(Type.String({ description: "New summary" })),
4326
- description: Type.Optional(Type.String({ description: "New description" })),
4641
+ goal: Type.Optional(Type.String({ description: "Backward-compatible single main goal" })),
4642
+ goals: Type.Optional(Type.Array(Type.String(), { description: "Replace the phase goals list" })),
4643
+ summary: Type.Optional(Type.String()),
4644
+ scope: Type.Optional(Type.String({ description: "Current phase scope; stored as the phase description" })),
4327
4645
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
4328
- featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
4646
+ nonGoals: Type.Optional(Type.Array(Type.String())),
4647
+ dependencies: Type.Optional(Type.Array(Type.String())),
4648
+ risks: Type.Optional(Type.Array(Type.String())),
4649
+ openQuestions: Type.Optional(Type.Array(Type.String())),
4650
+ completionCriteria: Type.Optional(Type.Array(Type.String())),
4651
+ }),
4652
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4653
+ const st = await requirePlan(ctx);
4654
+ if (!st)
4655
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4656
+ const features = (await st.loadFeatures()).features;
4657
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
4658
+ if (!resolvedPhase)
4659
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: { discussed: false, errorCode: "PHASE_NOT_FOUND" }, isError: true };
4660
+ const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4661
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4662
+ if (receivedFields.length === 0) {
4663
+ return mutationNoFieldsFailure({
4664
+ entity: "phase",
4665
+ ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4666
+ operation: "discuss",
4667
+ mutableFields,
4668
+ retryCommand: `phase_discuss ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
4669
+ readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
4670
+ descriptionFallback: {
4671
+ entityId: resolvedPhase.id,
4672
+ inputField: "scope",
4673
+ storedField: "phase description",
4674
+ descriptionRefField: "descriptionRef",
4675
+ },
4676
+ });
4677
+ }
4678
+ const phase = await st.updatePhase(resolvedPhase.id, (current) => {
4679
+ if (params.goals !== undefined)
4680
+ current.goals = params.goals.map((item) => item.trim()).filter(Boolean);
4681
+ else if (params.goal !== undefined)
4682
+ current.goals = [params.goal.trim()].filter(Boolean);
4683
+ if (params.summary !== undefined)
4684
+ current.summary = params.summary.trim();
4685
+ if (params.scope !== undefined)
4686
+ current.description = params.scope.trim();
4687
+ if (params.descriptionRef !== undefined) {
4688
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4689
+ if (descriptionRef)
4690
+ current.descriptionRef = descriptionRef;
4691
+ else
4692
+ delete current.descriptionRef;
4693
+ }
4694
+ if (params.nonGoals !== undefined)
4695
+ current.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
4696
+ if (params.dependencies !== undefined)
4697
+ current.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
4698
+ if (params.risks !== undefined)
4699
+ current.risks = params.risks.map((item) => item.trim()).filter(Boolean);
4700
+ if (params.openQuestions !== undefined)
4701
+ current.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
4702
+ if (params.completionCriteria !== undefined)
4703
+ current.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
4704
+ current.discussedAt = nowISO();
4705
+ current.contextReady = true;
4706
+ current.contextReadyReason = "Updated through phase_discuss tool.";
4707
+ current.updatedAt = nowISO();
4708
+ return current;
4709
+ });
4710
+ await st.writeGenerated();
4711
+ const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
4712
+ return { content: [{ type: "text", text: `✅ Phase discussed/context ready: ${phaseRef} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, discussed: true, contextReady: true, updatedFields: receivedFields } };
4713
+ },
4714
+ });
4715
+ pi.registerTool({
4716
+ name: "phase_update",
4717
+ label: "Phase Update",
4718
+ description: "Update one or more canonical phase fields and optionally re-link the phase via featureId. Phase status is derived from task statuses and cannot be changed directly.",
4719
+ parameters: Type.Object({
4720
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
4721
+ title: Type.Optional(Type.String({ description: "New title" })),
4722
+ status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
4723
+ summary: Type.Optional(Type.String({ description: "New summary" })),
4724
+ description: Type.Optional(Type.String({ description: "New description" })),
4725
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
4726
+ featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
4329
4727
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
4330
4728
  goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
4331
4729
  nonGoals: Type.Optional(Type.Array(Type.String(), { description: "Replace non-goals list" })),
@@ -4340,7 +4738,7 @@ export default function planPiExtension(pi) {
4340
4738
  rationale: Type.String(),
4341
4739
  implementationNotes: Type.String(),
4342
4740
  acceptedAt: Type.String(),
4343
- }), { description: "Replace accepted decisions list" })),
4741
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
4344
4742
  completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
4345
4743
  }),
4346
4744
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -4362,7 +4760,14 @@ export default function planPiExtension(pi) {
4362
4760
  if (params.status !== undefined) {
4363
4761
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4364
4762
  }
4365
- const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "acceptedDecisions", "completionCriteria"];
4763
+ if (params.acceptedDecisions !== undefined) {
4764
+ return {
4765
+ content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
4766
+ details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4767
+ isError: true,
4768
+ };
4769
+ }
4770
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
4366
4771
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4367
4772
  if (receivedFields.length === 0) {
4368
4773
  return mutationNoFieldsFailure({
@@ -4382,39 +4787,6 @@ export default function planPiExtension(pi) {
4382
4787
  }
4383
4788
  const prevFeatureId = phase.featureId;
4384
4789
  let nextFeatureIdForUpdate = prevFeatureId;
4385
- if (params.title !== undefined) {
4386
- phase.title = params.title.trim();
4387
- phase.slug = normalizeSlug(params.title);
4388
- }
4389
- if (params.summary !== undefined)
4390
- phase.summary = params.summary.trim();
4391
- if (params.description !== undefined)
4392
- phase.description = params.description.trim();
4393
- if (params.descriptionRef !== undefined) {
4394
- const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4395
- if (descriptionRef)
4396
- phase.descriptionRef = descriptionRef;
4397
- else
4398
- delete phase.descriptionRef;
4399
- }
4400
- if (params.priority !== undefined)
4401
- phase.priority = params.priority;
4402
- if (params.goals !== undefined)
4403
- phase.goals = params.goals.map((item) => item.trim()).filter(Boolean);
4404
- if (params.nonGoals !== undefined)
4405
- phase.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
4406
- if (params.dependencies !== undefined)
4407
- phase.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
4408
- if (params.risks !== undefined)
4409
- phase.risks = params.risks.map((item) => item.trim()).filter(Boolean);
4410
- if (params.openQuestions !== undefined)
4411
- phase.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
4412
- if (params.decisions !== undefined)
4413
- phase.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
4414
- if (params.acceptedDecisions !== undefined)
4415
- phase.acceptedDecisions = params.acceptedDecisions;
4416
- if (params.completionCriteria !== undefined)
4417
- phase.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
4418
4790
  if (params.featureId !== undefined) {
4419
4791
  if (params.featureId === "") {
4420
4792
  nextFeatureIdForUpdate = undefined;
@@ -4425,24 +4797,60 @@ export default function planPiExtension(pi) {
4425
4797
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
4426
4798
  nextFeatureIdForUpdate = resolvedFeature.feature.id;
4427
4799
  }
4428
- phase.featureId = nextFeatureIdForUpdate;
4429
- if (nextFeatureIdForUpdate !== prevFeatureId) {
4430
- await st.updateFeatures((doc) => {
4431
- for (const f of doc.features) {
4432
- if (f.id === prevFeatureId)
4433
- f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
4434
- if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
4435
- f.phaseIds.push(phase.id);
4800
+ }
4801
+ let persistedPhase;
4802
+ await st.runBatch(async () => {
4803
+ persistedPhase = await st.updatePhase(phaseId, (current) => {
4804
+ const next = {
4805
+ ...current,
4806
+ ...(params.title !== undefined ? { title: params.title.trim(), slug: normalizeSlug(params.title) } : {}),
4807
+ ...(params.summary !== undefined ? { summary: params.summary.trim() } : {}),
4808
+ ...(params.description !== undefined ? { description: params.description.trim() } : {}),
4809
+ ...(params.priority !== undefined ? { priority: params.priority } : {}),
4810
+ ...(params.goals !== undefined ? { goals: params.goals.map((item) => item.trim()).filter(Boolean) } : {}),
4811
+ ...(params.nonGoals !== undefined ? { nonGoals: params.nonGoals.map((item) => item.trim()).filter(Boolean) } : {}),
4812
+ ...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
4813
+ ...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
4814
+ ...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
4815
+ ...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
4816
+ ...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
4817
+ ...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
4818
+ updatedAt: nowISO(),
4819
+ };
4820
+ if (params.descriptionRef !== undefined) {
4821
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4822
+ if (descriptionRef)
4823
+ next.descriptionRef = descriptionRef;
4824
+ else
4825
+ delete next.descriptionRef;
4826
+ }
4827
+ return next;
4828
+ });
4829
+ if (params.featureId !== undefined && nextFeatureIdForUpdate !== prevFeatureId) {
4830
+ await st.updateFeatures((document) => {
4831
+ for (const feature of document.features) {
4832
+ if (feature.id === prevFeatureId)
4833
+ feature.phaseIds = feature.phaseIds.filter((id) => id !== phaseId);
4834
+ if (feature.id === nextFeatureIdForUpdate && !feature.phaseIds.includes(phaseId))
4835
+ feature.phaseIds.push(phaseId);
4436
4836
  }
4437
- return doc;
4837
+ return document;
4438
4838
  });
4439
4839
  }
4440
- }
4441
- phase.updatedAt = nowISO();
4442
- await st.savePhase(phase);
4443
- await st.writeGenerated();
4840
+ await st.writeGenerated();
4841
+ });
4842
+ phase = persistedPhase;
4444
4843
  const phaseUpdateFeatures = (await st.loadFeatures()).features;
4445
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, updated: true, updatedFields: receivedFields } };
4844
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
4845
+ ? await st.previewDescriptionReconciliation()
4846
+ : null;
4847
+ const staleParentRefs = descriptionFreshness?.diagnostics
4848
+ .filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
4849
+ .map((entry) => entry.ownerRef) ?? [];
4850
+ const freshnessNotice = staleParentRefs.length > 0
4851
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
4852
+ : "";
4853
+ return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...phase, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
4446
4854
  },
4447
4855
  });
4448
4856
  pi.registerTool({
@@ -4480,7 +4888,7 @@ export default function planPiExtension(pi) {
4480
4888
  implementationNotes: params.implementationNotes.trim(),
4481
4889
  acceptedAt,
4482
4890
  };
4483
- await withFeatureLock(resolvedFeature.feature.id, async () => {
4891
+ await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
4484
4892
  let featureWritten = false;
4485
4893
  try {
4486
4894
  await st.updateFeatures((doc) => {
@@ -4511,11 +4919,128 @@ export default function planPiExtension(pi) {
4511
4919
  }
4512
4920
  throw error;
4513
4921
  }
4514
- });
4922
+ }));
4515
4923
  await st.writeGenerated();
4516
4924
  return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
4517
4925
  },
4518
4926
  });
4927
+ pi.registerTool({
4928
+ name: "accepted_decision_create",
4929
+ label: "Accepted Decision Create",
4930
+ description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
4931
+ parameters: Type.Object({
4932
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
4933
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
4934
+ title: Type.String({ minLength: 1 }),
4935
+ decision: Type.Optional(Type.String()),
4936
+ rationale: Type.Optional(Type.String()),
4937
+ implementationNotes: Type.Optional(Type.String()),
4938
+ }),
4939
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4940
+ const st = await requirePlan(ctx);
4941
+ if (!st)
4942
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4943
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
4944
+ if (!target.ok)
4945
+ return { content: [{ type: "text", text: target.error }], details: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
4946
+ const createInput = {
4947
+ title: params.title,
4948
+ ...(params.decision !== undefined ? { decision: params.decision } : {}),
4949
+ ...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
4950
+ ...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
4951
+ };
4952
+ try {
4953
+ const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
4954
+ await st.writeGenerated();
4955
+ return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
4956
+ }
4957
+ catch (error) {
4958
+ return acceptedDecisionMutationFailure(error, "created");
4959
+ }
4960
+ },
4961
+ });
4962
+ pi.registerTool({
4963
+ name: "accepted_decision_update",
4964
+ label: "Accepted Decision Update",
4965
+ description: "Update one accepted decision while preserving its id and acceptedAt timestamp.",
4966
+ parameters: Type.Object({
4967
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
4968
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
4969
+ decisionId: Type.String({ minLength: 1 }),
4970
+ title: Type.Optional(Type.String()),
4971
+ decision: Type.Optional(Type.String()),
4972
+ rationale: Type.Optional(Type.String()),
4973
+ implementationNotes: Type.Optional(Type.String()),
4974
+ }),
4975
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4976
+ const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
4977
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4978
+ if (receivedFields.length === 0) {
4979
+ return {
4980
+ isError: true,
4981
+ content: [{ type: "text", text: "❌ ACCEPTED DECISION UPDATE FAILED [NO_MUTABLE_FIELDS_RECEIVED]\nNo accepted decision fields were supplied." }],
4982
+ details: noMutableFieldsReceived({
4983
+ entity: "acceptedDecision",
4984
+ ref: params.decisionId,
4985
+ operation: "update",
4986
+ mutableFields,
4987
+ retryCommand: "accepted_decision_update with title, decision, rationale, or implementationNotes",
4988
+ readBackCommand: "Read back the owning project, feature, phase, or task.",
4989
+ }),
4990
+ };
4991
+ }
4992
+ const st = await requirePlan(ctx);
4993
+ if (!st)
4994
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4995
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
4996
+ if (!target.ok)
4997
+ return { content: [{ type: "text", text: target.error }], details: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
4998
+ const updateInput = {
4999
+ ...(params.title !== undefined ? { title: params.title } : {}),
5000
+ ...(params.decision !== undefined ? { decision: params.decision } : {}),
5001
+ ...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
5002
+ ...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
5003
+ };
5004
+ try {
5005
+ const acceptedDecision = await st.updateAcceptedDecision(target.owner, params.decisionId, updateInput);
5006
+ await st.writeGenerated();
5007
+ return { content: [{ type: "text", text: `✅ Accepted decision updated on ${target.ref}: ${acceptedDecision.title}` }], details: { updated: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision, updatedFields: receivedFields } };
5008
+ }
5009
+ catch (error) {
5010
+ return acceptedDecisionMutationFailure(error, "updated");
5011
+ }
5012
+ },
5013
+ });
5014
+ pi.registerTool({
5015
+ name: "accepted_decision_delete",
5016
+ label: "Accepted Decision Delete",
5017
+ description: "Delete one accepted decision from the project, feature, phase, or task. Requires confirmed=true.",
5018
+ parameters: Type.Object({
5019
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
5020
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
5021
+ decisionId: Type.String({ minLength: 1 }),
5022
+ confirmed: Type.Boolean({ description: "Must be true to confirm deleting this accepted decision." }),
5023
+ }),
5024
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5025
+ const st = await requirePlan(ctx);
5026
+ if (!st)
5027
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5028
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
5029
+ if (!target.ok)
5030
+ return { content: [{ type: "text", text: target.error }], details: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
5031
+ if (!params.confirmed) {
5032
+ return { content: [{ type: "text", text: `Delete not confirmed for accepted decision ${params.decisionId} on ${target.ref}. Retry with confirmed=true.` }], details: { deleted: false, confirmRequired: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId } };
5033
+ }
5034
+ try {
5035
+ const acceptedDecision = await st.deleteAcceptedDecision(target.owner, params.decisionId);
5036
+ await st.writeGenerated();
5037
+ return { content: [{ type: "text", text: `✅ Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}` }], details: { deleted: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId, acceptedDecision } };
5038
+ }
5039
+ catch (error) {
5040
+ return acceptedDecisionMutationFailure(error, "deleted");
5041
+ }
5042
+ },
5043
+ });
4519
5044
  pi.registerTool({
4520
5045
  name: "phase_delete",
4521
5046
  label: "Phase Delete",
@@ -4539,18 +5064,20 @@ export default function planPiExtension(pi) {
4539
5064
  catch {
4540
5065
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
4541
5066
  }
4542
- await st.deletePhase(phaseId);
4543
- if (phase.featureId) {
4544
- await st.updateFeatures((doc) => {
4545
- const feature = doc.features.find((f) => f.id === phase.featureId);
4546
- if (feature) {
4547
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
4548
- feature.updatedAt = nowISO();
4549
- }
4550
- return doc;
4551
- });
4552
- }
4553
- await st.writeGenerated();
5067
+ await st.runBatch(async () => {
5068
+ await st.deletePhase(phaseId);
5069
+ if (phase.featureId) {
5070
+ await st.updateFeatures((doc) => {
5071
+ const feature = doc.features.find((f) => f.id === phase.featureId);
5072
+ if (feature) {
5073
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
5074
+ feature.updatedAt = nowISO();
5075
+ }
5076
+ return doc;
5077
+ });
5078
+ }
5079
+ await st.writeGenerated();
5080
+ });
4554
5081
  return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
4555
5082
  },
4556
5083
  });
@@ -4598,21 +5125,20 @@ export default function planPiExtension(pi) {
4598
5125
  pi.registerTool({
4599
5126
  name: "task_get",
4600
5127
  label: "Task Get",
4601
- description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
5128
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, every canonical Accepted Decision field, resume checkpoint/advisory, and statusLog.",
4602
5129
  parameters: Type.Object({
4603
5130
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4604
5131
  full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
4605
5132
  }),
4606
5133
  async execute(_id, params, _signal, _onUpdate, ctx) {
4607
5134
  const st = await requirePlan(ctx);
5135
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
4608
5136
  if (!st)
4609
5137
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4610
5138
  const features = (await st.loadFeatures()).features;
4611
5139
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4612
5140
  if (!found)
4613
5141
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4614
- if (params.full)
4615
- markTaskReadForSessionId(plannerSessionId, found.task.id);
4616
5142
  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})`;
4617
5143
  if (!params.full)
4618
5144
  return { content: [{ type: "text", text: summary }], details: {} };
@@ -4643,7 +5169,20 @@ export default function planPiExtension(pi) {
4643
5169
  }
4644
5170
  if (log)
4645
5171
  sections.push(`Status log:\n${log}`);
4646
- return { content: [{ type: "text", text: sections.join("\n\n") }], details: { task: { ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`, description: found.task.description, descriptionRef: found.task.descriptionRef ?? "" } } };
5172
+ sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
5173
+ const result = {
5174
+ content: [{ type: "text", text: sections.join("\n\n") }],
5175
+ details: {
5176
+ task: {
5177
+ ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5178
+ description: found.task.description,
5179
+ descriptionRef: found.task.descriptionRef ?? "",
5180
+ acceptedDecisions: found.task.acceptedDecisions,
5181
+ },
5182
+ },
5183
+ };
5184
+ markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
5185
+ return result;
4647
5186
  },
4648
5187
  });
4649
5188
  pi.registerTool({
@@ -4721,6 +5260,7 @@ export default function planPiExtension(pi) {
4721
5260
  pauseHistory: [],
4722
5261
  startedAt: "",
4723
5262
  completedAt: "",
5263
+ activeOwnerSession: "",
4724
5264
  createdAt: now, updatedAt: now,
4725
5265
  };
4726
5266
  // Atomic read-modify-write on the phase file: serializes concurrent
@@ -4736,6 +5276,58 @@ export default function planPiExtension(pi) {
4736
5276
  return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
4737
5277
  },
4738
5278
  });
5279
+ pi.registerTool({
5280
+ name: "task_dependency_add",
5281
+ label: "Task Dependency Add",
5282
+ description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
5283
+ parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String() }),
5284
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5285
+ const st = await requirePlan(ctx);
5286
+ if (!st)
5287
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5288
+ const features = (await st.loadFeatures()).features;
5289
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
5290
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
5291
+ if (!found || !dependency)
5292
+ return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
5293
+ try {
5294
+ const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5295
+ await st.writeGenerated();
5296
+ return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
5297
+ }
5298
+ catch (error) {
5299
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5300
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency update failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }, isError: true };
5301
+ }
5302
+ },
5303
+ });
5304
+ pi.registerTool({
5305
+ name: "task_dependency_delete",
5306
+ label: "Task Dependency Delete",
5307
+ description: "Remove a task dependency atomically after explicit confirmation.",
5308
+ parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String(), confirmed: Type.Boolean() }),
5309
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5310
+ if (!params.confirmed)
5311
+ return { content: [{ type: "text", text: "Explicit confirmation is required." }], details: { updated: false, errorCode: "CONFIRMATION_REQUIRED" }, isError: true };
5312
+ const st = await requirePlan(ctx);
5313
+ if (!st)
5314
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5315
+ const features = (await st.loadFeatures()).features;
5316
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
5317
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
5318
+ if (!found || !dependency)
5319
+ return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
5320
+ try {
5321
+ const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5322
+ await st.writeGenerated();
5323
+ return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
5324
+ }
5325
+ catch (error) {
5326
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5327
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency deletion failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }, isError: true };
5328
+ }
5329
+ },
5330
+ });
4739
5331
  pi.registerTool({
4740
5332
  name: "task_update",
4741
5333
  label: "Task Update",
@@ -4756,9 +5348,10 @@ export default function planPiExtension(pi) {
4756
5348
  rationale: Type.String(),
4757
5349
  implementationNotes: Type.String(),
4758
5350
  acceptedAt: Type.String(),
4759
- }), { description: "Replace accepted decisions list" })),
5351
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
4760
5352
  priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
4761
5353
  checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
5354
+ subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), title: Type.String(), description: Type.Optional(Type.String()), status: Type.Optional(Type.String()) }), { description: "Replace subtasks; existing IDs must belong to this task and omitted IDs are planner-generated." })),
4762
5355
  }),
4763
5356
  async execute(_id, params, _signal, _onUpdate, ctx) {
4764
5357
  const st = await requirePlan(ctx);
@@ -4787,6 +5380,13 @@ export default function planPiExtension(pi) {
4787
5380
  details: {},
4788
5381
  };
4789
5382
  }
5383
+ if (params.acceptedDecisions !== undefined) {
5384
+ return {
5385
+ content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
5386
+ details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
5387
+ isError: true,
5388
+ };
5389
+ }
4790
5390
  if (params.status !== undefined &&
4791
5391
  params.status !== found.task.status &&
4792
5392
  needsMotivation(found.task.status, params.status)) {
@@ -4797,7 +5397,12 @@ export default function planPiExtension(pi) {
4797
5397
  };
4798
5398
  }
4799
5399
  }
4800
- const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "acceptedDecisions", "priority", "checklist"];
5400
+ if (params.subtasks !== undefined) {
5401
+ const existingIds = new Set(found.task.subtasks.map((item) => item.id));
5402
+ if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
5403
+ return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
5404
+ }
5405
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
4801
5406
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4802
5407
  if (receivedFields.length === 0) {
4803
5408
  return mutationNoFieldsFailure({
@@ -4816,10 +5421,7 @@ export default function planPiExtension(pi) {
4816
5421
  });
4817
5422
  }
4818
5423
  try {
4819
- const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
4820
- const task = phase.tasks.find((t) => t.id === found.task.id);
4821
- if (!task)
4822
- return phase;
5424
+ const updatedPhase = await st.updateTask(hostPhase.id, found.task.id, (task) => {
4823
5425
  if (params.title !== undefined)
4824
5426
  task.title = params.title;
4825
5427
  if (params.priority !== undefined)
@@ -4837,8 +5439,6 @@ export default function planPiExtension(pi) {
4837
5439
  task.notes = params.notes.trim();
4838
5440
  if (params.decisions !== undefined)
4839
5441
  task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
4840
- if (params.acceptedDecisions !== undefined)
4841
- task.acceptedDecisions = params.acceptedDecisions;
4842
5442
  if (params.checklist !== undefined) {
4843
5443
  task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
4844
5444
  id: createChecklistItemId(task.id, index + 1, itemTitle),
@@ -4846,6 +5446,9 @@ export default function planPiExtension(pi) {
4846
5446
  checked: false,
4847
5447
  }));
4848
5448
  }
5449
+ if (params.subtasks !== undefined) {
5450
+ task.subtasks = params.subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: (item.status ?? "planned"), createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? now, updatedAt: now }));
5451
+ }
4849
5452
  if (params.status !== undefined) {
4850
5453
  if (params.status !== task.status) {
4851
5454
  const entry = {
@@ -4861,20 +5464,34 @@ export default function planPiExtension(pi) {
4861
5464
  applyTaskLifecycleDates(task, params.status, now);
4862
5465
  }
4863
5466
  task.updatedAt = now;
4864
- phase.updatedAt = now;
4865
5467
  updatedTask = task;
4866
- return phase;
5468
+ return task;
4867
5469
  });
4868
5470
  void updatedPhase;
4869
5471
  }
4870
5472
  catch (e) {
4871
- return { content: [{ type: "text", text: `Update failed: ${e}` }], details: {} };
5473
+ const errorCode = e instanceof PlanStoreError ? e.details?.errorCode : undefined;
5474
+ return {
5475
+ content: [{ type: "text", text: `Update failed: ${e instanceof Error ? e.message : String(e)}` }],
5476
+ details: { updated: false, errorCode: errorCode ?? "TASK_UPDATE_FAILED" },
5477
+ isError: true,
5478
+ };
4872
5479
  }
4873
5480
  if (!updatedTask)
4874
- return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
5481
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { updated: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
4875
5482
  await st.syncTaskStatusRollup(found.phase.id);
4876
5483
  await st.writeGenerated();
4877
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields } };
5484
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
5485
+ ? await st.previewDescriptionReconciliation()
5486
+ : null;
5487
+ const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
5488
+ const staleParentRefs = descriptionFreshness?.diagnostics
5489
+ .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
5490
+ .map((entry) => entry.ownerRef) ?? [];
5491
+ const freshnessNotice = staleParentRefs.length > 0
5492
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
5493
+ : "";
5494
+ return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
4878
5495
  },
4879
5496
  });
4880
5497
  pi.registerTool({
@@ -4906,21 +5523,38 @@ export default function planPiExtension(pi) {
4906
5523
  pi.registerTool({
4907
5524
  name: "task_recommend",
4908
5525
  label: "Task Recommend",
4909
- 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.",
5526
+ description: "Return the next-work chain: active task if present, otherwise the lowest-priority ready feature → phase → task, plus bounded typed claims. Only persisted resume-ready handoffs are actionable; Markdown prose and terminal archives are not authority. Never blocks an explicit user choice.",
4910
5527
  parameters: Type.Object({}),
4911
5528
  async execute(_id, _params, _signal, _onUpdate, ctx) {
4912
5529
  const st = await requirePlan(ctx);
4913
5530
  if (!st)
4914
5531
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4915
- const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4916
- const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
4917
- if (!result.candidate)
4918
- return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4919
- const { candidate } = result;
5532
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
5533
+ st.loadFeatures(),
5534
+ st.loadAllPhases(),
5535
+ st.loadProject(),
5536
+ st.loadResume(),
5537
+ st.listHandoffs(),
5538
+ st.listArchivedHandoffs(),
5539
+ ]);
5540
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
5541
+ handoffs: handoffs.map((handoff) => ({
5542
+ compositeRef: handoff.compositeRef,
5543
+ firstLine: handoff.firstLine,
5544
+ resumeReady: handoff.resumeReady,
5545
+ })),
5546
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
5547
+ });
5548
+ const { selection } = result;
5549
+ if (!selection.candidate)
5550
+ return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
5551
+ const { candidate } = selection;
4920
5552
  const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
5553
+ const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
5554
+ const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
4921
5555
  return {
4922
- content: [{ type: "text", text: `Recommended (${result.kind}): ${reference} — ${candidate.task.title}\n${result.reason}${result.deviation ? `\nDeviation: ${result.deviation.id}; resume target ${result.deviation.resumeTaskId}.` : ""}` }],
4923
- details: result,
5556
+ content: [{ type: "text", text: `${activeLine}Next work (${selection.kind}): ${reference} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}` }],
5557
+ details: { ...selection, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims },
4924
5558
  };
4925
5559
  },
4926
5560
  });
@@ -5035,6 +5669,7 @@ export default function planPiExtension(pi) {
5035
5669
  }),
5036
5670
  async execute(_id, params, _signal, _onUpdate, ctx) {
5037
5671
  const st = await requirePlan(ctx);
5672
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
5038
5673
  if (!st)
5039
5674
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5040
5675
  const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
@@ -5050,6 +5685,9 @@ export default function planPiExtension(pi) {
5050
5685
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
5051
5686
  return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
5052
5687
  }
5688
+ if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
5689
+ return { content: [{ type: "text", text: `Task switch denied: source task is owned by another session.` }], details: {} };
5690
+ }
5053
5691
  const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
5054
5692
  const linkedRequirements = [
5055
5693
  ...(await st.linkedRequirementsForPhase(target.phase.id)),
@@ -5068,7 +5706,8 @@ export default function planPiExtension(pi) {
5068
5706
  requirementIds: linkedRequirementIds,
5069
5707
  };
5070
5708
  const contextEligibility = contextReadEligibilityForSession(contextInput);
5071
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5709
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5710
+ const requirementsReady = requirementEligibility.eligible;
5072
5711
  const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5073
5712
  const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5074
5713
  const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
@@ -5086,7 +5725,7 @@ export default function planPiExtension(pi) {
5086
5725
  return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
5087
5726
  }
5088
5727
  if (!requirementsReady) {
5089
- return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds, nextActions: ["requirement_list", `Retry task_switch to ${targetTaskRef}`] } };
5728
+ return { content: [{ type: "text", text: `Task switch denied: ${requirementEligibility.reason}` }], details: { requirementIds: linkedRequirementIds, requirementEligibility, nextActions: [`requirement_list with phaseRef=${targetPhaseRef}`, `Retry task_switch to ${targetTaskRef}`] } };
5090
5729
  }
5091
5730
  if (!hasValidSessionAttestation(contextInput)) {
5092
5731
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -5100,7 +5739,7 @@ export default function planPiExtension(pi) {
5100
5739
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
5101
5740
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
5102
5741
  };
5103
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
5742
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
5104
5743
  const record = {
5105
5744
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
5106
5745
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
@@ -5111,75 +5750,78 @@ export default function planPiExtension(pi) {
5111
5750
  const sourceWasActive = source.task.status === "in-progress";
5112
5751
  let sourceCheckpointed = false;
5113
5752
  let targetStarted = false;
5114
- try {
5115
- if (sourceWasActive) {
5116
- await st.pauseTask(source.phase.id, source.task.id, snapshot);
5117
- }
5118
- else {
5119
- await st.updatePhase(source.phase.id, (phase) => {
5120
- const task = phase.tasks.find((entry) => entry.id === source.task.id);
5121
- if (!task)
5122
- throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
5123
- task.pauseSnapshot = snapshot;
5124
- task.pauseHistory = [...task.pauseHistory, snapshot];
5125
- task.updatedAt = timestamp;
5126
- return phase;
5127
- });
5128
- }
5129
- sourceCheckpointed = true;
5130
- if (target.task.pauseSnapshot) {
5131
- await st.resumeTask(target.phase.id, target.task.id, timestamp);
5132
- }
5133
- else {
5134
- await st.updatePhase(target.phase.id, (phase) => {
5135
- const task = phase.tasks.find((entry) => entry.id === target.task.id);
5136
- if (!task)
5137
- throw new Error(`Temporary task disappeared: ${params.to_task}`);
5138
- const previousStatus = task.status;
5139
- applyTaskLifecycleDates(task, "in-progress", timestamp);
5140
- task.statusLog = [...task.statusLog, {
5141
- id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
5142
- date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
5143
- title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
5144
- }];
5145
- task.updatedAt = timestamp;
5146
- phase.updatedAt = timestamp;
5147
- return phase;
5148
- });
5149
- }
5150
- targetStarted = true;
5151
- await st.addWorkDeviation(record);
5152
- if (target.task.pauseSnapshot) {
5153
- const resumedDeviation = project.workDeviations
5154
- .filter((deviation) => deviation.resumeTaskId === target.task.id
5155
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
5156
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5157
- if (resumedDeviation)
5158
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
5159
- }
5160
- }
5161
- catch (error) {
5162
- if (targetStarted) {
5163
- await st.updatePhase(target.phase.id, (phase) => {
5164
- phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
5165
- return phase;
5166
- }).catch(() => { });
5167
- }
5168
- if (sourceCheckpointed) {
5169
- if (sourceWasActive)
5170
- await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
5171
- else
5753
+ await st.runBatch(async () => {
5754
+ try {
5755
+ if (sourceWasActive) {
5756
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
5757
+ }
5758
+ else {
5172
5759
  await st.updatePhase(source.phase.id, (phase) => {
5173
- phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
5760
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
5761
+ if (!task)
5762
+ throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
5763
+ task.pauseSnapshot = snapshot;
5764
+ task.pauseHistory = [...task.pauseHistory, snapshot];
5765
+ task.updatedAt = timestamp;
5766
+ return phase;
5767
+ });
5768
+ }
5769
+ sourceCheckpointed = true;
5770
+ if (target.task.pauseSnapshot) {
5771
+ await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
5772
+ }
5773
+ else {
5774
+ await st.updatePhase(target.phase.id, (phase) => {
5775
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
5776
+ if (!task)
5777
+ throw new Error(`Temporary task disappeared: ${params.to_task}`);
5778
+ const previousStatus = task.status;
5779
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
5780
+ task.activeOwnerSession = plannerSessionId;
5781
+ task.statusLog = [...task.statusLog, {
5782
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
5783
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
5784
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
5785
+ }];
5786
+ task.updatedAt = timestamp;
5787
+ phase.updatedAt = timestamp;
5788
+ return phase;
5789
+ });
5790
+ }
5791
+ targetStarted = true;
5792
+ await st.addWorkDeviation(record);
5793
+ if (target.task.pauseSnapshot) {
5794
+ const resumedDeviation = project.workDeviations
5795
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
5796
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
5797
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5798
+ if (resumedDeviation)
5799
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
5800
+ }
5801
+ }
5802
+ catch (error) {
5803
+ if (targetStarted) {
5804
+ await st.updatePhase(target.phase.id, (phase) => {
5805
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
5174
5806
  return phase;
5175
5807
  }).catch(() => { });
5808
+ }
5809
+ if (sourceCheckpointed) {
5810
+ if (sourceWasActive)
5811
+ await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
5812
+ else
5813
+ await st.updatePhase(source.phase.id, (phase) => {
5814
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
5815
+ return phase;
5816
+ }).catch(() => { });
5817
+ }
5818
+ throw error;
5176
5819
  }
5177
- throw error;
5178
- }
5179
- await st.syncTaskStatusRollup(source.phase.id);
5180
- if (target.phase.id !== source.phase.id)
5181
- await st.syncTaskStatusRollup(target.phase.id);
5182
- await st.writeGenerated();
5820
+ await st.syncTaskStatusRollup(source.phase.id);
5821
+ if (target.phase.id !== source.phase.id)
5822
+ await st.syncTaskStatusRollup(target.phase.id);
5823
+ await st.writeGenerated();
5824
+ });
5183
5825
  invalidateReads(plannerSessionId);
5184
5826
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
5185
5827
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
@@ -5194,6 +5836,31 @@ export default function planPiExtension(pi) {
5194
5836
  };
5195
5837
  },
5196
5838
  });
5839
+ pi.registerTool({
5840
+ name: "task_reopen",
5841
+ label: "Task Reopen",
5842
+ description: "Atomically reopen a completed task. Requires confirmed=true, retains completion evidence, and never pauses another task.",
5843
+ parameters: Type.Object({ taskId: Type.String({ description: "Task reference" }), confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }) }),
5844
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5845
+ const st = await requirePlan(ctx);
5846
+ if (!st)
5847
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: { reopened: false, errorCode: "PLAN_NOT_FOUND" }, isError: true };
5848
+ const features = (await st.loadFeatures()).features;
5849
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5850
+ if (!found)
5851
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { reopened: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
5852
+ try {
5853
+ const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed: params.confirmed, ownerSessionId: `pi:${ctx.sessionManager.getSessionId()}` });
5854
+ await st.syncTaskStatusRollup(found.phase.id);
5855
+ await st.writeGenerated();
5856
+ return { content: [{ type: "text", text: `✅ Task reopened: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(reopened.number)} — ${reopened.title} (in-progress)` }], details: { reopened: true, task: reopened } };
5857
+ }
5858
+ catch (error) {
5859
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5860
+ return { content: [{ type: "text", text: `Task reopen denied: ${error instanceof Error ? error.message : "unknown error"}` }], details: { reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED" }, isError: true };
5861
+ }
5862
+ },
5863
+ });
5197
5864
  pi.registerTool({
5198
5865
  name: "task_start",
5199
5866
  label: "Task Start",
@@ -5203,153 +5870,158 @@ export default function planPiExtension(pi) {
5203
5870
  }),
5204
5871
  async execute(_id, params, _signal, _onUpdate, ctx) {
5205
5872
  const st = await requirePlan(ctx);
5873
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
5206
5874
  if (!st)
5207
5875
  return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
5208
- const features = (await st.loadFeatures()).features;
5209
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5210
- if (!found)
5211
- return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
5212
- const task = found.task;
5213
- const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
5214
- const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
5215
- const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
5216
- const featureRef = feature ? formatFeatureRef(feature.number) : "";
5217
- if (task.status === "done")
5218
- 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}.`, `Retry task_start ${taskRef}. If it then reports read prerequisites, perform only the missing or stale nextActions before retrying again.`], { taskId: task.id }));
5219
- const project = await st.loadProject();
5220
- const linkedRequirements = [
5221
- ...(await st.linkedRequirementsForPhase(found.phase.id)),
5222
- ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
5223
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
5224
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
5225
- const contextInput = {
5226
- sessionId: plannerSessionId,
5227
- taskId: task.id,
5228
- phaseId: found.phase.id,
5229
- ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5230
- task,
5231
- phase: found.phase,
5232
- ...(feature ? { feature } : {}),
5233
- requirements: linkedRequirements,
5234
- requirementIds: linkedRequirementIds,
5235
- };
5236
- const contextEligibility = contextReadEligibilityForSession(contextInput);
5237
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5238
- const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5239
- const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5240
- if (!projectGuidelinesReady) {
5241
- return taskStartFailure(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { projectGuidelinesReadState, contextEligibility });
5242
- }
5243
- if (!contextEligibility.eligible) {
5244
- return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
5245
- }
5246
- if (!requirementsReady) {
5247
- 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 }));
5248
- }
5249
- if (!hasValidSessionAttestation(contextInput)) {
5250
- await st.recordContextRead({
5876
+ return st.runBatch(async () => {
5877
+ const features = (await st.loadFeatures()).features;
5878
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5879
+ if (!found)
5880
+ return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
5881
+ const task = found.task;
5882
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
5883
+ const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
5884
+ const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
5885
+ const featureRef = feature ? formatFeatureRef(feature.number) : "";
5886
+ if (task.status === "done")
5887
+ return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_reopen with taskId=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry task_start ${taskRef} only if reopening is not the intended operation.`], { taskId: task.id }));
5888
+ const project = await st.loadProject();
5889
+ const linkedRequirements = [
5890
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
5891
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
5892
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
5893
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
5894
+ const contextInput = {
5251
5895
  sessionId: plannerSessionId,
5252
- phaseId: found.phase.id,
5253
5896
  taskId: task.id,
5897
+ phaseId: found.phase.id,
5254
5898
  ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5899
+ task,
5900
+ phase: found.phase,
5901
+ ...(feature ? { feature } : {}),
5902
+ requirements: linkedRequirements,
5255
5903
  requirementIds: linkedRequirementIds,
5256
- });
5257
- }
5258
- if (task.status === "in-progress") {
5259
- const outcome = taskStartSucceeded(task.id, true);
5260
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5261
- }
5262
- const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5263
- const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
5264
- if (!eligibility.eligible)
5265
- return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
5266
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
5267
- if (selection.kind === "conflict") {
5268
- return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
5269
- }
5270
- if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
5271
- const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
5272
- 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 });
5273
- }
5274
- let resumeProposal;
5275
- if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
5276
- // Advisory only: a pending resume must be surfaced loudly, but it must
5277
- // not hard-block an explicit start of a different task. Capture a
5278
- // structured proposal so the resume-first guidance is explicit and
5279
- // machine-readable for the host/agent.
5280
- const resumeTask = selection.candidate;
5281
- const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
5282
- const resumeSnapshot = resumeTask.task.pauseSnapshot
5283
- ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
5284
- && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
5285
- ?? null;
5286
- resumeProposal = buildResumeRequiredProposal({
5287
- ref: resumeRef,
5288
- title: resumeTask.task.title,
5289
- taskId: resumeTask.task.id,
5290
- phaseId: resumeTask.phase.id,
5291
- snapshot: resumeSnapshot
5292
- ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
5293
- : null,
5294
- });
5295
- }
5296
- // Assemble the mandatory parent context before changing task lifecycle state.
5297
- // The output order is feature + its requirements, then phase + its requirements.
5298
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
5299
- const [phaseWithRequirements, featureRequirements] = await Promise.all([
5300
- st.loadPhaseWithRequirements(found.phase.id),
5301
- parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
5302
- ]);
5303
- const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
5304
- const advisory = selection.candidate && selection.candidate.task.id !== task.id
5305
- ? selection.kind === "resume"
5306
- ? `\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.`}`
5307
- : `\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.`
5308
- : "";
5309
- const now = nowISO();
5310
- let startedTask;
5311
- if (task.pauseSnapshot) {
5312
- startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
5313
- }
5314
- else {
5315
- await st.updatePhase(found.phase.id, (phase) => {
5316
- const t = phase.tasks.find((x) => x.id === found.task.id);
5317
- if (!t)
5904
+ };
5905
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
5906
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5907
+ const requirementsReady = requirementEligibility.eligible;
5908
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5909
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5910
+ if (!projectGuidelinesReady) {
5911
+ return taskStartFailure(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { projectGuidelinesReadState, contextEligibility });
5912
+ }
5913
+ if (!contextEligibility.eligible) {
5914
+ return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
5915
+ }
5916
+ if (!requirementsReady) {
5917
+ return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", `Linked requirements were not read; the task remains unchanged. ${requirementEligibility.reason}`, [`requirement_list with phaseRef=${phaseRef}`, `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }), { requirementEligibility });
5918
+ }
5919
+ if (!hasValidSessionAttestation(contextInput)) {
5920
+ await st.recordContextRead({
5921
+ sessionId: plannerSessionId,
5922
+ phaseId: found.phase.id,
5923
+ taskId: task.id,
5924
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5925
+ requirementIds: linkedRequirementIds,
5926
+ });
5927
+ }
5928
+ if (task.status === "in-progress") {
5929
+ const outcome = taskStartSucceeded(task.id, true);
5930
+ return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5931
+ }
5932
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5933
+ const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
5934
+ if (!eligibility.eligible)
5935
+ return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
5936
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
5937
+ if (selection.kind === "conflict") {
5938
+ return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
5939
+ }
5940
+ if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
5941
+ const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
5942
+ 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 });
5943
+ }
5944
+ let resumeProposal;
5945
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
5946
+ // Advisory only: a pending resume must be surfaced loudly, but it must
5947
+ // not hard-block an explicit start of a different task. Capture a
5948
+ // structured proposal so the resume-first guidance is explicit and
5949
+ // machine-readable for the host/agent.
5950
+ const resumeTask = selection.candidate;
5951
+ const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
5952
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
5953
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
5954
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
5955
+ ?? null;
5956
+ resumeProposal = buildResumeRequiredProposal({
5957
+ ref: resumeRef,
5958
+ title: resumeTask.task.title,
5959
+ taskId: resumeTask.task.id,
5960
+ phaseId: resumeTask.phase.id,
5961
+ snapshot: resumeSnapshot
5962
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
5963
+ : null,
5964
+ });
5965
+ }
5966
+ // Assemble the mandatory parent context before changing task lifecycle state.
5967
+ // The output order is feature + its requirements, then phase + its requirements.
5968
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
5969
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
5970
+ st.loadPhaseWithRequirements(found.phase.id),
5971
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
5972
+ ]);
5973
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
5974
+ const advisory = selection.candidate && selection.candidate.task.id !== task.id
5975
+ ? selection.kind === "resume"
5976
+ ? `\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.`}`
5977
+ : `\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.`
5978
+ : "";
5979
+ const now = nowISO();
5980
+ let startedTask;
5981
+ if (task.pauseSnapshot) {
5982
+ startedTask = await st.resumeTask(found.phase.id, found.task.id, now, plannerSessionId);
5983
+ }
5984
+ else {
5985
+ await st.updatePhase(found.phase.id, (phase) => {
5986
+ const t = phase.tasks.find((x) => x.id === found.task.id);
5987
+ if (!t)
5988
+ return phase;
5989
+ const previousStatus = t.status;
5990
+ applyTaskLifecycleDates(t, "in-progress", now);
5991
+ t.activeOwnerSession = plannerSessionId;
5992
+ t.statusLog = [...t.statusLog, {
5993
+ id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
5994
+ date: now, fromStatus: previousStatus, toStatus: "in-progress",
5995
+ title: `${previousStatus} → in-progress`, description: "",
5996
+ }];
5997
+ t.updatedAt = now;
5998
+ phase.updatedAt = now;
5999
+ startedTask = t;
5318
6000
  return phase;
5319
- const previousStatus = t.status;
5320
- applyTaskLifecycleDates(t, "in-progress", now);
5321
- t.statusLog = [...t.statusLog, {
5322
- id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
5323
- date: now, fromStatus: previousStatus, toStatus: "in-progress",
5324
- title: `${previousStatus} → in-progress`, description: "",
5325
- }];
5326
- t.updatedAt = now;
5327
- phase.updatedAt = now;
5328
- startedTask = t;
5329
- return phase;
5330
- });
5331
- }
5332
- await st.syncTaskStatusRollup(found.phase.id);
5333
- const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
5334
- if (approvedDeviation)
5335
- await st.setWorkDeviationState(approvedDeviation.id, "active", now);
5336
- const resumedDeviation = project.workDeviations
5337
- .filter((deviation) => deviation.resumeTaskId === task.id
5338
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
5339
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5340
- if (resumedDeviation)
5341
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
5342
- await st.writeGenerated();
5343
- const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
5344
- if (!startedTask || persistedTask?.status !== "in-progress") {
5345
- 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 }));
5346
- }
5347
- const outcome = taskStartSucceeded(persistedTask.id);
5348
- return {
5349
- content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
5350
- details: { ...persistedTask, ...outcome, task: persistedTask },
5351
- ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
5352
- };
6001
+ });
6002
+ }
6003
+ await st.syncTaskStatusRollup(found.phase.id);
6004
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
6005
+ if (approvedDeviation)
6006
+ await st.setWorkDeviationState(approvedDeviation.id, "active", now);
6007
+ const resumedDeviation = project.workDeviations
6008
+ .filter((deviation) => deviation.resumeTaskId === task.id
6009
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
6010
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
6011
+ if (resumedDeviation)
6012
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
6013
+ await st.writeGenerated();
6014
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
6015
+ if (!startedTask || persistedTask?.status !== "in-progress") {
6016
+ 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 }));
6017
+ }
6018
+ const outcome = taskStartSucceeded(persistedTask.id);
6019
+ return {
6020
+ content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
6021
+ details: { ...persistedTask, ...outcome, task: persistedTask },
6022
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
6023
+ };
6024
+ });
5353
6025
  },
5354
6026
  });
5355
6027
  pi.registerTool({
@@ -5772,12 +6444,15 @@ export default function planPiExtension(pi) {
5772
6444
  const openQuestions = plan.phases
5773
6445
  .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
5774
6446
  .slice(0, 8);
5775
- const acceptedDecisions = [
5776
- ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
5777
- ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
5778
- ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
5779
- ...plan.phases.flatMap((phase) => phase.tasks.flatMap((task) => task.acceptedDecisions.map((decision) => `[task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}] ${decision.title}`))),
5780
- ].slice(0, 20);
6447
+ const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
6448
+ { scope: "project", decisions: project.acceptedDecisions },
6449
+ ...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
6450
+ ...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
6451
+ ...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
6452
+ scope: `task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
6453
+ decisions: task.acceptedDecisions,
6454
+ }))),
6455
+ ]);
5781
6456
  const webServer = server;
5782
6457
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
5783
6458
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -5850,8 +6525,7 @@ export default function planPiExtension(pi) {
5850
6525
  `Scope: ${project.scope.join(", ") || "(none)"}`,
5851
6526
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
5852
6527
  "",
5853
- acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
5854
- ...acceptedDecisions.map((decision) => `- ${decision}`),
6528
+ acceptedDecisionContext.content,
5855
6529
  openQuestions.length ? "" : "",
5856
6530
  openQuestions.length ? "Open questions:" : "",
5857
6531
  ...openQuestions.map((question) => `- ${question}`),