@agent-plan/pi-adapter 0.2.24 → 0.2.26

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,13 +10,12 @@
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, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureReadForSessionId, markPhaseReadForSessionId, markTaskReadForSessionId, contextReadEligibilityForSession, hasValidSessionAttestation, hasReadRequirementsForSession, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, HandoffContractError } from "@agent-plan/core";
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";
20
19
  import { createServer } from "node:net";
21
20
  import { fileURLToPath } from "node:url";
22
21
  import { serve } from "@agent-plan/server/serve";
@@ -30,7 +29,7 @@ let capturedPi = null;
30
29
  let store = null;
31
30
  let server = null;
32
31
  let lastKnownWebPort = null;
33
- let plannerSessionId = randomUUID();
32
+ let plannerSessionId = "pi:uninitialized";
34
33
  // True while the agent is mutating .planner/ files. The web server returns 503-busy
35
34
  // during this window so the UI doesn't render inconsistent data.
36
35
  let planBusy = false;
@@ -55,6 +54,97 @@ function taskStartFailure(outcome, details = {}) {
55
54
  details: { ...outcome, ...details },
56
55
  };
57
56
  }
57
+ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
58
+ const childKind = entity === "feature" ? "phases" : "tasks";
59
+ const retryAction = entity === "feature"
60
+ ? `Retry feature_update ${ref} without the status field if you need to update metadata.`
61
+ : `Retry phase_update ${ref} without the status field if you need to update metadata.`;
62
+ const nextActions = [
63
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
64
+ retryAction,
65
+ ];
66
+ return {
67
+ isError: true,
68
+ content: [{ type: "text", text: [
69
+ `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
70
+ `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
71
+ `Attempted status: ${attemptedStatus}`,
72
+ `Effective derived status: ${effectiveStatus}`,
73
+ "No planner data was changed.",
74
+ "Next required actions:",
75
+ ...nextActions.map((action, index) => `${index + 1}. ${action}`),
76
+ ].join("\n") }],
77
+ details: {
78
+ updated: false,
79
+ errorCode: "DERIVED_STATUS_READ_ONLY",
80
+ entity,
81
+ ref,
82
+ attemptedStatus,
83
+ effectiveStatus,
84
+ nextActions,
85
+ },
86
+ };
87
+ }
88
+ function mutationNoFieldsFailure(input) {
89
+ const outcome = noMutableFieldsReceived(input);
90
+ return {
91
+ isError: true,
92
+ content: [{ type: "text", text: outcome.message }],
93
+ details: outcome,
94
+ };
95
+ }
96
+ function legacyContextMigrationSummary(preview) {
97
+ return [
98
+ `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
99
+ `Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
100
+ `Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
101
+ `Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
102
+ `Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
103
+ ].join("\n");
104
+ }
105
+ function handoffContractFailure(error) {
106
+ return {
107
+ isError: true,
108
+ content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
109
+ details: { errorCode: error.code, ...error.details },
110
+ };
111
+ }
112
+ function boundedHandoffForTransport(content) {
113
+ if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
114
+ return { content, fullLength: content.length, truncated: false };
115
+ const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
116
+ return {
117
+ content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
118
+ fullLength: content.length,
119
+ truncated: true,
120
+ };
121
+ }
122
+ function boundedPage(items, page, pageSize) {
123
+ const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
124
+ const totalPages = Math.max(1, Math.ceil(items.length / size));
125
+ const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
126
+ return {
127
+ items: items.slice((currentPage - 1) * size, currentPage * size),
128
+ page: currentPage,
129
+ pageSize: size,
130
+ total: items.length,
131
+ totalPages,
132
+ };
133
+ }
134
+ function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
135
+ const actions = projectGuidelinesReadRequired ? ["project_guidelines_show"] : [];
136
+ actions.push(...(eligibility.requiredReads ?? []).flatMap((read) => {
137
+ if (read.kind === "task")
138
+ return [`task_get ${refs.task} with full=true`];
139
+ if (read.kind === "phase")
140
+ return [`phase_get ${refs.phase} with full=true`];
141
+ return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
142
+ }));
143
+ if (requirementReadRequired)
144
+ actions.push("requirement_list");
145
+ actions.push(retryAction);
146
+ return actions;
147
+ }
58
148
  // Helper to notify a running server in another process via HTTP.
59
149
  async function notifyExternalServer() {
60
150
  try {
@@ -129,6 +219,8 @@ const PLANNER_COMMAND_COMPLETIONS = [
129
219
  { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
130
220
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
131
221
  { value: "project language", label: "project language", description: "Set persistent language preferences" },
222
+ { value: "project guidelines", label: "project guidelines", description: "Edit the canonical Project Guidelines section" },
223
+ { value: "project migrate-context", label: "project migrate-context", description: "Preview and explicitly migrate legacy project rules and decisions" },
132
224
  { value: "feature list", label: "feature list", description: "List features" },
133
225
  { value: "feature add", label: "feature add", description: "Create a feature" },
134
226
  { value: "feature show", label: "feature show", description: "Show a feature" },
@@ -802,8 +894,8 @@ export default function planPiExtension(pi) {
802
894
  capturedPi = pi;
803
895
  // ── Restore on session start/reload ─────────────────────────────────
804
896
  pi.on("session_start", async (_event, ctx) => {
805
- plannerSessionId = randomUUID();
806
- invalidateReads();
897
+ plannerSessionId = `pi:${ctx.sessionManager.getSessionId()}`;
898
+ invalidateReads(plannerSessionId);
807
899
  startReadSession(plannerSessionId);
808
900
  try {
809
901
  ctx.ui.addAutocompleteProvider((current) => ({
@@ -1096,6 +1188,13 @@ export default function planPiExtension(pi) {
1096
1188
  "cleanup-orphans",
1097
1189
  "project discuss",
1098
1190
  "project language",
1191
+ "project guidelines",
1192
+ "idea list",
1193
+ "idea add",
1194
+ "idea show",
1195
+ "idea update",
1196
+ "idea delete",
1197
+ "idea promote",
1099
1198
  "feature list",
1100
1199
  "feature add",
1101
1200
  "feature show",
@@ -1127,7 +1226,7 @@ export default function planPiExtension(pi) {
1127
1226
  "load",
1128
1227
  "stop",
1129
1228
  ];
1130
- 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" +
1229
+ 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" +
1131
1230
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1132
1231
  "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
1133
1232
  if (!a) {
@@ -1429,7 +1528,7 @@ export default function planPiExtension(pi) {
1429
1528
  // ═══════════════════════════════════════════════════════════════
1430
1529
  if (a === "project") {
1431
1530
  if (!b) {
1432
- ctx.ui.notify("project actions: discuss | language", "info");
1531
+ ctx.ui.notify("project actions: discuss | language | guidelines | migrate-context", "info");
1433
1532
  return;
1434
1533
  }
1435
1534
  if (b === "discuss") {
@@ -1491,7 +1590,115 @@ export default function planPiExtension(pi) {
1491
1590
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
1492
1591
  return;
1493
1592
  }
1494
- ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language`, "warning");
1593
+ if (b === "guidelines") {
1594
+ const project = await st.loadProject();
1595
+ const nextContent = await ctx.ui.editor("Project Guidelines / Coding Standard (Markdown or plain text). Agents must read this before task work.", project.projectGuidelines.content || "");
1596
+ project.projectGuidelines = {
1597
+ content: nextContent?.trim() || "",
1598
+ updatedAt: project.projectGuidelines.updatedAt,
1599
+ sessionInfo: project.projectGuidelines.sessionInfo,
1600
+ };
1601
+ await st.saveProject(project);
1602
+ await st.writeGenerated();
1603
+ ctx.ui.notify(project.projectGuidelines.content ? "Project Guidelines updated." : "Project Guidelines cleared.", "info");
1604
+ return;
1605
+ }
1606
+ if (b === "migrate-context") {
1607
+ const preview = await st.previewLegacyProjectContextMigration();
1608
+ const summary = legacyContextMigrationSummary(preview);
1609
+ if (!preview.hasLegacyContext) {
1610
+ ctx.ui.notify(`Project context migration preview:\n${summary}\nNo migration is needed.`, "info");
1611
+ return;
1612
+ }
1613
+ const choice = await ctx.ui.select("Apply the previewed migration? This clears only migrated legacy fields.", ["apply", "cancel"]);
1614
+ if (choice !== "apply") {
1615
+ ctx.ui.notify(`Project context migration preview (no changes applied):\n${summary}`, "info");
1616
+ return;
1617
+ }
1618
+ const result = await st.migrateLegacyProjectContext();
1619
+ await st.writeGenerated();
1620
+ ctx.ui.notify(`${result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration."}\n${legacyContextMigrationSummary(result.preview)}`, "info");
1621
+ return;
1622
+ }
1623
+ ctx.ui.notify(`Unknown project action "${b}". Try: discuss, language, guidelines, migrate-context`, "warning");
1624
+ return;
1625
+ }
1626
+ // ═══════════════════════════════════════════════════════════════
1627
+ // idea <sub>
1628
+ // ═══════════════════════════════════════════════════════════════
1629
+ if (a === "idea") {
1630
+ if (!b) {
1631
+ ctx.ui.notify("idea actions: list | add [title] | show [ref] | update [ref] | delete [ref] | promote [ref]", "info");
1632
+ return;
1633
+ }
1634
+ const ideas = (await st.loadIdeas()).ideas;
1635
+ if (b === "list") {
1636
+ await paginatedNotify(ctx, {
1637
+ title: "ideas",
1638
+ lines: ideas.map((idea) => `${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}${idea.promotion ? ` → ${idea.promotion.targetRef}` : ""}`),
1639
+ pageSize: 10,
1640
+ });
1641
+ return;
1642
+ }
1643
+ if (b === "add") {
1644
+ const title = subArgs.trim() || await ctx.ui.input("Idea title");
1645
+ if (!title?.trim()) {
1646
+ ctx.ui.notify("Aborted", "warning");
1647
+ return;
1648
+ }
1649
+ const description = await ctx.ui.editor("Idea description", "");
1650
+ const idea = await st.createIdea({ title: title.trim(), ...(description?.trim() ? { description: description.trim() } : {}) });
1651
+ await st.writeGenerated();
1652
+ ctx.ui.notify(`Idea created: ${formatIdeaRef(idea.number)} · ${idea.shortId} — ${idea.title}`, "info");
1653
+ return;
1654
+ }
1655
+ const ref = subArgs.trim() || await ctx.ui.input("Idea ref (I00x, short ID, or title)");
1656
+ const idea = ref ? findIdeaByRef(ideas, ref) : undefined;
1657
+ if (!idea) {
1658
+ ctx.ui.notify(`Idea not found: ${ref || "(empty)"}`, "error");
1659
+ return;
1660
+ }
1661
+ if (b === "show") {
1662
+ 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");
1663
+ return;
1664
+ }
1665
+ if (b === "update") {
1666
+ const title = await ctx.ui.input(`Title [${idea.title}]`);
1667
+ const description = await ctx.ui.editor("Description", idea.description);
1668
+ const updated = await st.updateIdea(idea.id, { ...(title?.trim() ? { title: title.trim() } : {}), ...(description !== undefined ? { description: description.trim() } : {}) });
1669
+ await st.writeGenerated();
1670
+ ctx.ui.notify(`Idea updated: ${formatIdeaRef(updated.number)} — ${updated.title}`, "info");
1671
+ return;
1672
+ }
1673
+ if (b === "delete") {
1674
+ const answer = await ctx.ui.select(`Delete ${formatIdeaRef(idea.number)} — ${idea.title}?`, ["cancel", "delete"]);
1675
+ if (answer !== "delete") {
1676
+ ctx.ui.notify("Cancelled — no changes saved.", "info");
1677
+ return;
1678
+ }
1679
+ await st.deleteIdea(idea.id);
1680
+ await st.writeGenerated();
1681
+ ctx.ui.notify(`Idea deleted: ${formatIdeaRef(idea.number)}.`, "info");
1682
+ return;
1683
+ }
1684
+ if (b === "promote") {
1685
+ if (idea.promotion) {
1686
+ ctx.ui.notify(`Already promoted to ${idea.promotion.targetRef}.`, "warning");
1687
+ return;
1688
+ }
1689
+ const targetType = await ctx.ui.select("Promotion target", ["feature", "phase", "task"]);
1690
+ if (!targetType)
1691
+ return;
1692
+ const skill = await st.ideaDiscussionSkill();
1693
+ if (capturedPi) {
1694
+ 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.`);
1695
+ }
1696
+ else {
1697
+ ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
1698
+ }
1699
+ return;
1700
+ }
1701
+ ctx.ui.notify(`Unknown idea action "${b}". Try: list, add, show, update, delete, promote`, "warning");
1495
1702
  return;
1496
1703
  }
1497
1704
  // ═══════════════════════════════════════════════════════════════
@@ -1773,7 +1980,7 @@ export default function planPiExtension(pi) {
1773
1980
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1774
1981
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1775
1982
  createdAt: nowISO(), updatedAt: nowISO(),
1776
- handoff: "", handoffUpdatedAt: "",
1983
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
1777
1984
  handoffReadAt: "",
1778
1985
  handoffHistory: [],
1779
1986
  statusLog: [],
@@ -2245,11 +2452,24 @@ export default function planPiExtension(pi) {
2245
2452
  requirementIds: linkedRequirementIds,
2246
2453
  };
2247
2454
  const contextEligibility = contextReadEligibilityForSession(contextInput);
2455
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
2456
+ const project = await st.loadProject();
2457
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
2458
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
2459
+ const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2460
+ const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
2461
+ const featureRef = feature ? formatFeatureRef(feature.number) : "";
2462
+ if (!projectGuidelinesReady) {
2463
+ const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `/planner task start ${taskRef}`);
2464
+ ctx.ui.notify(`❌ TASK START FAILED [PROJECT_GUIDELINES_READ_REQUIRED]\nstarted: false\nProject Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2465
+ return;
2466
+ }
2248
2467
  if (!contextEligibility.eligible) {
2249
- ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nRead task(full), phase(full), feature(full) in order, then retry.`, "error");
2468
+ const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `/planner task start ${taskRef}`);
2469
+ ctx.ui.notify(`❌ TASK START FAILED [CONTEXT_READ_REQUIRED]\nstarted: false\n${contextEligibility.reason}\nNext required actions:\n${actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}`, "error");
2250
2470
  return;
2251
2471
  }
2252
- if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
2472
+ if (!requirementsReady) {
2253
2473
  ctx.ui.notify("❌ TASK START FAILED [REQUIREMENTS_READ_REQUIRED]\nstarted: false\nRun requirement_list, then retry /planner task start. The task remains unchanged.", "error");
2254
2474
  return;
2255
2475
  }
@@ -2601,6 +2821,15 @@ export default function planPiExtension(pi) {
2601
2821
  return;
2602
2822
  }
2603
2823
  if (a === "load") {
2824
+ const st = ensureStore(ctx);
2825
+ let preparation;
2826
+ try {
2827
+ preparation = await st.preparePlannerSession();
2828
+ }
2829
+ catch (error) {
2830
+ ctx.ui.notify(`Planner load aborted: ${error instanceof Error ? error.message : String(error)}`, "error");
2831
+ return;
2832
+ }
2604
2833
  enablePlannerSession();
2605
2834
  if (!server) {
2606
2835
  ctx.ui.notify("Starting web server (LAN) …", "info");
@@ -2630,17 +2859,22 @@ export default function planPiExtension(pi) {
2630
2859
  // triggerTurn messages), so the recap data must live here. Uses the shared
2631
2860
  // core buildRecap so Pi and Claude Code/Codex present identical content.
2632
2861
  let recapText = "";
2862
+ let plannerSkillContext = "";
2633
2863
  try {
2634
- const st = ensureStore(ctx);
2864
+ const plannerSkill = await st.syncPlannerSkill();
2865
+ await st.syncGrillMeSkill();
2635
2866
  const srv = server;
2636
2867
  recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2868
+ if (preparation.changed)
2869
+ recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
2870
+ 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]`;
2637
2871
  }
2638
2872
  catch (e) {
2639
2873
  recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2640
2874
  }
2641
2875
  pi.sendMessage({
2642
2876
  customType: "planner-resume-trigger",
2643
- 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 ---",
2877
+ 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 ---",
2644
2878
  display: false,
2645
2879
  }, {
2646
2880
  triggerTurn: true,
@@ -2789,6 +3023,7 @@ export default function planPiExtension(pi) {
2789
3023
  parameters: Type.Object({
2790
3024
  name: Type.Optional(Type.String({ description: "Project title" })),
2791
3025
  description: Type.Optional(Type.String({ description: "Short project description" })),
3026
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
2792
3027
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
2793
3028
  scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
2794
3029
  outOfScope: Type.Optional(Type.Array(Type.String(), { description: "Replace out-of-scope items" })),
@@ -2802,10 +3037,34 @@ export default function planPiExtension(pi) {
2802
3037
  if (!st)
2803
3038
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
2804
3039
  const project = await st.loadProject();
3040
+ const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
3041
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3042
+ if (receivedFields.length === 0) {
3043
+ return mutationNoFieldsFailure({
3044
+ entity: "project",
3045
+ ref: project.name,
3046
+ operation: "update",
3047
+ mutableFields,
3048
+ retryCommand: "project_update with a concise description summary and the intended metadata",
3049
+ readBackCommand: "plan_get",
3050
+ descriptionFallback: {
3051
+ inputField: "description",
3052
+ storedField: "project description",
3053
+ descriptionRefField: "descriptionRef",
3054
+ },
3055
+ });
3056
+ }
2805
3057
  if (params.name !== undefined)
2806
3058
  project.name = params.name.trim();
2807
3059
  if (params.description !== undefined)
2808
3060
  project.description = params.description.trim();
3061
+ if (params.descriptionRef !== undefined) {
3062
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
3063
+ if (descriptionRef)
3064
+ project.descriptionRef = descriptionRef;
3065
+ else
3066
+ delete project.descriptionRef;
3067
+ }
2809
3068
  if (params.goal !== undefined)
2810
3069
  project.goal = params.goal.trim();
2811
3070
  if (params.scope !== undefined)
@@ -2822,7 +3081,95 @@ export default function planPiExtension(pi) {
2822
3081
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
2823
3082
  await st.saveProject(project);
2824
3083
  await st.writeGenerated();
2825
- return { content: [{ type: "text", text: `Project updated: ${project.name}` }], details: project };
3084
+ return { content: [{ type: "text", text: `Project updated: ${project.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...project, updated: true, updatedFields: receivedFields } };
3085
+ },
3086
+ });
3087
+ pi.registerTool({
3088
+ name: "project_guidelines_show",
3089
+ label: "Project Guidelines Show",
3090
+ 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.",
3091
+ parameters: Type.Object({}),
3092
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
3093
+ const st = await requirePlan(ctx);
3094
+ if (!st)
3095
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3096
+ const project = await st.loadProject();
3097
+ if (project.projectGuidelines.content.trim()) {
3098
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
3099
+ }
3100
+ const refreshed = await st.loadProject();
3101
+ const content = refreshed.projectGuidelines.content.trim();
3102
+ return {
3103
+ content: [{ type: "text", text: content
3104
+ ? [
3105
+ "## Project Guidelines",
3106
+ content,
3107
+ ].join("\n\n")
3108
+ : "No project guidelines set."
3109
+ }],
3110
+ details: {
3111
+ projectGuidelines: refreshed.projectGuidelines,
3112
+ readRecorded: Boolean(content),
3113
+ },
3114
+ };
3115
+ },
3116
+ });
3117
+ pi.registerTool({
3118
+ name: "project_guidelines_update",
3119
+ label: "Project Guidelines Update",
3120
+ description: "Replace the canonical Project Guidelines / Coding Standard text that agents must read on load and before starting or switching task work.",
3121
+ parameters: Type.Object({
3122
+ content: Type.String({ description: "Full Project Guidelines markdown/text. Pass an empty string to clear the section." }),
3123
+ }),
3124
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3125
+ const st = await requirePlan(ctx);
3126
+ if (!st)
3127
+ return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3128
+ const project = await st.loadProject();
3129
+ project.projectGuidelines = {
3130
+ content: params.content.trim(),
3131
+ updatedAt: project.projectGuidelines.updatedAt,
3132
+ sessionInfo: project.projectGuidelines.sessionInfo,
3133
+ };
3134
+ await st.saveProject(project);
3135
+ await st.writeGenerated();
3136
+ const refreshed = await st.loadProject();
3137
+ return {
3138
+ content: [{ type: "text", text: refreshed.projectGuidelines.content
3139
+ ? `Project Guidelines updated. Updated at ${refreshed.projectGuidelines.updatedAt}.`
3140
+ : "Project Guidelines cleared."
3141
+ }],
3142
+ details: {
3143
+ updated: true,
3144
+ projectGuidelines: refreshed.projectGuidelines,
3145
+ },
3146
+ };
3147
+ },
3148
+ });
3149
+ pi.registerTool({
3150
+ name: "project_context_migrate",
3151
+ label: "Project Context Migrate",
3152
+ 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.",
3153
+ parameters: Type.Object({
3154
+ apply: Type.Optional(Type.Boolean({ description: "False previews without mutation. True explicitly applies the previewed migration and clears only the migrated legacy fields." })),
3155
+ }),
3156
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3157
+ const st = await requirePlan(ctx);
3158
+ if (!st)
3159
+ return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
3160
+ const preview = await st.previewLegacyProjectContextMigration();
3161
+ if (params.apply !== true) {
3162
+ return {
3163
+ 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") }],
3164
+ details: { applied: false, preview },
3165
+ };
3166
+ }
3167
+ const result = await st.migrateLegacyProjectContext();
3168
+ await st.writeGenerated();
3169
+ return {
3170
+ content: [{ type: "text", text: [result.applied ? "Legacy project context migration applied and verified." : "No legacy project context required migration.", legacyContextMigrationSummary(result.preview)].join("\n") }],
3171
+ details: result,
3172
+ };
2826
3173
  },
2827
3174
  });
2828
3175
  pi.registerTool({
@@ -2851,6 +3198,11 @@ export default function planPiExtension(pi) {
2851
3198
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2852
3199
  status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
2853
3200
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
3201
+ macroTasks: Type.Optional(Type.Array(Type.Object({
3202
+ title: Type.String({ description: "Macro-task title" }),
3203
+ description: Type.Optional(Type.String({ description: "Macro-task description" })),
3204
+ status: Type.Optional(Type.String({ description: "Macro-task status" })),
3205
+ }), { description: "Initial macro tasks. IDs and timestamps are planner-managed." })),
2854
3206
  }),
2855
3207
  async execute(_id, params, _signal, _onUpdate, ctx) {
2856
3208
  const st = await requirePlan(ctx);
@@ -2864,19 +3216,37 @@ export default function planPiExtension(pi) {
2864
3216
  return { content: [{ type: "text", text: links.error }], details: {} };
2865
3217
  const requirements = await st.loadRequirements();
2866
3218
  const now = nowISO();
3219
+ let macroTasks;
3220
+ try {
3221
+ macroTasks = reconcileRequirementMacroTasks([], (params.macroTasks ?? []).map((task) => ({
3222
+ title: task.title,
3223
+ ...(task.description !== undefined ? { description: task.description } : {}),
3224
+ status: task.status?.trim() ?? "planned",
3225
+ })), now);
3226
+ }
3227
+ catch (error) {
3228
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
3229
+ return { content: [{ type: "text", text: message }], details: {} };
3230
+ }
2867
3231
  const requirement = {
2868
3232
  id: createRequirementId(),
2869
3233
  title,
2870
3234
  description: params.description?.trim() ?? "",
2871
3235
  status: params.status?.trim() ?? "planned",
2872
- macroTasks: [],
3236
+ macroTasks,
2873
3237
  linkedPhaseIds: links.linkedPhaseIds,
2874
3238
  sessionInfo: [],
2875
3239
  createdAt: now,
2876
3240
  updatedAt: now,
2877
3241
  };
2878
3242
  requirements.requirements.push(requirement);
2879
- await st.saveRequirements(requirements);
3243
+ try {
3244
+ await st.saveRequirements(requirements);
3245
+ }
3246
+ catch (error) {
3247
+ const message = error instanceof RequirementMacroTaskError ? error.message : error instanceof Error ? error.message : "Invalid requirement.";
3248
+ return { content: [{ type: "text", text: message }], details: {} };
3249
+ }
2880
3250
  await st.writeGenerated();
2881
3251
  return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: requirement };
2882
3252
  },
@@ -2891,6 +3261,12 @@ export default function planPiExtension(pi) {
2891
3261
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2892
3262
  status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
2893
3263
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
3264
+ macroTasks: Type.Optional(Type.Array(Type.Object({
3265
+ id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
3266
+ title: Type.String({ description: "Macro-task title" }),
3267
+ description: Type.Optional(Type.String({ description: "Macro-task description" })),
3268
+ status: Type.String({ description: "Macro-task status" }),
3269
+ }), { description: "Replace, add, remove, or reorder macro tasks. IDs/timestamps are preserved or assigned by the planner." })),
2894
3270
  }),
2895
3271
  async execute(_id, params, _signal, _onUpdate, ctx) {
2896
3272
  const st = await requirePlan(ctx);
@@ -2916,7 +3292,22 @@ export default function planPiExtension(pi) {
2916
3292
  return { content: [{ type: "text", text: links.error }], details: {} };
2917
3293
  requirement.linkedPhaseIds = links.linkedPhaseIds;
2918
3294
  }
2919
- requirement.updatedAt = nowISO();
3295
+ const now = nowISO();
3296
+ if (params.macroTasks !== undefined) {
3297
+ try {
3298
+ requirement.macroTasks = reconcileRequirementMacroTasks(requirement.macroTasks, params.macroTasks.map((task) => ({
3299
+ ...(task.id ? { id: task.id } : {}),
3300
+ title: task.title,
3301
+ ...(task.description !== undefined ? { description: task.description } : {}),
3302
+ status: task.status.trim(),
3303
+ })), now);
3304
+ }
3305
+ catch (error) {
3306
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
3307
+ return { content: [{ type: "text", text: message }], details: {} };
3308
+ }
3309
+ }
3310
+ requirement.updatedAt = now;
2920
3311
  await st.saveRequirements(requirements);
2921
3312
  await st.writeGenerated();
2922
3313
  return { content: [{ type: "text", text: `Requirement updated: ${requirement.id}` }], details: requirement };
@@ -2943,6 +3334,77 @@ export default function planPiExtension(pi) {
2943
3334
  return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: params.requirementId } };
2944
3335
  },
2945
3336
  });
3337
+ pi.registerTool({
3338
+ name: "idea_list", label: "Idea List", description: "List rollup-independent Ideas Inbox entries.", parameters: Type.Object({}),
3339
+ async execute(_id, _params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3340
+ 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 } }; },
3341
+ });
3342
+ pi.registerTool({
3343
+ name: "idea_show", label: "Idea Show", description: "Show an idea by I-number, short ID, UUID, or title.", parameters: Type.Object({ idea: Type.String() }),
3344
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3345
+ 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" } }; },
3346
+ });
3347
+ pi.registerTool({
3348
+ name: "idea_create", label: "Idea Create", description: "Create an independent Ideas Inbox entry.", parameters: Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) }),
3349
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3350
+ 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 } }; },
3351
+ });
3352
+ pi.registerTool({
3353
+ 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()) }),
3354
+ async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3355
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3356
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
3357
+ return { content: [{ type: "text", text: "No mutable idea fields were received." }], details: { errorCode: "NO_MUTABLE_FIELDS_RECEIVED", updated: false } }; const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}` }], details: { idea, updated: true } }; },
3358
+ });
3359
+ pi.registerTool({
3360
+ name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
3361
+ async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.confirmed)
3362
+ 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)
3363
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3364
+ 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) } }; },
3365
+ });
3366
+ pi.registerTool({
3367
+ 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")]) }),
3368
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3369
+ const st = await requirePlan(ctx);
3370
+ if (!st)
3371
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3372
+ const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea);
3373
+ if (!idea)
3374
+ return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND" } };
3375
+ if (idea.promotion)
3376
+ return { content: [{ type: "text", text: `${formatIdeaRef(idea.number)} is already promoted to ${idea.promotion.targetRef}.` }], details: { errorCode: "ALREADY_PROMOTED", idea } };
3377
+ const features = (await st.loadFeatures()).features;
3378
+ const phases = await st.loadAllPhases();
3379
+ const recommendedFeature = features[0];
3380
+ const recommendedPhase = recommendedFeature ? phases.find((phase) => phase.featureId === recommendedFeature.id) : phases[0];
3381
+ const recommendation = params.targetType === "feature"
3382
+ ? "Create a new feature after the discussion."
3383
+ : params.targetType === "phase"
3384
+ ? 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."
3385
+ : 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.";
3386
+ 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."] } };
3387
+ },
3388
+ });
3389
+ pi.registerTool({
3390
+ 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() }),
3391
+ async execute(_id, params, _signal, _onUpdate, ctx) { if (!params.discussionCompleted || !params.confirmed)
3392
+ 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)
3393
+ return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const idea = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!idea)
3394
+ 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") {
3395
+ const resolved = resolveFeatureRefStrict(features, params.targetRef);
3396
+ target = resolved.ok ? { id: resolved.feature.id } : undefined;
3397
+ }
3398
+ else if (params.targetType === "phase") {
3399
+ const phase = findPhaseByRef(phases, features, params.targetRef);
3400
+ target = phase ? { id: phase.id } : undefined;
3401
+ }
3402
+ else {
3403
+ const found = findTaskByRef(phases, features, params.targetRef);
3404
+ target = found ? { id: found.task.id } : undefined;
3405
+ } if (!target)
3406
+ 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 } }; },
3407
+ });
2946
3408
  pi.registerTool({
2947
3409
  name: "plan_get",
2948
3410
  label: "Plan Get",
@@ -3041,9 +3503,11 @@ export default function planPiExtension(pi) {
3041
3503
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3042
3504
  const list = await st.listHandoffs();
3043
3505
  if (list.length === 0)
3044
- 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 } };
3045
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3046
- 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 } };
3506
+ return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
3507
+ const result = boundedPage(list, 1, 10);
3508
+ const { items, ...pageInfo } = result;
3509
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
3510
+ 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 } };
3047
3511
  },
