@agent-plan/pi-adapter 0.2.25 → 0.2.27

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, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded } from "@agent-plan/core";
14
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
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
+ 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";
@@ -85,16 +86,74 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
85
86
  },
86
87
  };
87
88
  }
88
- function piContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
89
- const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
89
+ function mutationNoFieldsFailure(input) {
90
+ const outcome = noMutableFieldsReceived(input);
91
+ return {
92
+ isError: true,
93
+ content: [{ type: "text", text: outcome.message }],
94
+ details: outcome,
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
+ }
106
+ function legacyContextMigrationSummary(preview) {
107
+ return [
108
+ `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
109
+ `Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
110
+ `Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
111
+ `Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
112
+ `Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
113
+ ].join("\n");
114
+ }
115
+ function handoffContractFailure(error) {
116
+ return {
117
+ isError: true,
118
+ content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
119
+ details: { errorCode: error.code, ...error.details },
120
+ };
121
+ }
122
+ function boundedHandoffForTransport(content) {
123
+ if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
124
+ return { content, fullLength: content.length, truncated: false };
125
+ const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
126
+ return {
127
+ content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
128
+ fullLength: content.length,
129
+ truncated: true,
130
+ };
131
+ }
132
+ function boundedPage(items, page, pageSize) {
133
+ const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
134
+ const totalPages = Math.max(1, Math.ceil(items.length / size));
135
+ const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
136
+ return {
137
+ items: items.slice((currentPage - 1) * size, currentPage * size),
138
+ page: currentPage,
139
+ pageSize: size,
140
+ total: items.length,
141
+ totalPages,
142
+ };
143
+ }
144
+ function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
145
+ const actions = projectGuidelinesReadRequired ? ["project_guidelines_show"] : [];
146
+ actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
90
147
  if (read.kind === "task")
91
148
  return [`task_get ${refs.task} with full=true`];
92
149
  if (read.kind === "phase")
93
150
  return [`phase_get ${refs.phase} with full=true`];
94
- return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
95
- });
151
+ if (read.kind === "feature")
152
+ return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
153
+ return [];
154
+ }));
96
155
  if (requirementReadRequired)
97
- actions.push("requirement_list");
156
+ actions.push(`requirement_list with phaseRef=${refs.phase}`);
98
157
  actions.push(retryAction);
99
158
  return actions;
100
159
  }
@@ -172,6 +231,14 @@ const PLANNER_COMMAND_COMPLETIONS = [
172
231
  { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
173
232
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
174
233
  { value: "project language", label: "project language", description: "Set persistent language preferences" },
234
+ { value: "project guidelines", label: "project guidelines", description: "Edit the canonical Project Guidelines section" },
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" },
175
242
  { value: "feature list", label: "feature list", description: "List features" },
176
243
  { value: "feature add", label: "feature add", description: "Create a feature" },
177
244
  { value: "feature show", label: "feature show", description: "Show a feature" },
@@ -280,7 +347,7 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
280
347
  project.contentLanguage = contentLanguage;
281
348
  project.chatLanguage = chatLanguage;
282
349
  if (persist)
283
- await st.saveProject(project);
350
+ await st.updateProject((current) => ({ ...current, contentLanguage, chatLanguage }));
284
351
  }
285
352
  return project;
286
353
  }
@@ -289,7 +356,11 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
289
356
  project.contentLanguage = contentLanguage || fallback;
290
357
  project.chatLanguage = chatLanguage || fallback;
291
358
  if (persist)
292
- await st.saveProject(project);
359
+ await st.updateProject((current) => ({
360
+ ...current,
361
+ contentLanguage: project.contentLanguage,
362
+ chatLanguage: project.chatLanguage,
363
+ }));
293
364
  return project;
294
365
  }
295
366
  return project;
@@ -624,6 +695,33 @@ function statusIcon(status) {
624
695
  function pad(n) {
625
696
  return String(n).padStart(3, "0");
626
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
+ }
627
725
  function applyTaskLifecycleDates(task, nextStatus, now) {
628
726
  const previousStatus = task.status;
629
727
  if (nextStatus === "in-progress" && !task.startedAt) {
@@ -656,8 +754,7 @@ async function pickProjectPort(ctx, explicitPort) {
656
754
  if (!(await isPortAvailable(explicitPort)) && project.webPort !== explicitPort) {
657
755
  throw new Error(`Port ${explicitPort} is already in use`);
658
756
  }
659
- project.webPort = explicitPort;
660
- await st.saveProject(project);
757
+ await st.updateProject((current) => ({ ...current, webPort: explicitPort }));
661
758
  return explicitPort;
662
759
  }
663
760
  // Reuse the persisted port if it's still free (stable port across restarts,
@@ -674,8 +771,7 @@ async function pickProjectPort(ctx, explicitPort) {
674
771
  continue;
675
772
  if (await isPortAvailable(port)) {
676
773
  if (project.webPort <= 0) {
677
- project.webPort = port;
678
- await st.saveProject(project);
774
+ await st.updateProject((current) => ({ ...current, webPort: port }));
679
775
  }
680
776
  return port;
681
777
  }
@@ -807,8 +903,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
807
903
  // existing canonical). Never persist a transient fallback port — that
808
904
  // hijacks the primary session's port when two instances share a .planner.
809
905
  if (project.webPort <= 0 || project.webPort === realPort) {
810
- project.webPort = realPort;
811
- await st.saveProject(project);
906
+ await st.updateProject((current) => ({ ...current, webPort: realPort }));
812
907
  }
813
908
  }
814
909
  }
@@ -1139,6 +1234,13 @@ export default function planPiExtension(pi) {
1139
1234
  "cleanup-orphans",
1140
1235
  "project discuss",
1141
1236
  "project language",
1237
+ "project guidelines",
1238
+ "idea list",
1239
+ "idea add",
1240
+ "idea show",
1241
+ "idea update",
1242
+ "idea delete",
1243
+ "idea promote",
1142
1244
  "feature list",
1143
1245
  "feature add",
1144
1246
  "feature show",
@@ -1170,7 +1272,7 @@ export default function planPiExtension(pi) {
1170
1272
  "load",
1171
1273
  "stop",
1172
1274
  ];
1173
- const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1275
+ const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, idea, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1174
1276
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1175
1277
  "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
1176
1278
  if (!a) {
@@ -1183,11 +1285,16 @@ export default function planPiExtension(pi) {
1183
1285
  return;
1184
1286
  }
1185
1287
  if (a === "version") {
1288
+ const packages = runtimePackagesDiagnostic([PI_ADAPTER_PACKAGE, CORE_PACKAGE, SERVER_PACKAGE]);
1289
+ const capabilities = runtimeCapabilities();
1186
1290
  ctx.ui.notify([
1187
- "Agent Plan runtime versions",
1188
- `${PI_ADAPTER_PACKAGE.name}: ${PI_ADAPTER_PACKAGE.version}`,
1189
- `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
1190
- `${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(", ")}`,
1191
1298
  ].join("\n"), "info");
1192
1299
  return;
1193
1300
  }
@@ -1254,12 +1361,18 @@ export default function planPiExtension(pi) {
1254
1361
  // ── show ──
1255
1362
  if (a === "show") {
1256
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)`));
1257
1367
  ctx.ui.notify([
1258
1368
  `📋 **${plan.project.name}**`,
1259
1369
  ` Description: ${plan.project.description || "*not set*"}`,
1260
1370
  ` Goal: ${plan.project.goal || "*not set*"}`,
1261
1371
  ` Phases: ${plan.phases.length}`,
1262
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}`)]),
1263
1376
  ` Updated: ${plan.manifest.updatedAt}`,
1264
1377
  ].join("\n"), "info");
1265
1378
  return;
@@ -1472,7 +1585,7 @@ export default function planPiExtension(pi) {
1472
1585
  // ═══════════════════════════════════════════════════════════════
1473
1586
  if (a === "project") {
1474
1587
  if (!b) {
1475
- ctx.ui.notify("project actions: discuss | language", "info");
1588
+ ctx.ui.notify("project actions: discuss | language | guidelines | migrate-context", "info");
1476
1589
  return;
1477
1590
  }
1478
1591
  if (b === "discuss") {
@@ -1503,7 +1616,17 @@ export default function planPiExtension(pi) {
1503
1616
  }))
1504
1617
  : project.acceptedDecisions,
1505
1618
  };
1506
- 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
+ }));
1507
1630
  await st.saveCodebaseProfile(profile);
1508
1631
  await st.writeGenerated();
1509
1632
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
@@ -1529,12 +1652,124 @@ export default function planPiExtension(pi) {
1529
1652
  const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
1530
1653
  project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
1531
1654
  project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
1532
- await st.saveProject(project);
1655
+ await st.updateProject((current) => ({
1656
+ ...current,
1657
+ contentLanguage: project.contentLanguage,
1658
+ chatLanguage: project.chatLanguage,
1659
+ }));
1533
1660
  await st.writeGenerated();
1534
1661
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
1535
1662
  return;
1536
1663
  }
