@agent-plan/pi-adapter 0.2.25 → 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,8 +10,8 @@
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";
@@ -85,14 +85,61 @@ function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStat
85
85
  },
86
86
  };
87
87
  }
88
- function piContextReadActions(eligibility, refs, requirementReadRequired, retryAction) {
89
- const actions = (eligibility.requiredReads ?? []).flatMap((read) => {
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) => {
90
137
  if (read.kind === "task")
91
138
  return [`task_get ${refs.task} with full=true`];
92
139
  if (read.kind === "phase")
93
140
  return [`phase_get ${refs.phase} with full=true`];
94
141
  return refs.feature ? [`feature_get ${refs.feature} with full=true`] : [];
95
- });
142
+ }));
96
143
  if (requirementReadRequired)
97
144
  actions.push("requirement_list");
98
145
  actions.push(retryAction);
@@ -172,6 +219,8 @@ const PLANNER_COMMAND_COMPLETIONS = [
172
219
  { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
173
220
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
174
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" },
175
224
  { value: "feature list", label: "feature list", description: "List features" },
176
225
  { value: "feature add", label: "feature add", description: "Create a feature" },
177
226
  { value: "feature show", label: "feature show", description: "Show a feature" },
@@ -1139,6 +1188,13 @@ export default function planPiExtension(pi) {
1139
1188
  "cleanup-orphans",
1140
1189
  "project discuss",
1141
1190
  "project language",
1191
+ "project guidelines",
1192
+ "idea list",
1193
+ "idea add",
1194
+ "idea show",
1195
+ "idea update",
1196
+ "idea delete",
1197
+ "idea promote",
1142
1198
  "feature list",
1143
1199
  "feature add",
1144
1200
  "feature show",
@@ -1170,7 +1226,7 @@ export default function planPiExtension(pi) {
1170
1226
  "load",
1171
1227
  "stop",
1172
1228
  ];
1173
- const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
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" +
1174
1230
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1175
1231
  "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
1176
1232
  if (!a) {
@@ -1472,7 +1528,7 @@ export default function planPiExtension(pi) {
1472
1528
  // ═══════════════════════════════════════════════════════════════
1473
1529
  if (a === "project") {
1474
1530
  if (!b) {
1475
- ctx.ui.notify("project actions: discuss | language", "info");
1531
+ ctx.ui.notify("project actions: discuss | language | guidelines | migrate-context", "info");
1476
1532
  return;
1477
1533
  }
1478
1534
  if (b === "discuss") {
@@ -1534,7 +1590,115 @@ export default function planPiExtension(pi) {
1534
1590
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
1535
1591
  return;
1536
1592
  }
1537
- 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");
1538
1702
  return;
1539
1703
  }
1540
1704
  // ═══════════════════════════════════════════════════════════════
@@ -1816,7 +1980,7 @@ export default function planPiExtension(pi) {
1816
1980
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
1817
1981
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
1818
1982
  createdAt: nowISO(), updatedAt: nowISO(),
1819
- handoff: "", handoffUpdatedAt: "",
1983
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
1820
1984
  handoffReadAt: "",
1821
1985
  handoffHistory: [],
1822
1986
  statusLog: [],
@@ -2289,11 +2453,19 @@ export default function planPiExtension(pi) {
2289
2453
  };
2290
2454
  const contextEligibility = contextReadEligibilityForSession(contextInput);
2291
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";
2292
2459
  const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
2293
2460
  const taskRef = `${phaseRef}/T${String(task.number).padStart(3, "0")}`;
2294
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
+ }
2295
2467
  if (!contextEligibility.eligible) {
2296
- const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `/planner task start ${taskRef}`);
2468
+ const actions = piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, false, !requirementsReady, `/planner task start ${taskRef}`);
2297
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");
2298
2470
  return;
2299
2471
  }
@@ -2649,6 +2821,15 @@ export default function planPiExtension(pi) {
2649
2821
  return;
2650
2822
  }
2651
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
+ }
2652
2833
  enablePlannerSession();
2653
2834
  if (!server) {
2654
2835
  ctx.ui.notify("Starting web server (LAN) …", "info");
@@ -2678,17 +2859,22 @@ export default function planPiExtension(pi) {
2678
2859
  // triggerTurn messages), so the recap data must live here. Uses the shared
2679
2860
  // core buildRecap so Pi and Claude Code/Codex present identical content.
2680
2861
  let recapText = "";
2862
+ let plannerSkillContext = "";
2681
2863
  try {
2682
- const st = ensureStore(ctx);
2864
+ const plannerSkill = await st.syncPlannerSkill();
2865
+ await st.syncGrillMeSkill();
2683
2866
  const srv = server;
2684
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]`;
2685
2871
  }
2686
2872
  catch (e) {
2687
2873
  recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2688
2874
  }
2689
2875
  pi.sendMessage({
2690
2876
  customType: "planner-resume-trigger",
2691
- content: "[internal trigger — not a user command] Present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
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 ---",
2692
2878
  display: false,
2693
2879
  }, {
2694
2880
  triggerTurn: true,
@@ -2837,6 +3023,7 @@ export default function planPiExtension(pi) {
2837
3023
  parameters: Type.Object({
2838
3024
  name: Type.Optional(Type.String({ description: "Project title" })),
2839
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." })),
2840
3027
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
2841
3028
  scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
2842
3029
  outOfScope: Type.Optional(Type.Array(Type.String(), { description: "Replace out-of-scope items" })),
@@ -2850,10 +3037,34 @@ export default function planPiExtension(pi) {
2850
3037
  if (!st)
2851
3038
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
2852
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
+ }
2853
3057
  if (params.name !== undefined)
2854
3058
  project.name = params.name.trim();
2855
3059
  if (params.description !== undefined)
2856
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
+ }
2857
3068
  if (params.goal !== undefined)
2858
3069
  project.goal = params.goal.trim();
2859
3070
  if (params.scope !== undefined)
@@ -2870,7 +3081,95 @@ export default function planPiExtension(pi) {
2870
3081
  project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
2871
3082
  await st.saveProject(project);
2872
3083
  await st.writeGenerated();
2873
- 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
+ };
2874
3173
  },
2875
3174
  });
2876
3175
  pi.registerTool({
@@ -2899,6 +3198,11 @@ export default function planPiExtension(pi) {
2899
3198
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2900
3199
  status: Type.Optional(Type.String({ description: "Initial status: planned|in-progress|done|blocked|canceled" })),
2901
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." })),
2902
3206
  }),
2903
3207
  async execute(_id, params, _signal, _onUpdate, ctx) {
2904
3208
  const st = await requirePlan(ctx);
@@ -2912,19 +3216,37 @@ export default function planPiExtension(pi) {
2912
3216
  return { content: [{ type: "text", text: links.error }], details: {} };
2913
3217
  const requirements = await st.loadRequirements();
2914
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
+ }
2915
3231
  const requirement = {
2916
3232
  id: createRequirementId(),
2917
3233
  title,
2918
3234
  description: params.description?.trim() ?? "",
2919
3235
  status: params.status?.trim() ?? "planned",
2920
- macroTasks: [],
3236
+ macroTasks,
2921
3237
  linkedPhaseIds: links.linkedPhaseIds,
2922
3238
  sessionInfo: [],
2923
3239
  createdAt: now,
2924
3240
  updatedAt: now,
2925
3241
  };
2926
3242
  requirements.requirements.push(requirement);
2927
- 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
+ }
2928
3250
  await st.writeGenerated();
2929
3251
  return { content: [{ type: "text", text: `Requirement created: ${requirement.id}` }], details: requirement };
2930
3252
  },
@@ -2939,6 +3261,12 @@ export default function planPiExtension(pi) {
2939
3261
  description: Type.Optional(Type.String({ description: "Requirement description" })),
2940
3262
  status: Type.Optional(Type.String({ description: "Status: planned|in-progress|done|blocked|canceled" })),
2941
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." })),
2942
3270
  }),
2943
3271
  async execute(_id, params, _signal, _onUpdate, ctx) {
2944
3272
  const st = await requirePlan(ctx);
@@ -2964,7 +3292,22 @@ export default function planPiExtension(pi) {
2964
3292
  return { content: [{ type: "text", text: links.error }], details: {} };
2965
3293
  requirement.linkedPhaseIds = links.linkedPhaseIds;
2966
3294
  }
2967
- 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;
2968
3311
  await st.saveRequirements(requirements);
2969
3312
  await st.writeGenerated();
2970
3313
  return { content: [{ type: "text", text: `Requirement updated: ${requirement.id}` }], details: requirement };
@@ -2991,6 +3334,77 @@ export default function planPiExtension(pi) {
2991
3334
  return { content: [{ type: "text", text: `Requirement deleted: ${params.requirementId}` }], details: { deleted: params.requirementId } };
2992
3335
  },
2993
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
+ });
2994
3408
  pi.registerTool({
2995
3409
  name: "plan_get",
2996
3410
  label: "Plan Get",
@@ -3089,9 +3503,11 @@ export default function planPiExtension(pi) {
3089
3503
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3090
3504
  const list = await st.listHandoffs();
3091
3505
  if (list.length === 0)
3092
- return { content: [{ type: "text", text: "⚠️ plan_get_handoff is deprecated — no phase handoffs set. Use handoff_list / handoff_show (entity-scoped, phase.handoff field)." }], details: { deprecated: true, count: 0 } };
3093
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3094
- return { content: [{ type: "text", text: `⚠️ plan_get_handoff is deprecated — use handoff_list / handoff_show. Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { deprecated: true, count: list.length, handoffs: list } };
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 } };
3095
3511
  },
3096
3512
  });
