@agent-plan/pi-adapter 0.2.28 → 0.3.0

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, 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, 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,8 +144,6 @@ 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
149
  // guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
@@ -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) {
@@ -387,46 +333,6 @@ async function getPlannerExecutionGuard(st) {
387
333
  focusTaskTitle: focus?.task.title ?? "",
388
334
  };
389
335
  }
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
336
  async function buildHandoffMarkdown(st, reason, overrides) {
431
337
  const [plan, resume, activity] = await Promise.all([
432
338
  st.loadAll(),
@@ -684,59 +590,9 @@ function readUiConfig(ctx) {
684
590
  },
685
591
  };
686
592
  }
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
593
  function pad(n) {
696
594
  return String(n).padStart(3, "0");
697
595
  }
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
596
  async function isPortAvailable(port) {
741
597
  return await new Promise((resolve) => {
742
598
  const probe = createServer();
@@ -918,7 +774,6 @@ function disablePlannerSession() {
918
774
  plannerSessionEnabled = false;
919
775
  startupResumePromptPending = false;
920
776
  startupResumeSummaryPending = false;
921
- startupResumeSummaryText = "";
922
777
  if (startupResumeSummaryTimer)
923
778
  clearTimeout(startupResumeSummaryTimer);
924
779
  startupResumeSummaryTimer = null;
@@ -978,22 +833,11 @@ export default function planPiExtension(pi) {
978
833
  },
979
834
  }));
980
835
  resetState();
981
- autoHandoffTriggered = false;
982
- previousContextPercent = 0;
983
836
  const st = ensureStore(ctx);
984
837
  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
838
  const exists = await st.exists().catch(() => false);
996
- let project = exists ? await st.loadProject().catch(() => null) : null;
839
+ if (exists)
840
+ await st.loadProject().catch(() => null);
997
841
  // ── Step 1: Enable gating ───────────────────────────────────────
998
842
  // No blocking prompt in session_start: ctx.ui.input()/select() do not
999
843
  // render for NPM-loaded ESM extensions during session_start (they hang
@@ -1046,12 +890,11 @@ export default function planPiExtension(pi) {
1046
890
  const st = ensureStore(ctx);
1047
891
  if (await st.exists().catch(() => false)) {
1048
892
  await writeProjectHandoff(st, "before compact").catch(() => { });
1049
- autoHandoffTriggered = true;
1050
893
  }
1051
894
  }
1052
895
  catch { }
1053
896
  });