1537
- ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language`, "warning");
1664
+ if (b === "guidelines") {
1665
+ const project = await st.loadProject();
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 || "");
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 });
1673
+ await st.writeGenerated();
1674
+ ctx.ui.notify(project.projectGuidelines.content ? "Project Guidelines updated." : "Project Guidelines cleared.", "info");
1675
+ return;
1676
+ }
1677
+ if (b === "migrate-context") {
1678
+ const preview = await st.previewLegacyProjectContextMigration();
1679
+ const summary = legacyContextMigrationSummary(preview);
1680
+ if (!preview.hasLegacyContext) {
1681
+ ctx.ui.notify(`Project context migration preview:\n${summary}\nNo migration is needed.`, "info");
1682
+ return;
1683
+ }
1684
+ const choice = await ctx.ui.select("Apply the previewed migration? This clears only migrated legacy fields.", ["apply", "cancel"]);
1685
+ if (choice !== "apply") {
1686
+ ctx.ui.notify(`Project context migration preview (no changes applied):\n${summary}`, "info");
1687
+ return;
1688
+ }
1689
+ const result = await st.migrateLegacyProjectContext();
1690
+ await st.writeGenerated();
1691
+ ctx.ui.notify(`${result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration."}\n${legacyContextMigrationSummary(result.preview)}`, "info");
1692
+ return;
1693
+ }
1694
+ ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language, guidelines, migrate-context`, "warning");
1695
+ return;
1696
+ }
1697
+ // ═══════════════════════════════════════════════════════════════
1698
+ // idea <sub>
1699
+ // ═══════════════════════════════════════════════════════════════
1700
+ if (a === "idea") {
1701
+ if (!b) {
1702
+ ctx.ui.notify("idea actions: list | add [title] | show [ref] | update [ref] | delete [ref] | promote [ref]", "info");
1703
+ return;
1704
+ }
1705
+ const ideas = (await st.loadIdeas()).ideas;
1706
+ if (b === "list") {
1707
+ await paginatedNotify(ctx, {
1708
+ title: "ideas",
1709
+ lines: ideas.map((idea) => `${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`),
1710
+ pageSize: 10,
1711
+ });
1712
+ return;
1713
+ }
1714
+ if (b === "add") {
1715
+ const title = subArgs.trim() || await ctx.ui.input("Idea title");
1716
+ if (!title?.trim()) {
1717
+ ctx.ui.notify("Aborted", "warning");
1718
+ return;
1719
+ }
1720
+ const description = await ctx.ui.editor("Idea description", "");
1721
+ const idea = await st.createIdea({ title: title.trim(), ...(description?.trim() ? { description: description.trim() } : {}) });
1722
+ await st.writeGenerated();
1723
+ ctx.ui.notify(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, "info");
1724
+ return;
1725
+ }
1726
+ const ref = subArgs.trim() || await ctx.ui.input("Idea ref (I00x, short ID, or title)");
1727
+ const idea = ref ? findIdeaByRef(ideas, ref) : undefined;
1728
+ if (!idea) {
1729
+ ctx.ui.notify(`Idea not found: ${ref || "(empty)"}`, "error");
1730
+ return;
1731
+ }
1732
+ if (b === "show") {
1733
+ ctx.ui.notify(`${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}${idea.promotion ? `\n\nPromoted to: ${idea.promotion.targetRef}` : ""}`, "info");
1734
+ return;
1735
+ }
1736
+ if (b === "update") {
1737
+ const title = await ctx.ui.input(`Title [${idea.title}]`);
1738
+ const description = await ctx.ui.editor("Description", idea.description);
1739
+ const updated = await st.updateIdea(idea.id, { ...(title?.trim() ? { title: title.trim() } : {}), ...(description !== undefined ? { description: description.trim() } : {}) });
1740
+ await st.writeGenerated();
1741
+ ctx.ui.notify(`Idea updated: ${formatIdeaRef(updated.number)} — ${updated.title}`, "info");
1742
+ return;
1743
+ }
1744
+ if (b === "delete") {
1745
+ const answer = await ctx.ui.select(`Delete ${formatIdeaRef(idea.number)} — ${idea.title}?`, ["cancel", "delete"]);
1746
+ if (answer !== "delete") {
1747
+ ctx.ui.notify("Cancelled — no changes saved.", "info");
1748
+ return;
1749
+ }
1750
+ await st.deleteIdea(idea.id);
1751
+ await st.writeGenerated();
1752
+ ctx.ui.notify(`Idea deleted: ${formatIdeaRef(idea.number)}.`, "info");
1753
+ return;
1754
+ }
1755
+ if (b === "promote") {
1756
+ if (idea.promotion) {
1757
+ ctx.ui.notify(`Already promoted to ${idea.promotion.targetRef}.`, "warning");
1758
+ return;
1759
+ }
1760
+ const targetType = await ctx.ui.select("Promotion target", ["feature", "phase", "task"]);
1761
+ if (!targetType)
1762
+ return;
1763
+ const skill = await st.ideaDiscussionSkill();
1764
+ if (capturedPi) {
1765
+ await capturedPi.sendUserMessage(`Discuss promotion of ${formatIdeaRef(idea.number)} — ${idea.title} to a ${targetType}.\n\n${skill}\n\nDo not create or persist a target until the discussion is complete and I explicitly confirm the exact destination. For a phase, recommend and confirm its feature. For a task, recommend and confirm its feature and phase. After creating the confirmed target with the normal planner tool, record the promotion with idea_promotion_finalize.`);
1766
+ }
1767
+ else {
1768
+ ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
1769
+ }
1770
+ return;
1771
+ }
1772
+ ctx.ui.notify(`Unknown idea action "${b}". Try: list, add, show, update, delete, promote`, "warning");
1538
1773
  return;
1539
1774
  }
1540
1775
  // ═══════════════════════════════════════════════════════════════
@@ -1762,9 +1997,7 @@ export default function planPiExtension(pi) {
1762
1997
  }
1763
1998
  else {
1764
1999
  for (const phase of phases) {
1765
- phase.featureId = undefined;
1766
- phase.updatedAt = nowISO();
1767
- await st.savePhase(phase);
2000
+ await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
1768
2001
  affectedPhases += 1;
1769
2002
  }
1770
2003
  }
@@ -1804,7 +2037,7 @@ export default function planPiExtension(pi) {
1804
2037
  // and only then abandoning the follow-up question.
1805
2038
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
1806
2039
  let phase;
1807
- await withFeatureLock(feature.id, async () => {
2040
+ await st.runBatch(() => withFeatureLock(feature.id, async () => {
1808
2041
  const phases = await st.loadAllPhases();
1809
2042
  const featurePhases = phases.filter((p) => p.featureId === feature.id);
1810
2043
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1816,7 +2049,7 @@ export default function planPiExtension(pi) {
1816
2049
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1817
2050
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1818
2051
  createdAt: nowISO(), updatedAt: nowISO(),
1819
- handoff: "", handoffUpdatedAt: "",
2052
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
1820
2053
  handoffReadAt: "",
1821
2054
  handoffHistory: [],
1822
2055
  statusLog: [],
@@ -1832,7 +2065,7 @@ export default function planPiExtension(pi) {
1832
2065
  return doc;
1833
2066
  });
1834
2067
  await st.writeGenerated();
1835
- });
2068
+ }));
1836
2069
  if (!phase)
1837
2070
  return;
1838
2071
  ctx.ui.notify(`Phase created: ${formatPhaseRef(phase.number, feature.number)} — ${title}${phase.shortId ? ` · ${phase.shortId}` : ""} (feature ${feature.name})`, "info");
@@ -1880,8 +2113,12 @@ export default function planPiExtension(pi) {
1880
2113
  ctx.ui.notify(recapLines.join("\n"), "info");
1881
2114
  const changeNote = await ctx.ui.input("Anything changed? (free text, or leave empty to keep current plan)");
1882
2115
  if (changeNote?.trim()) {
1883
- phase.notes = `${phase.notes ? phase.notes + "\n" : ""}[discuss ${nowISO()}] ${changeNote.trim()}`;
1884
- 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
+ }));
1885
2122
  if (capturedPi) {
1886
2123
  await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
1887
2124
  "Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
@@ -1903,25 +2140,22 @@ export default function planPiExtension(pi) {
1903
2140
  if (ans?.trim())
1904
2141
  answers[q.key] = ans.trim();
1905
2142
  }
1906
- if (answers.goal)
1907
- phase.goals = [answers.goal];
1908
- if (answers.summary)
1909
- phase.summary = answers.summary;
1910
- if (answers.scope)
1911
- phase.description = answers.scope;
1912
- if (answers.non_scope)
1913
- phase.nonGoals = [answers.non_scope];
1914
- if (answers.deps)
1915
- phase.dependencies = splitCsv(answers.deps);
1916
- if (answers.risks)
1917
- phase.risks = splitCsv(answers.risks);
1918
- if (answers.completion)
1919
- phase.completionCriteria = splitCsv(answers.completion);
1920
- phase.status = "planned";
1921
- phase.updatedAt = nowISO();
1922
- 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
+ }));
1923
2157
  await st.writeGenerated();
1924
- 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");
1925
2159
  return;
1926
2160
  }
1927
2161
  if (b === "show") {
@@ -1993,33 +2227,25 @@ export default function planPiExtension(pi) {
1993
2227
  const deps = await ctx.ui.input(`Dependencies (comma-separated) [${phase.dependencies.join(", ") || ""}]`);
1994
2228
  const risks = await ctx.ui.input(`Risks (comma-separated) [${phase.risks.join(", ") || ""}]`);
1995
2229
  const completion = await ctx.ui.input(`Completion criteria (comma-separated) [${phase.completionCriteria.join(", ") || ""}]`);
1996
- // Update only changed fields
1997
- if (title?.trim())
1998
- phase.title = title.trim();
1999
2230
  if (statusInput?.trim()) {
2000
2231
  const valid = ["draft", "discovery", "planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
2001
2232
  if (!valid.includes(statusInput.trim())) {
2002
2233
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2003
2234
  return;
2004
2235
  }
2005
- phase.status = statusInput.trim();
2006
2236
  }
2007
- if (summary !== undefined)
2008
- phase.summary = summary;
2009
- if (desc?.trim())
2010
- phase.description = desc.trim();
2011
- if (goals?.trim())
2012
- phase.goals = goals.split(",").map((g) => g.trim()).filter(Boolean);
2013
- if (nonGoals?.trim())
2014
- phase.nonGoals = nonGoals.split(",").map((g) => g.trim()).filter(Boolean);
2015
- if (deps?.trim())
2016
- phase.dependencies = deps.split(",").map((g) => g.trim()).filter(Boolean);
2017
- if (risks?.trim())
2018
- phase.risks = risks.split(",").map((g) => g.trim()).filter(Boolean);
2019
- if (completion?.trim())
2020
- phase.completionCriteria = completion.split(",").map((g) => g.trim()).filter(Boolean);
2021
- phase.updatedAt = nowISO();
2022
- 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
+ }));
2023
2249
  await st.writeGenerated();
2024
2250
  ctx.ui.notify(`Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))} — ${phase.title} (${phase.status})${phase.shortId ? ` · ${phase.shortId}` : ""}`, "info");
2025
2251
  return;
@@ -2068,12 +2294,15 @@ export default function planPiExtension(pi) {
2068
2294
  dependsOn: [],
2069
2295
  pauseSnapshot: null, pauseHistory: [],
2070
2296
  startedAt: "", completedAt: "",
2297
+ activeOwnerSession: "",
2071
2298
  createdAt: now, updatedAt: now,
2072
2299
  };
2073
- phase.tasks.push(task);
2074
- phase.taskIds.push(taskId);
2075
- phase.updatedAt = nowISO();
2076
- 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
+ }));
2077
2306
  await st.writeGenerated();
2078
2307
  ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
2079
2308
  if (isYes(startDiscuss)) {
@@ -2104,18 +2333,20 @@ export default function planPiExtension(pi) {
2104
2333
  const { phase, task } = resolved;
2105
2334
  const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
2106
2335
  const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
2107
- if (description?.trim())
2108
- task.description = description.trim();
2109
- if (checklistSeed?.trim()) {
2110
- 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,
2111
2339
  id: createChecklistItemId(task.id, index + 1, itemTitle),
2112
2340
  title: itemTitle,
2113
2341
  checked: false,
2114
- }));
2115
- }
2116
- task.updatedAt = nowISO();
2117
- phase.updatedAt = nowISO();
2118
- 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
+ }));
2119
2350
  await st.writeGenerated();
2120
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");
2121
2352
  return;
@@ -2181,10 +2412,12 @@ export default function planPiExtension(pi) {
2181
2412
  ctx.ui.notify("Aborted", "warning");
2182
2413
  return;
2183
2414
  }
2184
- phase.tasks = phase.tasks.filter((t) => t.id !== task.id);
2185
- phase.taskIds = phase.taskIds.filter((id) => id !== task.id);
2186
- phase.updatedAt = nowISO();
2187
- 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
+ }));
2188
2421
  await st.writeGenerated();
2189
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");
2190
2423
  return;
@@ -2213,125 +2446,137 @@ export default function planPiExtension(pi) {
2213
2446
  const title = await ctx.ui.input(`Title [${task.title}]`);
2214
2447
  const statusInput = await ctx.ui.input(`Status [${task.status}] (planned|in-progress|done|blocked|canceled)`);
2215
2448
  const desc = await ctx.ui.input(`Description [leave empty to keep]`);
2216
- // Update only changed fields
2217
- if (title?.trim())
2218
- task.title = title.trim();
2219
2449
  const now = nowISO();
2220
- if (statusInput?.trim()) {
2221
- const normalizedStatus = statusInput.trim();
2450
+ const normalizedStatus = statusInput?.trim();
2451
+ if (normalizedStatus) {
2222
2452
  const valid = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
2223
2453
  if (!valid.includes(normalizedStatus)) {
2224
2454
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2225
2455
  return;
2226
2456
  }
2227
2457
  if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
2228
- 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");
2229
2459
  return;
2230
2460
  }
2231
2461
  if (normalizedStatus === "done" && normalizedStatus !== task.status) {
2232
2462
  ctx.ui.notify("Task completion requires /planner task complete with durable completion and verification evidence.", "warning");
2233
2463
  return;
2234
2464
  }
2235
- applyTaskLifecycleDates(task, normalizedStatus, now);
2236
2465
  }
2237
- if (desc?.trim())
2238
- task.description = desc.trim();
2239
- task.updatedAt = now;
2240
- phase.updatedAt = now;
2241
- 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
+ });
2242
2478
  await st.syncTaskStatusRollup(phase.id);
2243
2479
  await st.writeGenerated();
2244
- 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");
2245
2481
  return;
2246
2482
  }
2247
2483
  if (b === "start") {
2248
- let phase = null;
2249
- let task = null;
2250
- if (subArgs.trim()) {
2251
- const resolved = findTaskByRef(await st.loadAllPhases(), (await st.loadFeatures()).features, subArgs.trim());
2252
- phase = resolved?.phase ?? null;
2253
- task = resolved?.task ?? null;
2254
- }
2255
- if (!task) {
2256
- phase = await pickPhase();
2257
- if (!phase) {
2258
- ctx.ui.notify("No phases available", "warning");
2259
- return;
2260
- }
2261
- if (phase.tasks.length === 0) {
2262
- ctx.ui.notify(`No tasks in "${phase.title}"`, "info");
2263
- 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;
2264
2491
  }
2265
- task = await pickTask(phase);
2266
2492
  if (!task) {
2267
- ctx.ui.notify("Aborted", "warning");
2268
- 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
+ }
2269
2507
  }
2270
- }
2271
- if (phase) {
2272
- const features = (await st.loadFeatures()).features;
2273
- const feature = phase.featureId ? features.find((candidate) => candidate.id === phase.featureId) : undefined;
2274
- const linkedRequirements = [
2275
- ...(await st.linkedRequirementsForPhase(phase.id)),
2276
- ...(phase.featureId ? await st.linkedRequirementsForFeature(phase.featureId) : []),
2277
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
2278
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
2279
- const contextInput = {
2280
- sessionId: plannerSessionId,
2281
- taskId: task.id,
2282
- phaseId: phase.id,
2283
- ...(phase.featureId ? { featureId: phase.featureId } : {}),
2284
- task,
2285
- phase,
2286
- ...(feature ? { feature } : {}),
2287
- requirements: linkedRequirements,
2288
- requirementIds: linkedRequirementIds,
2289
- };
2290
- const contextEligibility = contextReadEligibilityForSession(contextInput);
2291
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2292
- const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2293
- const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
2294
- const featureRef = feature ? formatFeatureRef(feature.number) : "";
2295
- if (!contextEligibility.eligible) {
2296
- const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `/planner task start ${taskRef}`);
2297
- ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2298
- 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
+ }
2299
2553
  }
2300
- if (!requirementsReady) {
2301
- ctx.ui.notify("❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
2554
+ if (task.status === "in-progress") {
2555
+ ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2302
2556
  return;
2303
2557
  }
2304
- if (!hasValidSessionAttestation(contextInput)) {
2305
- await st.recordContextRead({ sessionId: plannerSessionId, phaseId: phase.id, taskId: task.id, ...(phase.featureId ? { featureId: phase.featureId } : {}), requirementIds: linkedRequirementIds });
2306
- }
2307
- }
2308
- if (task.status === "in-progress") {
2309
- 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");
2310
2578
  return;
2311
- }
2312
- // A pending handoff is context, not a lock: task start RETAINS it.
2313
- // It is archived only when the phase completes (auto) or the user
2314
- // explicitly clears it.
2315
- const now = nowISO();
2316
- // Record status change in the incremental statusLog.
2317
- const prevStatus = task.status;
2318
- const entry = {
2319
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-in-progress`),
2320
- date: now,
2321
- fromStatus: task.status,
2322
- toStatus: "in-progress",
2323
- title: task.status === "done" ? "Reopened" : `→ in-progress`,
2324
- description: task.status === "done" ? "Task reopened from done status." : "",
2325
- };
2326
- task.statusLog = [...(task.statusLog ?? []), entry];
2327
- applyTaskLifecycleDates(task, "in-progress", now);
2328
- task.updatedAt = now;
2329
- phase.updatedAt = now;
2330
- await st.savePhase(phase);
2331
- await st.syncTaskStatusRollup(phase.id);
2332
- await st.writeGenerated();
2333
- 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");
2334
- return;
2579
+ });
2335
2580
  }
2336
2581
  if (b === "complete") {
2337
2582
  let phase = null;
@@ -2375,26 +2620,30 @@ export default function planPiExtension(pi) {
2375
2620
  return;
2376
2621
  }
2377
2622
  const now = nowISO();
2378
- // Record status change in the incremental statusLog.
2379
- const entry = {
2380
- id: createChecklistItemId(task.id, (task.statusLog?.length ?? 0) + 1, `${task.status}-done`),
2381
- date: now,
2382
- fromStatus: task.status,
2383
- toStatus: "done",
2384
- title: `→ done`,
2385
- description: completionSummary.trim(),
2386
- };
2387
- task.statusLog = [...(task.statusLog ?? []), entry];
2388
- applyTaskLifecycleDates(task, "done", now);
2389
- const separator = task.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
2390
- task.description = `${task.description}${separator}${completionSummary.trim()}`;
2391
- task.descriptionUpdatedAt = now;
2392
- task.updatedAt = now;
2393
- phase.updatedAt = now;
2394
- 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
+ });
2395
2644
  const clearedRef = await st.syncTaskStatusRollup(phase.id);
2396
2645
  await st.writeGenerated();
2397
- ctx.ui.notify(`✅ Task completed: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} — ${task.title} (done)${task.shortId ? ` · ${task.shortId}` : ""}${clearedRef ? ` — phase handoff archived (${clearedRef})` : ""}`, "info");
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");
2398
2647
  return;
2399
2648
  }
2400
2649
  // checklist-add <T00x> <title> / checklist-remove <T00x> <C{n}|title> / checklist-toggle <T00x> <C{n}|title> [on|off]
@@ -2500,8 +2749,15 @@ export default function planPiExtension(pi) {
2500
2749
  ctx.ui.notify(r.error, "warning");
2501
2750
  return;
2502
2751
  }
2503
- const content = await st.getPhaseHandoff(r.phase.id);
2504
- 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");
2505
2761
  return;
2506
2762
  }