3097
3513
  pi.registerTool({
@@ -3182,17 +3598,22 @@ export default function planPiExtension(pi) {
3182
3598
  pi.registerTool({
3183
3599
  name: "handoff_list",
3184
3600
  label: "Handoff List",
3185
- description: "List all phases with a non-empty entity-scoped handoff (phase.handoff field). Returns composite refs (P00x / P00x(F00x)) with the first line and last-updated time.",
3186
- parameters: Type.Object({}),
3187
- async execute(_id, _params, _signal, _onUpdate, ctx) {
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) {
3188
3607
  const st = await requirePlan(ctx);
3189
3608
  if (!st)
3190
3609
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3191
3610
  const list = await st.listHandoffs();
3192
3611
  if (list.length === 0)
3193
- return { content: [{ type: "text", text: "No phase handoffs set." }], details: { count: 0 } };
3194
- const lines = list.map((e) => `- ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`);
3195
- return { content: [{ type: "text", text: `Phase handoffs (${list.length}):\n${lines.join("\n")}` }], details: { count: list.length, handoffs: list } };
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 } };
3196
3617
  },
3197
3618
  });
3198
3619
  pi.registerTool({
@@ -3207,16 +3628,20 @@ export default function planPiExtension(pi) {
3207
3628
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
3208
3629
  if (!r.ok)
3209
3630
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3210
- const content = await st.getPhaseHandoff(r.phase.id);
3211
- if (!content.trim())
3212
- return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, empty: true } };
3213
- return { content: [{ type: "text", text: `Handoff for ${r.compositeRef}:\n\n${content}` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id } };
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
+ };
3214
3639
  },
3215
3640
  });