3048
3512
  });
3049
3513
  pi.registerTool({
@@ -3134,17 +3598,22 @@ export default function planPiExtension(pi) {
3134
3598
  pi.registerTool({
3135
3599
  name: "handoff_list",
3136
3600
  label: "Handoff List",
3137
- 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.",
3138
- parameters: Type.Object({}),
3139
- async execute(_id, _params, _signal, _onUpdate, ctx) {
3601
+ 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.",
3602
+ parameters: Type.Object({
3603
+ page: Type.Optional(Type.Integer({ minimum: 1, description: "1-based page number (default 1)." })),
3604
+ pageSize: Type.Optional(Type.Integer({ minimum: 1, maximum: 25, description: "Summaries per page (default 10, maximum 25)." })),
3605
+ }),
3606
+ async execute(_id, params, _signal, _onUpdate, ctx) {
3140
3607
  const st = await requirePlan(ctx);
3141
3608
  if (!st)
3142
3609
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3143
3610
  const list = await st.listHandoffs();
3144
3611
  if (list.length === 0)
3145
- return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
3146
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3147
- return { content: [{ type: "text", text: `Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { count: list.length, handoffs: list } };
3612
+ return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0, total: 0, page: 1, totalPages: 1, handoffs: [] } };
3613
+ const result = boundedPage(list, params.page ?? 1, params.pageSize ?? 10);
3614
+ const { items, ...pageInfo } = result;
3615
+ const lines = items.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt}; ${e.contentLength} chars)`);
3616
+ 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 } };
3148
3617
  },
3149
3618
  });
3150
3619
  pi.registerTool({
@@ -3159,16 +3628,20 @@ export default function planPiExtension(pi) {
3159
3628
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
3160
3629
  if (!r.ok)
3161
3630
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3162
- const content = await st.getPhaseHandoff(r.phase.id);
3163
- if (!content.trim())
3164
- return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, empty: true } };
3165
- return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
3631
+ const phase = await st.loadPhase(r.phase.id);
3632
+ const bounded = boundedHandoffForTransport(phase.handoff);
3633
+ if (!bounded.content.trim())
3634
+ return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, handoffAudit: phase.handoffAudit } };
3635
+ return {
3636
+ content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${bounded.content}` }],
3637
+ details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...bounded, handoffAudit: phase.handoffAudit },
3638
+ };
3166
3639
  },
3167
3640
  });
3168
3641
  pi.registerTool({
3169
3642
  name: "handoff_prepare",
3170
3643
  label: "Handoff Prepare",
3171
- description: "Audit one exact phase before writing a handoff. Returns the current handoff/version and every done task missing durable completion or verification evidence. Call after the user confirms the target, then reconcile the returned context through handoff_write.",
3644
+ description: "Audit one exact phase before writing a handoff. Returns the current handoff token, missing task evidence, the required versioned completeness categories, and the 24,000-character inline budget. Reconcile everything through handoff_write.",
3172
3645
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3173
3646
  async execute(_id, params, _signal, _onUpdate, ctx) {
3174
3647
  const st = await requirePlan(ctx);
@@ -3179,8 +3652,10 @@ export default function planPiExtension(pi) {
3179
3652
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3180
3653
  try {
3181
3654
  const audit = await st.preparePhaseHandoff(r.phase.id);
3655
+ const { handoff, ...auditDetails } = audit;
3656
+ const boundedExisting = boundedHandoffForTransport(handoff);
3182
3657
  const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3183
- const existing = audit.handoff.trim() || "(none)";
3658
+ const existing = boundedExisting.content.trim() || "(none)";
3184
3659
  return {
3185
3660
  content: [{ type: "text", text: [
3186
3661
  `Handoff preparation audit for ${r.compositeRef}`,
@@ -3188,10 +3663,14 @@ export default function planPiExtension(pi) {
3188
3663
  "Done tasks missing durable completion/verification evidence:",
3189
3664
  missing,
3190
3665
  "",
3666
+ `Required completeness audit v${audit.completenessVersion} — every category must be captured or marked not-applicable with a substantive reason:`,
3667
+ ...audit.completenessCategories.map((entry) => `- ${entry.id} — ${entry.label}`),
3668
+ `Maximum canonical inline content: ${audit.maxContentChars} characters. Link extended material from .planner/docs/.`,
3669
+ "",
3191
3670
  "Existing active handoff (reconcile all still-relevant content):",
3192
3671
  existing,
3193
3672
  ].join("\n") }],
3194
- details: { phaseRef: r.compositeRef, ...audit },
3673
+ details: { phaseRef: r.compositeRef, ...auditDetails, existingHandoffLength: boundedExisting.fullLength, existingHandoffTruncated: boundedExisting.truncated },
3195
3674
  };
3196
3675
  }
3197
3676
  catch (error) {
@@ -3203,7 +3682,7 @@ export default function planPiExtension(pi) {
3203
3682
  pi.registerTool({
3204
3683
  name: "handoff_write",
3205
3684
  label: "Handoff Write",
3206
- description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run handoff_prepare first and pass its exact handoffUpdatedAt token. Existing context must be reconciled; refreshes do not create superseded archives.",
3685
+ description: "Reconcile and refresh one active handoff with durable context synchronization and a mandatory versioned completeness audit. Run handoff_prepare first. Every category must be captured or substantively not-applicable; extended detail belongs in linked .planner/docs/ Markdown.",
3207
3686
  parameters: Type.Object({
3208
3687
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3209
3688
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
@@ -3212,6 +3691,18 @@ export default function planPiExtension(pi) {
3212
3691
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
3213
3692
  expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
3214
3693
  reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
3694
+ completenessAudit: Type.Optional(Type.Object({
3695
+ version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
3696
+ entries: Type.Array(Type.Object({
3697
+ category: Type.String({ description: `One required category id from handoff_prepare: ${HANDOFF_COMPLETENESS_CATEGORIES.map((entry) => entry.id).join(", ")}` }),
3698
+ status: Type.Union([Type.Literal("captured"), Type.Literal("not-applicable")]),
3699
+ detail: Type.String({ description: "Substantive captured detail or a substantive reason why the category is not applicable." }),
3700
+ })),
3701
+ })),
3702
+ supportingDocuments: Type.Optional(Type.Array(Type.Object({
3703
+ path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
3704
+ description: Type.String({ description: "What the document contains and why the next agent needs it." }),
3705
+ }))),
3215
3706
  taskUpdates: Type.Optional(Type.Array(Type.Object({
3216
3707
  taskRef: Type.String(),
3217
3708
  completionSummary: Type.String(),
@@ -3280,6 +3771,8 @@ export default function planPiExtension(pi) {
3280
3771
  content: body,
3281
3772
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
3282
3773
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
3774
+ ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
3775
+ ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
3283
3776
  contextSync: {
3284
3777
  taskUpdates,
3285
3778
  ...(params.phaseUpdate ? { phaseUpdate: {
@@ -3293,9 +3786,20 @@ export default function planPiExtension(pi) {
3293
3786
  },
3294
3787
  });
3295
3788
  await st.writeGenerated();
3296
- return { content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result } };
3789
+ return {
3790
+ content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }],
3791
+ details: {
3792
+ phaseRef: r.compositeRef,
3793
+ phaseId: r.phase.id,
3794
+ updatedTaskIds: result.updatedTaskIds,
3795
+ handoffUpdatedAt: result.handoffUpdatedAt,
3796
+ handoffAudit: result.handoffAudit,
3797
+ },
3798
+ };
3297
3799
  }
3298
3800
  catch (error) {
3801
+ if (error instanceof HandoffContractError)
3802
+ return handoffContractFailure(error);
3299
3803
  const message = error instanceof Error ? error.message : String(error);
3300
3804
  return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
3301
3805
  }
@@ -3350,7 +3854,7 @@ export default function planPiExtension(pi) {
3350
3854
  pi.registerTool({
3351
3855
  name: "feature_list",
3352
3856
  label: "Feature List",
3353
- 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.",
3857
+ 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.",
3354
3858
  parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
3355
3859
  async execute(_id, params, _signal, _onUpdate, ctx) {
3356
3860
  const st = await requirePlan(ctx);
@@ -3363,7 +3867,7 @@ export default function planPiExtension(pi) {
3363
3867
  const phases = await st.loadAllPhases();
3364
3868
  const summary = features.map((f) => {
3365
3869
  const fp = phases.filter((p) => p.featureId === f.id);
3366
- 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)`;
3870
+ 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)`;
3367
3871
  }).join("\n");
3368
3872
  return { content: [{ type: "text", text: summary || "No features" }], details: {} };
3369
3873
  },
@@ -3391,10 +3895,14 @@ export default function planPiExtension(pi) {
3391
3895
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3392
3896
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3393
3897
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
3898
+ const descriptionRefBlock = feature.descriptionRef ? `\n\nDescription reference:\n- ${feature.descriptionRef}` : "";
3394
3899
  const requirementsBlock = linkedRequirements.length > 0
3395
3900
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3396
3901
  : "\n\nLinked requirements:\n- None linked to this feature.";
3397
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
3902
+ return {
3903
+ content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary }],
3904
+ details: { feature: params.full ? { ref: formatFeatureRef(feature.number), description: feature.description, descriptionRef: feature.descriptionRef ?? "" } : undefined, linkedRequirements },
3905
+ };
3398
3906
  },
3399
3907
  });
3400
3908
  pi.registerTool({
@@ -3457,6 +3965,7 @@ export default function planPiExtension(pi) {
3457
3965
  parameters: Type.Object({
3458
3966
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3459
3967
  description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
3968
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
3460
3969
  workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3461
3970
  workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3462
3971
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
@@ -3470,16 +3979,41 @@ export default function planPiExtension(pi) {
3470
3979
  if (!resolvedFeature.ok)
3471
3980
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3472
3981
  const featureId = resolvedFeature.feature.id;
3982
+ const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
3983
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3984
+ if (receivedFields.length === 0) {
3985
+ return mutationNoFieldsFailure({
3986
+ entity: "feature",
3987
+ ref: formatFeatureRef(resolvedFeature.feature.number),
3988
+ operation: "discuss",
3989
+ mutableFields,
3990
+ retryCommand: `feature_discuss ${formatFeatureRef(resolvedFeature.feature.number)}`,
3991
+ readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
3992
+ descriptionFallback: {
3993
+ entityId: featureId,
3994
+ inputField: "description",
3995
+ storedField: "feature description",
3996
+ descriptionRefField: "descriptionRef",
3997
+ },
3998
+ });
3999
+ }
3473
4000
  const updatedDoc = await st.updateFeatures((doc) => {
3474
4001
  const feature = doc.features.find((f) => f.id === featureId);
3475
4002
  if (!feature)
3476
4003
  return doc;
3477
4004
  if (params.description !== undefined)
3478
- feature.description = params.description;
4005
+ feature.description = params.description.trim();
4006
+ if (params.descriptionRef !== undefined) {
4007
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4008
+ if (descriptionRef)
4009
+ feature.descriptionRef = descriptionRef;
4010
+ else
4011
+ delete feature.descriptionRef;
4012
+ }
3479
4013
  if (params.workDone !== undefined)
3480
- feature.workDone = params.workDone;
4014
+ feature.workDone = params.workDone.trim();
3481
4015
  if (params.workRemaining !== undefined)
3482
- feature.workRemaining = params.workRemaining;
4016
+ feature.workRemaining = params.workRemaining.trim();
3483
4017
  if (params.dependencies !== undefined)
3484
4018
  feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3485
4019
  feature.discussedAt = nowISO();
@@ -3492,7 +4026,7 @@ export default function planPiExtension(pi) {
3492
4026
  if (!feature)
3493
4027
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3494
4028
  await st.writeGenerated();
3495
- return { content: [{ type: "text", text: `✅ Feature discussed/updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}` }], details: feature };
4029
+ 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 } };
3496
4030
  },
3497
4031
  });
3498
4032
  pi.registerTool({
@@ -3503,6 +4037,7 @@ export default function planPiExtension(pi) {
3503
4037
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3504
4038
  name: Type.Optional(Type.String({ description: "New name" })),
3505
4039
  description: Type.Optional(Type.String({ description: "New description" })),
4040
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
3506
4041
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: feature status is derived from child phases/tasks." })),
3507
4042
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
3508
4043
  endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
@@ -3527,10 +4062,13 @@ export default function planPiExtension(pi) {
3527
4062
  if (!resolvedFeature.ok)
3528
4063
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3529
4064
  const featureId = resolvedFeature.feature.id;
4065
+ if (params.status !== undefined) {
4066
+ return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4067
+ }
3530
4068
  const mutableFields = [
3531
4069
  "name",
3532
4070
  "description",
3533
- "status",
4071
+ "descriptionRef",
3534
4072
  "startDate",
3535
4073
  "endDate",
3536
4074
  "workDone",
@@ -3540,13 +4078,20 @@ export default function planPiExtension(pi) {
3540
4078
  ];
3541
4079
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3542
4080
  if (receivedFields.length === 0) {
3543
- return {
3544
- content: [{
3545
- type: "text",
3546
- 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.",
3547
- }],
3548
- details: { updated: false, reason: "no-mutable-fields" },
3549
- };
4081
+ return mutationNoFieldsFailure({
4082
+ entity: "feature",
4083
+ ref: formatFeatureRef(resolvedFeature.feature.number),
4084
+ operation: "update",
4085
+ mutableFields,
4086
+ retryCommand: `feature_update ${formatFeatureRef(resolvedFeature.feature.number)}`,
4087
+ readBackCommand: `feature_get ${formatFeatureRef(resolvedFeature.feature.number)} with full=true`,
4088
+ descriptionFallback: {
4089
+ entityId: featureId,
4090
+ inputField: "description",
4091
+ storedField: "feature description",
4092
+ descriptionRefField: "descriptionRef",
4093
+ },
4094
+ });
3550
4095
  }
3551
4096
  let foundDoc;
3552
4097
  try {
@@ -3555,33 +4100,28 @@ export default function planPiExtension(pi) {
3555
4100
  if (!feature)
3556
4101
  return doc; // not found: no-op, handled below
3557
4102
  if (params.name !== undefined)
3558
- feature.name = params.name;
4103
+ feature.name = params.name.trim();
3559
4104
  if (params.description !== undefined)
3560
- feature.description = params.description;
4105
+ feature.description = params.description.trim();
4106
+ if (params.descriptionRef !== undefined) {
4107
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4108
+ if (descriptionRef)
4109
+ feature.descriptionRef = descriptionRef;
4110
+ else
4111
+ delete feature.descriptionRef;
4112
+ }
3561
4113
  if (params.workDone !== undefined)
3562
- feature.workDone = params.workDone;
4114
+ feature.workDone = params.workDone.trim();
3563
4115
  if (params.workRemaining !== undefined)
3564
- feature.workRemaining = params.workRemaining;
4116
+ feature.workRemaining = params.workRemaining.trim();
3565
4117
  if (params.startDate !== undefined)
3566
- feature.startDate = params.startDate;
4118
+ feature.startDate = params.startDate.trim();
3567
4119
  if (params.endDate !== undefined)
3568
- feature.endDate = params.endDate;
4120
+ feature.endDate = params.endDate.trim();
3569
4121
  if (params.priority !== undefined)
3570
4122
  feature.priority = params.priority;
3571
4123
  if (params.acceptedDecisions !== undefined)
3572
4124
  feature.acceptedDecisions = params.acceptedDecisions;
3573
- if (params.status !== undefined) {
3574
- const status = params.status;
3575
- if (feature.status !== status) {
3576
- if (status === "in-progress" && !feature.startDate) {
3577
- feature.startDate = new Date().toISOString().slice(0, 10);
3578
- }
3579
- if (status === "done" && !feature.endDate) {
3580
- feature.endDate = new Date().toISOString().slice(0, 10);
3581
- }
3582
- }
3583
- feature.status = status;
3584
- }
3585
4125
  feature.updatedAt = nowISO();
3586
4126
  return doc;
3587
4127
  });
@@ -3643,7 +4183,7 @@ export default function planPiExtension(pi) {
3643
4183
  pi.registerTool({
3644
4184
  name: "phase_list",
3645
4185
  label: "Phase List",
3646
- 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.",
4186
+ 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.",
3647
4187
  parameters: Type.Object({
3648
4188
  featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3649
4189
  status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
@@ -3666,7 +4206,7 @@ export default function planPiExtension(pi) {
3666
4206
  const summary = phases.map((p) => {
3667
4207
  const fNum = featureNumberOfPhase(p, features);
3668
4208
  const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3669
- return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
4209
+ return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}${p.priority > 0 ? `; priority ${p.priority}` : ""}; ${p.tasks.length} tasks)${fTag}`;
3670
4210
  }).join("\n");