2507
2763
  if (action === "write") {
@@ -2516,14 +2772,16 @@ export default function planPiExtension(pi) {
2516
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" +
2517
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" +
2518
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" +
2519
- "After confirmation, call `handoff_prepare` with that exact phaseRef. Read the entire existing handoff and its handoffUpdatedAt token. Audit every listed done task that lacks durable completion/verification evidence. Then call `handoff_write` with the exact token, one reconciled handoff body, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps.\n\n" +
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" +
2520
2776
  "The reconciled handoff MUST contain at minimum:\n" +
2521
2777
  "- `Created at:` and `Updated at:` lines (ISO timestamps)\n" +
2522
2778
  "- `Reason:` why this handoff is being written\n" +
2523
2779
  "- `## Current focus`: feature, phase and task refs/titles/statuses derived from the confirmed target\n" +
2524
- "- `## 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" +
2525
2784
  "- `## How to resume`: explicit ordered steps\n" +
2526
- "- `## Files touched`, `## Blockers`, `## Next steps`, and `## Recent decisions`\n" +
2527
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.");
2528
2786
  ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2529
2787
  return;
@@ -2649,6 +2907,15 @@ export default function planPiExtension(pi) {
2649
2907
  return;
2650
2908
  }
2651
2909
  if (a === "load") {
2910
+ const st = ensureStore(ctx);
2911
+ let preparation;
2912
+ try {
2913
+ preparation = await st.preparePlannerSession();
2914
+ }
2915
+ catch (error) {
2916
+ ctx.ui.notify(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, "error");
2917
+ return;
2918
+ }
2652
2919
  enablePlannerSession();
2653
2920
  if (!server) {
2654
2921
  ctx.ui.notify("Starting web server (LAN) …", "info");
@@ -2678,17 +2945,22 @@ export default function planPiExtension(pi) {
2678
2945
  // triggerTurn messages), so the recap data must live here. Uses the shared
2679
2946
  // core buildRecap so Pi and Claude Code/Codex present identical content.
2680
2947
  let recapText = "";
2948
+ let plannerSkillContext = "";
2681
2949
  try {
2682
- const st = ensureStore(ctx);
2950
+ const plannerSkill = await st.syncPlannerSkill();
2951
+ await st.syncGrillMeSkill();
2683
2952
  const srv = server;
2684
2953
  recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2954
+ if (preparation.changed)
2955
+ recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
2956
+ plannerSkillContext = `[agent-only planner usage skill; do not quote in the recap]\n${plannerSkill.customized ? `${plannerSkill.message}\n` : ""}${plannerSkill.content}\n[end agent-only planner usage skill]`;
2685
2957
  }
2686
2958
  catch (e) {
2687
2959
  recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2688
2960
  }
2689
2961
  pi.sendMessage({
2690
2962
  customType: "planner-resume-trigger",
2691
- content: "[internal trigger — not a user command] Present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2963
+ content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill, then present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n" + plannerSkillContext + "\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2692
2964
  display: false,
2693
2965
  }, {
2694
2966
  triggerTurn: true,
@@ -2767,10 +3039,14 @@ export default function planPiExtension(pi) {
2767
3039
  project.contentLanguage = normalizedContent || fallback;
2768
3040
  project.chatLanguage = normalizedChat || fallback;
2769
3041
  }
2770
- await st.saveProject(project);
3042
+ const persisted = await st.updateProject((current) => ({
3043
+ ...current,
3044
+ contentLanguage: project.contentLanguage,
3045
+ chatLanguage: project.chatLanguage,
3046
+ }));
2771
3047
  await st.writeGenerated();
2772
3048
  return {
2773
- 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)"}` }],
2774
3050
  details: { contentLanguage: project.contentLanguage, chatLanguage: project.chatLanguage },
2775
3051
  };
2776
3052
  },
@@ -2783,7 +3059,7 @@ export default function planPiExtension(pi) {
2783
3059
  projectName: Type.String({ description: "Concise project name/title" }),
2784
3060
  description: Type.Optional(Type.String({ description: "Short project description" })),
2785
3061
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
2786
- 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." })),
2787
3063
  }),
2788
3064
  async execute(_id, params, _signal, _onUpdate, ctx) {
2789
3065
  const st = loadStore(ctx);
@@ -2814,7 +3090,6 @@ export default function planPiExtension(pi) {
2814
3090
  id: createRequirementId(),
2815
3091
  title,
2816
3092
  description: "",
2817
- status: "planned",
2818
3093
  macroTasks: [],
2819
3094
  linkedPhaseIds: [],
2820
3095
  sessionInfo: [],
@@ -2837,6 +3112,7 @@ export default function planPiExtension(pi) {
2837
3112
  parameters: Type.Object({
2838
3113
  name: Type.Optional(Type.String({ description: "Project title" })),
2839
3114
  description: Type.Optional(Type.String({ description: "Short project description" })),
3115
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
2840
3116
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
2841
3117
  scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
2842
3118
  outOfScope: Type.Optional(Type.Array(Type.String(), { description: "Replace out-of-scope items" })),
@@ -2850,10 +3126,34 @@ export default function planPiExtension(pi) {
2850
3126
  if (!st)
2851
3127
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
2852
3128
  const project = await st.loadProject();
3129
+ const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
3130
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3131
+ if (receivedFields.length === 0) {
3132
+ return mutationNoFieldsFailure({
3133
+ entity: "project",
3134
+ ref: project.name,
3135
+ operation: "update",
3136
+ mutableFields,
3137
+ retryCommand: "project_update with a concise description summary and the intended metadata",
3138
+ readBackCommand: "plan_get",
3139
+ descriptionFallback: {
3140
+ inputField: "description",
3141
+ storedField: "project description",
3142
+ descriptionRefField: "descriptionRef",
3143
+ },
3144
+ });
3145
+ }
2853
3146
  if (params.name !== undefined)
2854
3147
  project.name = params.name.trim();
2855
3148
  if (params.description !== undefined)
2856
3149
  project.description = params.description.trim();
3150
+ if (params.descriptionRef !== undefined) {
3151
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
3152
+ if (descriptionRef)
3153
+ project.descriptionRef = descriptionRef;
3154
+ else
3155
+ delete project.descriptionRef;
3156
+ }
2857
3157
  if (params.goal !== undefined)
2858
3158
  project.goal = params.goal.trim();
2859
3159
  if (params.scope !== undefined)
@@ -2868,37 +3168,178 @@ export default function planPiExtension(pi) {
2868
3168
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
2869
3169
  if (params.decisions !== undefined)
2870
3170
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
2871
- 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
+ });
2872
3193
  await st.writeGenerated();
2873
- return { content: [{ type: "text", text: `Project updated: ${project.name}` }], details: project };
3194
+ return { content: [{ type: "text", text: `Project updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...persisted, updated: true, updatedFields: receivedFields } };
2874
3195
  },
2875
3196
  });
2876
3197
  pi.registerTool({
2877
- name: "requirement_list",
2878
- label: "Requirement List",
2879
- description: "List all top-level requirements in requirements.json.",
3198
+ name: "project_guidelines_show",
3199
+ label: "Project Guidelines Show",
3200
+ description: "Read the canonical Project Guidelines / Coding Standard section for this project. Call this when task_start or task_switch nextActions require it, and keep the guidance in working memory while doing task work.",
2880
3201
  parameters: Type.Object({}),
2881
3202
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2882
3203
  const st = await requirePlan(ctx);
3204
+ if (!st)
3205
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3206
+ const project = await st.loadProject();
3207
+ if (project.projectGuidelines.content.trim()) {
3208
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
3209
+ }
3210
+ const refreshed = await st.loadProject();
3211
+ const content = refreshed.projectGuidelines.content.trim();
3212
+ return {
3213
+ content: [{ type: "text", text: content
3214
+ ? [
3215
+ "## Project Guidelines",
3216
+ content,
3217
+ ].join("\n\n")
3218
+ : "No project guidelines set."
3219
+ }],
3220
+ details: {
3221
+ projectGuidelines: refreshed.projectGuidelines,
3222
+ readRecorded: Boolean(content),
3223
+ },
3224
+ };
3225
+ },
3226
+ });
3227
+ pi.registerTool({
3228
+ name: "project_guidelines_update",
3229
+ label: "Project Guidelines Update",
3230
+ description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
3231
+ parameters: Type.Object({
3232
+ content: Type.String({ description: "Full Project Guidelines markdown/text. Pass an empty string to clear the section." }),
3233
+ }),
3234
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3235
+ const st = await requirePlan(ctx);
3236
+ if (!st)
3237
+ return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3238
+ const project = await st.loadProject();
3239
+ await st.updateProject((current) => ({
3240
+ ...current,
3241
+ projectGuidelines: { ...current.projectGuidelines, content: params.content.trim() },
3242
+ }), { expectedGuidelinesUpdatedAt: project.projectGuidelines.updatedAt });
3243
+ await st.writeGenerated();
3244
+ const refreshed = await st.loadProject();
3245
+ return {
3246
+ content: [{ type: "text", text: refreshed.projectGuidelines.content
3247
+ ? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
3248
+ : "Project Guidelines cleared."
3249
+ }],
3250
+ details: {
3251
+ updated: true,
3252
+ projectGuidelines: refreshed.projectGuidelines,
3253
+ },
3254
+ };
3255
+ },
3256
+ });
3257
+ pi.registerTool({
3258
+ name: "project_context_migrate",
3259
+ label: "Project Context Migrate",
3260
+ description: "Preview or explicitly apply the lossless migration of legacy project globalRules/workflowRules into Project Guidelines and legacy decisions into structured accepted decisions. Ordinary planner loads never run this migration.",
3261
+ parameters: Type.Object({
3262
+ apply: Type.Optional(Type.Boolean({ description: "False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields." })),
3263
+ }),
3264
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3265
+ const st = await requirePlan(ctx);
3266
+ if (!st)
3267
+ return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3268
+ const preview = await st.previewLegacyProjectContextMigration();
3269
+ if (params.apply !== true) {
3270
+ return {
3271
+ content: [{ type: "text", text: ["Project context migration preview (no changes applied).", legacyContextMigrationSummary(preview), preview.hasLegacyContext ? "Retry with apply=true to confirm." : "No migration is needed."].join("\n") }],
3272
+ details: { applied: false, preview },
3273
+ };
3274
+ }
3275
+ const result = await st.migrateLegacyProjectContext();
3276
+ await st.writeGenerated();
3277
+ return {
3278
+ content: [{ type: "text", text: [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n") }],
3279
+ details: result,
3280
+ };
3281
+ },
3282
+ });
3283
+ pi.registerTool({
3284
+ name: "requirement_list",
3285
+ label: "Requirement List",
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) {
3291
+ const st = await requirePlan(ctx);
3292
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
2883
3293
  if (!st)
2884
3294
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2885
3295
  const requirements = await st.loadRequirements();
2886
- 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");
2887
3318
  return {
2888
- content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
2889
- 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
+ },
2890
3327
  };
2891
3328
  },
2892
3329
  });
2893
3330
  pi.registerTool({
2894
3331
  name: "requirement_create",
2895
3332
  label: "Requirement Create",
2896
- 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.",
2897
3334
  parameters: Type.Object({
2898
3335
  title: Type.String({ description: "Requirement title" }),
2899
3336
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2900
- status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
2901
3337
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
3338
+ macroTasks: Type.Optional(Type.Array(Type.Object({
3339
+ title: Type.String({ description: "Macro-task title" }),
3340
+ description: Type.Optional(Type.String({ description: "Macro-task description" })),
3341
+ status: Type.Optional(Type.String({ description: "Macro-task status" })),
3342
+ }), { description: "Initial macro tasks. IDs and timestamps are planner-managed." })),
2902
3343
  }),
2903
3344
  async execute(_id, params, _signal, _onUpdate, ctx) {
2904
3345
  const st = await requirePlan(ctx);
@@ -2910,35 +3351,55 @@ export default function planPiExtension(pi) {
2910
3351
  const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2911
3352
  if (!links.ok)
2912
3353
  return { content: [{ type: "text", text: links.error }], details: {} };
2913
- const requirements = await st.loadRequirements();
2914
3354
  const now = nowISO();
3355
+ let macroTasks;
3356
+ try {
3357
+ macroTasks = reconcileRequirementMacroTasks([], (params.macroTasks ?? []).map((task) => ({
3358
+ title: task.title,
3359
+ ...(task.description !== undefined ? { description: task.description } : {}),
3360
+ status: task.status?.trim() ?? "planned",
3361
+ })), now);
3362
+ }
3363
+ catch (error) {
3364
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
3365
+ return { content: [{ type: "text", text: message }], details: {} };
3366
+ }
2915
3367
  const requirement = {
2916
3368
  id: createRequirementId(),
2917
3369
  title,
2918
3370
  description: params.description?.trim() ?? "",
2919
- status: params.status?.trim() ?? "planned",
2920
- macroTasks: [],
3371
+ macroTasks,
2921
3372
  linkedPhaseIds: links.linkedPhaseIds,
2922
3373
  sessionInfo: [],
2923
3374
  createdAt: now,
2924
3375
  updatedAt: now,
2925
3376
  };
2926
- requirements.requirements.push(requirement);
2927
- await st.saveRequirements(requirements);
3377
+ try {
3378
+ await st.updateRequirements((document) => ({ requirements: [...document.requirements, requirement] }));
3379
+ }
3380
+ catch (error) {
3381
+ const message = error instanceof RequirementMacroTaskError ? error.message : error instanceof Error ? error.message : "Invalid requirement.";
3382
+ return { content: [{ type: "text", text: message }], details: {} };
3383
+ }
2928
3384
  await st.writeGenerated();
2929
- 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 } };
2930
3386
  },
2931
3387
  });
2932
3388
  pi.registerTool({
2933
3389
  name: "requirement_update",
2934
3390
  label: "Requirement Update",
2935
- 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.",
2936
3392
  parameters: Type.Object({
2937
3393
  requirementId: Type.String({ description: "Requirement ID" }),
2938
3394
  title: Type.Optional(Type.String({ description: "Requirement title" })),
2939
3395
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2940
- status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
2941
3396
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
3397
+ macroTasks: Type.Optional(Type.Array(Type.Object({
3398
+ id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
3399
+ title: Type.String({ description: "Macro-task title" }),
3400
+ description: Type.Optional(Type.String({ description: "Macro-task description" })),
3401
+ status: Type.String({ description: "Macro-task status" }),
3402
+ }), { description: "Replace, add, remove, or reorder macro tasks. IDs/timestamps are preserved or assigned by the planner." })),
2942
3403
  }),
2943
3404
  async execute(_id, params, _signal, _onUpdate, ctx) {
2944
3405
  const st = await requirePlan(ctx);
@@ -2948,28 +3409,48 @@ export default function planPiExtension(pi) {
2948
3409
  const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
2949
3410
  if (!requirement)
2950
3411
  return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
2951
- if (params.title !== undefined) {
2952
- const title = params.title.trim();
2953
- if (!title)
2954
- return { content: [{ type: "text", text: "title required" }], details: {} };
2955
- 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 } };
3414
+ }
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
+ });
2956
3423
  }
2957
- if (params.description !== undefined)
2958
- requirement.description = params.description.trim();
2959
- if (params.status !== undefined)
2960
- requirement.status = params.status.trim();
2961
- if (params.linkedPhaseIds !== undefined) {
2962
- const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2963
- if (!links.ok)
2964
- return { content: [{ type: "text", text: links.error }], details: {} };
2965
- requirement.linkedPhaseIds = links.linkedPhaseIds;
2966
- }
2967
- requirement.updatedAt = nowISO();
2968
- await st.saveRequirements(requirements);
2969
- await st.writeGenerated();
2970
- return { content: [{ type: "text", text: `Requirement updated: ${requirement.id}` }], details: requirement };
2971
- },
2972
- });
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 } };
3429
+ const now = nowISO();
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: {} };
3449
+ }
3450
+ await st.writeGenerated();
3451
+ return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
3452
+ },
3453
+ });
2973
3454
  pi.registerTool({
2974
3455
  name: "requirement_delete",
2975
3456
  label: "Requirement Delete",
@@ -2981,32 +3462,134 @@ export default function planPiExtension(pi) {
2981
3462
  const st = await requirePlan(ctx);
2982
3463
  if (!st)
2983
3464
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2984
- const requirements = await st.loadRequirements();
2985
- const next = requirements.requirements.filter((entry) => entry.id !== params.requirementId);
2986
- if (next.length === requirements.requirements.length) {
2987
- 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 } };
2988
3473
  }
2989
- await st.saveRequirements({ requirements: next });
2990
3474
  await st.writeGenerated();
2991
- 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 } };
3476
+ },
3477
+ });
3478
+ pi.registerTool({
3479
+ name: "idea_list", label: "Idea List", description: "List rollup-independent Ideas Inbox entries.", parameters: Type.Object({}),
3480
+ async execute(_id, _params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3481
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const ideas = (await st.loadIdeas()).ideas; return { content: [{ type: "text", text: ideas.map((idea) => `- ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`).join("\n") || "No ideas" }], details: { ideas } }; },
3482
+ });
3483
+ pi.registerTool({
3484
+ name: "idea_show", label: "Idea Show", description: "Show an idea by I-number, short ID, UUID, or title.", parameters: Type.Object({ idea: Type.String() }),
3485
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3486
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); return idea ? { content: [{ type: "text", text: `${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}\n\n${idea.description || "(no description)"}` }], details: { idea } } : { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND" } }; },
3487
+ });
3488
+ pi.registerTool({
3489
+ name: "idea_create", label: "Idea Create", description: "Create an independent Ideas Inbox entry.", parameters: Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) }),
3490
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3491
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = await st.createIdea({ title: params.title, ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}` }], details: { idea, created: true } }; },
3492
+ });
3493
+ pi.registerTool({
3494
+ name: "idea_update", label: "Idea Update", description: "Update an Ideas Inbox title or description.", parameters: Type.Object({ idea: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }),
3495
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3496
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3497
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
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 } }; },
3499
+ });
3500
+ pi.registerTool({
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() }),
3502
+ async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.confirmed)
3503
+ return { content: [{ type: "text", text: "Idea deletion requires confirmed=true after explicit user confirmation." }], details: { errorCode: "CONFIRMATION_REQUIRED", deleted: false } }; const st = await requirePlan(ctx); if (!st)
3504
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3505
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", deleted: false } }; await st.deleteIdea(current.id); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea deleted: ${formatIdeaRef(current.number)}.` }], details: { deleted: true, ideaRef: formatIdeaRef(current.number) } }; },
3506
+ });
3507
+ pi.registerTool({
3508
+ name: "idea_promotion_begin", label: "Idea Promotion Begin", description: "Begin a grill-me idea promotion discussion without persisting a target or promotion.", parameters: Type.Object({ idea: Type.String(), targetType: Type.Union([Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]) }),
3509
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3510
+ const st = await requirePlan(ctx);
3511
+ if (!st)
3512
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3513
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea);
3514
+ if (!idea)
3515
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND" } };
3516
+ if (idea.promotion)
3517
+ return { content: [{ type: "text", text: `${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.` }], details: { errorCode: "ALREADY_PROMOTED", idea } };
3518
+ const features = (await st.loadFeatures()).features;
3519
+ const phases = await st.loadAllPhases();
3520
+ const recommendedFeature = features[0];
3521
+ const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
3522
+ const recommendation = params.targetType === "feature"
3523
+ ? "Create a new feature after the discussion."
3524
+ : params.targetType === "phase"
3525
+ ? recommendedFeature ? `Recommended parent feature: ${formatFeatureRef(recommendedFeature.number)} — ${recommendedFeature.name}. Confirm or choose another feature before creating the phase.` : "Create and confirm a parent feature before promoting to a phase."
3526
+ : recommendedPhase ? `Recommended destination: ${formatPhaseRef(recommendedPhase.number, features.find((feature) => feature.id === recommendedPhase.featureId)?.number)} — ${recommendedPhase.title}. Confirm its feature and phase before creating the task.` : "Create and confirm a parent feature and phase before promoting to a task.";
3527
+ return { content: [{ type: "text", text: `Promotion discussion started for ${formatIdeaRef(idea.number)}. ${recommendation}` }], details: { idea, targetType: params.targetType, recommendation, grillMeSkill: await st.ideaDiscussionSkill(), persisted: false, nextActions: ["Follow grill-me one question at a time.", "Obtain explicit parent and target confirmation.", "Create the agreed target using feature_create, phase_create, or task_create.", "Call idea_promotion_finalize with discussionCompleted=true and confirmed=true."] } };
2992
3528
  },
2993
3529
  });
3530
+ pi.registerTool({
3531
+ name: "idea_promotion_finalize", label: "Idea Promotion Finalize", description: "Record an idea promotion after grill-me discussion, explicit confirmation, and successful normal target creation.", parameters: Type.Object({ idea: Type.String(), targetType: Type.Union([Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]), targetRef: Type.String(), discussionCompleted: Type.Boolean(), confirmed: Type.Boolean() }),
3532
+ async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.discussionCompleted || !params.confirmed)
3533
+ return { content: [{ type: "text", text: "Promotion requires discussionCompleted=true and confirmed=true; no promotion was persisted." }], details: { errorCode: "IDEA_PROMOTION_CONFIRMATION_REQUIRED", promoted: false } }; const st = await requirePlan(ctx); if (!st)
3534
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!idea)
3535
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", promoted: false } }; const features = (await st.loadFeatures()).features; const phases = await st.loadAllPhases(); let target; if (params.targetType === "feature") {
3536
+ const resolved = resolveFeatureRefStrict(features, params.targetRef);
3537
+ target = resolved.ok ? { id: resolved.feature.id } : undefined;
3538
+ }
3539
+ else if (params.targetType === "phase") {
3540
+ const phase = findPhaseByRef(phases, features, params.targetRef);
3541
+ target = phase ? { id: phase.id } : undefined;
3542
+ }
3543
+ else {
3544
+ const found = findTaskByRef(phases, features, params.targetRef);
3545
+ target = found ? { id: found.task.id } : undefined;
3546
+ } if (!target)
3547
+ return { content: [{ type: "text", text: `Confirmed ${params.targetType} target not found: ${params.targetRef}; no promotion was persisted.` }], details: { errorCode: "TARGET_NOT_FOUND", promoted: false } }; const promoted = await st.promoteIdea(idea.id, { targetType: params.targetType, targetId: target.id }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea promoted: ${formatIdeaRef(promoted.number)} → ${promoted.promotion.targetRef}` }], details: { idea: promoted, promoted: true, targetRef: promoted.promotion.targetRef } }; },
3548
+ });
2994
3549
  pi.registerTool({
2995
3550
  name: "plan_get",
2996
3551
  label: "Plan Get",
2997
- 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.",
2998
3553
  parameters: Type.Object({}),
2999
3554
  async execute(_id, _params, _signal, _onUpdate, ctx) {
3000
3555
  const st = await requirePlan(ctx);
3001
3556
  if (!st)
3002
3557
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3003
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 }]);
3004
3571
  return {
3005
- content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements` }],
3006
- 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 },
3007
3574
  };
3008
3575
  },
3009
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
+ });
3010
3593
  pi.registerTool({
3011
3594
  name: "plan_render",
3012
3595
  label: "Plan Render",
@@ -3089,9 +3672,11 @@ export default function planPiExtension(pi) {
3089
3672
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3090
3673
  const list = await st.listHandoffs();
3091
3674
  if (list.length === 0)
3092
- 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 } };
3093
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3094
- return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { deprecated: true, count: list.length, handoffs: list } };
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: [] } };
3676
+ const result = boundedPage(list, 1, 10);
3677
+ const { items, ...pageInfo } = result;
3678
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
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 } };
3095
3680
  },
3096
3681
  });