1054
- pi.on("session_shutdown", async (event, ctx) => {
897
+ pi.on("session_shutdown", async (event) => {
1055
898
  try {
1056
899
  const st = store;
1057
900
  if (st && await st.exists().catch(() => false)) {
@@ -1153,7 +996,6 @@ export default function planPiExtension(pi) {
1153
996
  });
1154
997
  // Reset per-turn flags at the start of each turn.
1155
998
  pi.on("turn_start", async () => {
1156
- editedThisTurn = false;
1157
999
  taskCompleteReminderSaidThisTurn = false;
1158
1000
  });
1159
1001
  // After edit/write succeeds, track activity and (once per turn, when a task
@@ -1163,7 +1005,6 @@ export default function planPiExtension(pi) {
1163
1005
  return;
1164
1006
  if (event.toolName !== "edit" && event.toolName !== "write")
1165
1007
  return;
1166
- editedThisTurn = true;
1167
1008
  if (taskCompleteReminderSaidThisTurn)
1168
1009
  return;
1169
1010
  try {
@@ -1453,34 +1294,6 @@ export default function planPiExtension(pi) {
1453
1294
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1454
1295
  ?? null;
1455
1296
  }
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
1297
  function profile_packageManager(pkg, lockfile) {
1485
1298
  if (pkg?.packageManager)
1486
1299
  return pkg.packageManager;
@@ -1630,20 +1443,18 @@ export default function planPiExtension(pi) {
1630
1443
  await st.saveCodebaseProfile(profile);
1631
1444
  await st.writeGenerated();
1632
1445
  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
- }
1446
+ sendToAgent("I have provided the initial project goal and description. You are now the Lead Architect for Agent Plan only. " +
1447
+ "Do NOT invoke GSD workflows, GSD skills, or reinterpret this discuss flow as GSD orchestration. " +
1448
+ "Your mission is to produce a professional-grade specification. " +
1449
+ `\n\nLANGUAGE PREFERENCES: plan content language=${draftProject.contentLanguage || "(not set)"}; chat language=${draftProject.chatLanguage || "(not set)"}. ` +
1450
+ "If these preferences are already set, follow them and do NOT ask again. " +
1451
+ "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" +
1452
+ "DO NOT generate the plan yet. Instead, enter the DISCOVERY PHASE:\n" +
1453
+ "1. Use your tools to research the codebase and perform online research on the tech stack and domain.\n" +
1454
+ "2. Analyze the current state and identify knowledge gaps, ambiguities, or missing requirements regarding scope and technical constraints.\n" +
1455
+ "3. Formulate and ask me targeted, iterative questions to resolve these gaps.\n" +
1456
+ "4. We will iterate until you have a complete, detailed picture.\n\n" +
1457
+ "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
1458
  return;
1648
1459
  }
1649
1460
  if (b === "language") {
@@ -1761,10 +1572,7 @@ export default function planPiExtension(pi) {
1761
1572
  if (!targetType)
1762
1573
  return;
1763
1574
  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 {
1575
+ 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
1576
  ctx.ui.notify("Promotion discussion instructions loaded. Use the idea_promotion_begin tool in an agent session.", "info");
1769
1577
  }
1770
1578
  return;
@@ -1983,26 +1791,13 @@ export default function planPiExtension(pi) {
1983
1791
  ctx.ui.notify("Aborted", "warning");
1984
1792
  return;
1985
1793
  }
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
- }
1794
+ const outcome = await deleteFeatureCascade(st, feature.id, { cascade });
1795
+ if (!outcome.ok) {
1796
+ ctx.ui.notify(`Feature delete failed: ${outcome.error}`, "error");
1797
+ return;
2003
1798
  }
2004
- await st.writeGenerated();
2005
- ctx.ui.notify(`Feature deleted: ${featureId}${phases.length > 0 ? cascade ? `; deleted ${affectedPhases} phase(s)` : `; unlinked ${affectedPhases} phase(s)` : ""}`, "info");
1799
+ const { result } = outcome;
1800
+ ctx.ui.notify(`Feature deleted: ${result.ref}${result.phaseCount > 0 ? (result.cascade ? `; deleted ${result.phaseCount} phase(s)` : `; unlinked ${result.phaseCount} phase(s)`) : ""}`, "info");
2006
1801
  return;
2007
1802
  }
2008
1803
  ctx.ui.notify(`Unknown feature action "${b}". Try: list, add, show, discuss, update, delete`, "warning");
@@ -2038,8 +1833,6 @@ export default function planPiExtension(pi) {
2038
1833
  const startDiscuss = await ctx.ui.input("Start phase discuss now? Type yes to continue");
2039
1834
  let phase;
2040
1835
  await st.runBatch(() => withFeatureLock(feature.id, async () => {
2041
- const phases = await st.loadAllPhases();
2042
- const featurePhases = phases.filter((p) => p.featureId === feature.id);
2043
1836
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2044
1837
  const id = createPhaseId();
2045
1838
  const identity = await st.allocateEntityIdentity("phase", id);
@@ -2119,11 +1912,9 @@ export default function planPiExtension(pi) {
2119
1912
  notes: `${current.notes ? current.notes + "\n" : ""}[discuss ${nowISO()}] ${note}`,
2120
1913
  updatedAt: nowISO(),
2121
1914
  }));
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
- }
1915
+ sendToAgent(`Before planning tasks for phase ${phase.id} (${phase.title}), the user reported a change: ${changeNote.trim()}. ` +
1916
+ "Discuss this change with the user, update the phase scope/dependencies/risks if needed, then propose the task breakdown. " +
1917
+ "Do NOT invoke GSD. Stay in Agent Plan.");
2127
1918
  }
2128
1919
  const answers = {};
2129
1920
  const questions = [
@@ -2258,7 +2049,7 @@ export default function planPiExtension(pi) {
2258
2049
  // ═══════════════════════════════════════════════════════════════
2259
2050
  if (a === "task") {
2260
2051
  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");
2052
+ 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
2053
  return;
2263
2054
  }
2264
2055
  if (b === "add") {
@@ -2304,7 +2095,7 @@ export default function planPiExtension(pi) {
2304
2095
  updatedAt: nowISO(),
2305
2096
  }));
2306
2097
  await st.writeGenerated();
2307
- ctx.ui.notify(`Task created: ${taskId} — ${title}`, "info");
2098
+ ctx.ui.notify(`Task created: ${taskId} — ${title} (${taskCreatedPriorityFragment(priority)})`, "info");
2308
2099
  if (isYes(startDiscuss)) {
2309
2100
  await handlePlanner(`task discuss ${taskId}`, ctx);
2310
2101
  }
@@ -2333,22 +2124,24 @@ export default function planPiExtension(pi) {
2333
2124
  const { phase, task } = resolved;
2334
2125
  const description = await ctx.ui.input(`Execution notes / description [${task.description || ""}]`);
2335
2126
  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
- }));
2127
+ const checklistTitles = checklistSeed?.trim() ? splitCsv(checklistSeed) : undefined;
2128
+ let checklistLostTicks = [];
2129
+ await st.updateTask(phase.id, task.id, (current) => {
2130
+ const next = { ...current, updatedAt: nowISO() };
2131
+ if (description?.trim())
2132
+ next.description = description.trim();
2133
+ if (checklistTitles) {
2134
+ const replacement = replaceChecklist(current.checklist ?? [], checklistTitles, task.id);
2135
+ next.checklist = replacement.items;
2136
+ checklistLostTicks = replacement.lostTicks;
2137
+ }
2138
+ return next;
2139
+ });
2350
2140
  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");
2141
+ const lostTicksNotice = checklistLostTicks.length > 0
2142
+ ? ` Warning: lost tick on ${checklistLostTicks.length} item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
2143
+ : "";
2144
+ 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
2145
  return;
2353
2146
  }
2354
2147
  if (b === "show") {
@@ -2480,6 +2273,35 @@ export default function planPiExtension(pi) {
2480
2273
  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
2274
  return;
2482
2275
  }
2276
+ if (b === "recommend") {
2277
+ // "What should I work on next" is a supervisor question, not only
2278
+ // an agent one (see P104(F005)/T419) — the only reason it lacked an
2279
+ // interactive path was that the other agent-only commands (pause,
2280
+ // switch, deviation) set the pattern by default. Reuses the same
2281
+ // recommendNextWork + buildRecommendationReply pipeline as the
2282
+ // task_recommend tool, so a person at the command line and an agent
2283
+ // calling the tool see identical reasoning, just a compact few-line
2284
+ // notify instead of a structured payload.
2285
+ const [features, phases, project, resume, handoffs, archivedHandoffs] = await Promise.all([
2286
+ st.loadFeatures(),
2287
+ st.loadAllPhases(),
2288
+ st.loadProject(),
2289
+ st.loadResume(),
2290
+ st.listHandoffs(),
2291
+ st.listArchivedHandoffs(),
2292
+ ]);
2293
+ const result = recommendNextWork(features.features, phases, project.workDeviations, resume?.currentPhaseId, "", {
2294
+ handoffs: handoffs.map((handoff) => ({
2295
+ compositeRef: handoff.compositeRef,
2296
+ firstLine: handoff.firstLine,
2297
+ resumeReady: handoff.resumeReady,
2298
+ })),
2299
+ archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
2300
+ });
2301
+ const reply = buildRecommendationReply(result, features.features);
2302
+ ctx.ui.notify(reply.text, "info");
2303
+ return;
2304
+ }
2483
2305
  if (b === "start") {
2484
2306
  return st.runBatch(async () => {
2485
2307
  let phase = null;
@@ -2765,11 +2587,7 @@ export default function planPiExtension(pi) {
2765
2587
  return;
2766
2588
  }
2767
2589
  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" +
2590
+ 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
2591
  "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
2592
  "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
2593
  "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 +2601,9 @@ export default function planPiExtension(pi) {
2783
2601
  "- `## Blockers and risks`: blockers/risks or an explicit verified none statement\n" +
2784
2602
  "- `## How to resume`: explicit ordered steps\n" +
2785
2603
  "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");
2604
+ ctx.ui.notify(handoffPrepared
2605
+ ? "Instructing the agent to prepare the phase handoff…"
2606
+ : "Agent bridge unavailable; use /planner handoff write for an auto-generated handoff.", handoffPrepared ? "info" : "warning");
2787
2607
  return;
2788
2608
  }
2789
2609
  if (action === "clear" || action === "delete") {
@@ -2946,6 +2766,7 @@ export default function planPiExtension(pi) {
2946
2766
  // core buildRecap so Pi and Claude Code/Codex present identical content.
2947
2767
  let recapText = "";
2948
2768
  let plannerSkillContext = "";
2769
+ let projectContextBlock = "";
2949
2770
  try {
2950
2771
  const plannerSkill = await st.syncPlannerSkill();
2951
2772
  await st.syncGrillMeSkill();
@@ -2953,6 +2774,20 @@ export default function planPiExtension(pi) {
2953
2774
  recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2954
2775
  if (preparation.changed)
2955
2776
  recapText = `${preparation.legacyProjectContext.summary}\n\n${recapText}`;
2777
+ // Explicit planner load must deliver the complete project-level
2778
+ // context (P102(F005)/T404), attested through the same lossless
2779
+ // contract used everywhere else. buildProjectContextLoadReply is the
2780
+ // single shared shaping for this reply — the planner-load tool below
2781
+ // uses the identical builder. Like plannerSkillContext, this is
2782
+ // agent-only: it must never be quoted into the human-facing recap
2783
+ // (see the "must not leak into the human recap" invariant covered by
2784
+ // host-registration.test.mjs for the planner-load tool).
2785
+ const projectContextDelivery = await st.loadProjectContextDelivery(DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
2786
+ const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS);
2787
+ if (projectContextReply.structured.contextComplete) {
2788
+ await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
2789
+ }
2790
+ projectContextBlock = `[agent-only project context; do not quote in the recap]\n${projectContextReply.text}\n[end agent-only project context]`;
2956
2791
  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]`;
2957
2792
  }
2958
2793
  catch (e) {
@@ -2960,7 +2795,7 @@ export default function planPiExtension(pi) {
2960
2795
  }
2961
2796
  pi.sendMessage({
2962
2797
  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 ---",
2798
+ content: "[internal trigger — not a user command] Read and retain the agent-only planner usage skill and project context below, then present the planner startup recap 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" + projectContextBlock + "\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2964
2799
  display: false,
2965
2800
  }, {
2966
2801
  triggerTurn: true,
@@ -3057,7 +2892,7 @@ export default function planPiExtension(pi) {
3057
2892
  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
2893
  parameters: Type.Object({
3059
2894
  projectName: Type.String({ description: "Concise project name/title" }),
3060
- description: Type.Optional(Type.String({ description: "Short project description" })),
2895
+ description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3061
2896
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
3062
2897
  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
2898
  }),
@@ -3086,7 +2921,7 @@ export default function planPiExtension(pi) {
3086
2921
  if (requirementTitles.length > 0) {
3087
2922
  const now = nowISO();
3088
2923
  await st.saveRequirements({
3089
- requirements: requirementTitles.map((title, index) => ({
2924
+ requirements: requirementTitles.map((title) => ({
3090
2925
  id: createRequirementId(),
3091
2926
  title,
3092
2927
  description: "",
@@ -3111,7 +2946,7 @@ export default function planPiExtension(pi) {
3111
2946
  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
2947
  parameters: Type.Object({
3113
2948
  name: Type.Optional(Type.String({ description: "Project title" })),
3114
- description: Type.Optional(Type.String({ description: "Short project description" })),
2949
+ description: Type.Optional(Type.String({ description: `Short project description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3115
2950
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full project description." })),
3116
2951
  goal: Type.Optional(Type.String({ description: "Main project goal / objective" })),
3117
2952
  scope: Type.Optional(Type.Array(Type.String(), { description: "Replace in-scope items" })),
@@ -3119,17 +2954,24 @@ export default function planPiExtension(pi) {
3119
2954
  technologies: Type.Optional(Type.Array(Type.String(), { description: "Replace technologies list" })),
3120
2955
  tools: Type.Optional(Type.Array(Type.String(), { description: "Replace tools list" })),
3121
2956
  globalRules: Type.Optional(Type.Array(Type.String(), { description: "Replace global rules" })),
3122
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace project decisions" })),
2957
+ 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
2958
  }),
3124
2959
  async execute(_id, params, _signal, _onUpdate, ctx) {
3125
2960
  const st = await requirePlan(ctx);
3126
2961
  if (!st)
3127
2962
  return { content: [{ type: "text", text: "No .planner/ found. Use plan_init first." }], details: {} };
2963
+ if (params.decisions !== undefined) {
2964
+ return {
2965
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
2966
+ details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "project" },
2967
+ isError: true,
2968
+ };
2969
+ }
3128
2970
  const project = await st.loadProject();
3129
- const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules", "decisions"];
2971
+ const mutableFields = ["name", "description", "descriptionRef", "goal", "scope", "outOfScope", "technologies", "tools", "globalRules"];
3130
2972
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
3131
2973
  if (receivedFields.length === 0) {
3132
- return mutationNoFieldsFailure({
2974
+ return piMutationNoFieldsFailure({
3133
2975
  entity: "project",
3134
2976
  ref: project.name,
3135
2977
  operation: "update",
@@ -3166,8 +3008,6 @@ export default function planPiExtension(pi) {
3166
3008
  project.tools = params.tools.map((entry) => entry.trim()).filter(Boolean);
3167
3009
  if (params.globalRules !== undefined)
3168
3010
  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
3011
  const persisted = await st.updateProject((current) => {
3172
3012
  const next = {
3173
3013
  ...current,
@@ -3179,7 +3019,6 @@ export default function planPiExtension(pi) {
3179
3019
  ...(params.technologies !== undefined ? { technologies: params.technologies.map((entry) => entry.trim()).filter(Boolean) } : {}),
3180
3020
  ...(params.tools !== undefined ? { tools: params.tools.map((entry) => entry.trim()).filter(Boolean) } : {}),
3181
3021
  ...(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
3022
  };
3184
3023
  if (params.descriptionRef !== undefined) {
3185
3024
  const descriptionRef = normalizeDescriptionRef(params.descriptionRef);
@@ -3191,7 +3030,14 @@ export default function planPiExtension(pi) {
3191
3030
  return next;
3192
3031
  });
3193
3032
  await st.writeGenerated();
3194
- return { content: [{ type: "text", text: `Project updated: ${persisted.name}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...persisted, updated: true, updatedFields: receivedFields } };
3033
+ const projectUpdateReply = buildMutationReply({
3034
+ identity: { entity: "project", ref: persisted.name, title: persisted.name },
3035
+ operation: "update",
3036
+ updatedFields: receivedFields,
3037
+ changedValues: persisted,
3038
+ readBackCommand: "plan_get",
3039
+ });
3040
+ return { content: [{ type: "text", text: projectUpdateReply.text }], details: projectUpdateReply.structured };
3195
3041
  },
3196
3042
  });
3197
3043
  pi.registerTool({
@@ -3333,7 +3179,7 @@ export default function planPiExtension(pi) {
3333
3179
  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
3180
  parameters: Type.Object({
3335
3181
  title: Type.String({ description: "Requirement title" }),
3336
- description: Type.Optional(Type.String({ description: "Requirement description" })),
3182
+ description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3337
3183
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Optional linked phase IDs" })),
3338
3184
  macroTasks: Type.Optional(Type.Array(Type.Object({
3339
3185
  title: Type.String({ description: "Macro-task title" }),
@@ -3392,7 +3238,7 @@ export default function planPiExtension(pi) {
3392
3238
  parameters: Type.Object({
3393
3239
  requirementId: Type.String({ description: "Requirement ID" }),
3394
3240
  title: Type.Optional(Type.String({ description: "Requirement title" })),
3395
- description: Type.Optional(Type.String({ description: "Requirement description" })),
3241
+ description: Type.Optional(Type.String({ description: `Requirement description. ${LONG_TEXT_LIMIT_NOTICE}` })),
3396
3242
  linkedPhaseIds: Type.Optional(Type.Array(Type.String(), { description: "Replace linked phase IDs" })),
3397
3243
  macroTasks: Type.Optional(Type.Array(Type.Object({
3398
3244
  id: Type.Optional(Type.String({ description: "Existing planner-assigned macro-task ID; omit for a new item." })),
@@ -3413,7 +3259,7 @@ export default function planPiExtension(pi) {
3413
3259
  return { content: [{ type: "text", text: "title required" }], details: { errorCode: "TITLE_REQUIRED", updated: false } };
3414
3260
  }
3415
3261
  if (params.title === undefined && params.description === undefined && params.linkedPhaseIds === undefined && params.macroTasks === undefined) {
3416
- return mutationNoFieldsFailure({
3262
+ return piMutationNoFieldsFailure({
3417
3263
  entity: "requirement",
3418
3264
  ref: params.requirementId,
3419
3265
  operation: "update",
@@ -3421,6 +3267,12 @@ export default function planPiExtension(pi) {
3421
3267
  retryCommand: `requirement_update ${params.requirementId}`,
3422
3268
  });
3423
3269
  }
3270
+ const receivedFields = [
3271
+ ...(params.title !== undefined ? ["title"] : []),
3272
+ ...(params.description !== undefined ? ["description"] : []),
3273
+ ...(params.linkedPhaseIds !== undefined ? ["linkedPhaseIds"] : []),
3274
+ ...(params.macroTasks !== undefined ? ["macroTasks"] : []),
3275
+ ];
3424
3276
  const links = params.linkedPhaseIds === undefined
3425
3277
  ? undefined
3426
3278
  : await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
@@ -3448,7 +3300,14 @@ export default function planPiExtension(pi) {
3448
3300
  return { content: [{ type: "text", text: message }], details: {} };
3449
3301
  }
3450
3302
  await st.writeGenerated();
3451
- return { content: [{ type: "text", text: `Requirement updated: ${persisted.id}` }], details: { requirement: persisted, updated: true } };
3303
+ const requirementUpdateReply = buildMutationReply({
3304
+ identity: { entity: "requirement", ref: persisted.id, id: persisted.id, title: persisted.title },
3305
+ operation: "update",
3306
+ updatedFields: receivedFields,
3307
+ changedValues: persisted,
3308
+ readBackCommand: "requirement_list",
3309
+ });
3310
+ return { content: [{ type: "text", text: requirementUpdateReply.text }], details: requirementUpdateReply.structured };
3452
3311
  },
3453
3312
  });
3454
3313
  pi.registerTool({
@@ -3495,7 +3354,7 @@ export default function planPiExtension(pi) {
3495
3354
  async execute(_id, params, _signal, _onUpdate, ctx) { const st = await requirePlan(ctx); if (!st)
3496
3355
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} }; const current = findIdeaByRef((await st.loadIdeas()).ideas, params.idea); if (!current)
3497
3356
  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 } }; },
3357
+ 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
3358
  });
3500
3359
  pi.registerTool({
3501
3360
  name: "idea_delete", label: "Idea Delete", description: "Delete an Idea only after explicit user confirmation.", parameters: Type.Object({ idea: Type.String(), confirmed: Type.Boolean() }),
@@ -3568,9 +3427,20 @@ export default function planPiExtension(pi) {
3568
3427
  ? "Active tasks: none (verified from all persisted phase task statuses)"
3569
3428
  : `Active tasks (${activeTasks.length})${activeTasks.length > 1 ? " — CONFLICT" : ""}: ${activeTasks.map((task) => `${task.ref} — ${task.title}`).join("; ")}`;
3570
3429
  const projectDecisionContext = buildBoundedAcceptedDecisionContext([{ scope: "project", decisions: plan.project.acceptedDecisions }]);
3430
+ // P104(F005)/T422: a phase can be left in-progress with no active task
3431
+ // and nothing else said so on this cheap read — render that fact,
3432
+ // priority-ordered, the same shape planner-show renders on the MCP side.
3433
+ const openPhaseWork = listOpenPhaseWork(plan.phases, plan.features.features);
3434
+ const openPhaseWorkText = openPhaseWorkLines(openPhaseWork);
3571
3435
  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 },
3436
+ 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}` }],
3437
+ details: {
3438
+ ...plan,
3439
+ activeTaskState: activeTasks.length === 0 ? "none" : activeTasks.length === 1 ? "single" : "conflict",
3440
+ activeTasks,
3441
+ openPhaseWork: boundedOpenPhaseWork(openPhaseWork),
3442
+ openPhaseWorkTotal: openPhaseWork.length,
3443
+ },
3574
3444
  };
3575
3445
  },
3576
3446
  });
@@ -3798,53 +3668,27 @@ export default function planPiExtension(pi) {
3798
3668
  if (!r.ok)
3799
3669
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3800
3670
  const phase = await st.loadPhase(r.phase.id);
3801
- const bounded = boundedHandoffForTransport(phase.handoff);
3802
- if (!bounded.content.trim()) {
3671
+ if (!phase.handoff.trim()) {
3803
3672
  const archived = (await st.listArchivedHandoffs()).find((entry) => entry.phaseId === phase.id
3804
3673
  && 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 } };
3674
+ const reply = archived
3675
+ ? buildHandoffShowReply({
3676
+ kind: "archived",
3677
+ phaseRef: r.compositeRef,
3678
+ phaseId: phase.id,
3679
+ content: archived.content,
3680
+ archiveReason: archived.reason,
3681
+ archivedAt: archived.archivedAt,
3682
+ archiveFile: archived.file,
3683
+ handoffAudit: phase.handoffAudit,
3684
+ })
3685
+ : buildHandoffShowReply({ kind: "empty", phaseRef: r.compositeRef, phaseId: r.phase.id, handoffAudit: phase.handoffAudit });
3686
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3833
3687
  }
3834
3688
  const feature = (await st.loadFeatures()).features.find((entry) => entry.id === phase.featureId);
3835
3689
  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
- };
3690
+ const reply = buildHandoffShowReply({ kind: "active", phaseRef: r.compositeRef, phase, phaseWorkMap });
3691
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3848
3692
  },
3849
3693
  });
3850
3694
  pi.registerTool({
@@ -3881,7 +3725,7 @@ export default function planPiExtension(pi) {
3881
3725
  }
3882
3726
  catch (error) {
3883
3727
  if (error instanceof HandoffContractError)
3884
- return handoffContractFailure(error);
3728
+ return piHandoffContractFailure(error);
3885
3729
  const message = error instanceof Error ? error.message : String(error);
3886
3730
  return { isError: true, content: [{ type: "text", text: `❌ Handoff read-back verification denied: ${message}` }], details: { resumeReady: false, error: message } };
3887
3731
  }
@@ -3891,7 +3735,13 @@ export default function planPiExtension(pi) {
3891
3735
  name: "handoff_prepare",
3892
3736
  label: "Handoff Prepare",
3893
3737
  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." }) }),
3738
+ parameters: Type.Object({
3739
+ phaseRef: Type.String({ description: "Exact confirmed phase ref: P00x | P00x(F00x) | UUID | title." }),
3740
+ supportingDocuments: Type.Optional(Type.Array(Type.Object({
3741
+ path: Type.String({ description: "Committed Markdown path under .planner/docs/." }),
3742
+ description: Type.String({ description: "What the document contains and why the next agent needs it." }),
3743
+ }), { description: "Optional: validate an already-known manifest now, before drafting. Usually unnecessary — most handoffs need none; oversized content is externalized automatically." })),
3744
+ }),
3895
3745
  async execute(_id, params, _signal, _onUpdate, ctx) {
3896
3746
  const st = await requirePlan(ctx);
3897
3747
  if (!st)
@@ -3900,42 +3750,13 @@ export default function planPiExtension(pi) {
3900
3750
  if (!r.ok)
3901
3751
  return { content: [{ type: "text", text: `❌ ${r.error}` }], details: { error: r.error } };
3902
3752
  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
- };
3753
+ const audit = await st.preparePhaseHandoff(r.phase.id, params.supportingDocuments);
3754
+ const reply = buildHandoffPrepareReply({ phaseRef: r.compositeRef, audit });
3755
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
3937
3756
  }
3938
3757
  catch (error) {
3758
+ if (error instanceof HandoffContractError)
3759
+ return piHandoffContractFailure(error, "preparation");
3939
3760
  const message = error instanceof Error ? error.message : String(error);
3940
3761
  return { content: [{ type: "text", text: `❌ Handoff preparation failed: ${message}` }], details: { error: message } };
3941
3762
  }
@@ -3944,16 +3765,20 @@ export default function planPiExtension(pi) {
3944
3765
  pi.registerTool({
3945
3766
  name: "handoff_write",
3946
3767
  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.",
3768
+ 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
3769
  parameters: Type.Object({
3949
3770
  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)." })),
3771
+ 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
3772
  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
3773
  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." })),
3774
+ 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." })),
3775
+ 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
3776
  expectedHandoffUpdatedAt: Type.Optional(Type.String({ description: "Exact handoffUpdatedAt returned by handoff_prepare; use an empty string when no handoff exists." })),
3956
3777
  reconciledExistingHandoff: Type.Optional(Type.Boolean({ description: "Confirm that all still-relevant information from the existing handoff was retained in this single refreshed body." })),
3778
+ sectionDispositions: Type.Optional(Type.Array(Type.Object({
3779
+ section: Type.String({ description: "Exact prior heading text named by a HANDOFF_SECTION_RECONCILIATION_REQUIRED refusal." }),
3780
+ disposition: Type.String({ description: "What happened to that section: dropped, superseded, or folded into another section." }),
3781
+ }), { 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
3782
  completenessAudit: Type.Optional(Type.Object({
3958
3783
  version: Type.Literal(HANDOFF_COMPLETENESS_AUDIT_VERSION),
3959
3784
  entries: Type.Array(Type.Object({
@@ -3999,24 +3824,29 @@ export default function planPiExtension(pi) {
3999
3824
  const st = await requirePlan(ctx);
4000
3825
  if (!st)
4001
3826
  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" } };
3827
+ // content/markdown_content are optional so a retry after a failed write
3828
+ // can omit them and reuse the body PlanStore retained against this
3829
+ // phase + expectedHandoffUpdatedAt (see PlanStore.refreshPhaseHandoff).
3830
+ // body stays undefined only in that case; the title/heading checks
3831
+ // below apply only to freshly-submitted text.
3832
+ let body = (params.markdown_content ?? params.content ?? "").trim() || undefined;
4005
3833
  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");
3834
+ if (body !== undefined) {
3835
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
3836
+ const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3837
+ const effectiveHeadingText = title || firstHeadingText;
3838
+ if (!effectiveHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(effectiveHeadingText)) {
3839
+ return { content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful title summarizing the work." }], details: { error: "generic title" } };
3840
+ }
3841
+ if (title) {
3842
+ const lines = body.split(/\r?\n/);
3843
+ const firstIdx = lines.findIndex((line) => line.trim().length > 0);
3844
+ if (firstIdx !== -1 && /^#+\s/.test(lines[firstIdx] ?? ""))
3845
+ lines[firstIdx] = `# ${title}`;
3846
+ else
3847
+ lines.unshift(`# ${title}`, "");
3848
+ body = lines.join("\n");
3849
+ }
4020
3850
  }
4021
3851
  const r = await resolvePhaseForHandoff(st, params.phaseRef);
4022
3852
  if (!r.ok)
@@ -4046,9 +3876,10 @@ export default function planPiExtension(pi) {
4046
3876
  try {
4047
3877
  const result = await st.refreshPhaseHandoff(r.phase.id, {
4048
3878
  reason: params.reason.trim(),
4049
- content: body,
3879
+ ...(body !== undefined ? { content: body } : {}),
4050
3880
  expectedHandoffUpdatedAt: params.expectedHandoffUpdatedAt,
4051
3881
  reconciledExistingHandoff: params.reconciledExistingHandoff === true,
3882
+ ...(params.sectionDispositions ? { sectionDispositions: params.sectionDispositions } : {}),
4052
3883
  ...(params.completenessAudit ? { completenessAudit: params.completenessAudit } : {}),
4053
3884
  ...(params.coldStartInventory ? { coldStartInventory: params.coldStartInventory } : {}),
4054
3885
  ...(params.supportingDocuments ? { supportingDocuments: params.supportingDocuments } : {}),
@@ -4085,9 +3916,9 @@ export default function planPiExtension(pi) {
4085
3916
  }
4086
3917
  catch (error) {
4087
3918
  if (error instanceof HandoffContractError)
4088
- return handoffContractFailure(error);
3919
+ return piHandoffContractFailure(error);
4089
3920
  const message = error instanceof Error ? error.message : String(error);
4090
- return { content: [{ type: "text", text: `❌ Handoff refresh denied: ${message}` }], details: { error: message } };
3921
+ 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
3922
  }
4092
3923
  },
4093
3924
  });
@@ -4219,7 +4050,6 @@ export default function planPiExtension(pi) {
4219
4050
  return st.runBatch(async () => {
4220
4051
  const now = nowISO();
4221
4052
  const status = params.status ?? "planned";
4222
- const currentFeatures = (await st.loadFeatures()).features;
4223
4053
  const id = createFeatureId();
4224
4054
  const identity = await st.allocateEntityIdentity("feature", id);
4225
4055
  const priority = await st.nextPriority("feature");
@@ -4264,7 +4094,7 @@ export default function planPiExtension(pi) {
4264
4094
  description: "Persist feature discovery/governance fields so the feature context is ready before detailed implementation planning.",
4265
4095
  parameters: Type.Object({
4266
4096
  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" })),
4097
+ description: Type.Optional(Type.String({ description: `Current implementation state, scope, and goals for this feature. ${LONG_TEXT_LIMIT_NOTICE}` })),
4268
4098
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4269
4099
  workDone: Type.Optional(Type.String({ description: "What is already implemented / decided" })),
4270
4100
  workRemaining: Type.Optional(Type.String({ description: "What still needs to be done" })),
@@ -4282,7 +4112,7 @@ export default function planPiExtension(pi) {
4282
4112
  const mutableFields = ["description", "descriptionRef", "workDone", "workRemaining", "dependencies"];
4283
4113
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4284
4114
  if (receivedFields.length === 0) {
4285
- return mutationNoFieldsFailure({
4115
+ return piMutationNoFieldsFailure({
4286
4116
  entity: "feature",
4287
4117
  ref: formatFeatureRef(resolvedFeature.feature.number),
4288
4118
  operation: "discuss",
@@ -4326,7 +4156,14 @@ export default function planPiExtension(pi) {
4326
4156
  if (!feature)
4327
4157
  return { content: [{ type: "text", text: `Feature not found: ${params.featureId}` }], details: {} };
4328
4158
  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 } };
4159
+ const featureDiscussReply = buildMutationReply({
4160
+ identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
4161
+ operation: "discuss",
4162
+ updatedFields: receivedFields,
4163
+ changedValues: feature,
4164
+ readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
4165
+ });
4166
+ return { content: [{ type: "text", text: featureDiscussReply.text }], details: featureDiscussReply.structured };
4330
4167
  },
4331
4168
  });
4332
4169
  pi.registerTool({
@@ -4336,7 +4173,7 @@ export default function planPiExtension(pi) {
4336
4173
  parameters: Type.Object({
4337
4174
  featureId: Type.String({ description: "Feature ref: F00x, shortId, UUID, or name" }),
4338
4175
  name: Type.Optional(Type.String({ description: "New name" })),
4339
- description: Type.Optional(Type.String({ description: "New description" })),
4176
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
4340
4177
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full feature description." })),
4341
4178
  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
4179
  startDate: Type.Optional(Type.String({ description: "Start date (YYYY-MM-DD)" })),
@@ -4363,12 +4200,12 @@ export default function planPiExtension(pi) {
4363
4200
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
4364
4201
  const featureId = resolvedFeature.feature.id;
4365
4202
  if (params.status !== undefined) {
4366
- return derivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4203
+ return piDerivedStatusReadOnlyResult("feature", formatFeatureRef(resolvedFeature.feature.number), String(params.status), resolvedFeature.feature.status);
4367
4204
  }
4368
4205
  if (params.acceptedDecisions !== undefined) {
4369
4206
  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) },
4207
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
4208
+ details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "feature", targetRef: formatFeatureRef(resolvedFeature.feature.number) },
4372
4209
  isError: true,
4373
4210
  };
4374
4211
  }
@@ -4384,7 +4221,7 @@ export default function planPiExtension(pi) {
4384
4221
  ];
4385
4222
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4386
4223
  if (receivedFields.length === 0) {
4387
- return mutationNoFieldsFailure({
4224
+ return piMutationNoFieldsFailure({
4388
4225
  entity: "feature",
4389
4226
  ref: formatFeatureRef(resolvedFeature.feature.number),
4390
4227
  operation: "update",
@@ -4442,7 +4279,13 @@ export default function planPiExtension(pi) {
4442
4279
  type: "text",
4443
4280
  text: `✅ Feature updated: ${formatFeatureRef(feature.number)} — ${feature.name}${feature.shortId ? ` · ${feature.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.`,
4444
4281
  }],
4445
- details: { ...feature, updated: true, updatedFields: receivedFields },
4282
+ details: buildMutationReply({
4283
+ identity: { entity: "feature", ref: formatFeatureRef(feature.number), id: feature.id, shortId: feature.shortId, title: feature.name, status: feature.status },
4284
+ operation: "update",
4285
+ updatedFields: receivedFields,
4286
+ changedValues: feature,
4287
+ readBackCommand: `feature_get ${formatFeatureRef(feature.number)}`,
4288
+ }).structured,
4446
4289
  };
4447
4290
  },
4448
4291
  });
@@ -4462,27 +4305,15 @@ export default function planPiExtension(pi) {
4462
4305
  const resolvedFeature = resolveFeatureRefStrict(features, params.featureId);
4463
4306
  if (!resolvedFeature.ok)
4464
4307
  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 } };
4308
+ const outcome = await deleteFeatureCascade(st, resolvedFeature.feature.id, { cascade: Boolean(params.cascade) });
4309
+ if (!outcome.ok) {
4310
+ return { content: [{ type: "text", text: `Feature delete failed: ${outcome.error}` }], details: { deleted: false, errorCode: outcome.errorCode }, isError: true };
4311
+ }
4312
+ const { result } = outcome;
4313
+ return {
4314
+ content: [{ type: "text", text: `Feature deleted: ${result.ref}${result.cascade ? ` (cascade: ${result.phaseCount} phases)` : result.phaseCount > 0 ? ` (unlinked: ${result.phaseCount} phases)` : ""}` }],
4315
+ details: { deleted: true, ref: result.ref, id: result.id, cascadedPhases: result.cascade ? result.phaseCount : 0, unlinkedPhases: result.cascade ? 0 : result.phaseCount },
4316
+ };
4486
4317
  },
4487
4318
  });
4488
4319
  // ── phases ──────────────────────────────────────────────────────────
@@ -4660,7 +4491,7 @@ export default function planPiExtension(pi) {
4660
4491
  const mutableFields = ["goal", "goals", "summary", "scope", "descriptionRef", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4661
4492
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4662
4493
  if (receivedFields.length === 0) {
4663
- return mutationNoFieldsFailure({
4494
+ return piMutationNoFieldsFailure({
4664
4495
  entity: "phase",
4665
4496
  ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4666
4497
  operation: "discuss",
@@ -4709,7 +4540,15 @@ export default function planPiExtension(pi) {
4709
4540
  });
4710
4541
  await st.writeGenerated();
4711
4542
  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 } };
4543
+ const phaseDiscussReply = buildMutationReply({
4544
+ identity: { entity: "phase", ref: phaseRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
4545
+ operation: "discuss",
4546
+ updatedFields: receivedFields,
4547
+ changedValues: phase,
4548
+ extras: { contextReady: true },
4549
+ readBackCommand: `phase_get ${phaseRef}`,
4550
+ });
4551
+ return { content: [{ type: "text", text: phaseDiscussReply.text }], details: phaseDiscussReply.structured };
4713
4552
  },
4714
4553
  });
4715
4554
  pi.registerTool({
@@ -4721,7 +4560,7 @@ export default function planPiExtension(pi) {
4721
4560
  title: Type.Optional(Type.String({ description: "New title" })),
4722
4561
  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
4562
  summary: Type.Optional(Type.String({ description: "New summary" })),
4724
- description: Type.Optional(Type.String({ description: "New description" })),
4563
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
4725
4564
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full phase description." })),
4726
4565
  featureId: Type.Optional(Type.String({ description: "Link/unlink phase to a feature. Use empty string to unlink." })),
4727
4566
  priority: Type.Optional(Type.Number({ description: "Display order within the feature (lower = higher)" })),
@@ -4730,7 +4569,7 @@ export default function planPiExtension(pi) {
4730
4569
  dependencies: Type.Optional(Type.Array(Type.String(), { description: "Replace dependencies list" })),
4731
4570
  risks: Type.Optional(Type.Array(Type.String(), { description: "Replace risks list" })),
4732
4571
  openQuestions: Type.Optional(Type.Array(Type.String(), { description: "Replace open questions list" })),
4733
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
4572
+ 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
4573
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
4735
4574
  id: Type.String(),
4736
4575
  title: Type.String(),
@@ -4758,19 +4597,26 @@ export default function planPiExtension(pi) {
4758
4597
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
4759
4598
  }
4760
4599
  if (params.status !== undefined) {
4761
- return derivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4600
+ return piDerivedStatusReadOnlyResult("phase", formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)), String(params.status), resolvedPhase.status);
4762
4601
  }
4763
4602
  if (params.acceptedDecisions !== undefined) {
4764
4603
  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)) },
4604
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
4605
+ details: { updated: false, errorCode: ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4767
4606
  isError: true,
4768
4607
  };
4769
4608
  }
4770
- const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria"];
4609
+ if (params.decisions !== undefined) {
4610
+ return {
4611
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
4612
+ details: { updated: false, errorCode: LEGACY_DECISIONS_ARRAY_READ_ONLY_ERROR_CODE, targetType: "phase", targetRef: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)) },
4613
+ isError: true,
4614
+ };
4615
+ }
4616
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "completionCriteria"];
4771
4617
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
4772
4618
  if (receivedFields.length === 0) {
4773
- return mutationNoFieldsFailure({
4619
+ return piMutationNoFieldsFailure({
4774
4620
  entity: "phase",
4775
4621
  ref: formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features)),
4776
4622
  operation: "update",
@@ -4812,7 +4658,6 @@ export default function planPiExtension(pi) {
4812
4658
  ...(params.dependencies !== undefined ? { dependencies: params.dependencies.map((item) => item.trim()).filter(Boolean) } : {}),
4813
4659
  ...(params.risks !== undefined ? { risks: params.risks.map((item) => item.trim()).filter(Boolean) } : {}),
4814
4660
  ...(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
4661
  ...(params.completionCriteria !== undefined ? { completionCriteria: params.completionCriteria.map((item) => item.trim()).filter(Boolean) } : {}),
4817
4662
  ...(params.featureId !== undefined ? { featureId: nextFeatureIdForUpdate } : {}),
4818
4663
  updatedAt: nowISO(),
@@ -4847,16 +4692,22 @@ export default function planPiExtension(pi) {
4847
4692
  const staleParentRefs = descriptionFreshness?.diagnostics
4848
4693
  .filter((entry) => entry.state === "stale" && entry.ownerId === phase.featureId)
4849
4694
  .map((entry) => entry.ownerRef) ?? [];
4850
- const freshnessNotice = staleParentRefs.length > 0
4851
- ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
4852
- : "";
4853
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...phase, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
4695
+ const phaseUpdateRef = formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures));
4696
+ const phaseUpdateReply = buildMutationReply({
4697
+ identity: { entity: "phase", ref: phaseUpdateRef, id: phase.id, shortId: phase.shortId, title: phase.title, status: phase.status },
4698
+ operation: "update",
4699
+ updatedFields: receivedFields,
4700
+ changedValues: phase,
4701
+ extras: descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {},
4702
+ readBackCommand: `phase_get ${phaseUpdateRef}`,
4703
+ });
4704
+ return { content: [{ type: "text", text: phaseUpdateReply.text }], details: phaseUpdateReply.structured };
4854
4705
  },
4855
4706
  });
4856
4707
  pi.registerTool({
4857
4708
  name: "decision_record",
4858
4709
  label: "Decision Record",
4859
- 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.",
4710
+ 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.",
4860
4711
  parameters: Type.Object({
4861
4712
  featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
4862
4713
  phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
@@ -4879,49 +4730,17 @@ export default function planPiExtension(pi) {
4879
4730
  if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
4880
4731
  return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
4881
4732
  }
4882
- const acceptedAt = nowISO();
4883
- const acceptedDecision = {
4884
- id: crypto.randomUUID(),
4885
- title: params.title.trim(),
4886
- decision: params.decision.trim(),
4887
- rationale: params.rationale.trim(),
4888
- implementationNotes: params.implementationNotes.trim(),
4889
- acceptedAt,
4890
- };
4891
- await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
4892
- let featureWritten = false;
4893
- try {
4894
- await st.updateFeatures((doc) => {
4895
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4896
- if (!feature)
4897
- throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
4898
- feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
4899
- feature.updatedAt = acceptedAt;
4900
- return doc;
4901
- });
4902
- featureWritten = true;
4903
- await st.updatePhase(resolvedPhase.id, (phase) => {
4904
- phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
4905
- phase.updatedAt = acceptedAt;
4906
- return phase;
4907
- });
4908
- }
4909
- catch (error) {
4910
- // Best-effort compensation keeps a failed dual-write from reporting
4911
- // success with a decision persisted only on the feature.
4912
- if (featureWritten) {
4913
- await st.updateFeatures((doc) => {
4914
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4915
- if (feature)
4916
- feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
4917
- return doc;
4918
- }).catch(() => { });
4919
- }
4920
- throw error;
4921
- }
4922
- }));
4923
- await st.writeGenerated();
4924
- return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
4733
+ // No write happens here — see buildDecisionRecordRedirectReply. This
4734
+ // tool used to append the same AcceptedDecision to both the feature
4735
+ // and the phase; that violated the single-owner rule and mishandled
4736
+ // project-wide decisions discovered during task work, so it is now a
4737
+ // pure, non-mutating redirect (AGENTS.md rule 4: the rule and its
4738
+ // wording live once, in plan-core, not reinvented per adapter).
4739
+ const reply = buildDecisionRecordRedirectReply({
4740
+ featureRef: formatFeatureRef(resolvedFeature.feature.number),
4741
+ phaseRef: formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number),
4742
+ });
4743
+ return { content: [{ type: "text", text: `⚠️ decision_record is deprecated and write-disabled. ${reply.text}` }], details: { deprecated: true, ...reply.structured } };
4925
4744
  },
4926
4745
  });
4927
4746
  pi.registerTool({
@@ -4955,7 +4774,7 @@ export default function planPiExtension(pi) {
4955
4774
  return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
4956
4775
  }
4957
4776
  catch (error) {
4958
- return acceptedDecisionMutationFailure(error, "created");
4777
+ return piAcceptedDecisionMutationFailure(error, "created");
4959
4778
  }
4960
4779
  },
4961
4780
  });
@@ -5007,7 +4826,7 @@ export default function planPiExtension(pi) {
5007
4826
  return { content: [{ type: "text", text: `✅ Accepted decision updated on ${target.ref}: ${acceptedDecision.title}` }], details: { updated: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision, updatedFields: receivedFields } };
5008
4827
  }
5009
4828
  catch (error) {
5010
- return acceptedDecisionMutationFailure(error, "updated");
4829
+ return piAcceptedDecisionMutationFailure(error, "updated");
5011
4830
  }
5012
4831
  },
5013
4832
  });
@@ -5037,7 +4856,7 @@ export default function planPiExtension(pi) {
5037
4856
  return { content: [{ type: "text", text: `✅ Accepted decision deleted from ${target.ref}: ${acceptedDecision.title}` }], details: { deleted: true, targetType: params.targetType, targetRef: target.ref, decisionId: params.decisionId, acceptedDecision } };
5038
4857
  }
5039
4858
  catch (error) {
5040
- return acceptedDecisionMutationFailure(error, "deleted");
4859
+ return piAcceptedDecisionMutationFailure(error, "deleted");
5041
4860
  }
5042
4861
  },
5043
4862
  });
@@ -5136,7 +4955,8 @@ export default function planPiExtension(pi) {
5136
4955
  if (!st)
5137
4956
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5138
4957
  const features = (await st.loadFeatures()).features;
5139
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
4958
+ const allPhases = await st.loadAllPhases();
4959
+ const found = findTaskByRef(allPhases, features, params.taskId.trim());
5140
4960
  if (!found)
5141
4961
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
5142
4962
  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})`;
@@ -5151,7 +4971,15 @@ export default function planPiExtension(pi) {
5151
4971
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5152
4972
  const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
5153
4973
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
5154
- const sections = [summary];
4974
+ // P104(F005)/T421: priority and dependsOn at the point an agent reads
4975
+ // the task and decides what to do next — the fact task_list and
4976
+ // task_recommend already have, made visible here instead of
4977
+ // requiring a second call.
4978
+ const orderContext = buildTaskOrderContext(found.task, found.phase, allPhases, features);
4979
+ const sections = [summary, taskOrderContextLine(orderContext, found.task.status)];
4980
+ const completionMismatch = taskCompletionMismatchLine(found.task.status, found.task.checklist);
4981
+ if (completionMismatch)
4982
+ sections.push(completionMismatch);
5155
4983
  if (found.task.description?.trim())
5156
4984
  sections.push(found.task.description.trim());
5157
4985
  if (found.task.descriptionRef)
@@ -5178,11 +5006,56 @@ export default function planPiExtension(pi) {
5178
5006
  description: found.task.description,
5179
5007
  descriptionRef: found.task.descriptionRef ?? "",
5180
5008
  acceptedDecisions: found.task.acceptedDecisions,
5009
+ priority: orderContext.priority,
5010
+ dependsOn: orderContext.dependsOn,
5011
+ ...(completionMismatch ? { checklistMismatch: completionMismatch } : {}),
5012
+ },
5013
+ taskOrder: {
5014
+ readyCount: orderContext.readyCount,
5015
+ readyAheadCount: orderContext.readyAheadCount,
5016
+ nextByPriorityRef: orderContext.nextByPriorityRef,
5181
5017
  },
5182
5018
  },
5183
5019
  };
5184
5020
  markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
5185
- return result;
5021
+ // Advisory only (P104(F005)/T420): report whether task_start would
5022
+ // succeed right now, using the exact checks it runs itself, so a
5023
+ // caller reading the task before starting it learns what else is
5024
+ // needed in this same call instead of from a denial. task_start
5025
+ // recomputes this at call time and remains the sole authority on
5026
+ // whether work may begin.
5027
+ const parentFeature = found.phase.featureId ? features.find((candidate) => candidate.id === found.phase.featureId) : undefined;
5028
+ const phaseRef = formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features));
5029
+ const featureRef = parentFeature ? formatFeatureRef(parentFeature.number) : "";
5030
+ const taskRef = `${phaseRef}/T${pad(found.task.number)}`;
5031
+ const linkedRequirements = [
5032
+ ...(await st.linkedRequirementsForPhase(found.phase.id)),
5033
+ ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
5034
+ ].filter((requirement, index, all) => all.findIndex((candidate) => candidate.id === requirement.id) === index);
5035
+ const linkedRequirementIds = linkedRequirements.map((requirement) => requirement.id);
5036
+ const contextEligibility = contextReadEligibilityForSession({
5037
+ sessionId: plannerSessionId,
5038
+ taskId: found.task.id,
5039
+ phaseId: found.phase.id,
5040
+ ...(found.phase.featureId ? { featureId: found.phase.featureId } : {}),
5041
+ task: found.task,
5042
+ phase: found.phase,
5043
+ ...(parentFeature ? { feature: parentFeature } : {}),
5044
+ requirements: linkedRequirements,
5045
+ requirementIds: linkedRequirementIds,
5046
+ });
5047
+ const requirementEligibility = requirementReadEligibilityForSession(plannerSessionId, linkedRequirementIds, linkedRequirements);
5048
+ const projectGuidelinesReadState = projectGuidelinesReadStateForSession(project, plannerSessionId);
5049
+ const gateState = taskStartGateState(contextEligibility, requirementEligibility, projectGuidelinesReadState);
5050
+ const gateNextActions = gateState.ready ? [] : piContextReadActions(contextEligibility, { task: taskRef, phase: phaseRef, ...(featureRef ? { feature: featureRef } : {}) }, projectGuidelinesReadState !== "valid" && projectGuidelinesReadState !== "not-required", !requirementEligibility.eligible, `task_start ${taskRef}`);
5051
+ return {
5052
+ content: [{ type: "text", text: `${result.content[0].text}\n\n${taskStartGateStateLine(gateState)}` }],
5053
+ details: {
5054
+ ...result.details,
5055
+ taskStartGateState: gateState,
5056
+ ...(gateNextActions.length > 0 ? { taskStartNextActions: gateNextActions } : {}),
5057
+ },
5058
+ };
5186
5059
  },
5187
5060
  });
5188
5061
  pi.registerTool({
@@ -5273,7 +5146,7 @@ export default function planPiExtension(pi) {
5273
5146
  });
5274
5147
  await st.writeGenerated();
5275
5148
  const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
5276
- return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5149
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title} (${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5277
5150
  },
5278
5151
  });
5279
5152
  pi.registerTool({
@@ -5293,7 +5166,8 @@ export default function planPiExtension(pi) {
5293
5166
  try {
5294
5167
  const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5295
5168
  await st.writeGenerated();
5296
- return { content: [{ type: "text", text: "Dependency added." }], details: { updated: true, task } };
5169
+ 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}` });
5170
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
5297
5171
  }
5298
5172
  catch (error) {
5299
5173
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5320,7 +5194,8 @@ export default function planPiExtension(pi) {
5320
5194
  try {
5321
5195
  const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5322
5196
  await st.writeGenerated();
5323
- return { content: [{ type: "text", text: "Dependency removed." }], details: { updated: true, task } };
5197
+ 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}` });
5198
+ return { content: [{ type: "text", text: reply.text }], details: reply.structured };
5324
5199
  }
5325
5200
  catch (error) {
5326
5201
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5336,11 +5211,11 @@ export default function planPiExtension(pi) {
5336
5211
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
5337
5212
  title: Type.Optional(Type.String({ description: "New title" })),
5338
5213
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
5339
- description: Type.Optional(Type.String({ description: "New description" })),
5214
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
5340
5215
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
5341
5216
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
5342
5217
  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." })),
5343
- decisions: Type.Optional(Type.Array(Type.String(), { description: "Replace decisions list" })),
5218
+ 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." })),
5344
5219
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
5345
5220
  id: Type.String(),
5346
5221
  title: Type.String(),
@@ -5350,7 +5225,7 @@ export default function planPiExtension(pi) {
5350
5225
  acceptedAt: Type.String(),
5351
5226
  }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
5352
5227
  priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
5353
- checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
5228
+ 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." })),
5354
5229
  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." })),
5355
5230
  }),
5356
5231
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -5365,6 +5240,7 @@ export default function planPiExtension(pi) {
5365
5240
  const hostPhase = found.phase;
5366
5241
  const now = nowISO();
5367
5242
  let updatedTask;
5243
+ let checklistLostTicks = [];
5368
5244
  // Validate the motivation requirement for restrictive status transitions,
5369
5245
  // mirroring the MCP adapter and the /planner task update command
5370
5246
  // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
@@ -5382,8 +5258,15 @@ export default function planPiExtension(pi) {
5382
5258
  }
5383
5259
  if (params.acceptedDecisions !== undefined) {
5384
5260
  return {
5385
- content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
5386
- details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
5261
+ content: [{ type: "text", text: ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE }],
5262
+ 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)}` },
5263
+ isError: true,
5264
+ };
5265
+ }
5266
+ if (params.decisions !== undefined) {
5267
+ return {
5268
+ content: [{ type: "text", text: LEGACY_DECISIONS_ARRAY_READ_ONLY_MESSAGE }],
5269
+ 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)}` },
5387
5270
  isError: true,
5388
5271
  };
5389
5272
  }
@@ -5402,10 +5285,10 @@ export default function planPiExtension(pi) {
5402
5285
  if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
5403
5286
  return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
5404
5287
  }
5405
- const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
5288
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
5406
5289
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
5407
5290
  if (receivedFields.length === 0) {
5408
- return mutationNoFieldsFailure({
5291
+ return piMutationNoFieldsFailure({
5409
5292
  entity: "task",
5410
5293
  ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5411
5294
  operation: "update",
@@ -5437,14 +5320,10 @@ export default function planPiExtension(pi) {
5437
5320
  }
5438
5321
  if (params.notes !== undefined)
5439
5322
  task.notes = params.notes.trim();
5440
- if (params.decisions !== undefined)
5441
- task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
5442
5323
  if (params.checklist !== undefined) {
5443
- task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
5444
- id: createChecklistItemId(task.id, index + 1, itemTitle),
5445
- title: itemTitle,
5446
- checked: false,
5447
- }));
5324
+ const replacement = replaceChecklist(task.checklist ?? [], params.checklist, task.id);
5325
+ task.checklist = replacement.items;
5326
+ checklistLostTicks = replacement.lostTicks;
5448
5327
  }
5449
5328
  if (params.subtasks !== undefined) {
5450
5329
  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 }));
@@ -5488,10 +5367,22 @@ export default function planPiExtension(pi) {
5488
5367
  const staleParentRefs = descriptionFreshness?.diagnostics
5489
5368
  .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
5490
5369
  .map((entry) => entry.ownerRef) ?? [];
5491
- const freshnessNotice = staleParentRefs.length > 0
5492
- ? `\n⚠️ Parent description review required: ${staleParentRefs.join(", ")}. Run description_freshness for the non-mutating reconciliation preview.`
5370
+ const lostTicksNotice = checklistLostTicks.length > 0
5371
+ ? ` ⚠️ Lost tick on ${checklistLostTicks.length} checklist item(s) that could not be matched to a new title: ${checklistLostTicks.join(", ")}.`
5493
5372
  : "";
5494
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.${freshnessNotice}` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
5373
+ const taskUpdateReply = buildMutationReply({
5374
+ identity: { entity: "task", ref: updatedTask.id, id: updatedTask.id, shortId: updatedTask.shortId, title: updatedTask.title, status: updatedTask.status },
5375
+ operation: "update",
5376
+ updatedFields: receivedFields,
5377
+ changedValues: updatedTask,
5378
+ extras: {
5379
+ ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}),
5380
+ ...(checklistLostTicks.length > 0 ? { checklistLostTicks } : {}),
5381
+ },
5382
+ notices: [lostTicksNotice],
5383
+ readBackCommand: `task_get ${updatedTask.id}`,
5384
+ });
5385
+ return { content: [{ type: "text", text: taskUpdateReply.text }], details: taskUpdateReply.structured };
5495
5386
  },
5496
5387
  });
5497
5388
  pi.registerTool({
@@ -5545,16 +5436,10 @@ export default function planPiExtension(pi) {
5545
5436
  })),
5546
5437
  archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
5547
5438
  });
5548
- const { selection } = result;
5549
- if (!selection.candidate)
5550
- return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
5551
- const { candidate } = selection;
5552
- const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
5553
- const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
5554
- const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
5439
+ const reply = buildRecommendationReply(result, features.features);
5555
5440
  return {
5556
- content: [{ type: "text", text: `${activeLine}Next work (${selection.kind}): ${reference} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}` }],
5557
- details: { ...selection, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims },
5441
+ content: [{ type: "text", text: reply.text }],
5442
+ details: reply.structured,
5558
5443
  };
5559
5444
  },
5560
5445
  });
@@ -5875,7 +5760,8 @@ export default function planPiExtension(pi) {
5875
5760
  return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
5876
5761
  return st.runBatch(async () => {
5877
5762
  const features = (await st.loadFeatures()).features;
5878
- const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
5763
+ const initialPhases = await st.loadAllPhases();
5764
+ const found = findTaskByRef(initialPhases, features, params.taskId.trim());
5879
5765
  if (!found)
5880
5766
  return taskStartFailure(taskStartDenied("TASK_NOT_FOUND", `Task not found: ${params.taskId}`, ["Resolve the task with task_list or task_get, then retry task_start."]));
5881
5767
  const task = found.task;
@@ -5886,6 +5772,13 @@ export default function planPiExtension(pi) {
5886
5772
  if (task.status === "done")
5887
5773
  return taskStartFailure(taskStartDenied("TASK_DONE", `Task ${taskRef} is done and was not reopened.`, [`Use task_reopen with taskId=${taskRef} and confirmed=true after explicit user confirmation.`, `Retry task_start ${taskRef} only if reopening is not the intended operation.`], { taskId: task.id }));
5888
5774
  const project = await st.loadProject();
5775
+ // Targeted stale-context refresh (P102(F005)/T404): task_start delivery
5776
+ // stays scoped to feature/phase/task context, but a session that
5777
+ // already did a full planner-load gets a non-blocking advisory when
5778
+ // project-level context has drifted since, instead of silently going
5779
+ // stale.
5780
+ const projectContextReadState = await st.projectContextReadStateForSession(plannerSessionId);
5781
+ const projectContextAdvisory = projectContextStaleAdvisory(projectContextReadState);
5889
5782
  const linkedRequirements = [
5890
5783
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
5891
5784
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -5927,7 +5820,9 @@ export default function planPiExtension(pi) {
5927
5820
  }
5928
5821
  if (task.status === "in-progress") {
5929
5822
  const outcome = taskStartSucceeded(task.id, true);
5930
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
5823
+ const higherPriorityPhase = findHigherPriorityOpenPhase(initialPhases, features, found.phase.id, found.phase.priority ?? found.phase.number);
5824
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
5825
+ 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 } };
5931
5826
  }
5932
5827
  const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5933
5828
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
@@ -6016,9 +5911,11 @@ export default function planPiExtension(pi) {
6016
5911
  return taskStartFailure(taskStartDenied("PERSISTENCE_VERIFICATION_FAILED", `The lifecycle write could not be verified as in-progress for ${taskRef}.`, ["Inspect task_get for the persisted state and retry task_start only after resolving the persistence failure."], { taskId: task.id }));
6017
5912
  }
6018
5913
  const outcome = taskStartSucceeded(persistedTask.id);
5914
+ const higherPriorityPhase = findHigherPriorityOpenPhase(phases, features, found.phase.id, found.phase.priority ?? found.phase.number);
5915
+ const phaseOrderAdvisory = higherPriorityOpenPhaseAdvisory(higherPriorityPhase);
6019
5916
  return {
6020
- content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
6021
- details: { ...persistedTask, ...outcome, task: persistedTask },
5917
+ content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}${projectContextAdvisory}${phaseOrderAdvisory}` }],
5918
+ details: { ...persistedTask, ...outcome, task: persistedTask, projectContextStale: projectContextReadState === "stale", higherPriorityOpenPhase: higherPriorityPhase },
6022
5919
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
6023
5920
  };
6024
5921
  });
@@ -6027,10 +5924,11 @@ export default function planPiExtension(pi) {
6027
5924
  pi.registerTool({
6028
5925
  name: "task_complete",
6029
5926
  label: "Task Complete",
6030
- 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.",
5927
+ 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.",
6031
5928
  parameters: Type.Object({
6032
5929
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
6033
5930
  force: Type.Optional(Type.Boolean({ description: "Skip checklist completion check. Default: false" })),
5931
+ motivation: Type.Optional(Type.String({ description: "Required when force=true overrides unchecked checklist items: why the checklist no longer matches the work." })),
6034
5932
  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." }),
6035
5933
  }),
6036
5934
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -6049,13 +5947,18 @@ export default function planPiExtension(pi) {
6049
5947
  return { content: [{ type: "text", text: "Task completion denied: provide at least 10 characters of durable completion and verification evidence." }], details: { error: "completion evidence required" } };
6050
5948
  if (task.pauseSnapshot)
6051
5949
  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 };
6052
- const unchecked = task.checklist.filter((item) => !item.checked);
6053
- if (unchecked.length > 0 && !params.force) {
5950
+ const gate = evaluateTaskCompletionGate(task.checklist, params.force === true, params.motivation);
5951
+ if (!gate.allowed) {
5952
+ const message = gate.errorCode === "FORCE_MOTIVATION_REQUIRED"
5953
+ ? taskCompletionForceMotivationRequired(gate.uncheckedItems)
5954
+ : taskCompletionChecklistRefusal(gate.uncheckedItems, `task_checklist_toggle ${params.taskId.trim()} <item>`);
6054
5955
  return {
6055
- 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.` }],
6056
- details: { task, uncheckedChecklistItems: unchecked },
5956
+ content: [{ type: "text", text: message }],
5957
+ details: { task, errorCode: gate.errorCode, uncheckedChecklistItems: gate.uncheckedItems },
6057
5958
  };
6058
5959
  }
5960
+ const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, params.motivation);
5961
+ const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
6059
5962
  const now = nowISO();
6060
5963
  let completedTask;
6061
5964
  await st.updatePhase(found.phase.id, (phase) => {
@@ -6067,10 +5970,10 @@ export default function planPiExtension(pi) {
6067
5970
  t.statusLog = [...t.statusLog, {
6068
5971
  id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
6069
5972
  date: now, fromStatus: previousStatus, toStatus: "done",
6070
- title: `${previousStatus} → done`, description: completionSummary,
5973
+ title: `${previousStatus} → done`, description: completionEvidence,
6071
5974
  }];
6072
5975
  const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
6073
- t.description = t.description + sep + completionSummary;
5976
+ t.description = t.description + sep + completionEvidence;
6074
5977
  t.descriptionUpdatedAt = now;
6075
5978
  t.updatedAt = now;
6076
5979
  phase.updatedAt = now;
@@ -6277,9 +6180,11 @@ export default function planPiExtension(pi) {
6277
6180
  pi.registerTool({
6278
6181
  name: "planner-load",
6279
6182
  label: "Planner Load",
6280
- 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.",
6281
- parameters: Type.Object({}),
6282
- async execute(_id, _params, _signal, _onUpdate, ctx) {
6183
+ 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.",
6184
+ parameters: Type.Object({
6185
+ 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.` })),
6186
+ }),
6187
+ async execute(_id, params, _signal, _onUpdate, ctx) {
6283
6188
  const st = await requirePlan(ctx);
6284
6189
  if (!st)
6285
6190
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
@@ -6311,7 +6216,20 @@ export default function planPiExtension(pi) {
6311
6216
  }
6312
6217
  if (preparation.changed)
6313
6218
  recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
6314
- 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 } } };
6219
+ // Explicit planner load must deliver the complete project-level context
6220
+ // (P102(F005)/T404), attested through the same lossless contract used
6221
+ // everywhere else — shared with the /planner load command and MCP's
6222
+ // planner-load tool via buildProjectContextLoadReply.
6223
+ const boundedMaxChars = params.maxChars ?? DEFAULT_PROJECT_CONTEXT_CHUNK_CHARS;
6224
+ const projectContextDelivery = await st.loadProjectContextDelivery(boundedMaxChars);
6225
+ const projectContextReply = buildProjectContextLoadReply(projectContextDelivery, boundedMaxChars);
6226
+ if (projectContextReply.structured.contextComplete) {
6227
+ await st.recordProjectContextRead({ sessionId: plannerSessionId, chunks: projectContextDelivery.chunks });
6228
+ }
6229
+ // The project context text (like the rest of `details`) is agent-only:
6230
+ // read and retain it, but never quote it or its Accepted Decisions in
6231
+ // the human-facing recap (`content[].text` stays the recap alone).
6232
+ 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 }, projectContext: { text: projectContextReply.text, ...projectContextReply.structured } } };
6315
6233
  },
6316
6234
  });
6317
6235
  pi.registerTool({
@@ -6491,7 +6409,7 @@ export default function planPiExtension(pi) {
6491
6409
  "Operational rules:",
6492
6410
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
6493
6411
  "- 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.",
6494
- "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
6412
+ `- ${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.`,
6495
6413
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
6496
6414
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
6497
6415
  "- 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).",