3671
4211
  return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3672
4212
  },
@@ -3692,10 +4232,14 @@ export default function planPiExtension(pi) {
3692
4232
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3693
4233
  const reqCount = linkedRequirements.length;
3694
4234
  const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
4235
+ const descriptionRefBlock = phase.descriptionRef ? `\n\nDescription reference:\n- ${phase.descriptionRef}` : "";
3695
4236
  const requirementsBlock = reqCount > 0
3696
4237
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3697
4238
  : "";
3698
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
4239
+ return {
4240
+ content: [{ type: "text", text: params.full ? `${summary}\n\n${phase.description || ""}${descriptionRefBlock}${requirementsBlock}` : summary }],
4241
+ details: { phase: params.full ? { ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)), description: phase.description, descriptionRef: phase.descriptionRef ?? "" } : undefined, linkedRequirements },
4242
+ };
3699
4243
  },
3700
4244
  });
3701
4245
  pi.registerTool({
@@ -3746,7 +4290,7 @@ export default function planPiExtension(pi) {
3746
4290
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
3747
4291
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3748
4292
  createdAt: now, updatedAt: now,
3749
- handoff: "", handoffUpdatedAt: "",
4293
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
3750
4294
  handoffReadAt: "",
3751
4295
  handoffHistory: [],
3752
4296
  statusLog: [],
@@ -3780,6 +4324,7 @@ export default function planPiExtension(pi) {
3780
4324
  status: Type.Optional(Type.String({ description: "New status: draft|discovery|planned|in-progress|done|blocked|canceled. Avoid setting this directly unless you truly need an override: phase status is derived from task statuses." })),
3781
4325
  summary: Type.Optional(Type.String({ description: "New summary" })),
3782
4326
  description: Type.Optional(Type.String({ description: "New description" })),
4327
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
3783
4328
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
3784
4329
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
3785
4330
  goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
@@ -3814,36 +4359,62 @@ export default function planPiExtension(pi) {
3814
4359
  catch {
3815
4360
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3816
4361
  }
4362
+ if (params.status !== undefined) {
4363
+ return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4364
+ }
4365
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "acceptedDecisions", "completionCriteria"];
4366
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4367
+ if (receivedFields.length === 0) {
4368
+ return mutationNoFieldsFailure({
4369
+ entity: "phase",
4370
+ ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4371
+ operation: "update",
4372
+ mutableFields,
4373
+ retryCommand: `phase_update ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))}`,
4374
+ readBackCommand: `phase_get ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} with full=true`,
4375
+ descriptionFallback: {
4376
+ entityId: phaseId,
4377
+ inputField: "description",
4378
+ storedField: "phase description",
4379
+ descriptionRefField: "descriptionRef",
4380
+ },
4381
+ });
4382
+ }
3817
4383
  const prevFeatureId = phase.featureId;
3818
4384
  let nextFeatureIdForUpdate = prevFeatureId;
3819
4385
  if (params.title !== undefined) {
3820
- phase.title = params.title;
4386
+ phase.title = params.title.trim();
3821
4387
  phase.slug = normalizeSlug(params.title);
3822
4388
  }
3823
- if (params.status !== undefined)
3824
- phase.status = params.status;
3825
4389
  if (params.summary !== undefined)
3826
- phase.summary = params.summary;
4390
+ phase.summary = params.summary.trim();
3827
4391
  if (params.description !== undefined)
3828
- phase.description = params.description;
4392
+ phase.description = params.description.trim();
4393
+ if (params.descriptionRef !== undefined) {
4394
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4395
+ if (descriptionRef)
4396
+ phase.descriptionRef = descriptionRef;
4397
+ else
4398
+ delete phase.descriptionRef;
4399
+ }
3829
4400
  if (params.priority !== undefined)
3830
4401
  phase.priority = params.priority;
3831
4402
  if (params.goals !== undefined)
3832
- phase.goals = params.goals;
4403
+ phase.goals = params.goals.map((item) => item.trim()).filter(Boolean);
3833
4404
  if (params.nonGoals !== undefined)
3834
- phase.nonGoals = params.nonGoals;
4405
+ phase.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
3835
4406
  if (params.dependencies !== undefined)
3836
- phase.dependencies = params.dependencies;
4407
+ phase.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
3837
4408
  if (params.risks !== undefined)
3838
- phase.risks = params.risks;
4409
+ phase.risks = params.risks.map((item) => item.trim()).filter(Boolean);
3839
4410
  if (params.openQuestions !== undefined)
3840
- phase.openQuestions = params.openQuestions;
4411
+ phase.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
3841
4412
  if (params.decisions !== undefined)
3842
- phase.decisions = params.decisions;
4413
+ phase.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
3843
4414
  if (params.acceptedDecisions !== undefined)
3844
4415
  phase.acceptedDecisions = params.acceptedDecisions;
3845
4416
  if (params.completionCriteria !== undefined)
3846
- phase.completionCriteria = params.completionCriteria;
4417
+ phase.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
3847
4418
  if (params.featureId !== undefined) {
3848
4419
  if (params.featureId === "") {
3849
4420
  nextFeatureIdForUpdate = undefined;
@@ -3871,7 +4442,7 @@ export default function planPiExtension(pi) {
3871
4442
  await st.savePhase(phase);
3872
4443
  await st.writeGenerated();
3873
4444
  const phaseUpdateFeatures = (await st.loadFeatures()).features;
3874
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
4445
+ return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, updated: true, updatedFields: receivedFields } };
3875
4446
  },
3876
4447
  });
3877
4448
  pi.registerTool({
@@ -3987,7 +4558,7 @@ export default function planPiExtension(pi) {
3987
4558
  pi.registerTool({
3988
4559
  name: "task_list",
3989
4560
  label: "Task List",
3990
- 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.",
4561
+ 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.",
3991
4562
  parameters: Type.Object({
3992
4563
  featureRef: Type.Optional(Type.String({})),
3993
4564
  phaseRef: Type.Optional(Type.String({})),
@@ -4018,7 +4589,7 @@ export default function planPiExtension(pi) {
4018
4589
  for (const task of phase.tasks) {
4019
4590
  if (params.status && task.status !== params.status)
4020
4591
  continue;
4021
- out.push(`- ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}/T${pad(task.number)}${task.shortId ? ` · ${task.shortId}` : ""} — ${task.title} (${task.status})`);
4592
+ 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}` : ""})`);
4022
4593
  }
4023
4594
  }
4024
4595
  return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
@@ -4057,6 +4628,8 @@ export default function planPiExtension(pi) {
4057
4628
  const sections = [summary];
4058
4629
  if (found.task.description?.trim())
4059
4630
  sections.push(found.task.description.trim());
4631
+ if (found.task.descriptionRef)
4632
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
4060
4633
  if (snapshot || pendingDeviation) {
4061
4634
  sections.push([
4062
4635
  pendingDeviation
@@ -4070,7 +4643,7 @@ export default function planPiExtension(pi) {
4070
4643
  }
4071
4644
  if (log)
4072
4645
  sections.push(`Status log:\n${log}`);
4073
- return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
4646
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: { task: { ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`, description: found.task.description, descriptionRef: found.task.descriptionRef ?? "" } } };
4074
4647
  },
4075
4648
  });
4076
4649
  pi.registerTool({
@@ -4172,6 +4745,7 @@ export default function planPiExtension(pi) {
4172
4745
  title: Type.Optional(Type.String({ description: "New title" })),
4173
4746
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
4174
4747
  description: Type.Optional(Type.String({ description: "New description" })),
4748
+ descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
4175
4749
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
4176
4750
  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." })),
4177
4751
  decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
@@ -4223,6 +4797,24 @@ export default function planPiExtension(pi) {
4223
4797
  };
4224
4798
  }
4225
4799
  }
4800
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "acceptedDecisions", "priority", "checklist"];
4801
+ const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4802
+ if (receivedFields.length === 0) {
4803
+ return mutationNoFieldsFailure({
4804
+ entity: "task",
4805
+ ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
4806
+ operation: "update",
4807
+ mutableFields,
4808
+ retryCommand: `task_update ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
4809
+ readBackCommand: `task_get ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)} with full=true`,
4810
+ descriptionFallback: {
4811
+ entityId: found.task.id,
4812
+ inputField: "description",
4813
+ storedField: "task description",
4814
+ descriptionRefField: "descriptionRef",
4815
+ },
4816
+ });
4817
+ }
4226
4818
  try {
4227
4819
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
4228
4820
  const task = phase.tasks.find((t) => t.id === found.task.id);
@@ -4233,11 +4825,18 @@ export default function planPiExtension(pi) {
4233
4825
  if (params.priority !== undefined)
4234
4826
  task.priority = params.priority;
4235
4827
  if (params.description !== undefined)
4236
- task.description = params.description;
4828
+ task.description = params.description.trim();
4829
+ if (params.descriptionRef !== undefined) {
4830
+ const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
4831
+ if (descriptionRef)
4832
+ task.descriptionRef = descriptionRef;
4833
+ else
4834
+ delete task.descriptionRef;
4835
+ }
4237
4836
  if (params.notes !== undefined)
4238
- task.notes = params.notes;
4837
+ task.notes = params.notes.trim();
4239
4838
  if (params.decisions !== undefined)
4240
- task.decisions = params.decisions;
4839
+ task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
4241
4840
  if (params.acceptedDecisions !== undefined)
4242
4841
  task.acceptedDecisions = params.acceptedDecisions;
4243
4842
  if (params.checklist !== undefined) {
@@ -4275,7 +4874,7 @@ export default function planPiExtension(pi) {
4275
4874
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4276
4875
  await st.syncTaskStatusRollup(found.phase.id);
4277
4876
  await st.writeGenerated();
4278
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status})` }], details: updatedTask };
4877
+ return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields } };
4279
4878
  },
4280
4879
  });
4281
4880
  pi.registerTool({
@@ -4391,7 +4990,7 @@ export default function planPiExtension(pi) {
4391
4990
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
4392
4991
  pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
4393
4992
  };
4394
- invalidateReads();
4993
+ invalidateReads(plannerSessionId);
4395
4994
  const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4396
4995
  await st.syncTaskStatusRollup(found.phase.id);
4397
4996
  const activeDeviation = (await st.loadProject()).workDeviations
@@ -4424,7 +5023,7 @@ export default function planPiExtension(pi) {
4424
5023
  pi.registerTool({
4425
5024
  name: "task_switch",
4426
5025
  label: "Task Switch",
4427
- description: "Switch from active work to a temporary task, even outside priority order. Reuse the target's valid sessionInfo context or require the exact full tree when missing or stale. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
5026
+ 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.",
4428
5027
  parameters: Type.Object({
4429
5028
  from_task: Type.String({ description: "Currently in-progress task ref" }),
4430
5029
  to_task: Type.String({ description: "Temporary task ref to start" }),
@@ -4469,11 +5068,25 @@ export default function planPiExtension(pi) {
4469
5068
  requirementIds: linkedRequirementIds,
4470
5069
  };
4471
5070
  const contextEligibility = contextReadEligibilityForSession(contextInput);
5071
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5072
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5073
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5074
+ const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
5075
+ const targetTaskRef = `${targetPhaseRef}/T${String(target.task.number).padStart(3, "0")}`;
5076
+ const targetFeatureRef = targetFeature ? formatFeatureRef(targetFeature.number) : "";
5077
+ if (!projectGuidelinesReady) {
5078
+ const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, true, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
5079
+ return {
5080
+ content: [{ type: "text", text: `Task switch denied: read Project Guidelines before switching task work. Current state: ${projectGuidelinesReadState}.` }],
5081
+ details: { projectGuidelinesReadState, contextEligibility, nextActions },
5082
+ };
5083
+ }
4472
5084
  if (!contextEligibility.eligible) {
4473
- return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
5085
+ const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
5086
+ return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
4474
5087
  }
4475
- if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
4476
- return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
5088
+ if (!requirementsReady) {
5089
+ return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds, nextActions: ["requirement_list", `Retry task_switch to ${targetTaskRef}`] } };
4477
5090
  }
4478
5091
  if (!hasValidSessionAttestation(contextInput)) {
4479
5092
  await st.recordContextRead({ sessionId: plannerSessionId, phaseId: target.phase.id, taskId: target.task.id, ...(target.phase.featureId ? { featureId: target.phase.featureId } : {}), requirementIds: linkedRequirementIds });
@@ -4567,7 +5180,7 @@ export default function planPiExtension(pi) {
4567
5180
  if (target.phase.id !== source.phase.id)
4568
5181
  await st.syncTaskStatusRollup(target.phase.id);
4569
5182
  await st.writeGenerated();
4570
- invalidateReads();
5183
+ invalidateReads(plannerSessionId);
4571
5184
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
4572
5185
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
4573
5186
  return {
@@ -4584,7 +5197,7 @@ export default function planPiExtension(pi) {
4584
5197
  pi.registerTool({
4585
5198
  name: "task_start",
4586
5199
  label: "Task Start",
4587
- description: "Set a task to in-progress or resume checkpointed work. Read the exact full task → phase → feature tree and linked requirements once per harness session; subsequent starts reuse persisted sessionInfo while entity updatedAt values remain unchanged. A stale or denied start is an isError result with started=false and an exact retry sequence; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
5200
+ 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.",
4588
5201
  parameters: Type.Object({
4589
5202
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4590
5203
  }),
@@ -4601,12 +5214,9 @@ export default function planPiExtension(pi) {
4601
5214
  const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
4602
5215
  const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
4603
5216
  const featureRef = feature ? formatFeatureRef(feature.number) : "";
4604
- if (task.status === "in-progress") {
4605
- const outcome = taskStartSucceeded(task.id, true);
4606
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
4607
- }
4608
5217
  if (task.status === "done")
4609
- return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry task_start ${taskRef}.`], { taskId: task.id }));
5218
+ return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Retry task_start ${taskRef}. If it then reports read prerequisites, perform only the missing or stale nextActions before retrying again.`], { taskId: task.id }));
5219
+ const project = await st.loadProject();
4610
5220
  const linkedRequirements = [
4611
5221
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
4612
5222
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -4624,16 +5234,16 @@ export default function planPiExtension(pi) {
4624
5234
  requirementIds: linkedRequirementIds,
4625
5235
  };
4626
5236
  const contextEligibility = contextReadEligibilityForSession(contextInput);
5237
+ const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5238
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5239
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
5240
+ if (!projectGuidelinesReady) {
5241
+ return taskStartFailure(taskStartDenied("PROJECT_GUIDELINES_READ_REQUIRED", `Project Guidelines must be read before task work. Current state: ${projectGuidelinesReadState}.`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, true, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { projectGuidelinesReadState, contextEligibility });
5242
+ }
4627
5243
  if (!contextEligibility.eligible) {
4628
- return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, [
4629
- `task_get ${taskRef} with full=true`,
4630
- `phase_get ${phaseRef} with full=true`,
4631
- ...(featureRef ? [`feature_get ${featureRef} with full=true`] : []),
4632
- ...(linkedRequirementIds.length > 0 ? ["requirement_list"] : []),
4633
- `Retry task_start ${taskRef}`,
4634
- ], { taskId: task.id }), { contextEligibility });
4635
- }
4636
- if (!hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements)) {
5244
+ return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
5245
+ }
5246
+ if (!requirementsReady) {
4637
5247
  return taskStartFailure(taskStartDenied("REQUIREMENTS_READ_REQUIRED", "Linked requirements were not read; the task remains unchanged.", ["requirement_list", `Retry task_start ${taskRef}`], { taskId: task.id, requirementIds: linkedRequirementIds }));
4638
5248
  }
4639
5249
  if (!hasValidSessionAttestation(contextInput)) {
@@ -4645,7 +5255,11 @@ export default function planPiExtension(pi) {
4645
5255
  requirementIds: linkedRequirementIds,
4646
5256
  });
4647
5257
  }
4648
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
5258
+ if (task.status === "in-progress") {
5259
+ const outcome = taskStartSucceeded(task.id, true);
5260
+ return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5261
+ }
5262
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
4649
5263
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4650
5264
  if (!eligibility.eligible)
4651
5265
  return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
@@ -4997,10 +5611,24 @@ export default function planPiExtension(pi) {
4997
5611
  const st = await requirePlan(ctx);
4998
5612
  if (!st)
4999
5613
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
5614
+ let preparation;
5615
+ try {
5616
+ preparation = await st.preparePlannerSession();
5617
+ }
5618
+ catch (error) {
5619
+ 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 } };
5620
+ }
5000
5621
  enablePlannerSession();
5001
5622
  if (!server) {
5002
5623
  await startServer(ctx, undefined, "lan").catch(() => { });
5003
5624
  }
5625
+ const project = await st.loadProject();
5626
+ if (project.projectGuidelines.content.trim()) {
5627
+ await st.recordProjectGuidelinesRead({ sessionId: plannerSessionId });
5628
+ }
5629
+ const plannerSkill = await st.syncPlannerSkill();
5630
+ await st.syncGrillMeSkill();
5631
+ contextBlockDirty = true;
5004
5632
  const srv = server;
5005
5633
  let recap = "";
5006
5634
  try {
@@ -5009,7 +5637,9 @@ export default function planPiExtension(pi) {
5009
5637
  catch (e) {
5010
5638
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
5011
5639
  }
5012
- return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
5640
+ if (preparation.changed)
5641
+ recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
5642
+ 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 } } };
5013
5643
  },
5014
5644
  });
5015
5645
  pi.registerTool({
@@ -5070,6 +5700,7 @@ export default function planPiExtension(pi) {
5070
5700
  const recentActivity = activity.entries.slice(-3).reverse();
5071
5701
  const handoffs = await st.listHandoffs();
5072
5702
  const extensionRules = await st.extensionRules();
5703
+ const plannerSkill = await st.syncPlannerSkill();
5073
5704
  const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
5074
5705
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
5075
5706
  const orderedPhases = [
@@ -5184,11 +5815,11 @@ export default function planPiExtension(pi) {
5184
5815
  "",
5185
5816
  "Operational rules:",
5186
5817
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
5187
- "- BEFORE work: read the exact task(full), parent phase(full), parent feature(full), and linked requirements once per harness session in that order. Later task_start, resume, or task_switch calls reuse valid sessionInfo; reread only after a new session, a lineage change, or an entity updatedAt change. A denial is an error with started=false: follow its nextActions and retry; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
5818
+ "- 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.",
5188
5819
  "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
5189
5820
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
5190
5821
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
5191
- "- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
5822
+ "- 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).",
5192
5823
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
5193
5824
  ...(extensionRules.length > 0
5194
5825
  ? [
@@ -5198,12 +5829,20 @@ export default function planPiExtension(pi) {
5198
5829
  ]
5199
5830
  : []),
5200
5831
  "",
5832
+ "Project-local planner usage skill (agent-only; keep in working context and do not quote in user recaps):",
5833
+ plannerSkill.customized ? `Skill drift: ${plannerSkill.message}` : "",
5834
+ plannerSkill.content,
5835
+ "End project-local planner usage skill.",
5836
+ "",
5201
5837
  `Goal: ${project.goal || "(not set)"}`,
5202
5838
  project.description ? `Description: ${project.description}` : "",
5203
5839
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
5204
5840
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
5205
5841
  `Ambient: ${ambientSummary}`,
5206
5842
  "",
5843
+ "Project Guidelines (mandatory when present):",
5844
+ project.projectGuidelines.content.trim() || "- (none)",
5845
+ "",
5207
5846
  "Global rules:",
5208
5847
  project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
5209
5848
  "",