@agent-plan/pi-adapter 0.2.29 → 0.3.1

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,12 @@
10
10
  */
11
11
  import { Type } from "typebox";
12
12
  import { paginatedSelect, paginatedNotify } from "./ui/paginate.js";
13
- import { ExportService, PlanStore, PlanStoreError, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_SOURCE_REVIEWS, HANDOFF_COLD_START_INVENTORY_CATEGORIES, MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, HandoffContractError } from "@agent-plan/core";
14
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
13
+ import { ExportService, PlanStore, PlanStoreError, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, findIdeaByRef, buildRecap, classifyGuardedTool, loadNoTaskGuardState, noTaskWarning, addChecklistItem, removeChecklistItem, toggleChecklistItem, replaceChecklist, buildPhaseContextBlock, buildPhaseWorkMap, buildBoundedAcceptedDecisionContext, renderAcceptedDecisionsSection, checkExplicitTaskStart, recommendNextTask, recommendNextWork, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, runtimeCapabilities, runtimePackagesDiagnostic, markCanonicalFullReadForSessionId, contextReadEligibilityForSession, requirementReadEligibilityForSession, hasValidSessionAttestation, markRequirementReadForSessionId, startReadSession, invalidateReads, taskStartDenied, taskStartSucceeded, taskStartGateState, taskStartGateStateLine, noMutableFieldsReceived, normalizeDescriptionRef, projectGuidelinesReadStateForSession, reconcileRequirementMacroTasks, RequirementMacroTaskError, HANDOFF_COMPLETENESS_AUDIT_VERSION, HANDOFF_COMPLETENESS_CATEGORIES, HANDOFF_COLD_START_INVENTORY_VERSION, HANDOFF_COLD_START_SOURCE_REVIEWS, HANDOFF_COLD_START_INVENTORY_CATEGORIES, handoffContentHash, HandoffContractError, buildHandoffShowReply, buildHandoffPrepareReply, buildProjectContextLoadReply, buildPlannerLoadReply, buildScopedDecisionContext, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS, projectContextStaleAdvisory, ACCEPTED_DECISION_OWNERSHIP_RULE, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, buildDecisionRecordRedirectReply, buildMutationReply, longTextFieldLimitNotice, buildRecommendationReply, buildTaskOrderContext, taskOrderContextLine, taskCreatedPriorityFragment, findHigherPriorityOpenPhase, higherPriorityOpenPhaseAdvisory, listOpenPhaseWork, openPhaseWorkLines, boundedOpenPhaseWork, deleteFeatureCascade, evaluateTaskCompletionGate, taskCompletionChecklistRefusal, taskCompletionForceMotivationRequired, taskCompletionOverrideNote, taskCompletionMismatchLine, boundedPage, nowISO, resolveFeatureRefStrict, resolveAcceptedDecisionTarget, applyTaskLifecycleDates, handoffContractFailureReply, derivedStatusReadOnlyReply, acceptedDecisionMutationFailureReply, legacyContextMigrationSummary, statusIcon } from "@agent-plan/core";
14
+ import { createChecklistItemId, createFeatureId, createPhaseId, createRequirementId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, formatIdeaRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
15
+ /** Stated on every long-prose input so a caller learns the limit before the
16
+ * call rather than from the rejection. The number lives in the core
17
+ * (PLANNER_LONG_TEXT_FIELD_MAX_CHARS) so both adapters state the same one. */
18
+ const LONG_TEXT_LIMIT_NOTICE = longTextFieldLimitNotice();
15
19
  import { join, dirname } from "node:path";
16
20
  import { homedir } from "node:os";
17
21
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -27,6 +31,23 @@ const PI_ADAPTER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-pla
27
31
  const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
28
32
  const SERVER_PACKAGE = resolvedPackageVersion("@agent-plan/server", import.meta.url);
29
33
  let capturedPi = null;
34
+ /**
35
+ * Hand a message to the agent, and report whether the bridge was there.
36
+ *
37
+ * `ExtensionAPI.sendUserMessage` returns `void`, not a promise: it queues
38
+ * the message and returns immediately. Four call sites awaited it, which
39
+ * reads as "the message has been delivered, now continue" and is not true —
40
+ * TypeScript flagged every one of them. They also each rewrote the same
41
+ * `capturedPi` null check. Both live here now, so no caller re-derives
42
+ * either, and the missing-bridge case stays each caller's own decision:
43
+ * some notify the user, some carry on silently.
44
+ */
45
+ function sendToAgent(message) {
46
+ if (!capturedPi)
47
+ return false;
48
+ capturedPi.sendUserMessage(message);
49
+ return true;
50
+ }
30
51
  let store = null;
31
52
  let server = null;
32
53
  let lastKnownWebPort = null;
@@ -34,7 +55,6 @@ let plannerSessionId = "pi:uninitialized";
34
55
  // True while the agent is mutating .planner/ files. The web server returns 503-busy
35
56
  // during this window so the UI doesn't render inconsistent data.
36
57
  let planBusy = false;
37
- function setPlanBusy(value) { planBusy = value; }
38
58
  function withPlanBusy(fn) {
39
59
  return Promise.resolve()
40
60
  .then(() => { planBusy = true; })
@@ -55,38 +75,14 @@ function taskStartFailure(outcome, details = {}) {
55
75
  details: { ...outcome, ...details },
56
76
  };
57
77
  }
58
- function derivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
59
- const childKind = entity === "feature" ? "phases" : "tasks";
78
+ function piDerivedStatusReadOnlyResult(entity, ref, attemptedStatus, effectiveStatus) {
60
79
  const retryAction = entity === "feature"
61
80
  ? `Retry feature_update ${ref} without the status field if you need to update metadata.`
62
81
  : `Retry phase_update ${ref} without the status field if you need to update metadata.`;
63
- const nextActions = [
64
- `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind}; update the child ${childKind} instead of setting ${entity}.status.`,
65
- retryAction,
66
- ];
67
- return {
68
- isError: true,
69
- content: [{ type: "text", text: [
70
- `❌ ${entity.toUpperCase()} UPDATE FAILED [DERIVED_STATUS_READ_ONLY]`,
71
- `${entity[0].toUpperCase()}${entity.slice(1)} status is derived from child ${childKind} and cannot be set directly.`,
72
- `Attempted status: ${attemptedStatus}`,
73
- `Effective derived status: ${effectiveStatus}`,
74
- "No planner data was changed.",
75
- "Next required actions:",
76
- ...nextActions.map((action, index) => `${index + 1}. ${action}`),
77
- ].join("\n") }],
78
- details: {
79
- updated: false,
80
- errorCode: "DERIVED_STATUS_READ_ONLY",
81
- entity,
82
- ref,
83
- attemptedStatus,
84
- effectiveStatus,
85
- nextActions,
86
- },
87
- };
82
+ const reply = derivedStatusReadOnlyReply({ entity, ref, attemptedStatus, effectiveStatus, retryAction });
83
+ return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
88
84
  }
89
- function mutationNoFieldsFailure(input) {
85
+ function piMutationNoFieldsFailure(input) {
90
86
  const outcome = noMutableFieldsReceived(input);
91
87
  return {
92
88
  isError: true,
@@ -94,52 +90,13 @@ function mutationNoFieldsFailure(input) {
94
90
  details: outcome,
95
91
  };
96
92
  }
97
- function acceptedDecisionMutationFailure(error, outcome) {
98
- if (!(error instanceof PlanStoreError) || !String(error.details?.errorCode ?? "").startsWith("ACCEPTED_DECISION_"))
99
- throw error;
100
- return {
101
- isError: true,
102
- content: [{ type: "text", text: `❌ Accepted Decision mutation failed [${error.details?.errorCode}]: ${error.message}` }],
103
- details: { [outcome]: false, ...error.details },
104
- };
105
- }
106
- function legacyContextMigrationSummary(preview) {
107
- return [
108
- `Legacy context present: ${preview.hasLegacyContext ? "yes" : "no"}`,
109
- `Guideline additions: ${preview.guidelineAdditions.length} (${preview.skippedGuidelineDuplicates} duplicate(s) skipped)`,
110
- `Accepted decision additions: ${preview.acceptedDecisionAdditions.length} (${preview.skippedDecisionDuplicates} duplicate(s) skipped)`,
111
- `Legacy counts: globalRules=${preview.legacyCounts.globalRules}, workflowRules=${preview.legacyCounts.workflowRules}, decisions=${preview.legacyCounts.decisions}`,
112
- `Fields cleared on apply: ${preview.fieldsClearedOnApply.join(", ") || "none"}`,
113
- ].join("\n");
114
- }
115
- function handoffContractFailure(error) {
116
- return {
117
- isError: true,
118
- content: [{ type: "text", text: `❌ Handoff refresh denied [${error.code}]: ${error.message}` }],
119
- details: { errorCode: error.code, ...error.details },
120
- };
121
- }
122
- function boundedHandoffForTransport(content) {
123
- if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
124
- return { content, fullLength: content.length, truncated: false };
125
- const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
126
- return {
127
- content: `${content.slice(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length)}${suffix}`,
128
- fullLength: content.length,
129
- truncated: true,
130
- };
93
+ function piAcceptedDecisionMutationFailure(error, outcome) {
94
+ const reply = acceptedDecisionMutationFailureReply(error, outcome);
95
+ return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
131
96
  }
132
- function boundedPage(items, page, pageSize) {
133
- const size = Math.min(25, Math.max(1, Math.trunc(pageSize)));
134
- const totalPages = Math.max(1, Math.ceil(items.length / size));
135
- const currentPage = Math.min(totalPages, Math.max(1, Math.trunc(page)));
136
- return {
137
- items: items.slice((currentPage - 1) * size, currentPage * size),
138
- page: currentPage,
139
- pageSize: size,
140
- total: items.length,
141
- totalPages,
142
- };
97
+ function piHandoffContractFailure(error, action = "refresh") {
98
+ const reply = handoffContractFailureReply(error, action);
99
+ return { isError: true, content: [{ type: "text", text: reply.text }], details: reply.structured };
143
100
  }
144
101
  function piContextReadActions(eligibility, refs, projectGuidelinesReadRequired, requirementReadRequired, retryAction) {
145
102
  const actions = projectGuidelinesReadRequired ? ["project_guidelines_show"] : [];
@@ -187,11 +144,9 @@ setWriteNotifyHook(() => {
187
144
  }
188
145
  catch { }
189
146
  });
190
- let previousContextPercent = 0;
191
- let autoHandoffTriggered = false;
192
147
  let plannerSessionEnabled = false;
193
148
  // In-process guard bypass (per session, NOT persisted/shared). The edit/write
194
- // guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
149
+ // The no-task guard only ever warns the agent; it never blocks. When a
195
150
  // bypass is active the warning is silenced for the window. Lives in memory only:
196
151
  // not on disk, not in git, not shared across agents/sessions on the same folder.
197
152
  let guardBypassUntil = "";
@@ -216,11 +171,9 @@ function isGuardBypassed() {
216
171
  let startupResumePromptPending = false;
217
172
  let startupResumeSummaryPending = false;
218
173
  let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
219
- let startupResumeSummaryText = "";
220
174
  let startupResumeSummaryTimer = null;
221
175
  let contextBlockCache = ""; // cached before_agent_start context; rebuilt only when plan changes
222
176
  let contextBlockDirty = true; // build on first turn; invalidated by write notify hook
223
- let editedThisTurn = false; // tracks edit/write activity for the task_complete reminder
224
177
  let taskCompleteReminderSaidThisTurn = false;
225
178
  const healedStatusRoots = new Set();
226
179
  const PLANNER_COMMAND_COMPLETIONS = [
@@ -257,6 +210,7 @@ const PLANNER_COMMAND_COMPLETIONS = [
257
210
  { value: "task delete", label: "task delete", description: "Delete a task" },
258
211
  { value: "task start", label: "task start", description: "Mark a task in-progress" },
259
212
  { value: "task complete", label: "task complete", description: "Mark a task done" },
213
+ { value: "task recommend", label: "task recommend", description: "Show the next-work recommendation: active task if present, otherwise the priority pick" },
260
214
  { value: "task checklist-add", label: "task checklist-add", description: "Add a checklist step: /planner task checklist-add <T00x> <title>" },
261
215
  { value: "task checklist-remove", label: "task checklist-remove", description: "Remove a step: /planner task checklist-remove <T00x> <C{n}|title>" },
262
216
  { value: "task checklist-toggle", label: "task checklist-toggle", description: "Tick/untick a step: /planner task checklist-toggle <T00x> <C{n}|title> [on|off]" },
@@ -282,15 +236,9 @@ function hasPlannerCommandArgument(prefix) {
282
236
  return PLANNER_COMMAND_COMPLETIONS.some((item) => normalized.startsWith(`${item.value.toLowerCase()} `));
283
237
  }
284
238
  // ─── Helpers ────────────────────────────────────────────────────────────
285
- function nowISO() {
286
- return new Date().toISOString();
287
- }
288
239
  function manifestPathFor(root) {
289
240
  return join(root, "manifest.json");
290
241
  }
291
- function createRequirementId() {
292
- return crypto.randomUUID();
293
- }
294
242
  function planRootForCwd(cwd) {
295
243
  return join(cwd, PLAN_DIR_NAME);
296
244
  }
@@ -316,11 +264,9 @@ function resetState() {
316
264
  plannerSessionEnabled = false;
317
265
  startupResumePromptPending = false;
318
266
  startupResumeSummaryPending = false;
319
- startupResumeSummaryText = "";
320
267
  plannerHeavyInitDone = false;
321
268
  contextBlockCache = "";
322
269
  contextBlockDirty = true;
323
- editedThisTurn = false;
324
270
  taskCompleteReminderSaidThisTurn = false;
325
271
  }
326
272
  async function maybeHealStatuses(st, ctx) {
@@ -365,68 +311,6 @@ async function ensureProjectLanguagePreferences(st, persist = true) {
365
311
  }
366
312
  return project;
367
313
  }
368
- async function getPlannerExecutionGuard(st) {
369
- const [workspace, resume] = await Promise.all([st.loadAll(), st.loadResume()]);
370
- const allTasks = workspace.phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
371
- const inProgress = allTasks.filter(({ task }) => task.status === "in-progress");
372
- const totalTasks = allTasks.length;
373
- const focusFromResume = resume?.inProgressTaskIds?.[0]
374
- ? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
375
- : undefined;
376
- const focusFromCurrentPhase = resume?.currentPhaseId
377
- ? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
378
- : undefined;
379
- const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
380
- ?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
381
- ?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
382
- const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
383
- return {
384
- totalTasks,
385
- inProgressTaskIds: inProgress.map(({ task }) => task.id),
386
- focusTaskId: focus?.task.id ?? "",
387
- focusTaskTitle: focus?.task.title ?? "",
388
- };
389
- }
390
- function formatSequence(value) {
391
- return String(value && value > 0 ? value : 0).padStart(3, "0");
392
- }
393
- function featureLabel(feature) {
394
- return `F${formatSequence(feature.number)} — ${feature.name}`;
395
- }
396
- function phaseLabel(phase) {
397
- return `P${formatSequence(phase.number)} — ${phase.title}`;
398
- }
399
- function taskLabel(task) {
400
- return `T${formatSequence(task.number)} — ${task.title}`;
401
- }
402
- function resolveFeatureRefStrict(features, ref) {
403
- const raw = ref.trim();
404
- if (!raw)
405
- return { ok: false, error: "Feature ref is required." };
406
- const normalized = raw.toLowerCase();
407
- const byNumber = normalized.match(/^f(\d+)$/)
408
- ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
409
- : undefined;
410
- if (byNumber)
411
- return { ok: true, feature: byNumber };
412
- const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
413
- if (byShortId)
414
- return { ok: true, feature: byShortId };
415
- const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
416
- if (byId)
417
- return { ok: true, feature: byId };
418
- const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
419
- if (exactName.length === 1)
420
- return { ok: true, feature: exactName[0] };
421
- if (exactName.length > 1)
422
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
423
- const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
424
- if (partialName.length === 1)
425
- return { ok: true, feature: partialName[0] };
426
- if (partialName.length > 1)
427
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
428
- return { ok: false, error: `Feature not found: ${raw}` };
429
- }
430
314
  async function buildHandoffMarkdown(st, reason, overrides) {
431
315
  const [plan, resume, activity] = await Promise.all([
432
316
  st.loadAll(),
@@ -684,59 +568,9 @@ function readUiConfig(ctx) {
684
568
  },
685
569
  };
686
570
  }
687
- function statusIcon(status) {
688
- const icons = {
689
- draft: "📄", discovery: "🔍", planned: "📋",
690
- "in-progress": "🚧", done: "✅", blocked: "🚫", canceled: "❌",
691
- rejected: "❌", deferred: "⏳", waiting: "⏱️",
692
- };
693
- return icons[status] ?? "❓";
694
- }
695
571
  function pad(n) {
696
572
  return String(n).padStart(3, "0");
697
573
  }
698
- async function resolveAcceptedDecisionTarget(st, targetType, targetRef) {
699
- if (targetType === "project") {
700
- const project = await st.loadProject();
701
- return { ok: true, owner: { kind: "project" }, ref: project.name };
702
- }
703
- const ref = targetRef?.trim();
704
- if (!ref)
705
- return { ok: false, error: `targetRef is required for ${targetType} accepted decisions.` };
706
- const [featuresDoc, phases] = await Promise.all([st.loadFeatures(), st.loadAllPhases()]);
707
- const features = featuresDoc.features;
708
- if (targetType === "feature") {
709
- const resolved = resolveFeatureRefStrict(features, ref);
710
- if (!resolved.ok)
711
- return { ok: false, error: resolved.error };
712
- return { ok: true, owner: { kind: "feature", featureId: resolved.feature.id }, ref: formatFeatureRef(resolved.feature.number) };
713
- }
714
- if (targetType === "phase") {
715
- const phase = findPhaseByRef(phases, features, ref);
716
- if (!phase)
717
- return { ok: false, error: `Phase not found: ${ref}` };
718
- return { ok: true, owner: { kind: "phase", phaseId: phase.id }, ref: formatPhaseRef(phase.number, featureNumberOfPhase(phase, features)) };
719
- }
720
- const found = findTaskByRef(phases, features, ref);
721
- if (!found)
722
- return { ok: false, error: `Task not found: ${ref}` };
723
- return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` };
724
- }
725
- function applyTaskLifecycleDates(task, nextStatus, now) {
726
- const previousStatus = task.status;
727
- if (nextStatus === "in-progress" && !task.startedAt) {
728
- task.startedAt = now;
729
- }
730
- if (nextStatus === "done") {
731
- if (!task.startedAt)
732
- task.startedAt = now;
733
- task.completedAt = now;
734
- }
735
- else if (previousStatus === "done") {
736
- task.completedAt = "";
737
- }
738
- task.status = nextStatus;
739
- }
740
574
  async function isPortAvailable(port) {
741
575
  return await new Promise((resolve) => {
742
576
  const probe = createServer();
@@ -918,7 +752,6 @@ function disablePlannerSession() {
918
752
  plannerSessionEnabled = false;
919
753
  startupResumePromptPending = false;
920
754
  startupResumeSummaryPending = false;
921
- startupResumeSummaryText = "";
922
755
  if (startupResumeSummaryTimer)
923
756
  clearTimeout(startupResumeSummaryTimer);
924
757
  startupResumeSummaryTimer = null;
@@ -978,22 +811,11 @@ export default function planPiExtension(pi) {
978
811
  },
979
812
  }));
980
813
  resetState();
981
- autoHandoffTriggered = false;
982
- previousContextPercent = 0;
983
814
  const st = ensureStore(ctx);
984
815
  st.enableAutoSync(true);
985
- // Read any persisted web port (for reuse), but do NOT auto-start here.
986
- // The two-step gating below decides whether to start the server.
987
- let preferredPort;
988
- for (const entry of ctx.sessionManager.getEntries()) {
989
- if (entry.type === "custom" && entry.customType === "plan-web-state") {
990
- const data = entry.data;
991
- if (data?.port)
992
- preferredPort = data.port;
993
- }
994
- }
995
816
  const exists = await st.exists().catch(() => false);
996
- let project = exists ? await st.loadProject().catch(() => null) : null;
817
+ if (exists)
818
+ await st.loadProject().catch(() => null);
997
819
  // ── Step 1: Enable gating ───────────────────────────────────────
998
820
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
999
821
  // render for NPM-loaded ESM extensions during session_start (they hang
@@ -1046,12 +868,11 @@ export default function planPiExtension(pi) {
1046
868
  const st = ensureStore(ctx);
1047
869
  if (await st.exists().catch(() => false)) {
1048
870
  await writeProjectHandoff(st, "before compact").catch(() => { });
1049
- autoHandoffTriggered = true;
1050
871
  }
1051
872
  }
1052
873
  catch { }
1053
874
  });
1054
- pi.on("session_shutdown", async (event, ctx) => {
875
+ pi.on("session_shutdown", async (event) => {
1055
876
  try {
1056
877
  const st = store;
1057
878
  if (st && await st.exists().catch(() => false)) {
@@ -1124,59 +945,45 @@ export default function planPiExtension(pi) {
1124
945
  }
1125
946
  }
1126
947
  }
1127
- // Guard the code-writing tools (edit/write). bash stays free so that
1128
- // git pull, build, test, ls, etc. always work.
1129
- if (event.toolName !== "edit" && event.toolName !== "write")
1130
- return;
1131
- // Planner-internal writes (.planner/HANDOFF.md, resume.json, generated plan
1132
- // files, etc.) are planner operations, NOT code edits. They never require a
1133
- // task in-progress — skip the guard so the handoff can always be written.
1134
- const targetPath = String(event.input?.path ?? event.input?.filePath ?? "");
1135
- if (targetPath && (targetPath.includes("/.planner/") || targetPath.includes("\\.planner\\") || targetPath.startsWith(".planner/")))
1136
- return;
1137
- const st = loadStore(ctx);
1138
- if (!(await st.exists().catch(() => false)))
1139
- return;
1140
- await maybeHealStatuses(st, ctx).catch(() => { });
1141
- const guard = await getPlannerExecutionGuard(st).catch(() => null);
1142
- if (!guard || guard.totalTasks === 0)
1143
- return; // nothing to enforce yet
1144
- if (guard.inProgressTaskIds.length > 0)
1145
- return; // a task is open → we're good
1146
- if (isGuardBypassed())
1147
- return; // user authorized proceeding without a task
1148
- const focusHint = guard.focusTaskId
1149
- ? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
1150
- : `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
1151
- ctx.ui.notify(`⚠️ NO ACTIVE TASK: You are editing files without an in-progress task. Remember to update the plan to maintain dashboard integrity. ${focusHint}`, "warning");
1152
- return; // Allow the tool to proceed
1153
948
  });
1154
949
  // Reset per-turn flags at the start of each turn.
1155
950
  pi.on("turn_start", async () => {
1156
- editedThisTurn = false;
1157
951
  taskCompleteReminderSaidThisTurn = false;
1158
952
  });
1159
- // After edit/write succeeds, track activity and (once per turn, when a task
1160
- // is in-progress) remind the agent to complete the task when the work is done.
953
+ // After a tool runs, tell the agent (never block) when it changed project
954
+ // code with no task in progress; otherwise, once per turn while a task is
955
+ // in progress, remind it to complete the task. Notes are appended to the
956
+ // tool's own output, which Pi would otherwise replace.
1161
957
  pi.on("tool_result", async (event, ctx) => {
1162
958
  if (!plannerSessionEnabled)
1163
959
  return;
1164
- if (event.toolName !== "edit" && event.toolName !== "write")
1165
- return;
1166
- editedThisTurn = true;
1167
- if (taskCompleteReminderSaidThisTurn)
960
+ if (event.toolName !== "edit" && event.toolName !== "write" && event.toolName !== "bash")
1168
961
  return;
1169
962
  try {
1170
- const st = loadStore(ctx);
1171
- if (!(await st.exists().catch(() => false)))
963
+ const input = event.input;
964
+ const stringField = (value) => typeof value === "string" && value ? value : undefined;
965
+ const filePath = stringField(input.path ?? input.filePath);
966
+ const command = stringField(input.command);
967
+ const guardEvent = {
968
+ toolName: event.toolName,
969
+ cwd: ctx.cwd,
970
+ plannerRoot: resolvePlanRoot(ctx.cwd),
971
+ ...(filePath !== undefined ? { filePath } : {}),
972
+ ...(command !== undefined ? { command } : {}),
973
+ };
974
+ if (!classifyGuardedTool(guardEvent).guarded)
975
+ return;
976
+ const state = await loadNoTaskGuardState(loadStore(ctx));
977
+ const { warning } = noTaskWarning(guardEvent, { ...state, guardBypassed: state.guardBypassed || isGuardBypassed() });
978
+ const note = warning
979
+ ?? (state.hasInProgressTask && !taskCompleteReminderSaidThisTurn
980
+ ? "Reminder: when this implementation work is finished, call task_complete (or /planner task complete) so the task status moves to done and phase/feature rollups stay correct."
981
+ : undefined);
982
+ if (!note)
1172
983
  return;
1173
- const guard = await getPlannerExecutionGuard(st).catch(() => null);
1174
- if (guard && guard.inProgressTaskIds.length > 0) {
984
+ if (!warning)
1175
985
  taskCompleteReminderSaidThisTurn = true;
1176
- return {
1177
- content: [{ type: "text", text: "Reminder: when this implementation work is finished, call task_complete (or /planner task complete) so the task status moves to done and phase/feature rollups stay correct." }],
1178
- };
1179
- }
986
+ return { content: [...event.content, { type: "text", text: note }] };
1180
987
  }
1181
988
  catch { }
1182
989
  });
@@ -1453,34 +1260,6 @@ export default function planPiExtension(pi) {
1453
1260
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1454
1261
  ?? null;
1455
1262
  }
1456
- function resolveFeatureRef(features, ref) {
1457
- const raw = ref.trim();
1458
- if (!raw)
1459
- return { ok: false, error: "Feature ref is required." };
1460
- const normalized = raw.toLowerCase();
1461
- const byNumber = normalized.match(/^f(\d+)$/)
1462
- ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
1463
- : undefined;
1464
- if (byNumber)
1465
- return { ok: true, feature: byNumber };
1466
- const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
1467
- if (byShortId)
1468
- return { ok: true, feature: byShortId };
1469
- const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
1470
- if (byId)
1471
- return { ok: true, feature: byId };
1472
- const exactName = features.filter((feature) => feature.name.toLowerCase() === normalized);
1473
- if (exactName.length == 1)
1474
- return { ok: true, feature: exactName[0] };
1475
- if (exactName.length > 1)
1476
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Multiple features have that exact name; use F00x, shortId, or UUID.` };
1477
- const partialName = features.filter((feature) => feature.name.toLowerCase().includes(normalized));
1478
- if (partialName.length == 1)
1479
- return { ok: true, feature: partialName[0] };
1480
- if (partialName.length > 1)
1481
- return { ok: false, error: `Ambiguous feature ref: ${raw}. Matches: ${partialName.map((feature) => formatFeatureRef(feature.number)).join(", ")}. Use a specific F00x, shortId, or UUID.` };
1482
- return { ok: false, error: `Feature not found: ${raw}` };
1483
- }
1484
1263
  function profile_packageManager(pkg, lockfile) {
1485
1264
  if (pkg?.packageManager)
1486
1265
  return pkg.packageManager;
@@ -1630,20 +1409,18 @@ export default function planPiExtension(pi) {
1630
1409
  await st.saveCodebaseProfile(profile);
1631
1410
  await st.writeGenerated();
1632
1411
  ctx.ui.notify("Initial profile saved. The agent will now take over as Lead Architect to conduct discovery.", "info");
1633
- if (capturedPi) {
1634
- await capturedPi.sendUserMessage("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
1635
- "Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
1636
- "Your mission is to produce a professional-grade specification. " +
1637
- `\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
1638
- "If these preferences are already set, follow them and do NOT ask again. " +
1639
- "Ask for language only if BOTH preferences are unset. If one is set and the other is unset, infer the missing one from the saved value and persist it with project_set_language_preferences without asking.\n\n" +
1640
- "DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
1641
- "1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
1642
- "2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
1643
- "3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
1644
- "4. We will iterate until you have a complete, detailed picture.\n\n" +
1645
- "Once you are 100% certain and we agree the context is complete, you will then generate the exhaustive professional specification (Features, Phases, Tasks) including deep logic, code examples, and technical references as previously required.");
1646
- }
1412
+ sendToAgent("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
1413
+ "Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
1414
+ "Your mission is to produce a professional-grade specification. " +
1415
+ `\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
1416
+ "If these preferences are already set, follow them and do NOT ask again. " +
1417
+ "Ask for language only if BOTH preferences are unset. If one is set and the other is unset, infer the missing one from the saved value and persist it with project_set_language_preferences without asking.\n\n" +
1418
+ "DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
1419
+ "1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
1420
+ "2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
1421
+ "3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
1422
+ "4. We will iterate until you have a complete, detailed picture.\n\n" +
1423
+ "Once you are 100% certain and we agree the context is complete, you will then generate the exhaustive professional specification (Features, Phases, Tasks) including deep logic, code examples, and technical references as previously required.");
1647
1424
  return;
1648
1425
  }
1649
1426
  if (b === "language") {
@@ -1761,10 +1538,7 @@ export default function planPiExtension(pi) {
1761
1538
  if (!targetType)
1762
1539
  return;
1763
1540
  const skill = await st.ideaDiscussionSkill();
1764
- if (capturedPi) {
1765
- await capturedPi.sendUserMessage(`Discuss promotion of ${formatIdeaRef(idea.number)} — ${idea.title} to a ${targetType}.\n\n${skill}\n\nDo not create or persist a target until the discussion is complete and I explicitly confirm the exact destination. For a phase, recommend and confirm its feature. For a task, recommend and confirm its feature and phase. After creating the confirmed target with the normal planner tool, record the promotion with idea_promotion_finalize.`);
1766
- }
1767
- else {
1541
+ if (!sendToAgent(`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.`)) {
1768
1542
  ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
1769
1543
  }
1770
1544
  return;
@@ -1983,26 +1757,13 @@ export default function planPiExtension(pi) {
1983
1757
  ctx.ui.notify("Aborted", "warning");
1984
1758
  return;
1985
1759
  }
1986
- const featureId = feature.id;
1987
- await st.updateFeatures((doc) => {
1988
- doc.features = doc.features.filter((entry) => entry.id !== featureId);
1989
- return doc;
1990
- });
1991
- let affectedPhases = 0;
1992
- if (cascade) {
1993
- for (const phase of phases) {
1994
- await st.deletePhase(phase.id);
1995
- affectedPhases += 1;
1996
- }
1997
- }
1998
- else {
1999
- for (const phase of phases) {
2000
- await st.updatePhase(phase.id, (current) => ({ ...current, featureId: undefined, updatedAt: nowISO() }));
2001
- affectedPhases += 1;
2002
- }
1760
+ const outcome = await deleteFeatureCascade(st, feature.id, { cascade });
1761
+ if (!outcome.ok) {
1762
+ ctx.ui.notify(`Feature delete failed: ${outcome.error}`, "error");
1763
+ return;
2003
1764
  }
2004
- await st.writeGenerated();
2005
- ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1765
+ const { result } = outcome;
1766
+ ctx.ui.notify(`Feature deleted: ${result.ref}${result.phaseCount > 0 ? (result.cascade ? `; deleted ${result.phaseCount} phase(s)` : `; unlinked ${result.phaseCount} phase(s)`) : ""}`, "info");
2006
1767
  return;
2007
1768
  }
2008
1769
  ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
@@ -2038,8 +1799,6 @@ export default function planPiExtension(pi) {
2038
1799
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
2039
1800
  let phase;
2040
1801
  await st.runBatch(() => withFeatureLock(feature.id, async () => {
2041
- const phases = await st.loadAllPhases();
2042
- const featurePhases = phases.filter((p) => p.featureId === feature.id);
2043
1802
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2044
1803
  const id = createPhaseId();
2045
1804
  const identity = await st.allocateEntityIdentity("phase", id);
@@ -2119,11 +1878,9 @@ export default function planPiExtension(pi) {
2119
1878
  notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
2120
1879
  updatedAt: nowISO(),
2121
1880
  }));
2122
- if (capturedPi) {
2123
- await capturedPi.sendUserMessage(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
2124
- "Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
2125
- "Do NOT invoke GSD. Stay in Agent Plan.");
2126
- }
1881
+ sendToAgent(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
1882
+ "Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
1883
+ "Do NOT invoke GSD. Stay in Agent Plan.");
2127
1884
  }
2128
1885
  const answers = {};
2129
1886
  const questions = [
@@ -2258,7 +2015,7 @@ export default function planPiExtension(pi) {
2258
2015
  // ═══════════════════════════════════════════════════════════════
2259
2016
  if (a === "task") {
2260
2017
  if (!b) {
2261
- ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
2018
+ ctx.ui.notify("task actions: add | show [id] | discuss [id|name] | delete | update | start [id] | complete [id] | recommend | checklist-add <T00x> <title> | checklist-remove <T00x> <C{n}|title> | checklist-toggle <T00x> <C{n}|title> [on|off]", "info");
2262
2019
  return;
2263
2020
  }
2264
2021
  if (b === "add") {
@@ -2304,7 +2061,7 @@ export default function planPiExtension(pi) {
2304
2061
  updatedAt: nowISO(),
2305
2062
  }));
2306
2063
  await st.writeGenerated();
2307
- ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
2064
+ ctx.ui.notify(`Task created: ${taskId} — ${title} (${taskCreatedPriorityFragment(priority)})`, "info");
2308
2065
  if (isYes(startDiscuss)) {
2309
2066
  await handlePlanner(`task discuss ${taskId}`, ctx);
2310
2067
  }
@@ -2333,22 +2090,24 @@ export default function planPiExtension(pi) {
2333
2090
  const { phase, task } = resolved;
2334
2091
  const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
2335
2092
  const checklistSeed = await ctx.ui.input("Checklist items (comma-separated, blank to keep current)");
2336
- const checklist = checklistSeed?.trim()
2337
- ? splitCsv(checklistSeed).map((itemTitle, index) => ({
2338
- number: index + 1,
2339
- id: createChecklistItemId(task.id, index + 1, itemTitle),
2340
- title: itemTitle,
2341
- checked: false,
2342
- }))
2343
- : undefined;
2344
- await st.updateTask(phase.id, task.id, (current) => ({
2345
- ...current,
2346
- ...(description?.trim() ? { description: description.trim() } : {}),
2347
- ...(checklist ? { checklist } : {}),
2348
- updatedAt: nowISO(),
2349
- }));
2093
+ const checklistTitles = checklistSeed?.trim() ? splitCsv(checklistSeed) : undefined;
2094
+ let checklistLostTicks = [];
2095
+ await st.updateTask(phase.id, task.id, (current) => {
2096
+ const next = { ...current, updatedAt: nowISO() };
2097
+ if (description?.trim())
2098
+ next.description = description.trim();
2099
+ if (checklistTitles) {
2100
+ const replacement = replaceChecklist(current.checklist ?? [], checklistTitles, task.id);
2101
+ next.checklist = replacement.items;
2102
+ checklistLostTicks = replacement.lostTicks;
2103
+ }
2104
+ return next;
2105
+ });
2350
2106
  await st.writeGenerated();
2351
- ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.`, "info");
2107
+ const lostTicksNotice = checklistLostTicks.length > 0
2108
+ ? ` Warning: lost tick on ${checklistLostTicks.length} item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
2109
+ : "";
2110
+ ctx.ui.notify(`Task ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(task.number).padStart(3, "0")} discussed and updated.${lostTicksNotice}`, checklistLostTicks.length > 0 ? "warning" : "info");
2352
2111
  return;
2353
2112
  }
2354
2113
  if (b === "show") {
@@ -2480,6 +2239,35 @@ export default function planPiExtension(pi) {
2480
2239
  ctx.ui.notify(`Task updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, (await st.loadFeatures()).features))}/T${String(result.task.number).padStart(3, "0")} — ${result.task.title} (${result.task.status})${result.task.shortId ? ` · ${result.task.shortId}` : ""}`, "info");
2481
2240
  return;
2482
2241
  }
2242
+ if (b === "recommend") {
2243
+ // "What should I work on next" is a supervisor question, not only
2244
+ // an agent one (see P104(F005)/T419) — the only reason it lacked an
2245
+ // interactive path was that the other agent-only commands (pause,
2246
+ // switch, deviation) set the pattern by default. Reuses the same
2247
+ // recommendNextWork + buildRecommendationReply pipeline as the
2248
+ // task_recommend tool, so a person at the command line and an agent
2249
+ // calling the tool see identical reasoning, just a compact few-line
2250
+ // notify instead of a structured payload.
2251
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
2252
+ st.loadFeatures(),
2253
+ st.loadAllPhases(),
2254
+ st.loadProject(),
2255
+ st.loadResume(),
2256
+ st.listHandoffs(),
2257
+ st.listArchivedHandoffs(),
2258
+ ]);
2259
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
2260
+ handoffs: handoffs.map((handoff) => ({
2261
+ compositeRef: handoff.compositeRef,
2262
+ firstLine: handoff.firstLine,
2263
+ resumeReady: handoff.resumeReady,
2264
+ })),
2265
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
2266
+ });
2267
+ const reply = buildRecommendationReply(result, features.features);
2268
+ ctx.ui.notify(reply.text, "info");
2269
+ return;
2270
+ }
2483
2271
  if (b === "start") {
2484
2272
  return st.runBatch(async () => {
2485
2273
  let phase = null;
@@ -2765,11 +2553,7 @@ export default function planPiExtension(pi) {
2765
2553
  return;
2766
2554
  }
2767
2555
  if (action === "prepare") {
2768
- if (!capturedPi) {
2769
- ctx.ui.notify("Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", "warning");
2770
- return;
2771
- }
2772
- await capturedPi.sendUserMessage("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
2556
+ const handoffPrepared = sendToAgent("Prepare a canonical session handoff proposal. Do not write anything yet. First identify from this conversation and the latest planner lifecycle events the exact feature and phase where the decisions/work belong. Never use the first in-progress phase or a stale resume pointer as a target.\n\n" +
2773
2557
  "If the last phase was just completed, do not create an operational handoff for it; its existing handoff must have been archived by the phase-done lifecycle. If another non-done phase is the real continuation, identify that phase explicitly.\n\n" +
2774
2558
  "Before writing, tell the user exactly: `I propose writing this handoff on P00x(F00x) — <phase title>. Confirm?` Wait for an explicit confirmation. If the target is ambiguous, ask the user to identify the feature and phase instead of guessing.\n\n" +
2775
2559
  "After confirmation, call `handoff_prepare` with that exact phaseRef. BEFORE drafting prose, use its exact returned scaffold and cold-start inventory categories. Read the entire existing handoff and its handoffUpdatedAt token. Complete every source review by checking the conversation and approvals, planner entities and sibling tasks, current diff/implementation, verification/runtime evidence, and peer-agent or network messages (or explicitly record that no peer output exists). Inventory every exact file, symbol, working-tree ownership/completion state, commit authorization, work or deletion not yet started, command/tool path, runtime call site, preservation constraint, proof observation, related planned capability, user-visible behavior, operator action, blocker/risk, remaining item, and ordered resume step known from this session. Then call `handoff_write` once with the exact token, the completed scaffold, the structured coldStartInventory, taskUpdates for every missing task, and either durable phase/feature updates or explicit no-update reasons. Every inventory item must appear verbatim in the inline handoff or a validated supporting document. Never overwrite an existing handoff without retaining its still-relevant decisions, solved problems, architecture, files, and constraints. Replace stale focus, blockers, and next steps. The write persists only a candidate and returns resumeReady=false. Immediately call `handoff_show`, read the entire persisted body, compare it again against all five sources, and either rewrite every discovered omission or call `handoff_verify` with the shown contentHash. Never tell the user the handoff is complete until verification returns resumeReady=true.\n\n" +
@@ -2783,7 +2567,9 @@ export default function planPiExtension(pi) {
2783
2567
  "- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
2784
2568
  "- `## How to resume`: explicit ordered steps\n" +
2785
2569
  "Pass a meaningful title beginning with the confirmed phase ref. Generic titles are rejected. Handoff refresh synchronizes durable task/phase/feature context and updates the single active phase.handoff; `.planner/HANDOFF.md` is deprecated.");
2786
- ctx.ui.notify("Instructing the agent to prepare the phase handoff…", "info");
2570
+ ctx.ui.notify(handoffPrepared
2571
+ ? "Instructing the agent to prepare the phase handoff…"
2572
+ : "Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", handoffPrepared ? "info" : "warning");
2787
2573
  return;
2788
2574
  }
2789
2575
  if (action === "clear" || action === "delete") {
@@ -2944,23 +2730,38 @@ export default function planPiExtension(pi) {
2944
2730
  // turn (before_agent_start's systemPrompt injection is NOT applied to
2945
2731
  // triggerTurn messages), so the recap data must live here. Uses the shared
2946
2732
  // core buildRecap so Pi and Claude Code/Codex present identical content.
2947
- let recapText = "";
2948
- let plannerSkillContext = "";
2733
+ // One text, each piece once — the same builder MCP's planner-load and
2734
+ // Pi's planner-load tool use. In Pi this command is the only way the
2735
+ // operating guide reaches the agent, so the guide body is included.
2736
+ let loadText = "";
2949
2737
  try {
2950
2738
  const plannerSkill = await st.syncPlannerSkill();
2951
2739
  await st.syncGrillMeSkill();
2952
2740
  const srv = server;
2953
- recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2741
+ let recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2954
2742
  if (preparation.changed)
2955
2743
  recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
2956
- plannerSkillContext = `[agent-only planner usage skill; do not quote in the recap]\n${plannerSkill.customized ? `${plannerSkill.message}\n` : ""}${plannerSkill.content}\n[end agent-only planner usage skill]`;
2744
+ // Explicit planner load must deliver the complete project-level
2745
+ // context (P102(F005)/T404), attested through the same lossless
2746
+ // contract used everywhere else.
2747
+ const projectContextDelivery = await st.loadProjectContextDelivery(DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
2748
+ const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
2749
+ if (projectContextReply.structured.contextComplete) {
2750
+ await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
2751
+ }
2752
+ loadText = buildPlannerLoadReply({
2753
+ recap: recapText,
2754
+ projectContext: projectContextReply,
2755
+ scopedDecisions: buildScopedDecisionContext(await st.loadAll()),
2756
+ plannerGuide: { mode: "include", content: plannerSkill.content, customized: plannerSkill.customized, message: plannerSkill.message },
2757
+ }).text;
2957
2758
  }
2958
2759
  catch (e) {
2959
- recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2760
+ loadText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2960
2761
  }
2961
2762
  pi.sendMessage({
2962
2763
  customType: "planner-resume-trigger",
2963
- content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill, then present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n" + plannerSkillContext + "\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2764
+ content: "[internal trigger — not a user command] Read and retain the agent-only context below, then present the recap section 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" + loadText,
2964
2765
  display: false,
2965
2766
  }, {
2966
2767
  triggerTurn: true,
@@ -3057,7 +2858,7 @@ export default function planPiExtension(pi) {
3057
2858
  description: "Initialize a new project plan (.planner/) in the current cwd. Use once at the start of a project. When deriving the planner from an existing document or plan, use a concise human project title and ask the user to confirm it if your candidate title is long, compound, or looks like a phase heading.",
3058
2859
  parameters: Type.Object({
3059
2860
  projectName: Type.String({ description: "Concise project name/title" }),
3060
- description: Type.Optional(Type.String({ description: "Short project description" })),
2861
+ description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3061
2862
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
3062
2863
  requirements: Type.Optional(Type.Array(Type.String(), { description: "Initial declarative product outcomes to seed as Requirements. Never include coding standards, best practices, formatting, verification process, or other Project Guidelines." })),
3063
2864
  }),
@@ -3086,7 +2887,7 @@ export default function planPiExtension(pi) {
3086
2887
  if (requirementTitles.length > 0) {
3087
2888
  const now = nowISO();
3088
2889
  await st.saveRequirements({
3089
- requirements: requirementTitles.map((title, index) => ({
2890
+ requirements: requirementTitles.map((title) => ({
3090
2891
  id: createRequirementId(),
3091
2892
  title,
3092
2893
  description: "",
@@ -3111,7 +2912,7 @@ export default function planPiExtension(pi) {
3111
2912
  description: "Update project-level metadata such as title, description, goal, scope, technologies, and rules. Use this after importing or refining an existing plan so the planner root is not left empty.",
3112
2913
  parameters: Type.Object({
3113
2914
  name: Type.Optional(Type.String({ description: "Project title" })),
3114
- description: Type.Optional(Type.String({ description: "Short project description" })),
2915
+ description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3115
2916
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
3116
2917
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
3117
2918
  scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
@@ -3119,17 +2920,24 @@ export default function planPiExtension(pi) {
3119
2920
  technologies: Type.Optional(Type.Array(Type.String(), { description: "Replace technologies list" })),
3120
2921
  tools: Type.Optional(Type.Array(Type.String(), { description: "Replace tools list" })),
3121
2922
  globalRules: Type.Optional(Type.Array(Type.String(), { description: "Replace global rules" })),
3122
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace project decisions" })),
2923
+ decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
3123
2924
  }),
3124
2925
  async execute(_id, params, _signal, _onUpdate, ctx) {
3125
2926
  const st = await requirePlan(ctx);
3126
2927
  if (!st)
3127
2928
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
2929
+ if (params.decisions !== undefined) {
2930
+ return {
2931
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
2932
+ details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" },
2933
+ isError: true,
2934
+ };
2935
+ }
3128
2936
  const project = await st.loadProject();
3129
- const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
2937
+ const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
3130
2938
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3131
2939
  if (receivedFields.length === 0) {
3132
- return mutationNoFieldsFailure({
2940
+ return piMutationNoFieldsFailure({
3133
2941
  entity: "project",
3134
2942
  ref: project.name,
3135
2943
  operation: "update",
@@ -3166,8 +2974,6 @@ export default function planPiExtension(pi) {
3166
2974
  project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
3167
2975
  if (params.globalRules !== undefined)
3168
2976
  project.globalRules = params.globalRules.map((entry) => entry.trim()).filter(Boolean);
3169
- if (params.decisions !== undefined)
3170
- project.decisions = params.decisions.map((entry) => entry.trim()).filter(Boolean);
3171
2977
  const persisted = await st.updateProject((current) => {
3172
2978
  const next = {
3173
2979
  ...current,
@@ -3179,7 +2985,6 @@ export default function planPiExtension(pi) {
3179
2985
  ...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
3180
2986
  ...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
3181
2987
  ...(params.globalRules !== undefined ? { globalRules: params.globalRules.map((entry) => entry.trim()).filter(Boolean) } : {}),
3182
- ...(params.decisions !== undefined ? { decisions: params.decisions.map((entry) => entry.trim()).filter(Boolean) } : {}),
3183
2988
  };
3184
2989
  if (params.descriptionRef !== undefined) {
3185
2990
  const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
@@ -3191,7 +2996,14 @@ export default function planPiExtension(pi) {
3191
2996
  return next;
3192
2997
  });
3193
2998
  await st.writeGenerated();
3194
- return { content: [{ type: "text", text: `Project updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...persisted, updated: true, updatedFields: receivedFields } };
2999
+ const projectUpdateReply = buildMutationReply({
3000
+ identity: { entity: "project", ref: persisted.name, title: persisted.name },
3001
+ operation: "update",
3002
+ updatedFields: receivedFields,
3003
+ changedValues: persisted,
3004
+ readBackCommand: "plan_get",
3005
+ });
3006
+ return { content: [{ type: "text", text: projectUpdateReply.text }], details: projectUpdateReply.structured };
3195
3007
  },
3196
3008
  });
3197
3009
  pi.registerTool({
@@ -3333,7 +3145,7 @@ export default function planPiExtension(pi) {
3333
3145
  description: "Create a declarative product Requirement describing a user, business, or system outcome, with optional nested macro tasks. Never store coding standards, best practices, formatting, verification process, or other Project Guidelines here; use project_guidelines_update instead. Requirements have no lifecycle status.",
3334
3146
  parameters: Type.Object({
3335
3147
  title: Type.String({ description: "Requirement title" }),
3336
- description: Type.Optional(Type.String({ description: "Requirement description" })),
3148
+ description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3337
3149
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
3338
3150
  macroTasks: Type.Optional(Type.Array(Type.Object({
3339
3151
  title: Type.String({ description: "Macro-task title" }),
@@ -3392,7 +3204,7 @@ export default function planPiExtension(pi) {
3392
3204
  parameters: Type.Object({
3393
3205
  requirementId: Type.String({ description: "Requirement ID" }),
3394
3206
  title: Type.Optional(Type.String({ description: "Requirement title" })),
3395
- description: Type.Optional(Type.String({ description: "Requirement description" })),
3207
+ description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3396
3208
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
3397
3209
  macroTasks: Type.Optional(Type.Array(Type.Object({
3398
3210
  id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
@@ -3413,7 +3225,7 @@ export default function planPiExtension(pi) {
3413
3225
  return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
3414
3226
  }
3415
3227
  if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
3416
- return mutationNoFieldsFailure({
3228
+ return piMutationNoFieldsFailure({
3417
3229
  entity: "requirement",
3418
3230
  ref: params.requirementId,
3419
3231
  operation: "update",
@@ -3421,6 +3233,12 @@ export default function planPiExtension(pi) {
3421
3233
  retryCommand: `requirement_update ${params.requirementId}`,
3422
3234
  });
3423
3235
  }
3236
+ const receivedFields = [
3237
+ ...(params.title !== undefined ? ["title"] : []),
3238
+ ...(params.description !== undefined ? ["description"] : []),
3239
+ ...(params.linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
3240
+ ...(params.macroTasks !== undefined ? ["macroTasks"] : []),
3241
+ ];
3424
3242
  const links = params.linkedPhaseIds === undefined
3425
3243
  ? undefined
3426
3244
  : await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
@@ -3448,7 +3266,14 @@ export default function planPiExtension(pi) {
3448
3266
  return { content: [{ type: "text", text: message }], details: {} };
3449
3267
  }
3450
3268
  await st.writeGenerated();
3451
- return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
3269
+ const requirementUpdateReply = buildMutationReply({
3270
+ identity: { entity: "requirement", ref: persisted.id, id: persisted.id, title: persisted.title },
3271
+ operation: "update",
3272
+ updatedFields: receivedFields,
3273
+ changedValues: persisted,
3274
+ readBackCommand: "requirement_list",
3275
+ });
3276
+ return { content: [{ type: "text", text: requirementUpdateReply.text }], details: requirementUpdateReply.structured };
3452
3277
  },
3453
3278
  });
3454
3279
  pi.registerTool({
@@ -3495,7 +3320,7 @@ export default function planPiExtension(pi) {
3495
3320
  async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3496
3321
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3497
3322
  return { content: [{ type: "text", text: `Idea not found: ${params.idea}` }], details: { errorCode: "NOT_FOUND", updated: false } }; if (params.title === undefined && params.description === undefined)
3498
- return mutationNoFieldsFailure({ entity: "idea", ref: params.idea, operation: "update", mutableFields: ["title", "description"], retryCommand: `idea_update ${params.idea}` }); const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); return { content: [{ type: "text", text: `Idea updated: ${formatIdeaRef(idea.number)} — ${idea.title}` }], details: { idea, updated: true } }; },
3323
+ return piMutationNoFieldsFailure({ entity: "idea", ref: params.idea, operation: "update", mutableFields: ["title", "description"], retryCommand: `idea_update ${params.idea}` }); const ideaUpdatedFields = [...(params.title !== undefined ? ["title"] : []), ...(params.description !== undefined ? ["description"] : [])]; const idea = await st.updateIdea(current.id, { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}) }); await st.writeGenerated(); const ideaReply = buildMutationReply({ identity: { entity: "idea", ref: formatIdeaRef(idea.number), id: idea.id, title: idea.title }, operation: "update", updatedFields: ideaUpdatedFields, changedValues: idea, readBackCommand: `idea_show ${formatIdeaRef(idea.number)}` }); return { content: [{ type: "text", text: ideaReply.text }], details: ideaReply.structured }; },
3499
3324
  });
3500
3325
  pi.registerTool({
3501
3326
  name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
@@ -3568,9 +3393,20 @@ export default function planPiExtension(pi) {
3568
3393
  ? "Active tasks: none (verified from all persisted phase task statuses)"
3569
3394
  : `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
3570
3395
  const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
3396
+ // P104(F005)/T422: a phase can be left in-progress with no active task
3397
+ // and nothing else said so on this cheap read — render that fact,
3398
+ // priority-ordered, the same shape planner-show renders on the MCP side.
3399
+ const openPhaseWork = listOpenPhaseWork(plan.phases, plan.features.features);
3400
+ const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
3571
3401
  return {
3572
- content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements. ${activeTaskEvidence}\n${projectDecisionContext.content}` }],
3573
- details: { ...plan, activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict", activeTasks },
3402
+ content: [{ type: "text", text: `Plan "${plan.project.name}": ${plan.features.features.length} features, ${plan.phases.length} phases, ${plan.requirements.requirements.length} requirements. ${activeTaskEvidence}${openPhaseWorkText ? `\nIn-progress phases with work left (priority order):\n${openPhaseWorkText}` : ""}\n${projectDecisionContext.content}` }],
3403
+ details: {
3404
+ ...plan,
3405
+ activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict",
3406
+ activeTasks,
3407
+ openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
3408
+ openPhaseWorkTotal: openPhaseWork.length,
3409
+ },
3574
3410
  };
3575
3411
  },
3576
3412
  });
@@ -3798,53 +3634,27 @@ export default function planPiExtension(pi) {
3798
3634
  if (!r.ok)
3799
3635
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3800
3636
  const phase = await st.loadPhase(r.phase.id);
3801
- const bounded = boundedHandoffForTransport(phase.handoff);
3802
- if (!bounded.content.trim()) {
3637
+ if (!phase.handoff.trim()) {
3803
3638
  const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
3804
3639
  && new Set(["phase-done", "phase-rejected", "phase-canceled"]).has(entry.reason));
3805
- if (archived) {
3806
- const archivedContent = boundedHandoffForTransport(archived.content);
3807
- const supportingDocuments = phase.handoffAudit?.supportingDocuments ?? [];
3808
- const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
3809
- return {
3810
- content: [{ type: "text", text: [
3811
- `Archived terminal-phase handoff for ${r.compositeRef} (${archived.reason}; archived ${archived.archivedAt})`,
3812
- ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
3813
- "",
3814
- archivedContent.content,
3815
- ].join("\n") }],
3816
- details: {
3817
- phaseRef: r.compositeRef,
3818
- phaseId: phase.id,
3819
- active: false,
3820
- archived: true,
3821
- archiveReason: archived.reason,
3822
- archivedAt: archived.archivedAt,
3823
- archiveFile: archived.file,
3824
- ...archivedContent,
3825
- supportingDocuments,
3826
- empty: false,
3827
- resumeReady: false,
3828
- handoffAudit: phase.handoffAudit,
3829
- },
3830
- };
3831
- }
3832
- return { content: [{ type: "text", text: `No handoff set on ${r.compositeRef}.` }], details: { phaseRef: r.compositeRef, phaseId: r.phase.id, content: "", empty: true, resumeReady: false, handoffAudit: phase.handoffAudit } };
3640
+ const reply = archived
3641
+ ? buildHandoffShowReply({
3642
+ kind: "archived",
3643
+ phaseRef: r.compositeRef,
3644
+ phaseId: phase.id,
3645
+ content: archived.content,
3646
+ archiveReason: archived.reason,
3647
+ archivedAt: archived.archivedAt,
3648
+ archiveFile: archived.file,
3649
+ handoffAudit: phase.handoffAudit,
3650
+ })
3651
+ : buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
3652
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3833
3653
  }
3834
3654
  const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
3835
3655
  const phaseWorkMap = buildPhaseWorkMap(phase, feature?.number);
3836
- const contentHash = handoffContentHash(phase.handoff);
3837
- const persistenceVerified = Boolean(phase.handoffAudit
3838
- && phase.handoffAudit.contentHash === contentHash
3839
- && phase.handoffAudit.contentLength === phase.handoff.length);
3840
- const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
3841
- const status = resumeReady
3842
- ? "Resume-ready: persisted compact capsule read-back completed."
3843
- : "NOT resume-ready: after reading this persisted capsule, call handoff_verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
3844
- return {
3845
- content: [{ type: "text", text: `Handoff for ${r.compositeRef}\n${status}\nContent hash: ${contentHash}\n\n${phaseWorkMap.content}\n\nBefore proposing new work, reread the canonical phase and relevant sibling task full view; do not duplicate an already-owned capability.\n\n${bounded.content}` }],
3846
- details: { phaseRef: r.compositeRef, phaseId: r.phase.id, phaseWorkMap, ...bounded, contentHash, persistenceVerified, resumeReady, verificationRequired: !resumeReady, handoffAudit: phase.handoffAudit },
3847
- };
3656
+ const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
3657
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3848
3658
  },
3849
3659
  });
3850
3660
  pi.registerTool({
@@ -3881,7 +3691,7 @@ export default function planPiExtension(pi) {
3881
3691
  }
3882
3692
  catch (error) {
3883
3693
  if (error instanceof HandoffContractError)
3884
- return handoffContractFailure(error);
3694
+ return piHandoffContractFailure(error);
3885
3695
  const message = error instanceof Error ? error.message : String(error);
3886
3696
  return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
3887
3697
  }
@@ -3891,7 +3701,13 @@ export default function planPiExtension(pi) {
3891
3701
  name: "handoff_prepare",
3892
3702
  label: "Handoff Prepare",
3893
3703
  description: "Prepare one exact phase before writing a handoff. Returns a compact resume capsule scaffold, bounded phase work map, current token, missing task evidence, and inline budget. Reread the canonical phase and relevant sibling tasks before proposing work; do not duplicate planner evidence in Markdown.",
3894
- parameters: Type.Object({ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }) }),
3704
+ parameters: Type.Object({
3705
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3706
+ supportingDocuments: Type.Optional(Type.Array(Type.Object({
3707
+ path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
3708
+ description: Type.String({ description: "What the document contains and why the next agent needs it." }),
3709
+ }), { description: "Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically." })),
3710
+ }),
3895
3711
  async execute(_id, params, _signal, _onUpdate, ctx) {
3896
3712
  const st = await requirePlan(ctx);
3897
3713
  if (!st)
@@ -3900,42 +3716,13 @@ export default function planPiExtension(pi) {
3900
3716
  if (!r.ok)
3901
3717
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3902
3718
  try {
3903
- const audit = await st.preparePhaseHandoff(r.phase.id);
3904
- const { handoff, completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, ...auditDetails } = audit;
3905
- const boundedExisting = boundedHandoffForTransport(handoff);
3906
- const missing = audit.missingCompletionTasks.map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`).join("\n") || "- None";
3907
- const existing = boundedExisting.content.trim() || "(none)";
3908
- return {
3909
- content: [{ type: "text", text: [
3910
- `Handoff preparation audit for ${r.compositeRef}`,
3911
- `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
3912
- "Required human inputs before drafting:",
3913
- ...audit.requiredHumanInputs.map((input) => `- ${input.id} — ${input.description}`),
3914
- "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
3915
- "Use this exact scaffold before drafting; replace every angle-bracket placeholder and retain every heading:",
3916
- audit.draftTemplate,
3917
- "",
3918
- "Done tasks missing durable completion/verification evidence:",
3919
- missing,
3920
- "",
3921
- "Compact handoff contract: include only the exact focus/resume point, current or partial state, preservation constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
3922
- "No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
3923
- "Completeness audit and cold-start evidence are planner-owned metadata. Do not copy their categories or source reviews into Markdown; provide legacy evidence only when it already exists.",
3924
- `Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
3925
- "",
3926
- "Existing active handoff (reconcile all still-relevant content):",
3927
- existing,
3928
- ].join("\n") }],
3929
- details: {
3930
- phaseRef: r.compositeRef,
3931
- ...auditDetails,
3932
- evidenceContract: "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.",
3933
- existingHandoffLength: boundedExisting.fullLength,
3934
- existingHandoffTruncated: boundedExisting.truncated,
3935
- },
3936
- };
3719
+ const audit = await st.preparePhaseHandoff(r.phase.id, params.supportingDocuments);
3720
+ const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
3721
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3937
3722
  }
3938
3723
  catch (error) {
3724
+ if (error instanceof HandoffContractError)
3725
+ return piHandoffContractFailure(error, "preparation");
3939
3726
  const message = error instanceof Error ? error.message : String(error);
3940
3727
  return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
3941
3728
  }
@@ -3944,16 +3731,20 @@ export default function planPiExtension(pi) {
3944
3731
  pi.registerTool({
3945
3732
  name: "handoff_write",
3946
3733
  label: "Handoff Write",
3947
- description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown.",
3734
+ description: "Persist a compact reconciled handoff candidate with durable context synchronization. The result always has resumeReady=false until a separate handoff_show read-back and handoff_verify succeeds. Run handoff_prepare first; extended detail belongs in linked .planner/docs/ Markdown. On failure the submitted content is retained against phaseRef + expectedHandoffUpdatedAt: a retry against the exact same token may omit content and markdown_content to reuse it instead of resending the whole body.",
3948
3735
  parameters: Type.Object({
3949
3736
  phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3950
- title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists)." })),
3737
+ title: Type.Optional(Type.String({ description: "Meaningful handoff title summarizing the work (becomes the H1 / first line in lists). Ignored when content and markdown_content are both omitted (the retained title is reused)." })),
3951
3738
  reason: Type.Optional(Type.String({ description: "Why work is stopping and why a cold agent needs this handoff. Required for confirmed writes; rendered by the planner, not in Markdown." })),
3952
3739
  confirmed: Type.Boolean({ description: "Set true only after the user explicitly confirms the proposed feature+phase target." }),
3953
- content: Type.Optional(Type.String({ description: "Handoff text (plain)." })),
3954
- markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content." })),
3740
+ content: Type.Optional(Type.String({ description: "Handoff text (plain). Omit (with markdown_content) only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the body retained from that failed attempt." })),
3741
+ markdown_content: Type.Optional(Type.String({ description: "Handoff text (markdown). Preferred over content. Omit (with content) only on a retry after a failed write against the exact same expectedHandoffUpdatedAt, to reuse the retained body." })),
3955
3742
  expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
3956
3743
  reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
3744
+ sectionDispositions: Type.Optional(Type.Array(Type.Object({
3745
+ section: Type.String({ description: "Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal." }),
3746
+ disposition: Type.String({ description: "What happened to that section: dropped, superseded, or folded into another section." }),
3747
+ }), { description: "Required only when a prior write was refused with HANDOFF_SECTION_RECONCILIATION_REQUIRED; one entry per section it named. Omit on a first write." })),
3957
3748
  completenessAudit: Type.Optional(Type.Object({
3958
3749
  version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
3959
3750
  entries: Type.Array(Type.Object({
@@ -3999,24 +3790,29 @@ export default function planPiExtension(pi) {
3999
3790
  const st = await requirePlan(ctx);
4000
3791
  if (!st)
4001
3792
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4002
- let body = (params.markdown_content ?? params.content ?? "").trim();
4003
- if (!body)
4004
- return { content: [{ type: "text", text: "❌ Provide the handoff text (content or markdown_content)." }], details: { error: "empty text" } };
3793
+ // content/markdown_content are optional so a retry after a failed write
3794
+ // can omit them and reuse the body PlanStore retained against this
3795
+ // phase + expectedHandoffUpdatedAt (see PlanStore.refreshPhaseHandoff).
3796
+ // body stays undefined only in that case; the title/heading checks
3797
+ // below apply only to freshly-submitted text.
3798
+ let body = (params.markdown_content ?? params.content ?? "").trim() || undefined;
4005
3799
  const title = params.title?.trim();
4006
- const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
4007
- const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
4008
- const effectiveHeadingText = title || firstHeadingText;
4009
- if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
4010
- return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
4011
- }
4012
- if (title) {
4013
- const lines = body.split(/\r?\n/);
4014
- const firstIdx = lines.findIndex((line) => line.trim().length > 0);
4015
- if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
4016
- lines[firstIdx] = `# ${title}`;
4017
- else
4018
- lines.unshift(`# ${title}`, "");
4019
- body = lines.join("\n");
3800
+ if (body !== undefined) {
3801
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
3802
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3803
+ const effectiveHeadingText = title || firstHeadingText;
3804
+ if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
3805
+ return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
3806
+ }
3807
+ if (title) {
3808
+ const lines = body.split(/\r?\n/);
3809
+ const firstIdx = lines.findIndex((line) => line.trim().length > 0);
3810
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
3811
+ lines[firstIdx] = `# ${title}`;
3812
+ else
3813
+ lines.unshift(`# ${title}`, "");
3814
+ body = lines.join("\n");
3815
+ }
4020
3816
  }
4021
3817
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
4022
3818
  if (!r.ok)
@@ -4046,9 +3842,10 @@ export default function planPiExtension(pi) {
4046
3842
  try {
4047
3843
  const result = await st.refreshPhaseHandoff(r.phase.id, {
4048
3844
  reason: params.reason.trim(),
4049
- content: body,
3845
+ ...(body !== undefined ? { content: body } : {}),
4050
3846
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
4051
3847
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
3848
+ ...(params.sectionDispositions ? { sectionDispositions: params.sectionDispositions } : {}),
4052
3849
  ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
4053
3850
  ...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
4054
3851
  ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
@@ -4085,9 +3882,9 @@ export default function planPiExtension(pi) {
4085
3882
  }
4086
3883
  catch (error) {
4087
3884
  if (error instanceof HandoffContractError)
4088
- return handoffContractFailure(error);
3885
+ return piHandoffContractFailure(error);
4089
3886
  const message = error instanceof Error ? error.message : String(error);
4090
- return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
3887
+ return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message} If you retry against the same phaseRef and expectedHandoffUpdatedAt, you may omit content/markdown_content to reuse the body just submitted.` }], details: { error: message } };
4091
3888
  }
4092
3889
  },
4093
3890
  });
@@ -4219,7 +4016,6 @@ export default function planPiExtension(pi) {
4219
4016
  return st.runBatch(async () => {
4220
4017
  const now = nowISO();
4221
4018
  const status = params.status ?? "planned";
4222
- const currentFeatures = (await st.loadFeatures()).features;
4223
4019
  const id = createFeatureId();
4224
4020
  const identity = await st.allocateEntityIdentity("feature", id);
4225
4021
  const priority = await st.nextPriority("feature");
@@ -4264,7 +4060,7 @@ export default function planPiExtension(pi) {
4264
4060
  description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
4265
4061
  parameters: Type.Object({
4266
4062
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
4267
- description: Type.Optional(Type.String({ description: "Current implementation state, scope, and goals for this feature" })),
4063
+ description: Type.Optional(Type.String({ description: `Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}` })),
4268
4064
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4269
4065
  workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
4270
4066
  workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
@@ -4282,7 +4078,7 @@ export default function planPiExtension(pi) {
4282
4078
  const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
4283
4079
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4284
4080
  if (receivedFields.length === 0) {
4285
- return mutationNoFieldsFailure({
4081
+ return piMutationNoFieldsFailure({
4286
4082
  entity: "feature",
4287
4083
  ref: formatFeatureRef(resolvedFeature.feature.number),
4288
4084
  operation: "discuss",
@@ -4326,7 +4122,14 @@ export default function planPiExtension(pi) {
4326
4122
  if (!feature)
4327
4123
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
4328
4124
  await st.writeGenerated();
4329
- 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 } };
4125
+ const featureDiscussReply = buildMutationReply({
4126
+ identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
4127
+ operation: "discuss",
4128
+ updatedFields: receivedFields,
4129
+ changedValues: feature,
4130
+ readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
4131
+ });
4132
+ return { content: [{ type: "text", text: featureDiscussReply.text }], details: featureDiscussReply.structured };
4330
4133
  },
4331
4134
  });
4332
4135
  pi.registerTool({
@@ -4336,7 +4139,7 @@ export default function planPiExtension(pi) {
4336
4139
  parameters: Type.Object({
4337
4140
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
4338
4141
  name: Type.Optional(Type.String({ description: "New name" })),
4339
- description: Type.Optional(Type.String({ description: "New description" })),
4142
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
4340
4143
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4341
4144
  status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Feature status is derived from child phases/tasks; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
4342
4145
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
@@ -4363,12 +4166,12 @@ export default function planPiExtension(pi) {
4363
4166
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
4364
4167
  const featureId = resolvedFeature.feature.id;
4365
4168
  if (params.status !== undefined) {
4366
- return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4169
+ return piDerivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4367
4170
  }
4368
4171
  if (params.acceptedDecisions !== undefined) {
4369
4172
  return {
4370
- content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
4371
- details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
4173
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
4174
+ details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
4372
4175
  isError: true,
4373
4176
  };
4374
4177
  }
@@ -4384,7 +4187,7 @@ export default function planPiExtension(pi) {
4384
4187
  ];
4385
4188
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4386
4189
  if (receivedFields.length === 0) {
4387
- return mutationNoFieldsFailure({
4190
+ return piMutationNoFieldsFailure({
4388
4191
  entity: "feature",
4389
4192
  ref: formatFeatureRef(resolvedFeature.feature.number),
4390
4193
  operation: "update",
@@ -4442,7 +4245,13 @@ export default function planPiExtension(pi) {
4442
4245
  type: "text",
4443
4246
  text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
4444
4247
  }],
4445
- details: { ...feature, updated: true, updatedFields: receivedFields },
4248
+ details: buildMutationReply({
4249
+ identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
4250
+ operation: "update",
4251
+ updatedFields: receivedFields,
4252
+ changedValues: feature,
4253
+ readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
4254
+ }).structured,
4446
4255
  };
4447
4256
  },
4448
4257
  });
@@ -4462,27 +4271,15 @@ export default function planPiExtension(pi) {
4462
4271
  const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
4463
4272
  if (!resolvedFeature.ok)
4464
4273
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
4465
- const featureId = resolvedFeature.feature.id;
4466
- let deleted = false;
4467
- let cascadeCount = 0;
4468
- await st.runBatch(async () => {
4469
- await st.updateFeatures((doc) => {
4470
- const before = doc.features.length;
4471
- doc.features = doc.features.filter((f) => f.id !== featureId);
4472
- if (doc.features.length !== before)
4473
- deleted = true;
4474
- return doc;
4475
- });
4476
- if (params.cascade) {
4477
- const phases = await st.loadAllPhases();
4478
- for (const phase of phases.filter((p) => p.featureId === featureId)) {
4479
- await st.deletePhase(phase.id);
4480
- cascadeCount += 1;
4481
- }
4482
- }
4483
- await st.writeGenerated();
4484
- });
4485
- return { content: [{ type: "text", text: `Feature deleted: ${params.featureId}${params.cascade ? ` (cascade: ${cascadeCount} phases)` : ""}` }], details: { deleted: params.featureId, cascadedPhases: cascadeCount } };
4274
+ const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(params.cascade) });
4275
+ if (!outcome.ok) {
4276
+ return { content: [{ type: "text", text: `Feature delete failed: ${outcome.error}` }], details: { deleted: false, errorCode: outcome.errorCode }, isError: true };
4277
+ }
4278
+ const { result } = outcome;
4279
+ return {
4280
+ content: [{ type: "text", text: `Feature deleted: ${result.ref}${result.cascade ? ` (cascade: ${result.phaseCount} phases)` : result.phaseCount > 0 ? ` (unlinked: ${result.phaseCount} phases)` : ""}` }],
4281
+ details: { deleted: true, ref: result.ref, id: result.id, cascadedPhases: result.cascade ? result.phaseCount : 0, unlinkedPhases: result.cascade ? 0 : result.phaseCount },
4282
+ };
4486
4283
  },
4487
4284
  });
4488
4285
  // ── phases ──────────────────────────────────────────────────────────
@@ -4660,7 +4457,7 @@ export default function planPiExtension(pi) {
4660
4457
  const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4661
4458
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4662
4459
  if (receivedFields.length === 0) {
4663
- return mutationNoFieldsFailure({
4460
+ return piMutationNoFieldsFailure({
4664
4461
  entity: "phase",
4665
4462
  ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4666
4463
  operation: "discuss",
@@ -4709,7 +4506,15 @@ export default function planPiExtension(pi) {
4709
4506
  });
4710
4507
  await st.writeGenerated();
4711
4508
  const phaseRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, features));
4712
- return { content: [{ type: "text", text: `✅ Phase discussed/context ready: ${phaseRef} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, discussed: true, contextReady: true, updatedFields: receivedFields } };
4509
+ const phaseDiscussReply = buildMutationReply({
4510
+ identity: { entity: "phase", ref: phaseRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
4511
+ operation: "discuss",
4512
+ updatedFields: receivedFields,
4513
+ changedValues: phase,
4514
+ extras: { contextReady: true },
4515
+ readBackCommand: `phase_get ${phaseRef}`,
4516
+ });
4517
+ return { content: [{ type: "text", text: phaseDiscussReply.text }], details: phaseDiscussReply.structured };
4713
4518
  },
4714
4519
  });
4715
4520
  pi.registerTool({
@@ -4721,7 +4526,7 @@ export default function planPiExtension(pi) {
4721
4526
  title: Type.Optional(Type.String({ description: "New title" })),
4722
4527
  status: Type.Optional(Type.String({ description: "Deprecated compatibility field. Phase status is derived from task statuses; supplying this field returns DERIVED_STATUS_READ_ONLY." })),
4723
4528
  summary: Type.Optional(Type.String({ description: "New summary" })),
4724
- description: Type.Optional(Type.String({ description: "New description" })),
4529
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
4725
4530
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
4726
4531
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
4727
4532
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
@@ -4730,7 +4535,7 @@ export default function planPiExtension(pi) {
4730
4535
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
4731
4536
  risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
4732
4537
  openQuestions: Type.Optional(Type.Array(Type.String(), { description: "Replace open questions list" })),
4733
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
4538
+ decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
4734
4539
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
4735
4540
  id: Type.String(),
4736
4541
  title: Type.String(),
@@ -4758,19 +4563,26 @@ export default function planPiExtension(pi) {
4758
4563
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
4759
4564
  }
4760
4565
  if (params.status !== undefined) {
4761
- return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4566
+ return piDerivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4762
4567
  }
4763
4568
  if (params.acceptedDecisions !== undefined) {
4764
4569
  return {
4765
- content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
4766
- details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4570
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
4571
+ details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4767
4572
  isError: true,
4768
4573
  };
4769
4574
  }
4770
- const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
4575
+ if (params.decisions !== undefined) {
4576
+ return {
4577
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
4578
+ details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4579
+ isError: true,
4580
+ };
4581
+ }
4582
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4771
4583
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4772
4584
  if (receivedFields.length === 0) {
4773
- return mutationNoFieldsFailure({
4585
+ return piMutationNoFieldsFailure({
4774
4586
  entity: "phase",
4775
4587
  ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4776
4588
  operation: "update",
@@ -4812,7 +4624,6 @@ export default function planPiExtension(pi) {
4812
4624
  ...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
4813
4625
  ...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
4814
4626
  ...(params.openQuestions !== undefined ? { openQuestions: params.openQuestions.map((item) => item.trim()).filter(Boolean) } : {}),
4815
- ...(params.decisions !== undefined ? { decisions: params.decisions.map((item) => item.trim()).filter(Boolean) } : {}),
4816
4627
  ...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
4817
4628
  ...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
4818
4629
  updatedAt: nowISO(),
@@ -4847,13 +4658,22 @@ export default function planPiExtension(pi) {
4847
4658
  const staleParentRefs = descriptionFreshness?.diagnostics
4848
4659
  .filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
4849
4660
  .map((entry) => entry.ownerRef) ?? [];
4850
- 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, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
4661
+ const phaseUpdateRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures));
4662
+ const phaseUpdateReply = buildMutationReply({
4663
+ identity: { entity: "phase", ref: phaseUpdateRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
4664
+ operation: "update",
4665
+ updatedFields: receivedFields,
4666
+ changedValues: phase,
4667
+ extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
4668
+ readBackCommand: `phase_get ${phaseUpdateRef}`,
4669
+ });
4670
+ return { content: [{ type: "text", text: phaseUpdateReply.text }], details: phaseUpdateReply.structured };
4851
4671
  },
4852
4672
  });
4853
4673
  pi.registerTool({
4854
4674
  name: "decision_record",
4855
4675
  label: "Decision Record",
4856
- description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
4676
+ description: "DEPRECATED AND WRITE-DISABLED — retained only for compatibility. It no longer records a decision on both a feature and a phase (a decision has exactly one owner). Call accepted_decision_create instead with the single project/feature/phase/task owner that actually holds this decision; this tool only resolves the given feature/phase and returns that guidance.",
4857
4677
  parameters: Type.Object({
4858
4678
  featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
4859
4679
  phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
@@ -4876,49 +4696,17 @@ export default function planPiExtension(pi) {
4876
4696
  if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
4877
4697
  return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
4878
4698
  }
4879
- const acceptedAt = nowISO();
4880
- const acceptedDecision = {
4881
- id: crypto.randomUUID(),
4882
- title: params.title.trim(),
4883
- decision: params.decision.trim(),
4884
- rationale: params.rationale.trim(),
4885
- implementationNotes: params.implementationNotes.trim(),
4886
- acceptedAt,
4887
- };
4888
- await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
4889
- let featureWritten = false;
4890
- try {
4891
- await st.updateFeatures((doc) => {
4892
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4893
- if (!feature)
4894
- throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
4895
- feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
4896
- feature.updatedAt = acceptedAt;
4897
- return doc;
4898
- });
4899
- featureWritten = true;
4900
- await st.updatePhase(resolvedPhase.id, (phase) => {
4901
- phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
4902
- phase.updatedAt = acceptedAt;
4903
- return phase;
4904
- });
4905
- }
4906
- catch (error) {
4907
- // Best-effort compensation keeps a failed dual-write from reporting
4908
- // success with a decision persisted only on the feature.
4909
- if (featureWritten) {
4910
- await st.updateFeatures((doc) => {
4911
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4912
- if (feature)
4913
- feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
4914
- return doc;
4915
- }).catch(() => { });
4916
- }
4917
- throw error;
4918
- }
4919
- }));
4920
- await st.writeGenerated();
4921
- return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
4699
+ // No write happens here — see buildDecisionRecordRedirectReply. This
4700
+ // tool used to append the same AcceptedDecision to both the feature
4701
+ // and the phase; that violated the single-owner rule and mishandled
4702
+ // project-wide decisions discovered during task work, so it is now a
4703
+ // pure, non-mutating redirect (AGENTS.md rule 4: the rule and its
4704
+ // wording live once, in plan-core, not reinvented per adapter).
4705
+ const reply = buildDecisionRecordRedirectReply({
4706
+ featureRef: formatFeatureRef(resolvedFeature.feature.number),
4707
+ phaseRef: formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number),
4708
+ });
4709
+ return { content: [{ type: "text", text: `⚠️ decision_record is deprecated and write-disabled. ${reply.text}` }], details: { deprecated: true, ...reply.structured } };
4922
4710
  },
4923
4711
  });
4924
4712
  pi.registerTool({
@@ -4952,7 +4740,7 @@ export default function planPiExtension(pi) {
4952
4740
  return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
4953
4741
  }
4954
4742
  catch (error) {
4955
- return acceptedDecisionMutationFailure(error, "created");
4743
+ return piAcceptedDecisionMutationFailure(error, "created");
4956
4744
  }
4957
4745
  },
4958
4746
  });
@@ -5004,7 +4792,7 @@ export default function planPiExtension(pi) {
5004
4792
  return { content: [{ type: "text", text: `✅ Accepted decision updated on ${target.ref}: ${acceptedDecision.title}` }], details: { updated: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision, updatedFields: receivedFields } };
5005
4793
  }
5006
4794
  catch (error) {
5007
- return acceptedDecisionMutationFailure(error, "updated");
4795
+ return piAcceptedDecisionMutationFailure(error, "updated");
5008
4796
  }
5009
4797
  },
5010
4798
  });
@@ -5034,7 +4822,7 @@ export default function planPiExtension(pi) {
5034
4822
  return { content: [{ type: "text", text: `✅ Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}` }], details: { deleted: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId, acceptedDecision } };
5035
4823
  }
5036
4824
  catch (error) {
5037
- return acceptedDecisionMutationFailure(error, "deleted");
4825
+ return piAcceptedDecisionMutationFailure(error, "deleted");
5038
4826
  }
5039
4827
  },
5040
4828
  });
@@ -5133,7 +4921,8 @@ export default function planPiExtension(pi) {
5133
4921
  if (!st)
5134
4922
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5135
4923
  const features = (await st.loadFeatures()).features;
5136
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4924
+ const allPhases = await st.loadAllPhases();
4925
+ const found = findTaskByRef(allPhases, features, params.taskId.trim());
5137
4926
  if (!found)
5138
4927
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
5139
4928
  const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
@@ -5148,7 +4937,15 @@ export default function planPiExtension(pi) {
5148
4937
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5149
4938
  const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
5150
4939
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
5151
- const sections = [summary];
4940
+ // P104(F005)/T421: priority and dependsOn at the point an agent reads
4941
+ // the task and decides what to do next — the fact task_list and
4942
+ // task_recommend already have, made visible here instead of
4943
+ // requiring a second call.
4944
+ const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
4945
+ const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
4946
+ const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
4947
+ if (completionMismatch)
4948
+ sections.push(completionMismatch);
5152
4949
  if (found.task.description?.trim())
5153
4950
  sections.push(found.task.description.trim());
5154
4951
  if (found.task.descriptionRef)
@@ -5175,11 +4972,56 @@ export default function planPiExtension(pi) {
5175
4972
  description: found.task.description,
5176
4973
  descriptionRef: found.task.descriptionRef ?? "",
5177
4974
  acceptedDecisions: found.task.acceptedDecisions,
4975
+ priority: orderContext.priority,
4976
+ dependsOn: orderContext.dependsOn,
4977
+ ...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
4978
+ },
4979
+ taskOrder: {
4980
+ readyCount: orderContext.readyCount,
4981
+ readyAheadCount: orderContext.readyAheadCount,
4982
+ nextByPriorityRef: orderContext.nextByPriorityRef,
5178
4983
  },
5179
4984
  },
5180
4985
  };
5181
4986
  markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
5182
- return result;
4987
+ // Advisory only (P104(F005)/T420): report whether task_start would
4988
+ // succeed right now, using the exact checks it runs itself, so a
4989
+ // caller reading the task before starting it learns what else is
4990
+ // needed in this same call instead of from a denial. task_start
4991
+ // recomputes this at call time and remains the sole authority on
4992
+ // whether work may begin.
4993
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
4994
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
4995
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
4996
+ const taskRef = `${phaseRef}/T${pad(found.task.number)}`;
4997
+ const linkedRequirements = [
4998
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
4999
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
5000
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
5001
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
5002
+ const contextEligibility = contextReadEligibilityForSession({
5003
+ sessionId: plannerSessionId,
5004
+ taskId: found.task.id,
5005
+ phaseId: found.phase.id,
5006
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5007
+ task: found.task,
5008
+ phase: found.phase,
5009
+ ...(parentFeature ? { feature: parentFeature } : {}),
5010
+ requirements: linkedRequirements,
5011
+ requirementIds: linkedRequirementIds,
5012
+ });
5013
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5014
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5015
+ const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
5016
+ const gateNextActions = gateState.ready ? [] : piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `task_start ${taskRef}`);
5017
+ return {
5018
+ content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
5019
+ details: {
5020
+ ...result.details,
5021
+ taskStartGateState: gateState,
5022
+ ...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
5023
+ },
5024
+ };
5183
5025
  },
5184
5026
  });
5185
5027
  pi.registerTool({
@@ -5270,7 +5112,7 @@ export default function planPiExtension(pi) {
5270
5112
  });
5271
5113
  await st.writeGenerated();
5272
5114
  const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
5273
- return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5115
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title} (${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5274
5116
  },
5275
5117
  });
5276
5118
  pi.registerTool({
@@ -5290,7 +5132,8 @@ export default function planPiExtension(pi) {
5290
5132
  try {
5291
5133
  const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5292
5134
  await st.writeGenerated();
5293
- return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
5135
+ const reply = buildMutationReply({ identity: { entity: "task", ref: task.id, id: task.id, shortId: task.shortId, title: task.title, status: task.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: task.dependsOn }, readBackCommand: `task_get ${task.id}` });
5136
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
5294
5137
  }
5295
5138
  catch (error) {
5296
5139
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5317,7 +5160,8 @@ export default function planPiExtension(pi) {
5317
5160
  try {
5318
5161
  const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5319
5162
  await st.writeGenerated();
5320
- return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
5163
+ const reply = buildMutationReply({ identity: { entity: "task", ref: task.id, id: task.id, shortId: task.shortId, title: task.title, status: task.status }, operation: "update", updatedFields: ["dependsOn"], changedValues: { dependsOn: task.dependsOn }, readBackCommand: `task_get ${task.id}` });
5164
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
5321
5165
  }
5322
5166
  catch (error) {
5323
5167
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5333,11 +5177,11 @@ export default function planPiExtension(pi) {
5333
5177
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
5334
5178
  title: Type.Optional(Type.String({ description: "New title" })),
5335
5179
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
5336
- description: Type.Optional(Type.String({ description: "New description" })),
5180
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
5337
5181
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
5338
5182
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
5339
5183
  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." })),
5340
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
5184
+ decisions: Type.Optional(Type.Array(Type.String(), { description: "Deprecated legacy field. Read-only history now; supplying it returns LEGACY_DECISIONS_ARRAY_READ_ONLY. Use accepted_decision_create for new durable decisions." })),
5341
5185
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
5342
5186
  id: Type.String(),
5343
5187
  title: Type.String(),
@@ -5347,7 +5191,7 @@ export default function planPiExtension(pi) {
5347
5191
  acceptedAt: Type.String(),
5348
5192
  }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
5349
5193
  priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
5350
- checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
5194
+ checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). Ticks carry over by exact title match, and by position when the new list is the same length (so renaming an item keeps its tick); an item that cannot be matched loses its tick and that loss is reported. For interactive toggling use the web UI or checklist_toggle/add/remove." })),
5351
5195
  subtasks: Type.Optional(Type.Array(Type.Object({ id: Type.Optional(Type.String()), title: Type.String(), description: Type.Optional(Type.String()), status: Type.Optional(Type.String()) }), { description: "Replace subtasks; existing IDs must belong to this task and omitted IDs are planner-generated." })),
5352
5196
  }),
5353
5197
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -5362,6 +5206,7 @@ export default function planPiExtension(pi) {
5362
5206
  const hostPhase = found.phase;
5363
5207
  const now = nowISO();
5364
5208
  let updatedTask;
5209
+ let checklistLostTicks = [];
5365
5210
  // Validate the motivation requirement for restrictive status transitions,
5366
5211
  // mirroring the MCP adapter and the /planner task update command
5367
5212
  // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
@@ -5379,8 +5224,15 @@ export default function planPiExtension(pi) {
5379
5224
  }
5380
5225
  if (params.acceptedDecisions !== undefined) {
5381
5226
  return {
5382
- content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
5383
- details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
5227
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
5228
+ details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
5229
+ isError: true,
5230
+ };
5231
+ }
5232
+ if (params.decisions !== undefined) {
5233
+ return {
5234
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
5235
+ details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
5384
5236
  isError: true,
5385
5237
  };
5386
5238
  }
@@ -5399,10 +5251,10 @@ export default function planPiExtension(pi) {
5399
5251
  if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
5400
5252
  return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
5401
5253
  }
5402
- const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
5254
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
5403
5255
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
5404
5256
  if (receivedFields.length === 0) {
5405
- return mutationNoFieldsFailure({
5257
+ return piMutationNoFieldsFailure({
5406
5258
  entity: "task",
5407
5259
  ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5408
5260
  operation: "update",
@@ -5434,14 +5286,10 @@ export default function planPiExtension(pi) {
5434
5286
  }
5435
5287
  if (params.notes !== undefined)
5436
5288
  task.notes = params.notes.trim();
5437
- if (params.decisions !== undefined)
5438
- task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
5439
5289
  if (params.checklist !== undefined) {
5440
- task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
5441
- id: createChecklistItemId(task.id, index + 1, itemTitle),
5442
- title: itemTitle,
5443
- checked: false,
5444
- }));
5290
+ const replacement = replaceChecklist(task.checklist ?? [], params.checklist, task.id);
5291
+ task.checklist = replacement.items;
5292
+ checklistLostTicks = replacement.lostTicks;
5445
5293
  }
5446
5294
  if (params.subtasks !== undefined) {
5447
5295
  task.subtasks = params.subtasks.map((item) => ({ id: item.id ?? randomUUID(), title: item.title.trim(), description: item.description?.trim() ?? "", status: (item.status ?? "planned"), createdAt: task.subtasks.find((candidate) => candidate.id === item.id)?.createdAt ?? now, updatedAt: now }));
@@ -5485,7 +5333,22 @@ export default function planPiExtension(pi) {
5485
5333
  const staleParentRefs = descriptionFreshness?.diagnostics
5486
5334
  .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
5487
5335
  .map((entry) => entry.ownerRef) ?? [];
5488
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
5336
+ const lostTicksNotice = checklistLostTicks.length > 0
5337
+ ? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
5338
+ : "";
5339
+ const taskUpdateReply = buildMutationReply({
5340
+ identity: { entity: "task", ref: updatedTask.id, id: updatedTask.id, shortId: updatedTask.shortId, title: updatedTask.title, status: updatedTask.status },
5341
+ operation: "update",
5342
+ updatedFields: receivedFields,
5343
+ changedValues: updatedTask,
5344
+ extras: {
5345
+ ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
5346
+ ...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
5347
+ },
5348
+ notices: [lostTicksNotice],
5349
+ readBackCommand: `task_get ${updatedTask.id}`,
5350
+ });
5351
+ return { content: [{ type: "text", text: taskUpdateReply.text }], details: taskUpdateReply.structured };
5489
5352
  },
5490
5353
  });
5491
5354
  pi.registerTool({
@@ -5539,16 +5402,10 @@ export default function planPiExtension(pi) {
5539
5402
  })),
5540
5403
  archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
5541
5404
  });
5542
- const { selection } = result;
5543
- if (!selection.candidate)
5544
- return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
5545
- const { candidate } = selection;
5546
- const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
5547
- const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
5548
- const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
5405
+ const reply = buildRecommendationReply(result, features.features);
5549
5406
  return {
5550
- content: [{ type: "text", text: `${activeLine}Next work (${selection.kind}): ${reference} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}` }],
5551
- details: { ...selection, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims },
5407
+ content: [{ type: "text", text: reply.text }],
5408
+ details: reply.structured,
5552
5409
  };
5553
5410
  },
5554
5411
  });
@@ -5869,7 +5726,8 @@ export default function planPiExtension(pi) {
5869
5726
  return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
5870
5727
  return st.runBatch(async () => {
5871
5728
  const features = (await st.loadFeatures()).features;
5872
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5729
+ const initialPhases = await st.loadAllPhases();
5730
+ const found = findTaskByRef(initialPhases, features, params.taskId.trim());
5873
5731
  if (!found)
5874
5732
  return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
5875
5733
  const task = found.task;
@@ -5880,6 +5738,13 @@ export default function planPiExtension(pi) {
5880
5738
  if (task.status === "done")
5881
5739
  return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_reopen with taskId=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry task_start ${taskRef} only if reopening is not the intended operation.`], { taskId: task.id }));
5882
5740
  const project = await st.loadProject();
5741
+ // Targeted stale-context refresh (P102(F005)/T404): task_start delivery
5742
+ // stays scoped to feature/phase/task context, but a session that
5743
+ // already did a full planner-load gets a non-blocking advisory when
5744
+ // project-level context has drifted since, instead of silently going
5745
+ // stale.
5746
+ const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
5747
+ const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
5883
5748
  const linkedRequirements = [
5884
5749
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
5885
5750
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -5921,7 +5786,9 @@ export default function planPiExtension(pi) {
5921
5786
  }
5922
5787
  if (task.status === "in-progress") {
5923
5788
  const outcome = taskStartSucceeded(task.id, true);
5924
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5789
+ const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
5790
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
5791
+ return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true${projectContextAdvisory}${phaseOrderAdvisory}` }], details: { ...task, ...outcome, task, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase } };
5925
5792
  }
5926
5793
  const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5927
5794
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
@@ -6010,9 +5877,11 @@ export default function planPiExtension(pi) {
6010
5877
  return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
6011
5878
  }
6012
5879
  const outcome = taskStartSucceeded(persistedTask.id);
5880
+ const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
5881
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
6013
5882
  return {
6014
- content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
6015
- details: { ...persistedTask, ...outcome, task: persistedTask },
5883
+ content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}` }],
5884
+ details: { ...persistedTask, ...outcome, task: persistedTask, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase },
6016
5885
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
6017
5886
  };
6018
5887
  });
@@ -6021,10 +5890,11 @@ export default function planPiExtension(pi) {
6021
5890
  pi.registerTool({
6022
5891
  name: "task_complete",
6023
5892
  label: "Task Complete",
6024
- description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
5893
+ description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Fails if checklist is incomplete — tick the completed items with task_checklist_toggle first. force=true is the exception for a checklist that no longer matches the work, and requires a motivation; the overridden items are recorded, never auto-ticked.",
6025
5894
  parameters: Type.Object({
6026
5895
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
6027
5896
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
5897
+ motivation: Type.Optional(Type.String({ description: "Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work." })),
6028
5898
  description_update: Type.String({ minLength: 10, description: "Required evidence: shipped work, verification level including partial verification, remaining/unverified work, files, decisions, and updated code references." }),
6029
5899
  }),
6030
5900
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -6043,13 +5913,18 @@ export default function planPiExtension(pi) {
6043
5913
  return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
6044
5914
  if (task.pauseSnapshot)
6045
5915
  return { content: [{ type: "text", text: "Task completion denied: resume checkpointed work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
6046
- const unchecked = task.checklist.filter((item) => !item.checked);
6047
- if (unchecked.length > 0 && !params.force) {
5916
+ const gate = evaluateTaskCompletionGate(task.checklist, params.force === true, params.motivation);
5917
+ if (!gate.allowed) {
5918
+ const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
5919
+ ? taskCompletionForceMotivationRequired(gate.uncheckedItems)
5920
+ : taskCompletionChecklistRefusal(gate.uncheckedItems, `task_checklist_toggle ${params.taskId.trim()} <item>`);
6048
5921
  return {
6049
- content: [{ type: "text", text: `⚠️ ${unchecked.length} checklist item(s) not done: ${unchecked.map((i) => i.title).join(", ")}. Use task_complete with force=true to override.` }],
6050
- details: { task, uncheckedChecklistItems: unchecked },
5922
+ content: [{ type: "text", text: message }],
5923
+ details: { task, errorCode: gate.errorCode, uncheckedChecklistItems: gate.uncheckedItems },
6051
5924
  };
6052
5925
  }
5926
+ const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, params.motivation);
5927
+ const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
6053
5928
  const now = nowISO();
6054
5929
  let completedTask;
6055
5930
  await st.updatePhase(found.phase.id, (phase) => {
@@ -6061,10 +5936,10 @@ export default function planPiExtension(pi) {
6061
5936
  t.statusLog = [...t.statusLog, {
6062
5937
  id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
6063
5938
  date: now, fromStatus: previousStatus, toStatus: "done",
6064
- title: `${previousStatus} → done`, description: completionSummary,
5939
+ title: `${previousStatus} → done`, description: completionEvidence,
6065
5940
  }];
6066
5941
  const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
6067
- t.description = t.description + sep + completionSummary;
5942
+ t.description = t.description + sep + completionEvidence;
6068
5943
  t.descriptionUpdatedAt = now;
6069
5944
  t.updatedAt = now;
6070
5945
  phase.updatedAt = now;
@@ -6271,9 +6146,11 @@ export default function planPiExtension(pi) {
6271
6146
  pi.registerTool({
6272
6147
  name: "planner-load",
6273
6148
  label: "Planner Load",
6274
- description: "Enable the planner for this project and start the web dashboard (LAN), then return a resume recap (project status + handoff if present + Web UI address). This is the explicit way to enable the planner — it does NOT auto-start. Planner operations are NOT code edits.",
6275
- parameters: Type.Object({}),
6276
- async execute(_id, _params, _signal, _onUpdate, ctx) {
6149
+ description: "Enable the planner for this project and start the web dashboard (LAN), then return a human-facing resume recap (content[].text, Web UI address included) plus agent-only details.projectContext carrying the complete project-level context (description, goal, scope, out-of-scope, technologies, tools, language preferences, Project Guidelines, every Requirement, and every project Accepted Decision). This is the explicit way to enable the planner — it does NOT auto-start. Planner operations are NOT code edits. Never quote details.projectContext in the human-facing recap. If details.projectContext.contextComplete is false, the project-level context was NOT delivered this turn — follow details.projectContext.nextActions and retry before relying on it.",
6150
+ parameters: Type.Object({
6151
+ maxChars: Type.Optional(Type.Number({ description: `Single-response bound (chars) for the project-context delivery. Default ${DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS}. Raise this and retry if a prior call returned details.projectContext.contextComplete=false.` })),
6152
+ }),
6153
+ async execute(_id, params, _signal, _onUpdate, ctx) {
6277
6154
  const st = await requirePlan(ctx);
6278
6155
  if (!st)
6279
6156
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
@@ -6305,7 +6182,28 @@ export default function planPiExtension(pi) {
6305
6182
  }
6306
6183
  if (preparation.changed)
6307
6184
  recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
6308
- 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 } } };
6185
+ // Explicit planner load must deliver the complete project-level context
6186
+ // (P102(F005)/T404), attested through the same lossless contract used
6187
+ // everywhere else — shared with the /planner load command and MCP's
6188
+ // planner-load tool via buildProjectContextLoadReply.
6189
+ const boundedMaxChars = params.maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
6190
+ const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
6191
+ const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
6192
+ if (projectContextReply.structured.contextComplete) {
6193
+ await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
6194
+ }
6195
+ // Pi's model reads only `content`; `details` is for the UI. The project
6196
+ // context used to live only in `details`, so it was attested as read
6197
+ // while the agent never saw it. Same builder as MCP and /planner load.
6198
+ // The guide is already in this turn's context (the per-turn plan
6199
+ // context block carries it), so only point at it here.
6200
+ const reply = buildPlannerLoadReply({
6201
+ recap,
6202
+ projectContext: projectContextReply,
6203
+ scopedDecisions: buildScopedDecisionContext(await st.loadAll()),
6204
+ plannerGuide: { mode: "pointer", path: ".planner/SKILL.md", customized: plannerSkill.customized, message: plannerSkill.message },
6205
+ });
6206
+ return { content: [{ type: "text", text: reply.text }], 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 }, ...reply.structured } };
6309
6207
  },
6310
6208
  });
6311
6209
  pi.registerTool({
@@ -6485,11 +6383,11 @@ export default function planPiExtension(pi) {
6485
6383
  "Operational rules:",
6486
6384
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
6487
6385
  "- 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.",
6488
- "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
6386
+ `- ${ACCEPTED_DECISION_OWNERSHIP_RULE} Record it with accepted_decision_create on that single owner before treating the discussion as complete; decision_record no longer writes and only explains this.`,
6489
6387
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
6490
6388
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
6491
6389
  "- 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).",
6492
- "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
6390
+ "- Nothing is ever blocked for lack of a task. If you are warned that you changed project code with no task in progress, start the task that covers it.",
6493
6391
  ...(extensionRules.length > 0
6494
6392
  ? [
6495
6393
  "",