3216
3641
  pi.registerTool({
3217
3642
  name: "handoff_prepare",
3218
3643
  label: "Handoff Prepare",
3219
- description: "Audit one exact phase before writing a handoff. Returns the current handoff/version and every done task missing durable completion or verification evidence. Call after the user confirms the target, then reconcile the returned context through handoff_write.",
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.",
3220
3645
  parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3221
3646
  async execute(_id, params, _signal, _onUpdate, ctx) {
3222
3647
  const st = await requirePlan(ctx);
@@ -3227,8 +3652,10 @@ export default function planPiExtension(pi) {
3227
3652
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3228
3653
  try {
3229
3654
  const audit = await st.preparePhaseHandoff(r.phase.id);
3655
+ const { handoff, ...auditDetails } = audit;
3656
+ const boundedExisting = boundedHandoffForTransport(handoff);
3230
3657
  const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3231
- const existing = audit.handoff.trim() || "(none)";
3658
+ const existing = boundedExisting.content.trim() || "(none)";
3232
3659
  return {
3233
3660
  content: [{ type: "text", text: [
3234
3661
  `Handoff preparation audit for ${r.compositeRef}`,
@@ -3236,10 +3663,14 @@ export default function planPiExtension(pi) {
3236
3663
  "Done tasks missing durable completion/verification evidence:",
3237
3664
  missing,
3238
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
+ "",
3239
3670
  "Existing active handoff (reconcile all still-relevant content):",
3240
3671
  existing,
3241
3672
  ].join("\n") }],
3242
- details: { phaseRef: r.compositeRef, ...audit },
3673
+ details: { phaseRef: r.compositeRef, ...auditDetails, existingHandoffLength: boundedExisting.fullLength, existingHandoffTruncated: boundedExisting.truncated },
3243
3674
  };
3244
3675
  }
3245
3676
  catch (error) {
@@ -3251,7 +3682,7 @@ export default function planPiExtension(pi) {
3251
3682
  pi.registerTool({
3252
3683
  name: "handoff_write",
3253
3684
  label: "Handoff Write",
3254
- description: "Reconcile and refresh the single active phase handoff while synchronizing durable task, phase, and feature context. Run handoff_prepare first and pass its exact handoffUpdatedAt token. Existing context must be reconciled; refreshes do not create superseded archives.",
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.",
3255
3686
  parameters: Type.Object({
3256
3687
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3257
3688
  title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
@@ -3260,6 +3691,18 @@ export default function planPiExtension(pi) {
3260
3691
  markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
3261
3692
  expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
3262
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
+ }))),
3263
3706
  taskUpdates: Type.Optional(Type.Array(Type.Object({
3264
3707
  taskRef: Type.String(),
3265
3708
  completionSummary: Type.String(),
@@ -3328,6 +3771,8 @@ export default function planPiExtension(pi) {
3328
3771
  content: body,
3329
3772
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
3330
3773
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
3774
+ ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
3775
+ ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
3331
3776
  contextSync: {
3332
3777
  taskUpdates,
3333
3778
  ...(params.phaseUpdate ? { phaseUpdate: {
@@ -3341,9 +3786,20 @@ export default function planPiExtension(pi) {
3341
3786
  },
3342
3787
  });
3343
3788
  await st.writeGenerated();
3344
- return { content: [{ type: "text", text: `✅ Reconciled handoff and durable context on ${r.compositeRef}; updated ${result.updatedTaskIds.length} task(s).` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, ...result } };
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
+ };
3345
3799
  }
3346
3800
  catch (error) {
3801
+ if (error instanceof HandoffContractError)
3802
+ return handoffContractFailure(error);
3347
3803
  const message = error instanceof Error ? error.message : String(error);
3348
3804
  return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
3349
3805
  }
@@ -3398,7 +3854,7 @@ export default function planPiExtension(pi) {
3398
3854
  pi.registerTool({
3399
3855
  name: "feature_list",
3400
3856
  label: "Feature List",
3401
- description: "List features (compact: F00x · shortId — name (status; N phases, M tasks)). Use this to discover refs cheaply — do NOT read .planner/ files or plan_get full=true to find entities.",
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.",
3402
3858
  parameters: Type.Object({ featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })) }),
3403
3859
  async execute(_id, params, _signal, _onUpdate, ctx) {
3404
3860
  const st = await requirePlan(ctx);
@@ -3411,7 +3867,7 @@ export default function planPiExtension(pi) {
3411
3867
  const phases = await st.loadAllPhases();
3412
3868
  const summary = features.map((f) => {
3413
3869
  const fp = phases.filter((p) => p.featureId === f.id);
3414
- return `- ${formatFeatureRef(f.number)}${f.shortId ? ` · ${f.shortId}` : ""} — ${f.name} (${f.status}; ${fp.length} phases, ${fp.reduce((t, p) => t + p.tasks.length, 0)} tasks)`;
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)`;
3415
3871
  }).join("\n");
3416
3872
  return { content: [{ type: "text", text: summary || "No features" }], details: {} };
3417
3873
  },
@@ -3439,10 +3895,14 @@ export default function planPiExtension(pi) {
3439
3895
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3440
3896
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3441
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}` : "";
3442
3899
  const requirementsBlock = linkedRequirements.length > 0
3443
3900
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3444
3901
  : "\n\nLinked requirements:\n- None linked to this feature.";
3445
- return { content: [{ type: "text", text: params.full ? `${summary}\n\n${feature.description || ""}${requirementsBlock}` : summary }], details: { linkedRequirements } };
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
+ };
3446
3906
  },
3447
3907
  });
3448
3908
  pi.registerTool({
@@ -3505,6 +3965,7 @@ export default function planPiExtension(pi) {
3505
3965
  parameters: Type.Object({
3506
3966
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3507
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." })),
3508
3969
  workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
3509
3970
  workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
3510
3971
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Cross-feature or external dependencies" })),
@@ -3518,16 +3979,41 @@ export default function planPiExtension(pi) {
3518
3979
  if (!resolvedFeature.ok)
3519
3980
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3520
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
+ }
3521
4000
  const updatedDoc = await st.updateFeatures((doc) => {
3522
4001
  const feature = doc.features.find((f) => f.id === featureId);
3523
4002
  if (!feature)
3524
4003
  return doc;
3525
4004
  if (params.description !== undefined)
3526
- 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
+ }
3527
4013
  if (params.workDone !== undefined)
3528
- feature.workDone = params.workDone;
4014
+ feature.workDone = params.workDone.trim();
3529
4015
  if (params.workRemaining !== undefined)
3530
- feature.workRemaining = params.workRemaining;
4016
+ feature.workRemaining = params.workRemaining.trim();
3531
4017
  if (params.dependencies !== undefined)
3532
4018
  feature.dependsOn = params.dependencies.map((item) => item.trim()).filter(Boolean);
3533
4019
  feature.discussedAt = nowISO();
@@ -3540,7 +4026,7 @@ export default function planPiExtension(pi) {
3540
4026
  if (!feature)
3541
4027
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
3542
4028
  await st.writeGenerated();
3543
- 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 } };
3544
4030
  },
3545
4031
  });
3546
4032
  pi.registerTool({
@@ -3551,6 +4037,7 @@ export default function planPiExtension(pi) {
3551
4037
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
3552
4038
  name: Type.Optional(Type.String({ description: "New name" })),
3553
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." })),
3554
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." })),
3555
4042
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
3556
4043
  endDate: Type.Optional(Type.String({ description: "End date (YYYY-MM-DD)" })),
@@ -3581,6 +4068,7 @@ export default function planPiExtension(pi) {
3581
4068
  const mutableFields = [
3582
4069
  "name",
3583
4070
  "description",
4071
+ "descriptionRef",
3584
4072
  "startDate",
3585
4073
  "endDate",
3586
4074
  "workDone",
@@ -3590,13 +4078,20 @@ export default function planPiExtension(pi) {
3590
4078
  ];
3591
4079
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3592
4080
  if (receivedFields.length === 0) {
3593
- return {
3594
- content: [{
3595
- type: "text",
3596
- text: "Not updated — no mutable fields were received. No planner data was changed. If you attempted a large description, it may have exceeded the tool payload limit.",
3597
- }],
3598
- details: { updated: false, reason: "no-mutable-fields" },
3599
- };
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
+ });
3600
4095
  }
3601
4096
  let foundDoc;
3602
4097
  try {
@@ -3605,17 +4100,24 @@ export default function planPiExtension(pi) {
3605
4100
  if (!feature)
3606
4101
  return doc; // not found: no-op, handled below
3607
4102
  if (params.name !== undefined)
3608
- feature.name = params.name;
4103
+ feature.name = params.name.trim();
3609
4104
  if (params.description !== undefined)
3610
- 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
+ }
3611
4113
  if (params.workDone !== undefined)
3612
- feature.workDone = params.workDone;
4114
+ feature.workDone = params.workDone.trim();
3613
4115
  if (params.workRemaining !== undefined)
3614
- feature.workRemaining = params.workRemaining;
4116
+ feature.workRemaining = params.workRemaining.trim();
3615
4117
  if (params.startDate !== undefined)
3616
- feature.startDate = params.startDate;
4118
+ feature.startDate = params.startDate.trim();
3617
4119
  if (params.endDate !== undefined)
3618
- feature.endDate = params.endDate;
4120
+ feature.endDate = params.endDate.trim();
3619
4121
  if (params.priority !== undefined)
3620
4122
  feature.priority = params.priority;
3621
4123
  if (params.acceptedDecisions !== undefined)
@@ -3681,7 +4183,7 @@ export default function planPiExtension(pi) {
3681
4183
  pi.registerTool({
3682
4184
  name: "phase_list",
3683
4185
  label: "Phase List",
3684
- description: "List phases (compact: F00x/P00x · shortId — title (status; N tasks) [F00x]). Filters: featureRef, status. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
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.",
3685
4187
  parameters: Type.Object({
3686
4188
  featureRef: Type.Optional(Type.String({ description: "Optional: filter to one feature (F00x/shortId/UUID/name)" })),
3687
4189
  status: Type.Optional(Type.String({ description: "Optional: filter by status name" })),
@@ -3704,7 +4206,7 @@ export default function planPiExtension(pi) {
3704
4206
  const summary = phases.map((p) => {
3705
4207
  const fNum = featureNumberOfPhase(p, features);
3706
4208
  const fTag = fNum !== undefined ? ` [F${pad(fNum)}]` : "";
3707
- return `- ${formatPhaseRef(p.number, fNum)}${p.shortId ? ` · ${p.shortId}` : ""} — ${p.title} (${p.status}; ${p.tasks.length} tasks)${fTag}`;
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}`;
3708
4210
  }).join("\n");
3709
4211
  return { content: [{ type: "text", text: summary || "No phases" }], details: {} };
3710
4212
  },
@@ -3730,10 +4232,14 @@ export default function planPiExtension(pi) {
3730
4232
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3731
4233
  const reqCount = linkedRequirements.length;
3732
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}` : "";
3733
4236
  const requirementsBlock = reqCount > 0
3734
4237
  ? `\n\nLinked requirements:\n${linkedRequirements.map((requirement) => `- ${requirement.title} (${requirement.status})`).join("\n")}`
3735
4238
  : "";
3736
- 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
+ };
3737
4243
  },
3738
4244
  });
3739
4245
  pi.registerTool({
@@ -3784,7 +4290,7 @@ export default function planPiExtension(pi) {
3784
4290
  goals: [], nonGoals: [], dependencies: [], dependsOn: [], risks: [],
3785
4291
  openQuestions: [], decisions: [], acceptedDecisions: [], completionCriteria: [], taskIds: [], tasks: [],
3786
4292
  createdAt: now, updatedAt: now,
3787
- handoff: "", handoffUpdatedAt: "",
4293
+ handoff: "", handoffUpdatedAt: "", handoffAudit: null,
3788
4294
  handoffReadAt: "",
3789
4295
  handoffHistory: [],
3790
4296
  statusLog: [],
@@ -3818,6 +4324,7 @@ export default function planPiExtension(pi) {
3818
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." })),
3819
4325
  summary: Type.Optional(Type.String({ description: "New summary" })),
3820
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." })),
3821
4328
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
3822
4329
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
3823
4330
  goals: Type.Optional(Type.Array(Type.String(), { description: "Replace goals list" })),
@@ -3855,34 +4362,59 @@ export default function planPiExtension(pi) {
3855
4362
  if (params.status !== undefined) {
3856
4363
  return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
3857
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
+ }
3858
4383
  const prevFeatureId = phase.featureId;
3859
4384
  let nextFeatureIdForUpdate = prevFeatureId;
3860
4385
  if (params.title !== undefined) {
3861
- phase.title = params.title;
4386
+ phase.title = params.title.trim();
3862
4387
  phase.slug = normalizeSlug(params.title);
3863
4388
  }
3864
4389
  if (params.summary !== undefined)
3865
- phase.summary = params.summary;
4390
+ phase.summary = params.summary.trim();
3866
4391
  if (params.description !== undefined)
3867
- 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
+ }
3868
4400
  if (params.priority !== undefined)
3869
4401
  phase.priority = params.priority;
3870
4402
  if (params.goals !== undefined)
3871
- phase.goals = params.goals;
4403
+ phase.goals = params.goals.map((item) => item.trim()).filter(Boolean);
3872
4404
  if (params.nonGoals !== undefined)
3873
- phase.nonGoals = params.nonGoals;
4405
+ phase.nonGoals = params.nonGoals.map((item) => item.trim()).filter(Boolean);
3874
4406
  if (params.dependencies !== undefined)
3875
- phase.dependencies = params.dependencies;
4407
+ phase.dependencies = params.dependencies.map((item) => item.trim()).filter(Boolean);
3876
4408
  if (params.risks !== undefined)
3877
- phase.risks = params.risks;
4409
+ phase.risks = params.risks.map((item) => item.trim()).filter(Boolean);
3878
4410
  if (params.openQuestions !== undefined)
3879
- phase.openQuestions = params.openQuestions;
4411
+ phase.openQuestions = params.openQuestions.map((item) => item.trim()).filter(Boolean);
3880
4412
  if (params.decisions !== undefined)
3881
- phase.decisions = params.decisions;
4413
+ phase.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
3882
4414
  if (params.acceptedDecisions !== undefined)
3883
4415
  phase.acceptedDecisions = params.acceptedDecisions;
3884
4416
  if (params.completionCriteria !== undefined)
3885
- phase.completionCriteria = params.completionCriteria;
4417
+ phase.completionCriteria = params.completionCriteria.map((item) => item.trim()).filter(Boolean);
3886
4418
  if (params.featureId !== undefined) {
3887
4419
  if (params.featureId === "") {
3888
4420
  nextFeatureIdForUpdate = undefined;
@@ -3910,7 +4442,7 @@ export default function planPiExtension(pi) {
3910
4442
  await st.savePhase(phase);
3911
4443
  await st.writeGenerated();
3912
4444
  const phaseUpdateFeatures = (await st.loadFeatures()).features;
3913
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}` }], details: phase };
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 } };
3914
4446
  },
3915
4447
  });
3916
4448
  pi.registerTool({
@@ -4026,7 +4558,7 @@ export default function planPiExtension(pi) {
4026
4558
  pi.registerTool({
4027
4559
  name: "task_list",
4028
4560
  label: "Task List",
4029
- description: "List tasks (compact: F00x/P00x/T00x · shortId — title (status)). Filters: featureRef, phaseRef, status. Omit all to list every task. Cheap discovery — do NOT read .planner/ files or plan_get full=true.",
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.",
4030
4562
  parameters: Type.Object({
4031
4563
  featureRef: Type.Optional(Type.String({})),
4032
4564
  phaseRef: Type.Optional(Type.String({})),
@@ -4057,7 +4589,7 @@ export default function planPiExtension(pi) {
4057
4589
  for (const task of phase.tasks) {
4058
4590
  if (params.status && task.status !== params.status)
4059
4591
  continue;
4060
- 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}` : ""})`);
4061
4593
  }
4062
4594
  }
4063
4595
  return { content: [{ type: "text", text: out.join("\n") || "No tasks" }], details: {} };
@@ -4096,6 +4628,8 @@ export default function planPiExtension(pi) {
4096
4628
  const sections = [summary];
4097
4629
  if (found.task.description?.trim())
4098
4630
  sections.push(found.task.description.trim());
4631
+ if (found.task.descriptionRef)
4632
+ sections.push(`Description reference:\n- ${found.task.descriptionRef}`);
4099
4633
  if (snapshot || pendingDeviation) {
4100
4634
  sections.push([
4101
4635
  pendingDeviation
@@ -4109,7 +4643,7 @@ export default function planPiExtension(pi) {
4109
4643
  }
4110
4644
  if (log)
4111
4645
  sections.push(`Status log:\n${log}`);
4112
- 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 ?? "" } } };
4113
4647
  },
4114
4648
  });
4115
4649
  pi.registerTool({
@@ -4211,6 +4745,7 @@ export default function planPiExtension(pi) {
4211
4745
  title: Type.Optional(Type.String({ description: "New title" })),
4212
4746
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
4213
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." })),
4214
4749
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
4215
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." })),
4216
4751
  decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
@@ -4262,6 +4797,24 @@ export default function planPiExtension(pi) {
4262
4797
  };
4263
4798
  }
4264
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
+ }
4265
4818
  try {
4266
4819
  const updatedPhase = await st.updatePhase(hostPhase.id, (phase) => {
4267
4820
  const task = phase.tasks.find((t) => t.id === found.task.id);
@@ -4272,11 +4825,18 @@ export default function planPiExtension(pi) {
4272
4825
  if (params.priority !== undefined)
4273
4826
  task.priority = params.priority;
4274
4827
  if (params.description !== undefined)
4275
- 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
+ }
4276
4836
  if (params.notes !== undefined)
4277
- task.notes = params.notes;
4837
+ task.notes = params.notes.trim();
4278
4838
  if (params.decisions !== undefined)
4279
- task.decisions = params.decisions;
4839
+ task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
4280
4840
  if (params.acceptedDecisions !== undefined)
4281
4841
  task.acceptedDecisions = params.acceptedDecisions;
4282
4842
  if (params.checklist !== undefined) {
@@ -4314,7 +4874,7 @@ export default function planPiExtension(pi) {
4314
4874
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4315
4875
  await st.syncTaskStatusRollup(found.phase.id);
4316
4876
  await st.writeGenerated();
4317
- 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 } };
4318
4878
  },
4319
4879
  });
4320
4880
  pi.registerTool({
@@ -4463,7 +5023,7 @@ export default function planPiExtension(pi) {
4463
5023
  pi.registerTool({
4464
5024
  name: "task_switch",
4465
5025
  label: "Task Switch",
4466
- description: "Switch from active work to a temporary task, even outside priority order. Call this tool first so valid sessionInfo context can be reused; on denial, perform only the missing/stale reads listed in nextActions and retry. Atomically snapshot/pause the source, start the target, and push a durable LIFO return target.",
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.",
4467
5027
  parameters: Type.Object({
4468
5028
  from_task: Type.String({ description: "Currently in-progress task ref" }),
4469
5029
  to_task: Type.String({ description: "Temporary task ref to start" }),
@@ -4509,11 +5069,20 @@ export default function planPiExtension(pi) {
4509
5069
  };
4510
5070
  const contextEligibility = contextReadEligibilityForSession(contextInput);
4511
5071
  const requirementsReady = hasReadRequirementsForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5072
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5073
+ const projectGuidelinesReady = projectGuidelinesReadState === "valid" || projectGuidelinesReadState === "not-required";
4512
5074
  const targetPhaseRef = formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features));
4513
5075
  const targetTaskRef = `${targetPhaseRef}/T${String(target.task.number).padStart(3, "0")}`;
4514
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
+ }
4515
5084
  if (!contextEligibility.eligible) {
4516
- const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
5085
+ const nextActions = piContextReadActions(contextEligibility, { task: targetTaskRef, phase: targetPhaseRef, ...(targetFeatureRef ? { feature: targetFeatureRef } : {}) }, false, !requirementsReady, `Retry task_switch to ${targetTaskRef}`);
4517
5086
  return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: { ...contextEligibility, nextActions } };
4518
5087
  }
4519
5088
  if (!requirementsReady) {
@@ -4628,7 +5197,7 @@ export default function planPiExtension(pi) {
4628
5197
  pi.registerTool({
4629
5198
  name: "task_start",
4630
5199
  label: "Task Start",
4631
- description: "Set a task to in-progress or resume checkpointed work. Call this tool before reading context so it can reuse valid sessionInfo attestations; if denied, perform only the missing/stale reads listed in nextActions, in order, and retry. A denied start is an isError result with started=false; NEVER claim work started unless details.started is true. Success is returned only after persisted in-progress state is verified.",
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.",
4632
5201
  parameters: Type.Object({
4633
5202
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4634
5203
  }),
@@ -4646,7 +5215,8 @@ export default function planPiExtension(pi) {
4646
5215
  const feature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
4647
5216
  const featureRef = feature ? formatFeatureRef(feature.number) : "";
4648
5217
  if (task.status === "done")
4649
- return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_update with status=planned and a motivation to reopen ${taskRef}.`, `Repeat the required full context reads, then retry task_start ${taskRef}.`], { taskId: task.id }));
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();
4650
5220
  const linkedRequirements = [
4651
5221
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
4652
5222
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -4665,8 +5235,13 @@ export default function planPiExtension(pi) {
4665
5235
  };
4666
5236
  const contextEligibility = contextReadEligibilityForSession(contextInput);
4667
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
+ }
4668
5243
  if (!contextEligibility.eligible) {
4669
- return taskStartFailure(taskStartDenied("CONTEXT_READ_REQUIRED", `Required context reads are incomplete or stale. ${contextEligibility.reason}`, piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, !requirementsReady, `Retry task_start ${taskRef}`), { taskId: task.id }), { contextEligibility });
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 });
4670
5245
  }
4671
5246
  if (!requirementsReady) {
4672
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 }));
@@ -4684,7 +5259,7 @@ export default function planPiExtension(pi) {
4684
5259
  const outcome = taskStartSucceeded(task.id, true);
4685
5260
  return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
4686
5261
  }
4687
- const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
5262
+ const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
4688
5263
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4689
5264
  if (!eligibility.eligible)
4690
5265
  return taskStartFailure(taskStartDenied("START_NOT_ALLOWED", eligibility.reason, ["Resolve the reported task readiness condition, then retry task_start."], { taskId: task.id }), { eligibility });
@@ -5036,10 +5611,24 @@ export default function planPiExtension(pi) {
5036
5611
  const st = await requirePlan(ctx);
5037
5612
  if (!st)
5038
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
+ }
5039
5621
  enablePlannerSession();
5040
5622
  if (!server) {
5041
5623
  await startServer(ctx, undefined, "lan").catch(() => { });
5042
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;
5043
5632
  const srv = server;
5044
5633
  let recap = "";
5045
5634
  try {
@@ -5048,7 +5637,9 @@ export default function planPiExtension(pi) {
5048
5637
  catch (e) {
5049
5638
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
5050
5639
  }
5051
- 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 } } };
5052
5643
  },
5053
5644
  });
5054
5645
  pi.registerTool({
@@ -5109,6 +5700,7 @@ export default function planPiExtension(pi) {
5109
5700
  const recentActivity = activity.entries.slice(-3).reverse();
5110
5701
  const handoffs = await st.listHandoffs();
5111
5702
  const extensionRules = await st.extensionRules();
5703
+ const plannerSkill = await st.syncPlannerSkill();
5112
5704
  const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
5113
5705
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
5114
5706
  const orderedPhases = [
@@ -5223,11 +5815,11 @@ export default function planPiExtension(pi) {
5223
5815
  "",
5224
5816
  "Operational rules:",
5225
5817
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
5226
- "- BEFORE work: call task_start or task_switch first so valid sessionInfo attestations are reused. If denied, perform only the missing/stale full reads listed in nextActions, in that order, then retry. A denial is an error with started=false; only details.started=true proves the task is in-progress. Only then touch code. AFTER finishing: task_complete with durable completion/verification evidence.",
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.",
5227
5819
  "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
5228
5820
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
5229
5821
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
5230
- "- Prioritize work: continue an in-progress task; otherwise choose ready work feature → phase → task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
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).",
5231
5823
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
5232
5824
  ...(extensionRules.length > 0
5233
5825
  ? [
@@ -5237,12 +5829,20 @@ export default function planPiExtension(pi) {
5237
5829
  ]
5238
5830
  : []),
5239
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
+ "",
5240
5837
  `Goal: ${project.goal || "(not set)"}`,
5241
5838
  project.description ? `Description: ${project.description}` : "",
5242
5839
  `Stack: ${[...project.technologies, ...project.tools].join(", ") || "(not set)"}`,
5243
5840
  `Codebase: ${pkgSummary} | dirs=${profile?.directories.join(",") || "none"} | scanned=${profile?.scannedAt ?? "never"}`,
5244
5841
  `Ambient: ${ambientSummary}`,
5245
5842
  "",
5843
+ "Project Guidelines (mandatory when present):",
5844
+ project.projectGuidelines.content.trim() || "- (none)",
5845
+ "",
5246
5846
  "Global rules:",
5247
5847
  project.globalRules.length ? project.globalRules.map((rule) => `- ${rule}`).join("\n") : "- (none)",
5248
5848
  "",