3097
3682
  pi.registerTool({
@@ -3182,23 +3767,28 @@ export default function planPiExtension(pi) {
3182
3767
  pi.registerTool({
3183
3768
  name: "handoff_list",
3184
3769
  label: "Handoff List",
3185
- description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
3186
- parameters: Type.Object({}),
3187
- async execute(_id, _params, _signal, _onUpdate, ctx) {
3770
+ description: "List phases with active entity-scoped handoffs as compact summary-only pages. Full bodies are never embedded; call handoff_show for one exact phase.",
3771
+ parameters: Type.Object({
3772
+ page: Type.Optional(Type.Integer({ minimum: 1, description: "1-based page number (default 1)." })),
3773
+ pageSize: Type.Optional(Type.Integer({ minimum: 1, maximum: 25, description: "Summaries per page (default 10, maximum 25)." })),
3774
+ }),
3775
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3188
3776
  const st = await requirePlan(ctx);
3189
3777
  if (!st)
3190
3778
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3191
3779
  const list = await st.listHandoffs();
3192
3780
  if (list.length === 0)
3193
- return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
3194
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3195
- return { content: [{ type: "text", text: `Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { count: list.length, handoffs: list } };
3781
+ return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
3782
+ const result = boundedPage(list, params.page ?? 1, params.pageSize ?? 10);
3783
+ const { items, ...pageInfo } = result;
3784
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars; ${e.resumeReady ? "resume-ready" : "verification required"})`);
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 } };
3196
3786
  },
3197
3787
  });
3198
3788
  pi.registerTool({
3199
3789
  name: "handoff_show",
3200
3790
  label: "Handoff Show",
3201
- 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.",
3202
3792
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact phase ref: P00x | P00x(F00x) | UUID | title. Ask the user when the target is ambiguous." }) }),
3203
3793
  async execute(_id, params, _signal, _onUpdate, ctx) {
3204
3794
  const st = await requirePlan(ctx);
@@ -3207,16 +3797,100 @@ export default function planPiExtension(pi) {
3207
3797
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
3208
3798
  if (!r.ok)
3209
3799
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3210
- const content = await st.getPhaseHandoff(r.phase.id);
3211
- if (!content.trim())
3212
- return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, empty: true } };
3213
- return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3800
+ const phase = await st.loadPhase(r.phase.id);
3801
+ const bounded = boundedHandoffForTransport(phase.handoff);
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 read-back and source reconciliation completed."
3843
+ : "NOT resume-ready: after reading this entire persisted body, call handoff_verify with its contentHash and a fresh comparison against every required source. If any gap exists, rewrite instead of verifying.";
3844
+ return {
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 },
3847
+ };
3848
+ },
3849
+ });
3850
+ pi.registerTool({
3851
+ name: "handoff_verify",
3852
+ label: "Handoff Verify",
3853
+ description: "Finalize one persisted handoff as resume-ready only after handoff_show has displayed the entire body and the agent has compared it again with conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer output. Any discovered omission blocks verification and requires prepare+rewrite.",
3854
+ parameters: Type.Object({
3855
+ phaseRef: Type.String(),
3856
+ expectedContentHash: Type.String({ pattern: "^[a-f0-9]{64}$" }),
3857
+ sourceReviews: 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.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
+ sourceReviews: params.sourceReviews,
3874
+ 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
+ }
3214
3888
  },
3215
3889
  });
3216
3890
  pi.registerTool({
3217
3891
  name: "handoff_prepare",
3218
3892
  label: "Handoff Prepare",
3219
- description: "Audit one exact phase before writing a handoff. Returns the current handoff/version and every done task missing durable completion or verification evidence. Call after the user confirms the target, then reconcile the returned context through handoff_write.",
3893
+ description: "Audit one exact phase before writing a handoff. Returns the canonical scaffold, a bounded priority-ordered phase work map with sibling goals/dependencies/capability ownership, the current token, missing task evidence, required audits, and the 24,000-character inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; reconcile everything through handoff_write.",
3220
3894
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3221
3895
  async execute(_id, params, _signal, _onUpdate, ctx) {
3222
3896
  const st = await requirePlan(ctx);
@@ -3227,19 +3901,36 @@ export default function planPiExtension(pi) {
3227
3901
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3228
3902
  try {
3229
3903
  const audit = await st.preparePhaseHandoff(r.phase.id);
3904
+ const { handoff, ...auditDetails } = audit;
3905
+ const boundedExisting = boundedHandoffForTransport(handoff);
3230
3906
  const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3231
- const existing = audit.handoff.trim() || "(none)";
3907
+ const existing = boundedExisting.content.trim() || "(none)";
3232
3908
  return {
3233
3909
  content: [{ type: "text", text: [
3234
3910
  `Handoff preparation audit for ${r.compositeRef}`,
3235
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
+ "",
3236
3918
  "Done tasks missing durable completion/verification evidence:",
3237
3919
  missing,
3238
3920
  "",
3921
+ `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
3922
+ ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3923
+ "",
3924
+ `Required cold-start source review v${audit.coldStartInventoryVersion} — inspect and substantively summarize every source before drafting:`,
3925
+ ...audit.coldStartSourceReviews.map((entry) => `- ${entry.id} — ${entry.label}`),
3926
+ "Required cold-start inventory — every category needs a concrete item (use an explicit verified-negative statement when nothing exists), and every item must appear verbatim in the handoff or a validated supporting document:",
3927
+ ...audit.coldStartInventoryCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3928
+ `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
3929
+ "",
3239
3930
  "Existing active handoff (reconcile all still-relevant content):",
3240
3931
  existing,
3241
3932
  ].join("\n") }],
3242
- details: { phaseRef: r.compositeRef, ...audit },
3933
+ details: { phaseRef: r.compositeRef, ...auditDetails, existingHandoffLength: boundedExisting.fullLength, existingHandoffTruncated: boundedExisting.truncated },
3243
3934
  };
3244
3935
  }
3245
3936
  catch (error) {
@@ -3251,15 +3942,40 @@ export default function planPiExtension(pi) {
3251
3942
  pi.registerTool({
3252
3943
  name: "handoff_write",
3253
3944
  label: "Handoff Write",
3254
- description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run handoff_prepare first and pass its exact handoffUpdatedAt token. Existing context must be reconciled; refreshes do not create superseded archives.",
3945
+ description: "Persist a reconciled handoff candidate with durable context synchronization and mandatory audits. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify source reconciliation succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
3255
3946
  parameters: Type.Object({
3256
3947
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3257
3948
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
3949
+ 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." })),
3258
3950
  confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
3259
3951
  content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
3260
3952
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
3261
3953
  expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
3262
3954
  reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
3955
+ completenessAudit: Type.Optional(Type.Object({
3956
+ version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
3957
+ entries: Type.Array(Type.Object({
3958
+ category: Type.String({ description: `One required category id from handoff_prepare: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
3959
+ status: Type.Union([Type.Literal("captured"), Type.Literal("not-applicable")]),
3960
+ detail: Type.String({ description: "Substantive captured detail or a substantive reason why the category is not applicable." }),
3961
+ })),
3962
+ })),
3963
+ coldStartInventory: Type.Optional(Type.Object({
3964
+ version: Type.Literal(HANDOFF_COLD_START_INVENTORY_VERSION),
3965
+ sourceReviews: Type.Array(Type.Object({
3966
+ source: Type.String({ description: `One required source review id: ${HANDOFF_COLD_START_SOURCE_REVIEWS.map((entry) => entry.id).join(", ")}` }),
3967
+ detail: Type.String({ description: "Substantive summary of what was reviewed and which facts were extracted before drafting." }),
3968
+ })),
3969
+ entries: Type.Array(Type.Object({
3970
+ category: Type.String({ description: `One required cold-start category id from handoff_prepare: ${HANDOFF_COLD_START_INVENTORY_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
3971
+ items: Type.Array(Type.String({ description: "Exact resume-critical item; it must appear verbatim in the handoff or a validated supporting document." })),
3972
+ notApplicableReason: Type.Optional(Type.String({ description: "Optional explanation only; every category still requires a concrete item, using an explicit verified-negative statement when nothing exists." })),
3973
+ })),
3974
+ })),
3975
+ supportingDocuments: Type.Optional(Type.Array(Type.Object({
3976
+ path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
3977
+ description: Type.String({ description: "What the document contains and why the next agent needs it." }),
3978
+ }))),
3263
3979
  taskUpdates: Type.Optional(Type.Array(Type.Object({
3264
3980
  taskRef: Type.String(),
3265
3981
  completionSummary: Type.String(),
@@ -3306,7 +4022,9 @@ export default function planPiExtension(pi) {
3306
4022
  if (!params.confirmed)
3307
4023
  return { content: [{ type: "text", text: `Proposal only: I would refresh this handoff on ${r.compositeRef}. Ask the user to confirm, then run handoff_prepare and retry with confirmed=true.` }], details: { phaseRef: r.compositeRef, confirmationRequired: true } };
3308
4024
  if (params.expectedHandoffUpdatedAt === undefined)
3309
- return { content: [{ type: "text", text: "❌ Run handoff_prepare first and pass expectedHandoffUpdatedAt." }], details: { error: "preparation required" } };
4025
+ 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 } };
4026
+ if (!params.reason?.trim())
4027
+ 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"] } };
3310
4028
  const phases = await st.loadAllPhases();
3311
4029
  const features = (await st.loadFeatures()).features;
3312
4030
  const taskUpdates = [];
@@ -3325,9 +4043,13 @@ export default function planPiExtension(pi) {
3325
4043
  }
3326
4044
  try {
3327
4045
  const result = await st.refreshPhaseHandoff(r.phase.id, {
4046
+ reason: params.reason.trim(),
3328
4047
  content: body,
3329
4048
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
3330
4049
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
4050
+ ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
4051
+ ...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
4052
+ ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
3331
4053
  contextSync: {
3332
4054
  taskUpdates,
3333
4055
  ...(params.phaseUpdate ? { phaseUpdate: {
@@ -3341,9 +4063,28 @@ export default function planPiExtension(pi) {
3341
4063
  },
3342
4064
  });
3343
4065
  await st.writeGenerated();
3344
- return { content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result } };
4066
+ return {
4067
+ 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 entire persisted body, compare it again with every required source, then call handoff_verify with the returned contentHash. If that review finds any gap, run prepare+write again instead of verifying.` }],
4068
+ details: {
4069
+ phaseRef: r.compositeRef,
4070
+ phaseId: r.phase.id,
4071
+ updatedTaskIds: result.updatedTaskIds,
4072
+ handoffUpdatedAt: result.handoffUpdatedAt,
4073
+ handoffAudit: result.handoffAudit,
4074
+ persisted: true,
4075
+ resumeReady: false,
4076
+ verificationRequired: true,
4077
+ nextActions: [
4078
+ `Call handoff_show ${r.compositeRef} and read the entire persisted body.`,
4079
+ "Compare the persisted body against conversation corrections, planner entities, working tree/diff, verification/runtime evidence, and peer-agent output.",
4080
+ "If any omission exists, run handoff_prepare and handoff_write again; otherwise call handoff_verify with the shown contentHash.",
4081
+ ],
4082
+ },
4083
+ };
3345
4084
  }
3346
4085
  catch (error) {
4086
+ if (error instanceof HandoffContractError)
4087
+ return handoffContractFailure(error);
3347
4088
  const message = error instanceof Error ? error.message : String(error);
3348
4089
  return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
3349
4090
  }
@@ -3398,7 +4139,7 @@ export default function planPiExtension(pi) {
3398
4139
  pi.registerTool({
3399
4140
  name: "feature_list",
3400
4141
  label: "Feature List",
3401
- description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
4142
+ description: "List features (compact: F00x · shortId — name (status; priority N; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
3402
4143
  parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
3403
4144
  async execute(_id, params, _signal, _onUpdate, ctx) {
3404
4145
  const st = await requirePlan(ctx);
@@ -3411,7 +4152,7 @@ export default function planPiExtension(pi) {
3411
4152
  const phases = await st.loadAllPhases();
3412
4153
  const summary = features.map((f) => {
3413
4154
  const fp = phases.filter((p) => p.featureId === f.id);
3414
- return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
4155
+ return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}${f.priority > 0 ? `; priority ${f.priority}` : ""}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
3415
4156
  }).join("\n");
3416
4157
  return { content: [{ type: "text", text: summary || "No features" }], details: {} };
3417
4158
  },
@@ -3419,13 +4160,14 @@ export default function planPiExtension(pi) {
3419
4160
  pi.registerTool({
3420
4161
  name: "feature_get",
3421
4162
  label: "Feature Get",
3422
- description: "Show a feature. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
4163
+ 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.",
3423
4164
  parameters: Type.Object({
3424
4165
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3425
4166
  full: Type.Optional(Type.Boolean({ description: "If true, include the feature description. Default: compact identity only." })),
3426
4167
  }),
3427
4168
  async execute(_id, params, _signal, _onUpdate, ctx) {
3428
4169
  const st = await requirePlan(ctx);
4170
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
3429
4171
  if (!st)
3430
4172
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3431
4173
  const features = (await st.loadFeatures()).features;
@@ -3434,15 +4176,30 @@ export default function planPiExtension(pi) {
3434
4176
  if (!resolvedFeature.ok)
3435
4177
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3436
4178
  const feature = resolvedFeature.feature;
3437
- if (params.full)
3438
- markFeatureReadForSessionId(plannerSessionId, feature.id);
3439
4179
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3440
4180
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3441
4181
  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"}` : ""})`;
4182
+ const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
3442
4183
  const requirementsBlock = linkedRequirements.length > 0
3443
- ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
4184
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
3444
4185
  : "\n\nLinked requirements:\n- None linked to this feature.";
3445
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
4186
+ if (!params.full)
4187
+ return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
4188
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", feature.acceptedDecisions);
4189
+ const result = {
4190
+ content: [{ type: "text", text: `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
4191
+ details: {
4192
+ feature: {
4193
+ ref: formatFeatureRef(feature.number),
4194
+ description: feature.description,
4195
+ descriptionRef: feature.descriptionRef ?? "",
4196
+ acceptedDecisions: feature.acceptedDecisions,
4197
+ },
4198
+ linkedRequirements,
4199
+ },
4200
+ };
4201
+ markCanonicalFullReadForSessionId(plannerSessionId, "feature", feature, result.content[0].text);
4202
+ return result;
3446
4203
  },
3447
4204
  });
3448
4205
  pi.registerTool({
@@ -3458,44 +4215,46 @@ export default function planPiExtension(pi) {
3458
4215
  const st = await requirePlan(ctx);
3459
4216
  if (!st)
3460
4217
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3461
- const now = nowISO();
3462
- const status = params.status ?? "planned";
3463
- const currentFeatures = (await st.loadFeatures()).features;
3464
- const id = createFeatureId();
3465
- const identity = await st.allocateEntityIdentity("feature", id);
3466
- const priority = await st.nextPriority("feature");
3467
- const feature = {
3468
- id,
3469
- number: identity.number,
3470
- shortId: identity.shortId,
3471
- priority,
3472
- name: params.name,
3473
- description: params.description ?? "",
3474
- descriptionUpdatedAt: now,
3475
- status,
3476
- discussedAt: "",
3477
- contextReady: false,
3478
- contextReadyReason: "",
3479
- startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
3480
- endDate: "",
3481
- workDone: "",
3482
- workRemaining: "",
3483
- acceptedDecisions: [],
3484
- phaseIds: [],
3485
- dependsOn: [],
3486
- statusLog: [],
3487
- sessionInfo: [],
3488
- createdAt: now,
3489
- updatedAt: now,
3490
- };
3491
- // Atomic read-modify-write: serializes concurrent feature_create calls
3492
- // (batch) so they don't overwrite each other (last-write-wins race).
3493
- await st.updateFeatures((doc) => {
3494
- doc.features.push(feature);
3495
- return doc;
4218
+ return st.runBatch(async () => {
4219
+ const now = nowISO();
4220
+ const status = params.status ?? "planned";
4221
+ const currentFeatures = (await st.loadFeatures()).features;
4222
+ const id = createFeatureId();
4223
+ const identity = await st.allocateEntityIdentity("feature", id);
4224
+ const priority = await st.nextPriority("feature");
4225
+ const feature = {
4226
+ id,
4227
+ number: identity.number,
4228
+ shortId: identity.shortId,
4229
+ priority,
4230
+ name: params.name,
4231
+ description: params.description ?? "",
4232
+ descriptionUpdatedAt: now,
4233
+ status,
4234
+ discussedAt: "",
4235
+ contextReady: false,
4236
+ contextReadyReason: "",
4237
+ startDate: status === "in-progress" ? new Date().toISOString().slice(0, 10) : "",
4238
+ endDate: "",
4239
+ workDone: "",
4240
+ workRemaining: "",
4241
+ acceptedDecisions: [],
4242
+ phaseIds: [],
4243
+ dependsOn: [],
4244
+ statusLog: [],
4245
+ sessionInfo: [],
4246
+ createdAt: now,
4247
+ updatedAt: now,
4248
+ };
4249
+ // Atomic read-modify-write: serializes concurrent feature_create calls
4250
+ // (batch) so they don't overwrite each other (last-write-wins race).
4251
+ await st.updateFeatures((doc) => {
4252
+ doc.features.push(feature);
4253
+ return doc;
4254
+ });
4255
+ await st.writeGenerated();
4256
+ return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3496
4257
  });
3497
- await st.writeGenerated();
3498
- return { content: [{ type: "text", text: `✅ Feature created: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
3499
4258
  },
3500
4259
  });
3501
4260
  pi.registerTool({
@@ -3505,6 +4264,7 @@ export default function planPiExtension(pi) {
3505
4264
  parameters: Type.Object({
3506
4265
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3507
4266
  description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
4267
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
3508
4268
  workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3509
4269
  workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3510
4270
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
@@ -3518,16 +4278,41 @@ export default function planPiExtension(pi) {
3518
4278
  if (!resolvedFeature.ok)
3519
4279
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3520
4280
  const featureId = resolvedFeature.feature.id;
4281
+ const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
4282
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4283
+ if (receivedFields.length === 0) {
4284
+ return mutationNoFieldsFailure({
4285
+ entity: "feature",
4286
+ ref: formatFeatureRef(resolvedFeature.feature.number),
4287
+ operation: "discuss",
4288
+ mutableFields,
4289
+ retryCommand: `feature_discuss ${formatFeatureRef(resolvedFeature.feature.number)}`,
4290
+ readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
4291
+ descriptionFallback: {
4292
+ entityId: featureId,
4293
+ inputField: "description",
4294
+ storedField: "feature description",
4295
+ descriptionRefField: "descriptionRef",
4296
+ },
4297
+ });
4298
+ }
3521
4299
  const updatedDoc = await st.updateFeatures((doc) => {
3522
4300
  const feature = doc.features.find((f) => f.id === featureId);
3523
4301
  if (!feature)
3524
4302
  return doc;
3525
4303
  if (params.description !== undefined)
3526
- feature.description = params.description;
4304
+ feature.description = params.description.trim();
4305
+ if (params.descriptionRef !== undefined) {
4306
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4307
+ if (descriptionRef)
4308
+ feature.descriptionRef = descriptionRef;
4309
+ else
4310
+ delete feature.descriptionRef;
4311
+ }
3527
4312
  if (params.workDone !== undefined)
3528
- feature.workDone = params.workDone;
4313
+ feature.workDone = params.workDone.trim();
3529
4314
  if (params.workRemaining !== undefined)
3530
- feature.workRemaining = params.workRemaining;
4315
+ feature.workRemaining = params.workRemaining.trim();
3531
4316
  if (params.dependencies !== undefined)
3532
4317
  feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3533
4318
  feature.discussedAt = nowISO();
@@ -3540,18 +4325,19 @@ export default function planPiExtension(pi) {
3540
4325
  if (!feature)
3541
4326
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3542
4327
  await st.writeGenerated();
3543
- return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
4328
+ return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...feature, discussed: true, updatedFields: receivedFields } };
3544
4329
  },
3545
4330
  });
3546
4331
  pi.registerTool({
3547
4332
  name: "feature_update",
3548
4333
  label: "Feature Update",
3549
- 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.",
4334
+ description: "Update one or more canonical feature fields. Feature status is derived from child phases/tasks and cannot be changed directly.",
3550
4335
  parameters: Type.Object({
3551
4336
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3552
4337
  name: Type.Optional(Type.String({ description: "New name" })),
3553
4338
  description: Type.Optional(Type.String({ description: "New description" })),
3554
- 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." })),
4339
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4340
+ 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." })),
3555
4341
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
3556
4342
  endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
3557
4343
  workDone: Type.Optional(Type.String({ description: "Notes on work done" })),
@@ -3564,7 +4350,7 @@ export default function planPiExtension(pi) {
3564
4350
  rationale: Type.String(),
3565
4351
  implementationNotes: Type.String(),
3566
4352
  acceptedAt: Type.String(),
3567
- }), { description: "Replace accepted decisions list" })),
4353
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
3568
4354
  }),
3569
4355
  async execute(_id, params, _signal, _onUpdate, ctx) {
3570
4356
  const st = await requirePlan(ctx);
@@ -3578,25 +4364,39 @@ export default function planPiExtension(pi) {
3578
4364
  if (params.status !== undefined) {
3579
4365
  return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
3580
4366
  }
4367
+ if (params.acceptedDecisions !== undefined) {
4368
+ return {
4369
+ 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." }],
4370
+ details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
4371
+ isError: true,
4372
+ };
4373
+ }
3581
4374
  const mutableFields = [
3582
4375
  "name",
3583
4376
  "description",
4377
+ "descriptionRef",
3584
4378
  "startDate",
3585
4379
  "endDate",
3586
4380
  "workDone",
3587
4381
  "workRemaining",
3588
4382
  "priority",
3589
- "acceptedDecisions",
3590
4383
  ];
3591
4384
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3592
4385
  if (receivedFields.length === 0) {
3593
- return {
3594
- content: [{
3595
- type: "text",
3596
- text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
3597
- }],
3598
- details: { updated: false, reason: "no-mutable-fields" },
3599
- };
4386
+ return mutationNoFieldsFailure({
4387
+ entity: "feature",
4388
+ ref: formatFeatureRef(resolvedFeature.feature.number),
4389
+ operation: "update",
4390
+ mutableFields,
4391
+ retryCommand: `feature_update ${formatFeatureRef(resolvedFeature.feature.number)}`,
4392
+ readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
4393
+ descriptionFallback: {
4394
+ entityId: featureId,
4395
+ inputField: "description",
4396
+ storedField: "feature description",
4397
+ descriptionRefField: "descriptionRef",
4398
+ },
4399
+ });
3600
4400
  }
3601
4401
  let foundDoc;
3602
4402
  try {
@@ -3605,21 +4405,26 @@ export default function planPiExtension(pi) {
3605
4405
  if (!feature)
3606
4406
  return doc; // not found: no-op, handled below
3607
4407
  if (params.name !== undefined)
3608
- feature.name = params.name;
4408
+ feature.name = params.name.trim();
3609
4409
  if (params.description !== undefined)
3610
- feature.description = params.description;
4410
+ feature.description = params.description.trim();
4411
+ if (params.descriptionRef !== undefined) {
4412
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4413
+ if (descriptionRef)
4414
+ feature.descriptionRef = descriptionRef;
4415
+ else
4416
+ delete feature.descriptionRef;
4417
+ }
3611
4418
  if (params.workDone !== undefined)
3612
- feature.workDone = params.workDone;
4419
+ feature.workDone = params.workDone.trim();
3613
4420
  if (params.workRemaining !== undefined)
3614
- feature.workRemaining = params.workRemaining;
4421
+ feature.workRemaining = params.workRemaining.trim();
3615
4422
  if (params.startDate !== undefined)
3616
- feature.startDate = params.startDate;
4423
+ feature.startDate = params.startDate.trim();
3617
4424
  if (params.endDate !== undefined)
3618
- feature.endDate = params.endDate;
4425
+ feature.endDate = params.endDate.trim();
3619
4426
  if (params.priority !== undefined)
3620
4427
  feature.priority = params.priority;
3621
- if (params.acceptedDecisions !== undefined)
3622
- feature.acceptedDecisions = params.acceptedDecisions;
3623
4428
  feature.updatedAt = nowISO();
3624
4429
  return doc;
3625
4430
  });
@@ -3658,22 +4463,24 @@ export default function planPiExtension(pi) {
3658
4463
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3659
4464
  const featureId = resolvedFeature.feature.id;
3660
4465
  let deleted = false;
3661
- await st.updateFeatures((doc) => {
3662
- const before = doc.features.length;
3663
- doc.features = doc.features.filter((f) => f.id !== featureId);
3664
- if (doc.features.length !== before)
3665
- deleted = true;
3666
- return doc;
3667
- });
3668
4466
  let cascadeCount = 0;
3669
- if (params.cascade) {
3670
- const phases = await st.loadAllPhases();
3671
- for (const phase of phases.filter((p) => p.featureId === featureId)) {
3672
- await st.deletePhase(phase.id);
3673
- cascadeCount += 1;
4467
+ await st.runBatch(async () => {
4468
+ await st.updateFeatures((doc) => {
4469
+ const before = doc.features.length;
4470
+ doc.features = doc.features.filter((f) => f.id !== featureId);
4471
+ if (doc.features.length !== before)
4472
+ deleted = true;
4473
+ return doc;
4474
+ });
4475
+ if (params.cascade) {
4476
+ const phases = await st.loadAllPhases();
4477
+ for (const phase of phases.filter((p) => p.featureId === featureId)) {
4478
+ await st.deletePhase(phase.id);
4479
+ cascadeCount += 1;
4480
+ }
3674
4481
  }
3675
- }
3676
- await st.writeGenerated();
4482
+ await st.writeGenerated();
4483
+ });
3677
4484
  return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
3678
4485
  },
3679
4486
  });
@@ -3681,7 +4488,7 @@ export default function planPiExtension(pi) {
3681
4488
  pi.registerTool({
3682
4489
  name: "phase_list",
3683
4490
  label: "Phase List",
3684
- description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
4491
+ description: "List phases (compact: F00x/P00x · shortId — title (status; priority N; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
3685
4492
  parameters: Type.Object({
3686
4493
  featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3687
4494
  status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
@@ -3704,7 +4511,7 @@ export default function planPiExtension(pi) {
3704
4511
  const summary = phases.map((p) => {
3705
4512
  const fNum = featureNumberOfPhase(p, features);
3706
4513
  const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3707
- return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
4514
+ return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}${p.priority > 0 ? `; priority ${p.priority}` : ""}; ${p.tasks.length} tasks)${fTag}`;
3708
4515
  }).join("\n");
3709
4516
  return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3710
4517
  },
@@ -3712,28 +4519,44 @@ export default function planPiExtension(pi) {
3712
4519
  pi.registerTool({
3713
4520
  name: "phase_get",
3714
4521
  label: "Phase Get",
3715
- description: "Show a phase. Compact identity by default (saves tokens); pass full=true to include the description and derived linked requirements.",
4522
+ 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.",
3716
4523
  parameters: Type.Object({
3717
4524
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3718
4525
  full: Type.Optional(Type.Boolean({ description: "If true, include the phase description. Default: compact identity only." })),
3719
4526
  }),
3720
4527
  async execute(_id, params, _signal, _onUpdate, ctx) {
3721
4528
  const st = await requirePlan(ctx);
4529
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
3722
4530
  if (!st)
3723
4531
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3724
4532
  const features = (await st.loadFeatures()).features;
3725
4533
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3726
4534
  if (!phase)
3727
4535
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3728
- if (params.full)
3729
- markPhaseReadForSessionId(plannerSessionId, phase.id);
3730
4536
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3731
4537
  const reqCount = linkedRequirements.length;
3732
4538
  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"}` : ""})`;
4539
+ const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
3733
4540
  const requirementsBlock = reqCount > 0
3734
- ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
4541
+ ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title}`).join("\n")}`
3735
4542
  : "";
3736
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
4543
+ if (!params.full)
4544
+ return { content: [{ type: "text", text: summary }], details: { linkedRequirements } };
4545
+ const decisionBlock = renderAcceptedDecisionsSection("Accepted Decisions", phase.acceptedDecisions);
4546
+ const result = {
4547
+ content: [{ type: "text", text: `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}\n\n${decisionBlock}` }],
4548
+ details: {
4549
+ phase: {
4550
+ ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)),
4551
+ description: phase.description,
4552
+ descriptionRef: phase.descriptionRef ?? "",
4553
+ acceptedDecisions: phase.acceptedDecisions,
4554
+ },
4555
+ linkedRequirements,
4556
+ },
4557
+ };
4558
+ markCanonicalFullReadForSessionId(plannerSessionId, "phase", phase, result.content[0].text);
4559
+ return result;
3737
4560
  },
3738
4561
  });
3739
4562
  pi.registerTool({
@@ -3768,7 +4591,7 @@ export default function planPiExtension(pi) {
3768
4591
  if (!existingFeature)
3769
4592
  return { content: [{ type: "text", text: `Resolved feature ${featureId} no longer exists. Refusing to create phase.` }], details: {} };
3770
4593
  let phase;
3771
- await withFeatureLock(featureId, async () => {
4594
+ await st.runBatch(() => withFeatureLock(featureId, async () => {
3772
4595
  const id = createPhaseId();
3773
4596
  const identity = await st.allocateEntityIdentity("phase", id);
3774
4597
  const priority = await st.nextPriority("phase", featureId);
@@ -3784,7 +4607,7 @@ export default function planPiExtension(pi) {
3784
4607
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
3785
4608
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3786
4609
  createdAt: now, updatedAt: now,
3787
- handoff: "", handoffUpdatedAt: "",
4610
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
3788
4611
  handoffReadAt: "",
3789
4612
  handoffHistory: [],
3790
4613
  statusLog: [],
@@ -3801,23 +4624,104 @@ export default function planPiExtension(pi) {
3801
4624
  return doc;
3802
4625
  });
3803
4626
  await st.writeGenerated();
3804
- });
4627
+ }));
3805
4628
  if (!phase)
3806
4629
  return { content: [{ type: "text", text: "Phase creation failed." }], details: {} };
3807
4630
  const phaseCreateFeatures = (await st.loadFeatures()).features;
3808
4631
  return { content: [{ type: "text", text: `✅ Phase created: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseCreateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
3809
4632
  },
3810
4633
  });
4634
+ pi.registerTool({
4635
+ name: "phase_discuss",
4636
+ label: "Phase Discuss",
4637
+ description: "Persist phase discovery fields and mark governance context ready without overriding derived status.",
4638
+ parameters: Type.Object({
4639
+ phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
4640
+ goal: Type.Optional(Type.String({ description: "Backward-compatible single main goal" })),
4641
+ goals: Type.Optional(Type.Array(Type.String(), { description: "Replace the phase goals list" })),
4642
+ summary: Type.Optional(Type.String()),
4643
+ scope: Type.Optional(Type.String({ description: "Current phase scope; stored as the phase description" })),
4644
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
4645
+ nonGoals: Type.Optional(Type.Array(Type.String())),
4646
+ dependencies: Type.Optional(Type.Array(Type.String())),
4647
+ risks: Type.Optional(Type.Array(Type.String())),
4648
+ openQuestions: Type.Optional(Type.Array(Type.String())),
4649
+ completionCriteria: Type.Optional(Type.Array(Type.String())),
4650
+ }),
4651
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4652
+ const st = await requirePlan(ctx);
4653
+ if (!st)
4654
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4655
+ const features = (await st.loadFeatures()).features;
4656
+ const resolvedPhase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
4657
+ if (!resolvedPhase)
4658
+ return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: { discussed: false, errorCode: "PHASE_NOT_FOUND" }, isError: true };
4659
+ const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4660
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4661
+ if (receivedFields.length === 0) {
4662
+ return mutationNoFieldsFailure({
4663
+ entity: "phase",
4664
+ ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4665
+ operation: "discuss",
4666
+ mutableFields,
4667
+ retryCommand: `phase_discuss ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
4668
+ readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
4669
+ descriptionFallback: {
4670
+ entityId: resolvedPhase.id,
4671
+ inputField: "scope",
4672
+ storedField: "phase description",
4673
+ descriptionRefField: "descriptionRef",
4674
+ },
4675
+ });
4676
+ }
4677
+ const phase = await st.updatePhase(resolvedPhase.id, (current) => {
4678
+ if (params.goals !== undefined)
4679
+ current.goals = params.goals.map((item) => item.trim()).filter(Boolean);
4680
+ else if (params.goal !== undefined)
4681
+ current.goals = [params.goal.trim()].filter(Boolean);
4682
+ if (params.summary !== undefined)
4683
+ current.summary = params.summary.trim();
4684
+ if (params.scope !== undefined)
4685
+ current.description = params.scope.trim();
4686
+ if (params.descriptionRef !== undefined) {
4687
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4688
+ if (descriptionRef)
4689
+ current.descriptionRef = descriptionRef;
4690
+ else
4691
+ delete current.descriptionRef;
4692
+ }
4693
+ if (params.nonGoals !== undefined)
4694
+ current.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
4695
+ if (params.dependencies !== undefined)
4696
+ current.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
4697
+ if (params.risks !== undefined)
4698
+ current.risks = params.risks.map((item) => item.trim()).filter(Boolean);
4699
+ if (params.openQuestions !== undefined)
4700
+ current.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
4701
+ if (params.completionCriteria !== undefined)
4702
+ current.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
4703
+ current.discussedAt = nowISO();
4704
+ current.contextReady = true;
4705
+ current.contextReadyReason = "Updated through phase_discuss tool.";
4706
+ current.updatedAt = nowISO();
4707
+ return current;
4708
+ });
4709
+ await st.writeGenerated();
4710
+ const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
4711
+ 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 } };
4712
+ },
4713
+ });
3811
4714
  pi.registerTool({
3812
4715
  name: "phase_update",
3813
4716
  label: "Phase Update",
3814
- 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.",
4717
+ 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.",
3815
4718
  parameters: Type.Object({
3816
4719
  phaseId: Type.String({ description: "Phase ref: F00x/P00x, bare P00x (global), shortId, UUID, or title" }),
3817
4720
  title: Type.Optional(Type.String({ description: "New title" })),
3818
- 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." })),
4721
+ status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
3819
4722
  summary: Type.Optional(Type.String({ description: "New summary" })),
3820
4723
  description: Type.Optional(Type.String({ description: "New description" })),
4724
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
3821
4725
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
3822
4726
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
3823
4727
  goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
@@ -3833,7 +4737,7 @@ export default function planPiExtension(pi) {
3833
4737
  rationale: Type.String(),
3834
4738
  implementationNotes: Type.String(),
3835
4739
  acceptedAt: Type.String(),
3836
- }), { description: "Replace accepted decisions list" })),
4740
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
3837
4741
  completionCriteria: Type.Optional(Type.Array(Type.String(), { description: "Replace completion criteria list" })),
3838
4742
  }),
3839
4743
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -3855,34 +4759,33 @@ export default function planPiExtension(pi) {
3855
4759
  if (params.status !== undefined) {
3856
4760
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
3857
4761
  }
4762
+ if (params.acceptedDecisions !== undefined) {
4763
+ return {
4764
+ 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." }],
4765
+ details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4766
+ isError: true,
4767
+ };
4768
+ }
4769
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
4770
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4771
+ if (receivedFields.length === 0) {
4772
+ return mutationNoFieldsFailure({
4773
+ entity: "phase",
4774
+ ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4775
+ operation: "update",
4776
+ mutableFields,
4777
+ retryCommand: `phase_update ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
4778
+ readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
4779
+ descriptionFallback: {
4780
+ entityId: phaseId,
4781
+ inputField: "description",
4782
+ storedField: "phase description",
4783
+ descriptionRefField: "descriptionRef",
4784
+ },
4785
+ });
4786
+ }
3858
4787
  const prevFeatureId = phase.featureId;
3859
4788
  let nextFeatureIdForUpdate = prevFeatureId;
3860
- if (params.title !== undefined) {
3861
- phase.title = params.title;
3862
- phase.slug = normalizeSlug(params.title);
3863
- }
3864
- if (params.summary !== undefined)
3865
- phase.summary = params.summary;
3866
- if (params.description !== undefined)
3867
- phase.description = params.description;
3868
- if (params.priority !== undefined)
3869
- phase.priority = params.priority;
3870
- if (params.goals !== undefined)
3871
- phase.goals = params.goals;
3872
- if (params.nonGoals !== undefined)
3873
- phase.nonGoals = params.nonGoals;
3874
- if (params.dependencies !== undefined)
3875
- phase.dependencies = params.dependencies;
3876
- if (params.risks !== undefined)
3877
- phase.risks = params.risks;
3878
- if (params.openQuestions !== undefined)
3879
- phase.openQuestions = params.openQuestions;
3880
- if (params.decisions !== undefined)
3881
- phase.decisions = params.decisions;
3882
- if (params.acceptedDecisions !== undefined)
3883
- phase.acceptedDecisions = params.acceptedDecisions;
3884
- if (params.completionCriteria !== undefined)
3885
- phase.completionCriteria = params.completionCriteria;
3886
4789
  if (params.featureId !== undefined) {
3887
4790
  if (params.featureId === "") {
3888
4791
  nextFeatureIdForUpdate = undefined;
@@ -3893,24 +4796,60 @@ export default function planPiExtension(pi) {
3893
4796
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3894
4797
  nextFeatureIdForUpdate = resolvedFeature.feature.id;
3895
4798
  }
3896
- phase.featureId = nextFeatureIdForUpdate;
3897
- if (nextFeatureIdForUpdate !== prevFeatureId) {
3898
- await st.updateFeatures((doc) => {
3899
- for (const f of doc.features) {
3900
- if (f.id === prevFeatureId)
3901
- f.phaseIds = f.phaseIds.filter((pid) => pid !== phase.id);
3902
- if (f.id === nextFeatureIdForUpdate && !f.phaseIds.includes(phase.id))
3903
- f.phaseIds.push(phase.id);
4799
+ }
4800
+ let persistedPhase;
4801
+ await st.runBatch(async () => {
4802
+ persistedPhase = await st.updatePhase(phaseId, (current) => {
4803
+ const next = {
4804
+ ...current,
4805
+ ...(params.title !== undefined ? { title: params.title.trim(), slug: normalizeSlug(params.title) } : {}),
4806
+ ...(params.summary !== undefined ? { summary: params.summary.trim() } : {}),
4807
+ ...(params.description !== undefined ? { description: params.description.trim() } : {}),
4808
+ ...(params.priority !== undefined ? { priority: params.priority } : {}),
4809
+ ...(params.goals !== undefined ? { goals: params.goals.map((item) => item.trim()).filter(Boolean) } : {}),
4810
+ ...(params.nonGoals !== undefined ? { nonGoals: params.nonGoals.map((item) => item.trim()).filter(Boolean) } : {}),
4811
+ ...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
4812
+ ...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
4813
+ ...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
4814
+ ...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
4815
+ ...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
4816
+ ...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
4817
+ updatedAt: nowISO(),
4818
+ };
4819
+ if (params.descriptionRef !== undefined) {
4820
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4821
+ if (descriptionRef)
4822
+ next.descriptionRef = descriptionRef;
4823
+ else
4824
+ delete next.descriptionRef;
4825
+ }
4826
+ return next;
4827
+ });
4828
+ if (params.featureId !== undefined && nextFeatureIdForUpdate !== prevFeatureId) {
4829
+ await st.updateFeatures((document) => {
4830
+ for (const feature of document.features) {
4831
+ if (feature.id === prevFeatureId)
4832
+ feature.phaseIds = feature.phaseIds.filter((id) => id !== phaseId);
4833
+ if (feature.id === nextFeatureIdForUpdate && !feature.phaseIds.includes(phaseId))
4834
+ feature.phaseIds.push(phaseId);
3904
4835
  }
3905
- return doc;
4836
+ return document;
3906
4837
  });
3907
4838
  }
3908
- }
3909
- phase.updatedAt = nowISO();
3910
- await st.savePhase(phase);
3911
- await st.writeGenerated();
4839
+ await st.writeGenerated();
4840
+ });
4841
+ phase = persistedPhase;
3912
4842
  const phaseUpdateFeatures = (await st.loadFeatures()).features;
3913
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
4843
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
4844
+ ? await st.previewDescriptionReconciliation()
4845
+ : null;
4846
+ const staleParentRefs = descriptionFreshness?.diagnostics
4847
+ .filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
4848
+ .map((entry) => entry.ownerRef) ?? [];
4849
+ const freshnessNotice = staleParentRefs.length > 0
4850
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
4851
+ : "";
4852
+ 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 } : {}) } };
3914
4853
  },
3915
4854
  });
3916
4855
  pi.registerTool({
@@ -3948,7 +4887,7 @@ export default function planPiExtension(pi) {
3948
4887
  implementationNotes: params.implementationNotes.trim(),
3949
4888
  acceptedAt,
3950
4889
  };
3951
- await withFeatureLock(resolvedFeature.feature.id, async () => {
4890
+ await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
3952
4891
  let featureWritten = false;
3953
4892
  try {
3954
4893
  await st.updateFeatures((doc) => {
@@ -3979,11 +4918,128 @@ export default function planPiExtension(pi) {
3979
4918
  }
3980
4919
  throw error;
3981
4920
  }
3982
- });
4921
+ }));
3983
4922
  await st.writeGenerated();
3984
4923
  return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
3985
4924
  },
3986
4925
  });
4926
+ pi.registerTool({
4927
+ name: "accepted_decision_create",
4928
+ label: "Accepted Decision Create",
4929
+ description: "Create an accepted decision on the project, feature, phase, or task without replacing the full acceptedDecisions array.",
4930
+ parameters: Type.Object({
4931
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
4932
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
4933
+ title: Type.String({ minLength: 1 }),
4934
+ decision: Type.Optional(Type.String()),
4935
+ rationale: Type.Optional(Type.String()),
4936
+ implementationNotes: Type.Optional(Type.String()),
4937
+ }),
4938
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4939
+ const st = await requirePlan(ctx);
4940
+ if (!st)
4941
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4942
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
4943
+ if (!target.ok)
4944
+ return { content: [{ type: "text", text: target.error }], details: { created: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
4945
+ const createInput = {
4946
+ title: params.title,
4947
+ ...(params.decision !== undefined ? { decision: params.decision } : {}),
4948
+ ...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
4949
+ ...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
4950
+ };
4951
+ try {
4952
+ const acceptedDecision = await st.createAcceptedDecision(target.owner, createInput);
4953
+ await st.writeGenerated();
4954
+ return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
4955
+ }
4956
+ catch (error) {
4957
+ return acceptedDecisionMutationFailure(error, "created");
4958
+ }
4959
+ },
4960
+ });
4961
+ pi.registerTool({
4962
+ name: "accepted_decision_update",
4963
+ label: "Accepted Decision Update",
4964
+ description: "Update one accepted decision while preserving its id and acceptedAt timestamp.",
4965
+ parameters: Type.Object({
4966
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
4967
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
4968
+ decisionId: Type.String({ minLength: 1 }),
4969
+ title: Type.Optional(Type.String()),
4970
+ decision: Type.Optional(Type.String()),
4971
+ rationale: Type.Optional(Type.String()),
4972
+ implementationNotes: Type.Optional(Type.String()),
4973
+ }),
4974
+ async execute(_id, params, _signal, _onUpdate, ctx) {
4975
+ const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
4976
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4977
+ if (receivedFields.length === 0) {
4978
+ return {
4979
+ isError: true,
4980
+ content: [{ type: "text", text: "❌ ACCEPTED DECISION UPDATE FAILED [NO_MUTABLE_FIELDS_RECEIVED]\nNo accepted decision fields were supplied." }],
4981
+ details: noMutableFieldsReceived({
4982
+ entity: "acceptedDecision",
4983
+ ref: params.decisionId,
4984
+ operation: "update",
4985
+ mutableFields,
4986
+ retryCommand: "accepted_decision_update with title, decision, rationale, or implementationNotes",
4987
+ readBackCommand: "Read back the owning project, feature, phase, or task.",
4988
+ }),
4989
+ };
4990
+ }
4991
+ const st = await requirePlan(ctx);
4992
+ if (!st)
4993
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4994
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
4995
+ if (!target.ok)
4996
+ return { content: [{ type: "text", text: target.error }], details: { updated: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
4997
+ const updateInput = {
4998
+ ...(params.title !== undefined ? { title: params.title } : {}),
4999
+ ...(params.decision !== undefined ? { decision: params.decision } : {}),
5000
+ ...(params.rationale !== undefined ? { rationale: params.rationale } : {}),
5001
+ ...(params.implementationNotes !== undefined ? { implementationNotes: params.implementationNotes } : {}),
5002
+ };
5003
+ try {
5004
+ const acceptedDecision = await st.updateAcceptedDecision(target.owner, params.decisionId, updateInput);
5005
+ await st.writeGenerated();
5006
+ 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 } };
5007
+ }
5008
+ catch (error) {
5009
+ return acceptedDecisionMutationFailure(error, "updated");
5010
+ }
5011
+ },
5012
+ });
5013
+ pi.registerTool({
5014
+ name: "accepted_decision_delete",
5015
+ label: "Accepted Decision Delete",
5016
+ description: "Delete one accepted decision from the project, feature, phase, or task. Requires confirmed=true.",
5017
+ parameters: Type.Object({
5018
+ targetType: Type.Union([Type.Literal("project"), Type.Literal("feature"), Type.Literal("phase"), Type.Literal("task")]),
5019
+ targetRef: Type.Optional(Type.String({ description: "Feature/phase/task ref. Omit for targetType=project." })),
5020
+ decisionId: Type.String({ minLength: 1 }),
5021
+ confirmed: Type.Boolean({ description: "Must be true to confirm deleting this accepted decision." }),
5022
+ }),
5023
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5024
+ const st = await requirePlan(ctx);
5025
+ if (!st)
5026
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5027
+ const target = await resolveAcceptedDecisionTarget(st, params.targetType, params.targetRef);
5028
+ if (!target.ok)
5029
+ return { content: [{ type: "text", text: target.error }], details: { deleted: false, errorCode: "ACCEPTED_DECISION_TARGET_NOT_FOUND" }, isError: true };
5030
+ if (!params.confirmed) {
5031
+ 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 } };
5032
+ }
5033
+ try {
5034
+ const acceptedDecision = await st.deleteAcceptedDecision(target.owner, params.decisionId);
5035
+ await st.writeGenerated();
5036
+ 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 } };
5037
+ }
5038
+ catch (error) {
5039
+ return acceptedDecisionMutationFailure(error, "deleted");
5040
+ }
5041
+ },
5042
+ });
3987
5043
  pi.registerTool({
3988
5044
  name: "phase_delete",
3989
5045
  label: "Phase Delete",
@@ -4007,18 +5063,20 @@ export default function planPiExtension(pi) {
4007
5063
  catch {
4008
5064
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
4009
5065
  }
4010
- await st.deletePhase(phaseId);
4011
- if (phase.featureId) {
4012
- await st.updateFeatures((doc) => {
4013
- const feature = doc.features.find((f) => f.id === phase.featureId);
4014
- if (feature) {
4015
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
4016
- feature.updatedAt = nowISO();
4017
- }
4018
- return doc;
4019
- });
4020
- }
4021
- await st.writeGenerated();
5066
+ await st.runBatch(async () => {
5067
+ await st.deletePhase(phaseId);
5068
+ if (phase.featureId) {
5069
+ await st.updateFeatures((doc) => {
5070
+ const feature = doc.features.find((f) => f.id === phase.featureId);
5071
+ if (feature) {
5072
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== phaseId);
5073
+ feature.updatedAt = nowISO();
5074
+ }
5075
+ return doc;
5076
+ });
5077
+ }
5078
+ await st.writeGenerated();
5079
+ });
4022
5080
  return { content: [{ type: "text", text: `Phase deleted: ${params.phaseId}` }], details: { deleted: params.phaseId } };
4023
5081
  },
4024
5082
  });
@@ -4026,7 +5084,7 @@ export default function planPiExtension(pi) {
4026
5084
  pi.registerTool({
4027
5085
  name: "task_list",
4028
5086
  label: "Task List",
4029
- description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
5087
+ description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status; priority N)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
4030
5088
  parameters: Type.Object({
4031
5089
  featureRef: Type.Optional(Type.String({})),
4032
5090
  phaseRef: Type.Optional(Type.String({})),
@@ -4057,7 +5115,7 @@ export default function planPiExtension(pi) {
4057
5115
  for (const task of phase.tasks) {
4058
5116
  if (params.status && task.status !== params.status)
4059
5117
  continue;
4060
- out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
5118
+ out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status}${task.priority > 0 ? `; priority ${task.priority}` : ""})`);
4061
5119
  }
4062
5120
  }
4063
5121
  return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
@@ -4066,21 +5124,20 @@ export default function planPiExtension(pi) {
4066
5124
  pi.registerTool({
4067
5125
  name: "task_get",
4068
5126
  label: "Task Get",
4069
- description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
5127
+ 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.",
4070
5128
  parameters: Type.Object({
4071
5129
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
4072
5130
  full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
4073
5131
  }),
4074
5132
  async execute(_id, params, _signal, _onUpdate, ctx) {
4075
5133
  const st = await requirePlan(ctx);
5134
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
4076
5135
  if (!st)
4077
5136
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4078
5137
  const features = (await st.loadFeatures()).features;
4079
5138
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4080
5139
  if (!found)
4081
5140
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4082
- if (params.full)
4083
- markTaskReadForSessionId(plannerSessionId, found.task.id);
4084
5141
  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})`;
4085
5142
  if (!params.full)
4086
5143
  return { content: [{ type: "text", text: summary }], details: {} };
@@ -4096,6 +5153,8 @@ export default function planPiExtension(pi) {
4096
5153
  const sections = [summary];
4097
5154
  if (found.task.description?.trim())
4098
5155
  sections.push(found.task.description.trim());
5156
+ if (found.task.descriptionRef)
5157
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
4099
5158
  if (snapshot || pendingDeviation) {
4100
5159
  sections.push([
4101
5160
  pendingDeviation
@@ -4109,7 +5168,20 @@ export default function planPiExtension(pi) {
4109
5168
  }
4110
5169
  if (log)
4111
5170
  sections.push(`Status log:\n${log}`);
4112
- return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
5171
+ sections.push(renderAcceptedDecisionsSection("Accepted Decisions", found.task.acceptedDecisions));
5172
+ const result = {
5173
+ content: [{ type: "text", text: sections.join("\n\n") }],
5174
+ details: {
5175
+ task: {
5176
+ ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5177
+ description: found.task.description,
5178
+ descriptionRef: found.task.descriptionRef ?? "",
5179
+ acceptedDecisions: found.task.acceptedDecisions,
5180
+ },
5181
+ },
5182
+ };
5183
+ markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
5184
+ return result;
4113
5185
  },
4114
5186
  });
4115
5187
  pi.registerTool({
@@ -4187,6 +5259,7 @@ export default function planPiExtension(pi) {
4187
5259
  pauseHistory: [],
4188
5260
  startedAt: "",
4189
5261
  completedAt: "",
5262
+ activeOwnerSession: "",
4190
5263
  createdAt: now, updatedAt: now,
4191
5264
  };
4192
5265
  // Atomic read-modify-write on the phase file: serializes concurrent
@@ -4202,6 +5275,58 @@ export default function planPiExtension(pi) {
4202
5275
  return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
4203
5276
  },
4204
5277
  });
5278
+ pi.registerTool({
5279
+ name: "task_dependency_add",
5280
+ label: "Task Dependency Add",
5281
+ description: "Add a validated task dependency atomically; rejects self-dependencies, foreign tasks, and cycles.",
5282
+ parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String() }),
5283
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5284
+ const st = await requirePlan(ctx);
5285
+ if (!st)
5286
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5287
+ const features = (await st.loadFeatures()).features;
5288
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
5289
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
5290
+ if (!found || !dependency)
5291
+ return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
5292
+ try {
5293
+ const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5294
+ await st.writeGenerated();
5295
+ return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
5296
+ }
5297
+ catch (error) {
5298
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5299
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency update failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED" }, isError: true };
5300
+ }
5301
+ },
5302
+ });
5303
+ pi.registerTool({
5304
+ name: "task_dependency_delete",
5305
+ label: "Task Dependency Delete",
5306
+ description: "Remove a task dependency atomically after explicit confirmation.",
5307
+ parameters: Type.Object({ taskId: Type.String(), dependsOn: Type.String(), confirmed: Type.Boolean() }),
5308
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5309
+ if (!params.confirmed)
5310
+ return { content: [{ type: "text", text: "Explicit confirmation is required." }], details: { updated: false, errorCode: "CONFIRMATION_REQUIRED" }, isError: true };
5311
+ const st = await requirePlan(ctx);
5312
+ if (!st)
5313
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5314
+ const features = (await st.loadFeatures()).features;
5315
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId);
5316
+ const dependency = findTaskByRef(await st.loadAllPhases(), features, params.dependsOn);
5317
+ if (!found || !dependency)
5318
+ return { content: [{ type: "text", text: "Dependency target not found." }], details: { updated: false, errorCode: "DEPENDENCY_TASK_NOT_FOUND" }, isError: true };
5319
+ try {
5320
+ const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5321
+ await st.writeGenerated();
5322
+ return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
5323
+ }
5324
+ catch (error) {
5325
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5326
+ return { content: [{ type: "text", text: error instanceof Error ? error.message : "Dependency deletion failed." }], details: { updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED" }, isError: true };
5327
+ }
5328
+ },
5329
+ });
4205
5330
  pi.registerTool({
4206
5331
  name: "task_update",
4207
5332
  label: "Task Update",
@@ -4211,6 +5336,7 @@ export default function planPiExtension(pi) {
4211
5336
  title: Type.Optional(Type.String({ description: "New title" })),
4212
5337
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
4213
5338
  description: Type.Optional(Type.String({ description: "New description" })),
5339
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
4214
5340
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
4215
5341
  motivation: Type.Optional(Type.String({ description: "Motivation for status change. REQUIRED when changing to blocked, canceled, rejected, deferred, waiting, or back to planned from another status." })),
4216
5342
  decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
@@ -4221,9 +5347,10 @@ export default function planPiExtension(pi) {
4221
5347
  rationale: Type.String(),
4222
5348
  implementationNotes: Type.String(),
4223
5349
  acceptedAt: Type.String(),
4224
- }), { description: "Replace accepted decisions list" })),
5350
+ }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
4225
5351
  priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
4226
5352
  checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
5353
+ 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." })),
4227
5354
  }),
4228
5355
  async execute(_id, params, _signal, _onUpdate, ctx) {
4229
5356
  const st = await requirePlan(ctx);
@@ -4252,6 +5379,13 @@ export default function planPiExtension(pi) {
4252
5379
  details: {},
4253
5380
  };
4254
5381
  }
5382
+ if (params.acceptedDecisions !== undefined) {
5383
+ return {
5384
+ 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." }],
5385
+ 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)}` },
5386
+ isError: true,
5387
+ };
5388
+ }
4255
5389
  if (params.status !== undefined &&
4256
5390
  params.status !== found.task.status &&
4257
5391
  needsMotivation(found.task.status, params.status)) {
@@ -4262,6 +5396,29 @@ export default function planPiExtension(pi) {
4262
5396
  };
4263
5397
  }
4264
5398
  }
5399
+ if (params.subtasks !== undefined) {
5400
+ const existingIds = new Set(found.task.subtasks.map((item) => item.id));
5401
+ if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
5402
+ return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
5403
+ }
5404
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
5405
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
5406
+ if (receivedFields.length === 0) {
5407
+ return mutationNoFieldsFailure({
5408
+ entity: "task",
5409
+ ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5410
+ operation: "update",
5411
+ mutableFields,
5412
+ retryCommand: `task_update ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5413
+ readBackCommand: `task_get ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)} with full=true`,
5414
+ descriptionFallback: {
5415
+ entityId: found.task.id,
5416
+ inputField: "description",
5417
+ storedField: "task description",
5418
+ descriptionRefField: "descriptionRef",
5419
+ },
5420
+ });
5421
+ }
4265
5422
  try {
4266
5423
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
4267
5424
  const task = phase.tasks.find((t) => t.id === found.task.id);
@@ -4272,13 +5429,18 @@ export default function planPiExtension(pi) {
4272
5429
  if (params.priority !== undefined)
4273
5430
  task.priority = params.priority;
4274
5431
  if (params.description !== undefined)
4275
- task.description = params.description;
5432
+ task.description = params.description.trim();
5433
+ if (params.descriptionRef !== undefined) {
5434
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
5435
+ if (descriptionRef)
5436
+ task.descriptionRef = descriptionRef;
5437
+ else
5438
+ delete task.descriptionRef;
5439
+ }
4276
5440
  if (params.notes !== undefined)
4277
- task.notes = params.notes;
5441
+ task.notes = params.notes.trim();
4278
5442
  if (params.decisions !== undefined)
4279
- task.decisions = params.decisions;
4280
- if (params.acceptedDecisions !== undefined)
4281
- task.acceptedDecisions = params.acceptedDecisions;
5443
+ task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
4282
5444
  if (params.checklist !== undefined) {
4283
5445
  task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
4284
5446
  id: createChecklistItemId(task.id, index + 1, itemTitle),
@@ -4286,6 +5448,9 @@ export default function planPiExtension(pi) {
4286
5448
  checked: false,
4287
5449
  }));
4288
5450
  }
5451
+ if (params.subtasks !== undefined) {
5452
+ 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 }));
5453
+ }
4289
5454
  if (params.status !== undefined) {
4290
5455
  if (params.status !== task.status) {
4291
5456
  const entry = {
@@ -4314,7 +5479,17 @@ export default function planPiExtension(pi) {
4314
5479
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4315
5480
  await st.syncTaskStatusRollup(found.phase.id);
4316
5481
  await st.writeGenerated();
4317
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
5482
+ const descriptionFreshness = receivedFields.some((field) => field === "description" || field === "descriptionRef")
5483
+ ? await st.previewDescriptionReconciliation()
5484
+ : null;
5485
+ const ownerIds = new Set([found.phase.id, found.phase.featureId].filter((id) => Boolean(id)));
5486
+ const staleParentRefs = descriptionFreshness?.diagnostics
5487
+ .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
5488
+ .map((entry) => entry.ownerRef) ?? [];
5489
+ const freshnessNotice = staleParentRefs.length > 0
5490
+ ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
5491
+ : "";
5492
+ 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 } : {}) } };
4318
5493
  },
4319
5494
  });
4320
5495
  pi.registerTool({
@@ -4346,21 +5521,38 @@ export default function planPiExtension(pi) {
4346
5521
  pi.registerTool({
4347
5522
  name: "task_recommend",
4348
5523
  label: "Task Recommend",
4349
- 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.",
5524
+ 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.",
4350
5525
  parameters: Type.Object({}),
4351
5526
  async execute(_id, _params, _signal, _onUpdate, ctx) {
4352
5527
  const st = await requirePlan(ctx);
4353
5528
  if (!st)
4354
5529
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4355
- const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4356
- const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
4357
- if (!result.candidate)
4358
- return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4359
- const { candidate } = result;
5530
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
5531
+ st.loadFeatures(),
5532
+ st.loadAllPhases(),
5533
+ st.loadProject(),
5534
+ st.loadResume(),
5535
+ st.listHandoffs(),
5536
+ st.listArchivedHandoffs(),
5537
+ ]);
5538
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
5539
+ handoffs: handoffs.map((handoff) => ({
5540
+ compositeRef: handoff.compositeRef,
5541
+ firstLine: handoff.firstLine,
5542
+ resumeReady: handoff.resumeReady,
5543
+ })),
5544
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
5545
+ });
5546
+ const { selection } = result;
5547
+ if (!selection.candidate)
5548
+ 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 } };
5549
+ const { candidate } = selection;
4360
5550
  const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
5551
+ const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
5552
+ const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
4361
5553
  return {
4362
- 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}.` : ""}` }],
4363
- details: result,
5554
+ 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}` }],
5555
+ 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 },
4364
5556
  };
4365
5557
  },
4366
5558
  });
@@ -4463,7 +5655,7 @@ export default function planPiExtension(pi) {
4463
5655
  pi.registerTool({
4464
5656
  name: "task_switch",
4465
5657
  label: "Task Switch",
4466
- description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
5658
+ description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
4467
5659
  parameters: Type.Object({
4468
5660
  from_task: Type.String({ description: "Currently in-progress task ref" }),
4469
5661
  to_task: Type.String({ description: "Temporary task ref to start" }),
@@ -4475,6 +5667,7 @@ export default function planPiExtension(pi) {
4475
5667
  }),
4476
5668
  async execute(_id, params, _signal, _onUpdate, ctx) {
4477
5669
  const st = await requirePlan(ctx);
5670
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
4478
5671
  if (!st)
4479
5672
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4480
5673
  const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
@@ -4490,6 +5683,9 @@ export default function planPiExtension(pi) {
4490
5683
  if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
4491
5684
  return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
4492
5685
  }
5686
+ if (source.task.status === "in-progress" && source.task.activeOwnerSession && source.task.activeOwnerSession !== plannerSessionId) {
5687
+ return { content: [{ type: "text", text: `Task switch denied: source task is owned by another session.` }], details: {} };
5688
+ }
4493
5689
  const targetFeature = target.phase.featureId ? features.find((feature) => feature.id === target.phase.featureId) : undefined;
4494
5690
  const linkedRequirements = [
4495
5691
  ...(await st.linkedRequirementsForPhase(target.phase.id)),
@@ -4508,16 +5704,26 @@ export default function planPiExtension(pi) {
4508
5704
  requirementIds: linkedRequirementIds,
4509
5705
  };
4510
5706
  const contextEligibility = contextReadEligibilityForSession(contextInput);
4511
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5707
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5708
+ const requirementsReady = requirementEligibility.eligible;
5709
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5710
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
4512
5711
  const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
4513
5712
  const targetTaskRef = `${targetPhaseRef}/T${String(target.task.number).padStart(3, "0")}`;
4514
5713
  const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
5714
+ if (!projectGuidelinesReady) {
5715
+ const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
5716
+ return {
5717
+ content: [{ type: "text", text: `Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.` }],
5718
+ details: { projectGuidelinesReadState, contextEligibility, nextActions },
5719
+ };
5720
+ }
4515
5721
  if (!contextEligibility.eligible) {
4516
- const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
5722
+ const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
4517
5723
  return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
4518
5724
  }
4519
5725
  if (!requirementsReady) {
4520
- return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds, nextActions: ["requirement_list", `Retry task_switch to ${targetTaskRef}`] } };
5726
+ 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}`] } };
4521
5727
  }
4522
5728
  if (!hasValidSessionAttestation(contextInput)) {
4523
5729
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -4531,7 +5737,7 @@ export default function planPiExtension(pi) {
4531
5737
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
4532
5738
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
4533
5739
  };
4534
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
5740
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
4535
5741
  const record = {
4536
5742
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
4537
5743
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
@@ -4542,75 +5748,78 @@ export default function planPiExtension(pi) {
4542
5748
  const sourceWasActive = source.task.status === "in-progress";
4543
5749
  let sourceCheckpointed = false;
4544
5750
  let targetStarted = false;
4545
- try {
4546
- if (sourceWasActive) {
4547
- await st.pauseTask(source.phase.id, source.task.id, snapshot);
4548
- }
4549
- else {
4550
- await st.updatePhase(source.phase.id, (phase) => {
4551
- const task = phase.tasks.find((entry) => entry.id === source.task.id);
4552
- if (!task)
4553
- throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
4554
- task.pauseSnapshot = snapshot;
4555
- task.pauseHistory = [...task.pauseHistory, snapshot];
4556
- task.updatedAt = timestamp;
4557
- return phase;
4558
- });
4559
- }
4560
- sourceCheckpointed = true;
4561
- if (target.task.pauseSnapshot) {
4562
- await st.resumeTask(target.phase.id, target.task.id, timestamp);
4563
- }
4564
- else {
4565
- await st.updatePhase(target.phase.id, (phase) => {
4566
- const task = phase.tasks.find((entry) => entry.id === target.task.id);
4567
- if (!task)
4568
- throw new Error(`Temporary task disappeared: ${params.to_task}`);
4569
- const previousStatus = task.status;
4570
- applyTaskLifecycleDates(task, "in-progress", timestamp);
4571
- task.statusLog = [...task.statusLog, {
4572
- id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
4573
- date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
4574
- title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
4575
- }];
4576
- task.updatedAt = timestamp;
4577
- phase.updatedAt = timestamp;
4578
- return phase;
4579
- });
4580
- }
4581
- targetStarted = true;
4582
- await st.addWorkDeviation(record);
4583
- if (target.task.pauseSnapshot) {
4584
- const resumedDeviation = project.workDeviations
4585
- .filter((deviation) => deviation.resumeTaskId === target.task.id
4586
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
4587
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4588
- if (resumedDeviation)
4589
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
4590
- }
4591
- }
4592
- catch (error) {
4593
- if (targetStarted) {
4594
- await st.updatePhase(target.phase.id, (phase) => {
4595
- phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
4596
- return phase;
4597
- }).catch(() => { });
4598
- }
4599
- if (sourceCheckpointed) {
4600
- if (sourceWasActive)
4601
- await st.resumeTask(source.phase.id, source.task.id, nowISO()).catch(() => { });
4602
- else
5751
+ await st.runBatch(async () => {
5752
+ try {
5753
+ if (sourceWasActive) {
5754
+ await st.pauseTask(source.phase.id, source.task.id, snapshot);
5755
+ }
5756
+ else {
4603
5757
  await st.updatePhase(source.phase.id, (phase) => {
4604
- phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
5758
+ const task = phase.tasks.find((entry) => entry.id === source.task.id);
5759
+ if (!task)
5760
+ throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
5761
+ task.pauseSnapshot = snapshot;
5762
+ task.pauseHistory = [...task.pauseHistory, snapshot];
5763
+ task.updatedAt = timestamp;
5764
+ return phase;
5765
+ });
5766
+ }
5767
+ sourceCheckpointed = true;
5768
+ if (target.task.pauseSnapshot) {
5769
+ await st.resumeTask(target.phase.id, target.task.id, timestamp, plannerSessionId);
5770
+ }
5771
+ else {
5772
+ await st.updatePhase(target.phase.id, (phase) => {
5773
+ const task = phase.tasks.find((entry) => entry.id === target.task.id);
5774
+ if (!task)
5775
+ throw new Error(`Temporary task disappeared: ${params.to_task}`);
5776
+ const previousStatus = task.status;
5777
+ applyTaskLifecycleDates(task, "in-progress", timestamp);
5778
+ task.activeOwnerSession = plannerSessionId;
5779
+ task.statusLog = [...task.statusLog, {
5780
+ id: createChecklistItemId(task.id, task.statusLog.length + 1, `${previousStatus}-in-progress`),
5781
+ date: timestamp, fromStatus: previousStatus, toStatus: "in-progress",
5782
+ title: `${previousStatus} → in-progress`, description: `Temporary switch from ${source.task.title}.`,
5783
+ }];
5784
+ task.updatedAt = timestamp;
5785
+ phase.updatedAt = timestamp;
5786
+ return phase;
5787
+ });
5788
+ }
5789
+ targetStarted = true;
5790
+ await st.addWorkDeviation(record);
5791
+ if (target.task.pauseSnapshot) {
5792
+ const resumedDeviation = project.workDeviations
5793
+ .filter((deviation) => deviation.resumeTaskId === target.task.id
5794
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
5795
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5796
+ if (resumedDeviation)
5797
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", timestamp);
5798
+ }
5799
+ }
5800
+ catch (error) {
5801
+ if (targetStarted) {
5802
+ await st.updatePhase(target.phase.id, (phase) => {
5803
+ phase.tasks = phase.tasks.map((task) => task.id === target.task.id ? target.task : task);
4605
5804
  return phase;
4606
5805
  }).catch(() => { });
5806
+ }
5807
+ if (sourceCheckpointed) {
5808
+ if (sourceWasActive)
5809
+ await st.resumeTask(source.phase.id, source.task.id, nowISO(), (source.task.activeOwnerSession || plannerSessionId)).catch(() => { });
5810
+ else
5811
+ await st.updatePhase(source.phase.id, (phase) => {
5812
+ phase.tasks = phase.tasks.map((task) => task.id === source.task.id ? source.task : task);
5813
+ return phase;
5814
+ }).catch(() => { });
5815
+ }
5816
+ throw error;
4607
5817
  }
4608
- throw error;
4609
- }
4610
- await st.syncTaskStatusRollup(source.phase.id);
4611
- if (target.phase.id !== source.phase.id)
4612
- await st.syncTaskStatusRollup(target.phase.id);
4613
- await st.writeGenerated();
5818
+ await st.syncTaskStatusRollup(source.phase.id);
5819
+ if (target.phase.id !== source.phase.id)
5820
+ await st.syncTaskStatusRollup(target.phase.id);
5821
+ await st.writeGenerated();
5822
+ });
4614
5823
  invalidateReads(plannerSessionId);
4615
5824
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
4616
5825
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
@@ -4625,156 +5834,192 @@ export default function planPiExtension(pi) {
4625
5834
  };
4626
5835
  },
4627
5836
  });
5837
+ pi.registerTool({
5838
+ name: "task_reopen",
5839
+ label: "Task Reopen",
5840
+ description: "Atomically reopen a completed task. Requires confirmed=true, retains completion evidence, and never pauses another task.",
5841
+ parameters: Type.Object({ taskId: Type.String({ description: "Task reference" }), confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }) }),
5842
+ async execute(_id, params, _signal, _onUpdate, ctx) {
5843
+ const st = await requirePlan(ctx);
5844
+ if (!st)
5845
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: { reopened: false, errorCode: "PLAN_NOT_FOUND" }, isError: true };
5846
+ const features = (await st.loadFeatures()).features;
5847
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5848
+ if (!found)
5849
+ return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: { reopened: false, errorCode: "TASK_NOT_FOUND" }, isError: true };
5850
+ try {
5851
+ const reopened = await st.reopenTask(found.phase.id, found.task.id, { confirmed: params.confirmed, ownerSessionId: `pi:${ctx.sessionManager.getSessionId()}` });
5852
+ await st.syncTaskStatusRollup(found.phase.id);
5853
+ await st.writeGenerated();
5854
+ 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 } };
5855
+ }
5856
+ catch (error) {
5857
+ const details = error instanceof PlanStoreError ? error.details : undefined;
5858
+ 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 };
5859
+ }
5860
+ },
5861
+ });
4628
5862
  pi.registerTool({
4629
5863
  name: "task_start",
4630
5864
  label: "Task Start",
4631
- description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. A denied start is an isError result with started=false; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
5865
+ description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. A denied start is an isError result with started=false; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
4632
5866
  parameters: Type.Object({
4633
5867
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4634
5868
  }),
4635
5869
  async execute(_id, params, _signal, _onUpdate, ctx) {
4636
5870
  const st = await requirePlan(ctx);
5871
+ const plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
4637
5872
  if (!st)
4638
5873
  return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
4639
- const features = (await st.loadFeatures()).features;
4640
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4641
- if (!found)
4642
- return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
4643
- const task = found.task;
4644
- const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
4645
- const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
4646
- const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
4647
- const featureRef = feature ? formatFeatureRef(feature.number) : "";
4648
- if (task.status === "done")
4649
- return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry task_start ${taskRef}.`], { taskId: task.id }));
4650
- const linkedRequirements = [
4651
- ...(await st.linkedRequirementsForPhase(found.phase.id)),
4652
- ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
4653
- ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
4654
- const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
4655
- const contextInput = {
4656
- sessionId: plannerSessionId,
4657
- taskId: task.id,
4658
- phaseId: found.phase.id,
4659
- ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
4660
- task,
4661
- phase: found.phase,
4662
- ...(feature ? { feature } : {}),
4663
- requirements: linkedRequirements,
4664
- requirementIds: linkedRequirementIds,
4665
- };
4666
- const contextEligibility = contextReadEligibilityForSession(contextInput);
4667
- const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
4668
- if (!contextEligibility.eligible) {
4669
- return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
4670
- }
4671
- if (!requirementsReady) {
4672
- return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["requirement_list", `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }));
4673
- }
4674
- if (!hasValidSessionAttestation(contextInput)) {
4675
- await st.recordContextRead({
5874
+ return st.runBatch(async () => {
5875
+ const features = (await st.loadFeatures()).features;
5876
+ const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5877
+ if (!found)
5878
+ return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
5879
+ const task = found.task;
5880
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
5881
+ const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
5882
+ const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
5883
+ const featureRef = feature ? formatFeatureRef(feature.number) : "";
5884
+ if (task.status === "done")
5885
+ 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 }));
5886
+ const project = await st.loadProject();
5887
+ const linkedRequirements = [
5888
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
5889
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
5890
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
5891
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
5892
+ const contextInput = {
4676
5893
  sessionId: plannerSessionId,
4677
- phaseId: found.phase.id,
4678
5894
  taskId: task.id,
5895
+ phaseId: found.phase.id,
4679
5896
  ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5897
+ task,
5898
+ phase: found.phase,
5899
+ ...(feature ? { feature } : {}),
5900
+ requirements: linkedRequirements,
4680
5901
  requirementIds: linkedRequirementIds,
4681
- });
4682
- }
4683
- if (task.status === "in-progress") {
4684
- const outcome = taskStartSucceeded(task.id, true);
4685
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
4686
- }
4687
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
4688
- const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4689
- if (!eligibility.eligible)
4690
- return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
4691
- const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
4692
- if (selection.kind === "conflict") {
4693
- return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
4694
- }
4695
- if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
4696
- const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4697
- return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", `${activeRef} is active; ${taskRef} was not started.`, [`Use task_switch to snapshot and pause ${activeRef}.`, `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
4698
- }
4699
- let resumeProposal;
4700
- if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
4701
- // Advisory only: a pending resume must be surfaced loudly, but it must
4702
- // not hard-block an explicit start of a different task. Capture a
4703
- // structured proposal so the resume-first guidance is explicit and
4704
- // machine-readable for the host/agent.
4705
- const resumeTask = selection.candidate;
4706
- const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
4707
- const resumeSnapshot = resumeTask.task.pauseSnapshot
4708
- ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
4709
- && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
4710
- ?? null;
4711
- resumeProposal = buildResumeRequiredProposal({
4712
- ref: resumeRef,
4713
- title: resumeTask.task.title,
4714
- taskId: resumeTask.task.id,
4715
- phaseId: resumeTask.phase.id,
4716
- snapshot: resumeSnapshot
4717
- ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
4718
- : null,
4719
- });
4720
- }
4721
- // Assemble the mandatory parent context before changing task lifecycle state.
4722
- // The output order is feature + its requirements, then phase + its requirements.
4723
- const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
4724
- const [phaseWithRequirements, featureRequirements] = await Promise.all([
4725
- st.loadPhaseWithRequirements(found.phase.id),
4726
- parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
4727
- ]);
4728
- const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4729
- const advisory = selection.candidate && selection.candidate.task.id !== task.id
4730
- ? selection.kind === "resume"
4731
- ? `\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.`}`
4732
- : `\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.`
4733
- : "";
4734
- const now = nowISO();
4735
- let startedTask;
4736
- if (task.pauseSnapshot) {
4737
- startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
4738
- }
4739
- else {
4740
- await st.updatePhase(found.phase.id, (phase) => {
4741
- const t = phase.tasks.find((x) => x.id === found.task.id);
4742
- if (!t)
5902
+ };
5903
+ const contextEligibility = contextReadEligibilityForSession(contextInput);
5904
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5905
+ const requirementsReady = requirementEligibility.eligible;
5906
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5907
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5908
+ if (!projectGuidelinesReady) {
5909
+ 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 });
5910
+ }
5911
+ if (!contextEligibility.eligible) {
5912
+ 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 });
5913
+ }
5914
+ if (!requirementsReady) {
5915
+ 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 });
5916
+ }
5917
+ if (!hasValidSessionAttestation(contextInput)) {
5918
+ await st.recordContextRead({
5919
+ sessionId: plannerSessionId,
5920
+ phaseId: found.phase.id,
5921
+ taskId: task.id,
5922
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5923
+ requirementIds: linkedRequirementIds,
5924
+ });
5925
+ }
5926
+ if (task.status === "in-progress") {
5927
+ const outcome = taskStartSucceeded(task.id, true);
5928
+ return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5929
+ }
5930
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5931
+ const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
5932
+ if (!eligibility.eligible)
5933
+ return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
5934
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId, plannerSessionId);
5935
+ if (selection.kind === "conflict") {
5936
+ return taskStartFailure(taskStartDenied("ACTIVE_TASK_CONFLICT", selection.reason, ["Pause or reconcile the active tasks.", `Retry task_start ${taskRef}.`], { taskId: task.id }), { selection });
5937
+ }
5938
+ if (selection.kind === "active" && selection.candidate?.task.id !== task.id) {
5939
+ const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
5940
+ 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 });
5941
+ }
5942
+ let resumeProposal;
5943
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
5944
+ // Advisory only: a pending resume must be surfaced loudly, but it must
5945
+ // not hard-block an explicit start of a different task. Capture a
5946
+ // structured proposal so the resume-first guidance is explicit and
5947
+ // machine-readable for the host/agent.
5948
+ const resumeTask = selection.candidate;
5949
+ const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
5950
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
5951
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
5952
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
5953
+ ?? null;
5954
+ resumeProposal = buildResumeRequiredProposal({
5955
+ ref: resumeRef,
5956
+ title: resumeTask.task.title,
5957
+ taskId: resumeTask.task.id,
5958
+ phaseId: resumeTask.phase.id,
5959
+ snapshot: resumeSnapshot
5960
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
5961
+ : null,
5962
+ });
5963
+ }
5964
+ // Assemble the mandatory parent context before changing task lifecycle state.
5965
+ // The output order is feature + its requirements, then phase + its requirements.
5966
+ const parentFeature = found.phase.featureId ? features.find((f) => f.id === found.phase.featureId) : undefined;
5967
+ const [phaseWithRequirements, featureRequirements] = await Promise.all([
5968
+ st.loadPhaseWithRequirements(found.phase.id),
5969
+ parentFeature ? st.linkedRequirementsForFeature(parentFeature.id) : Promise.resolve([]),
5970
+ ]);
5971
+ const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements, found.task, project);
5972
+ const advisory = selection.candidate && selection.candidate.task.id !== task.id
5973
+ ? selection.kind === "resume"
5974
+ ? `\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.`}`
5975
+ : `\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.`
5976
+ : "";
5977
+ const now = nowISO();
5978
+ let startedTask;
5979
+ if (task.pauseSnapshot) {
5980
+ startedTask = await st.resumeTask(found.phase.id, found.task.id, now, plannerSessionId);
5981
+ }
5982
+ else {
5983
+ await st.updatePhase(found.phase.id, (phase) => {
5984
+ const t = phase.tasks.find((x) => x.id === found.task.id);
5985
+ if (!t)
5986
+ return phase;
5987
+ const previousStatus = t.status;
5988
+ applyTaskLifecycleDates(t, "in-progress", now);
5989
+ t.activeOwnerSession = plannerSessionId;
5990
+ t.statusLog = [...t.statusLog, {
5991
+ id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
5992
+ date: now, fromStatus: previousStatus, toStatus: "in-progress",
5993
+ title: `${previousStatus} → in-progress`, description: "",
5994
+ }];
5995
+ t.updatedAt = now;
5996
+ phase.updatedAt = now;
5997
+ startedTask = t;
4743
5998
  return phase;
4744
- const previousStatus = t.status;
4745
- applyTaskLifecycleDates(t, "in-progress", now);
4746
- t.statusLog = [...t.statusLog, {
4747
- id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-in-progress`),
4748
- date: now, fromStatus: previousStatus, toStatus: "in-progress",
4749
- title: `${previousStatus} → in-progress`, description: "",
4750
- }];
4751
- t.updatedAt = now;
4752
- phase.updatedAt = now;
4753
- startedTask = t;
4754
- return phase;
4755
- });
4756
- }
4757
- await st.syncTaskStatusRollup(found.phase.id);
4758
- const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
4759
- if (approvedDeviation)
4760
- await st.setWorkDeviationState(approvedDeviation.id, "active", now);
4761
- const resumedDeviation = project.workDeviations
4762
- .filter((deviation) => deviation.resumeTaskId === task.id
4763
- && (deviation.state === "resume-required" || deviation.state === "resolved"))
4764
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
4765
- if (resumedDeviation)
4766
- await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
4767
- await st.writeGenerated();
4768
- const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
4769
- if (!startedTask || persistedTask?.status !== "in-progress") {
4770
- return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
4771
- }
4772
- const outcome = taskStartSucceeded(persistedTask.id);
4773
- return {
4774
- content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
4775
- details: { ...persistedTask, ...outcome, task: persistedTask },
4776
- ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
4777
- };
5999
+ });
6000
+ }
6001
+ await st.syncTaskStatusRollup(found.phase.id);
6002
+ const approvedDeviation = project.workDeviations.find((deviation) => deviation.temporaryTaskId === task.id && deviation.state === "approved");
6003
+ if (approvedDeviation)
6004
+ await st.setWorkDeviationState(approvedDeviation.id, "active", now);
6005
+ const resumedDeviation = project.workDeviations
6006
+ .filter((deviation) => deviation.resumeTaskId === task.id
6007
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
6008
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
6009
+ if (resumedDeviation)
6010
+ await st.setWorkDeviationState(resumedDeviation.id, "resumed", now);
6011
+ await st.writeGenerated();
6012
+ const persistedTask = (await st.loadPhase(found.phase.id)).tasks.find((candidate) => candidate.id === task.id);
6013
+ if (!startedTask || persistedTask?.status !== "in-progress") {
6014
+ 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 }));
6015
+ }
6016
+ const outcome = taskStartSucceeded(persistedTask.id);
6017
+ return {
6018
+ content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
6019
+ details: { ...persistedTask, ...outcome, task: persistedTask },
6020
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
6021
+ };
6022
+ });
4778
6023
  },
4779
6024
  });
4780
6025
  pi.registerTool({
@@ -5036,10 +6281,24 @@ export default function planPiExtension(pi) {
5036
6281
  const st = await requirePlan(ctx);
5037
6282
  if (!st)
5038
6283
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
6284
+ let preparation;
6285
+ try {
6286
+ preparation = await st.preparePlannerSession();
6287
+ }
6288
+ catch (error) {
6289
+ return { content: [{ type: "text", text: `Planner load aborted: ${error instanceof Error ? error.message : String(error)}` }], details: { errorCode: "PLANNER_SESSION_PREPARATION_FAILED", enabled: false, running: false } };
6290
+ }
5039
6291
  enablePlannerSession();
5040
6292
  if (!server) {
5041
6293
  await startServer(ctx, undefined, "lan").catch(() => { });
5042
6294
  }
6295
+ const project = await st.loadProject();
6296
+ if (project.projectGuidelines.content.trim()) {
6297
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
6298
+ }
6299
+ const plannerSkill = await st.syncPlannerSkill();
6300
+ await st.syncGrillMeSkill();
6301
+ contextBlockDirty = true;
5043
6302
  const srv = server;
5044
6303
  let recap = "";
5045
6304
  try {
@@ -5048,7 +6307,9 @@ export default function planPiExtension(pi) {
5048
6307
  catch (e) {
5049
6308
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
5050
6309
  }
5051
- return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
6310
+ if (preparation.changed)
6311
+ recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
6312
+ return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort, preparation: preparation.legacyProjectContext, plannerSkill: { status: plannerSkill.status, customized: plannerSkill.customized, message: plannerSkill.message } } };
5052
6313
  },
5053
6314
  });
5054
6315
  pi.registerTool({
@@ -5109,6 +6370,7 @@ export default function planPiExtension(pi) {
5109
6370
  const recentActivity = activity.entries.slice(-3).reverse();
5110
6371
  const handoffs = await st.listHandoffs();
5111
6372
  const extensionRules = await st.extensionRules();
6373
+ const plannerSkill = await st.syncPlannerSkill();
5112
6374
  const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
5113
6375
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
5114
6376
  const orderedPhases = [
@@ -5180,12 +6442,15 @@ export default function planPiExtension(pi) {
5180
6442
  const openQuestions = plan.phases
5181
6443
  .flatMap((phase) => phase.openQuestions.map((question) => `[${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${question}`))
5182
6444
  .slice(0, 8);
5183
- const acceptedDecisions = [
5184
- ...project.acceptedDecisions.map((decision) => `[project] ${decision.title}`),
5185
- ...plan.features.features.flatMap((feature) => feature.acceptedDecisions.map((decision) => `[feature ${formatFeatureRef(feature.number)}] ${decision.title}`)),
5186
- ...plan.phases.flatMap((phase) => phase.acceptedDecisions.map((decision) => `[phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}] ${decision.title}`)),
5187
- ...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}`))),
5188
- ].slice(0, 20);
6445
+ const acceptedDecisionContext = buildBoundedAcceptedDecisionContext([
6446
+ { scope: "project", decisions: project.acceptedDecisions },
6447
+ ...plan.features.features.map((feature) => ({ scope: `feature ${formatFeatureRef(feature.number)}`, decisions: feature.acceptedDecisions })),
6448
+ ...plan.phases.map((phase) => ({ scope: `phase ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}`, decisions: phase.acceptedDecisions })),
6449
+ ...plan.phases.flatMap((phase) => phase.tasks.map((task) => ({
6450
+ scope: `task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, plan.features.features))}/T${pad(task.number)}`,
6451
+ decisions: task.acceptedDecisions,
6452
+ }))),
6453
+ ]);
5189
6454
  const webServer = server;
5190
6455
  const webLocalUrl = webServer?.localUrl ?? webServer?.url ?? "";
5191
6456
  const webLanUrl = webServer?.lanUrl ?? "";
@@ -5223,11 +6488,11 @@ export default function planPiExtension(pi) {
5223
6488
  "",
5224
6489
  "Operational rules:",
5225
6490
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
5226
- "- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale full reads listed in nextActions, in that order, then retry. A denial is an error with started=false; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
6491
+ "- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale reads listed in nextActions, then retry. Reads may be performed in any order within the current session. If nextActions includes project_guidelines_show, read that section and keep it in working memory while doing task work. A denial is an error with started=false; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
5227
6492
  "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
5228
6493
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
5229
6494
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
5230
- "- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
6495
+ "- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Compact feature_list / phase_list / task_list surfaces expose priority markers; when browsing manually, follow the lowest visible priority first among ready siblings. Prefer shortId or F00x/P00x/T00x refs. Read one entity via *_get(full=true).",
5231
6496
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
5232
6497
  ...(extensionRules.length > 0
5233
6498
  ? [
@@ -5237,12 +6502,20 @@ export default function planPiExtension(pi) {
5237
6502
  ]
5238
6503
  : []),
5239
6504
  "",
6505
+ "Project-local planner usage skill (agent-only; keep in working context and do not quote in user recaps):",
6506
+ plannerSkill.customized ? `Skill drift: ${plannerSkill.message}` : "",
6507
+ plannerSkill.content,
6508
+ "End project-local planner usage skill.",
6509
+ "",
5240
6510
  `Goal: ${project.goal || "(not set)"}`,
5241
6511
  project.description ? `Description: ${project.description}` : "",
5242
6512
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
5243
6513
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
5244
6514
  `Ambient: ${ambientSummary}`,
5245
6515
  "",
6516
+ "Project Guidelines (mandatory when present):",
6517
+ project.projectGuidelines.content.trim() || "- (none)",
6518
+ "",
5246
6519
  "Global rules:",
5247
6520
  project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
5248
6521
  "",
@@ -5250,8 +6523,7 @@ export default function planPiExtension(pi) {
5250
6523
  `Scope: ${project.scope.join(", ") || "(none)"}`,
5251
6524
  `Out of scope: ${project.outOfScope.join(", ") || "(none)"}`,
5252
6525
  "",
5253
- acceptedDecisions.length ? "Accepted decisions (do not re-litigate):" : "",
5254
- ...acceptedDecisions.map((decision) => `- ${decision}`),
6526
+ acceptedDecisionContext.content,
5255
6527
  openQuestions.length ? "" : "",
5256
6528
  openQuestions.length ? "Open questions:" : "",
5257
6529
  ...openQuestions.map((question) => `- ${question}`),