@agent-plan/pi-adapter 0.2.29 → 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,13 +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
- return { content: [{ type: "text", text: `✅ Phase updated: ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, phaseUpdateFeatures))} — ${phase.title}${phase.shortId ? ` · ${phase.shortId}` : ""}. Fields saved: ${receivedFields.join(", ")}.` }], details: { ...phase, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
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 };
4851
4705
  },
4852
4706
  });
4853
4707
  pi.registerTool({
4854
4708
  name: "decision_record",
4855
4709
  label: "Decision Record",
4856
- description: "Record a user-agreed decision on both a feature and one of its phases. This is planner metadata work, not a code edit. The decision is appended without overwriting existing history.",
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.",
4857
4711
  parameters: Type.Object({
4858
4712
  featureId: Type.String({ description: "Parent feature ref: F00x, shortId, UUID, or name" }),
4859
4713
  phaseId: Type.String({ description: "Active phase ref: P00x, shortId, UUID, or title" }),
@@ -4876,49 +4730,17 @@ export default function planPiExtension(pi) {
4876
4730
  if (resolvedPhase.featureId !== resolvedFeature.feature.id) {
4877
4731
  return { content: [{ type: "text", text: `Phase ${formatPhaseRef(resolvedPhase.number, featureNumberOfPhase(resolvedPhase, features))} does not belong to feature ${formatFeatureRef(resolvedFeature.feature.number)}.` }], details: {} };
4878
4732
  }
4879
- const acceptedAt = nowISO();
4880
- const acceptedDecision = {
4881
- id: crypto.randomUUID(),
4882
- title: params.title.trim(),
4883
- decision: params.decision.trim(),
4884
- rationale: params.rationale.trim(),
4885
- implementationNotes: params.implementationNotes.trim(),
4886
- acceptedAt,
4887
- };
4888
- await st.runBatch(() => withFeatureLock(resolvedFeature.feature.id, async () => {
4889
- let featureWritten = false;
4890
- try {
4891
- await st.updateFeatures((doc) => {
4892
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4893
- if (!feature)
4894
- throw new Error(`Resolved feature no longer exists: ${resolvedFeature.feature.id}`);
4895
- feature.acceptedDecisions = [...(feature.acceptedDecisions ?? []), acceptedDecision];
4896
- feature.updatedAt = acceptedAt;
4897
- return doc;
4898
- });
4899
- featureWritten = true;
4900
- await st.updatePhase(resolvedPhase.id, (phase) => {
4901
- phase.acceptedDecisions = [...(phase.acceptedDecisions ?? []), acceptedDecision];
4902
- phase.updatedAt = acceptedAt;
4903
- return phase;
4904
- });
4905
- }
4906
- catch (error) {
4907
- // Best-effort compensation keeps a failed dual-write from reporting
4908
- // success with a decision persisted only on the feature.
4909
- if (featureWritten) {
4910
- await st.updateFeatures((doc) => {
4911
- const feature = doc.features.find((entry) => entry.id === resolvedFeature.feature.id);
4912
- if (feature)
4913
- feature.acceptedDecisions = (feature.acceptedDecisions ?? []).filter((entry) => entry.id !== acceptedDecision.id);
4914
- return doc;
4915
- }).catch(() => { });
4916
- }
4917
- throw error;
4918
- }
4919
- }));
4920
- await st.writeGenerated();
4921
- return { content: [{ type: "text", text: `✅ Decision recorded on ${formatFeatureRef(resolvedFeature.feature.number)} and ${formatPhaseRef(resolvedPhase.number, resolvedFeature.feature.number)}: ${acceptedDecision.title}` }], details: acceptedDecision };
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 } };
4922
4744
  },
4923
4745
  });
4924
4746
  pi.registerTool({
@@ -4952,7 +4774,7 @@ export default function planPiExtension(pi) {
4952
4774
  return { content: [{ type: "text", text: `✅ Accepted decision created on ${target.ref}: ${acceptedDecision.title}` }], details: { created: true, targetType: params.targetType, targetRef: target.ref, acceptedDecision } };
4953
4775
  }
4954
4776
  catch (error) {
4955
- return acceptedDecisionMutationFailure(error, "created");
4777
+ return piAcceptedDecisionMutationFailure(error, "created");
4956
4778
  }
4957
4779
  },
4958
4780
  });
@@ -5004,7 +4826,7 @@ export default function planPiExtension(pi) {
5004
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 } };
5005
4827
  }
5006
4828
  catch (error) {
5007
- return acceptedDecisionMutationFailure(error, "updated");
4829
+ return piAcceptedDecisionMutationFailure(error, "updated");
5008
4830
  }
5009
4831
  },
5010
4832
  });
@@ -5034,7 +4856,7 @@ export default function planPiExtension(pi) {
5034
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 } };
5035
4857
  }
5036
4858
  catch (error) {
5037
- return acceptedDecisionMutationFailure(error, "deleted");
4859
+ return piAcceptedDecisionMutationFailure(error, "deleted");
5038
4860
  }
5039
4861
  },
5040
4862
  });
@@ -5133,7 +4955,8 @@ export default function planPiExtension(pi) {
5133
4955
  if (!st)
5134
4956
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
5135
4957
  const features = (await st.loadFeatures()).features;
5136
- 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());
5137
4960
  if (!found)
5138
4961
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
5139
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})`;
@@ -5148,7 +4971,15 @@ export default function planPiExtension(pi) {
5148
4971
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
5149
4972
  const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
5150
4973
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
5151
- 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);
5152
4983
  if (found.task.description?.trim())
5153
4984
  sections.push(found.task.description.trim());
5154
4985
  if (found.task.descriptionRef)
@@ -5175,11 +5006,56 @@ export default function planPiExtension(pi) {
5175
5006
  description: found.task.description,
5176
5007
  descriptionRef: found.task.descriptionRef ?? "",
5177
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,
5178
5017
  },
5179
5018
  },
5180
5019
  };
5181
5020
  markCanonicalFullReadForSessionId(plannerSessionId, "task", found.task, result.content[0].text);
5182
- 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
+ };
5183
5059
  },
5184
5060
  });
5185
5061
  pi.registerTool({
@@ -5270,7 +5146,7 @@ export default function planPiExtension(pi) {
5270
5146
  });
5271
5147
  await st.writeGenerated();
5272
5148
  const taskRef = `${formatPhaseRef(resolvedPhase.number, feature.number)}/T${String(task.number).padStart(3, "0")}`;
5273
- return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title}${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5149
+ return { content: [{ type: "text", text: `Task created: ${taskRef} — ${task.title} (${taskCreatedPriorityFragment(task.priority)})${task.shortId ? ` · ${task.shortId}` : ""}` }], details: task };
5274
5150
  },
5275
5151
  });
5276
5152
  pi.registerTool({
@@ -5290,7 +5166,8 @@ export default function planPiExtension(pi) {
5290
5166
  try {
5291
5167
  const task = await st.addTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5292
5168
  await st.writeGenerated();
5293
- 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 };
5294
5171
  }
5295
5172
  catch (error) {
5296
5173
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5317,7 +5194,8 @@ export default function planPiExtension(pi) {
5317
5194
  try {
5318
5195
  const task = await st.deleteTaskDependency(found.phase.id, found.task.id, dependency.task.id);
5319
5196
  await st.writeGenerated();
5320
- 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 };
5321
5199
  }
5322
5200
  catch (error) {
5323
5201
  const details = error instanceof PlanStoreError ? error.details : undefined;
@@ -5333,11 +5211,11 @@ export default function planPiExtension(pi) {
5333
5211
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
5334
5212
  title: Type.Optional(Type.String({ description: "New title" })),
5335
5213
  status: Type.Optional(Type.String({ description: "New status: planned|in-progress|done|blocked|canceled|rejected|deferred|waiting" })),
5336
- description: Type.Optional(Type.String({ description: "New description" })),
5214
+ description: Type.Optional(Type.String({ description: `New description. ${LONG_TEXT_LIMIT_NOTICE}` })),
5337
5215
  descriptionRef: Type.Optional(Type.String({ description: "Optional markdown reference under .planner/docs/ for the full task description." })),
5338
5216
  notes: Type.Optional(Type.String({ description: "New implementation notes" })),
5339
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." })),
5340
- 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." })),
5341
5219
  acceptedDecisions: Type.Optional(Type.Array(Type.Object({
5342
5220
  id: Type.String(),
5343
5221
  title: Type.String(),
@@ -5347,7 +5225,7 @@ export default function planPiExtension(pi) {
5347
5225
  acceptedAt: Type.String(),
5348
5226
  }), { description: "Deprecated compatibility field. Raw replacement is rejected; use accepted_decision_create, accepted_decision_update, or accepted_decision_delete." })),
5349
5227
  priority: Type.Optional(Type.Number({ description: "Display order within the phase (lower = higher)" })),
5350
- checklist: Type.Optional(Type.Array(Type.String(), { description: "Replace checklist (plain strings). For interactive toggling use the web UI." })),
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." })),
5351
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." })),
5352
5230
  }),
5353
5231
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -5362,6 +5240,7 @@ export default function planPiExtension(pi) {
5362
5240
  const hostPhase = found.phase;
5363
5241
  const now = nowISO();
5364
5242
  let updatedTask;
5243
+ let checklistLostTicks = [];
5365
5244
  // Validate the motivation requirement for restrictive status transitions,
5366
5245
  // mirroring the MCP adapter and the /planner task update command
5367
5246
  // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
@@ -5379,8 +5258,15 @@ export default function planPiExtension(pi) {
5379
5258
  }
5380
5259
  if (params.acceptedDecisions !== undefined) {
5381
5260
  return {
5382
- content: [{ type: "text", text: "Raw acceptedDecisions replacement is disabled. Use accepted_decision_create, accepted_decision_update, or accepted_decision_delete so IDs and acceptedAt are preserved." }],
5383
- details: { updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", targetType: "task", targetRef: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}` },
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)}` },
5384
5270
  isError: true,
5385
5271
  };
5386
5272
  }
@@ -5399,10 +5285,10 @@ export default function planPiExtension(pi) {
5399
5285
  if (params.subtasks.some((item) => item.id !== undefined && !existingIds.has(item.id)))
5400
5286
  return { content: [{ type: "text", text: "Subtask IDs must belong to the target task." }], details: { updated: false, errorCode: "SUBTASK_ID_INVALID" }, isError: true };
5401
5287
  }
5402
- const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "decisions", "priority", "checklist", "subtasks"];
5288
+ const mutableFields = ["title", "status", "description", "descriptionRef", "notes", "motivation", "priority", "checklist", "subtasks"];
5403
5289
  const receivedFields = mutableFields.filter((field) => params[field] !== undefined);
5404
5290
  if (receivedFields.length === 0) {
5405
- return mutationNoFieldsFailure({
5291
+ return piMutationNoFieldsFailure({
5406
5292
  entity: "task",
5407
5293
  ref: `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}`,
5408
5294
  operation: "update",
@@ -5434,14 +5320,10 @@ export default function planPiExtension(pi) {
5434
5320
  }
5435
5321
  if (params.notes !== undefined)
5436
5322
  task.notes = params.notes.trim();
5437
- if (params.decisions !== undefined)
5438
- task.decisions = params.decisions.map((item) => item.trim()).filter(Boolean);
5439
5323
  if (params.checklist !== undefined) {
5440
- task.checklist = params.checklist.map((itemTitle, index) => ({ number: index + 1,
5441
- id: createChecklistItemId(task.id, index + 1, itemTitle),
5442
- title: itemTitle,
5443
- checked: false,
5444
- }));
5324
+ const replacement = replaceChecklist(task.checklist ?? [], params.checklist, task.id);
5325
+ task.checklist = replacement.items;
5326
+ checklistLostTicks = replacement.lostTicks;
5445
5327
  }
5446
5328
  if (params.subtasks !== undefined) {
5447
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 }));
@@ -5485,7 +5367,22 @@ export default function planPiExtension(pi) {
5485
5367
  const staleParentRefs = descriptionFreshness?.diagnostics
5486
5368
  .filter((entry) => entry.state === "stale" && ownerIds.has(entry.ownerId))
5487
5369
  .map((entry) => entry.ownerRef) ?? [];
5488
- return { content: [{ type: "text", text: `Task updated: ${updatedTask.id} (${updatedTask.status}). Fields saved: ${receivedFields.join(", ")}.` }], details: { ...updatedTask, updated: true, updatedFields: receivedFields, ...(descriptionFreshness ? { descriptionFreshness, staleParentRefs } : {}) } };
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(", ")}.`
5372
+ : "";
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 };
5489
5386
  },
5490
5387
  });
5491
5388
  pi.registerTool({
@@ -5539,16 +5436,10 @@ export default function planPiExtension(pi) {
5539
5436
  })),
5540
5437
  archivedHandoffs: archivedHandoffs.map(({ compositeRef, firstLine, reason }) => ({ compositeRef, firstLine, reason })),
5541
5438
  });
5542
- const { selection } = result;
5543
- if (!selection.candidate)
5544
- return { content: [{ type: "text", text: `No work recommendation: ${selection.reason}${result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : ""}` }], details: { ...selection, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims } };
5545
- const { candidate } = selection;
5546
- const reference = `${formatPhaseRef(candidate.phase.number, featureNumberOfPhase(candidate.phase, features.features))}/T${String(candidate.task.number).padStart(3, "0")}`;
5547
- const activeLine = result.activeTask ? `Active task: ${result.activeTask.id} — ${result.activeTask.title}\n` : "";
5548
- const claimsText = result.claims.length ? `\nClaims:\n${result.claims.map((claim) => `- ${claim.kind} (${claim.source}): ${claim.ref || claim.title} — ${claim.reason}`).join("\n")}` : "";
5439
+ const reply = buildRecommendationReply(result, features.features);
5549
5440
  return {
5550
- content: [{ type: "text", text: `${activeLine}Next work (${selection.kind}): ${reference} — ${candidate.task.title}\n${selection.reason}${selection.deviation ? `\nDeviation: ${selection.deviation.id}; resume target ${selection.deviation.resumeTaskId}.` : ""}${claimsText}` }],
5551
- details: { ...selection, taskId: candidate.task.id, phaseId: candidate.phase.id, featureId: candidate.feature?.id, activeTask: result.activeTask, nextFeature: result.nextFeature, nextPhase: result.nextPhase, nextTask: result.nextTask, claims: result.claims },
5441
+ content: [{ type: "text", text: reply.text }],
5442
+ details: reply.structured,
5552
5443
  };
5553
5444
  },
5554
5445
  });
@@ -5869,7 +5760,8 @@ export default function planPiExtension(pi) {
5869
5760
  return taskStartFailure(taskStartDenied("PLAN_NOT_FOUND", "No .planner/ found.", ["Initialize or load the planner, then retry task_start."]));
5870
5761
  return st.runBatch(async () => {
5871
5762
  const features = (await st.loadFeatures()).features;
5872
- 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());
5873
5765
  if (!found)
5874
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."]));
5875
5767
  const task = found.task;
@@ -5880,6 +5772,13 @@ export default function planPiExtension(pi) {
5880
5772
  if (task.status === "done")
5881
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 }));
5882
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);
5883
5782
  const linkedRequirements = [
5884
5783
  ...(await st.linkedRequirementsForPhase(found.phase.id)),
5885
5784
  ...(found.phase.featureId ? await st.linkedRequirementsForFeature(found.phase.featureId) : []),
@@ -5921,7 +5820,9 @@ export default function planPiExtension(pi) {
5921
5820
  }
5922
5821
  if (task.status === "in-progress") {
5923
5822
  const outcome = taskStartSucceeded(task.id, true);
5924
- return { content: [{ type: "text", text: `✅ Task already started: ${taskRef} — ${task.title} (in-progress)\nstarted: true` }], details: { ...task, ...outcome, task } };
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 } };
5925
5826
  }
5926
5827
  const [phases, focus] = await Promise.all([st.loadAllPhases(), st.loadResume()]);
5927
5828
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
@@ -6010,9 +5911,11 @@ export default function planPiExtension(pi) {
6010
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 }));
6011
5912
  }
6012
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);
6013
5916
  return {
6014
- content: [{ type: "text", text: `✅ Task started: ${taskRef} — ${persistedTask.title} (in-progress)${persistedTask.shortId ? ` · ${persistedTask.shortId}` : ""}\nstarted: true${phaseContext}${advisory}` }],
6015
- details: { ...persistedTask, ...outcome, task: persistedTask },
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 },
6016
5919
  ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
6017
5920
  };
6018
5921
  });
@@ -6021,10 +5924,11 @@ export default function planPiExtension(pi) {
6021
5924
  pi.registerTool({
6022
5925
  name: "task_complete",
6023
5926
  label: "Task Complete",
6024
- description: "Mark a task as done with mandatory durable completion and verification evidence. Sets completedAt and startedAt (if missing) automatically. Checks for unchecked checklist items and warns unless force=true.",
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.",
6025
5928
  parameters: Type.Object({
6026
5929
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to complete" }),
6027
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." })),
6028
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." }),
6029
5933
  }),
6030
5934
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -6043,13 +5947,18 @@ export default function planPiExtension(pi) {
6043
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" } };
6044
5948
  if (task.pauseSnapshot)
6045
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 };
6046
- const unchecked = task.checklist.filter((item) => !item.checked);
6047
- 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>`);
6048
5955
  return {
6049
- content: [{ type: "text", text: `⚠️ ${unchecked.length} checklist item(s) not done: ${unchecked.map((i) => i.title).join(", ")}. Use task_complete with force=true to override.` }],
6050
- details: { task, uncheckedChecklistItems: unchecked },
5956
+ content: [{ type: "text", text: message }],
5957
+ details: { task, errorCode: gate.errorCode, uncheckedChecklistItems: gate.uncheckedItems },
6051
5958
  };
6052
5959
  }
5960
+ const overrideNote = taskCompletionOverrideNote(gate.overriddenItems, params.motivation);
5961
+ const completionEvidence = overrideNote ? `${completionSummary}\n\n${overrideNote}` : completionSummary;
6053
5962
  const now = nowISO();
6054
5963
  let completedTask;
6055
5964
  await st.updatePhase(found.phase.id, (phase) => {
@@ -6061,10 +5970,10 @@ export default function planPiExtension(pi) {
6061
5970
  t.statusLog = [...t.statusLog, {
6062
5971
  id: createChecklistItemId(t.id, t.statusLog.length + 1, `${previousStatus}-done`),
6063
5972
  date: now, fromStatus: previousStatus, toStatus: "done",
6064
- title: `${previousStatus} → done`, description: completionSummary,
5973
+ title: `${previousStatus} → done`, description: completionEvidence,
6065
5974
  }];
6066
5975
  const sep = t.description ? "\n\n---\n**Completion summary:**\n" : "**Completion summary:**\n";
6067
- t.description = t.description + sep + completionSummary;
5976
+ t.description = t.description + sep + completionEvidence;
6068
5977
  t.descriptionUpdatedAt = now;
6069
5978
  t.updatedAt = now;
6070
5979
  phase.updatedAt = now;
@@ -6271,9 +6180,11 @@ export default function planPiExtension(pi) {
6271
6180
  pi.registerTool({
6272
6181
  name: "planner-load",
6273
6182
  label: "Planner Load",
6274
- description: "Enable the planner for this project and start the web dashboard (LAN), then return a resume recap (project status + handoff if present + Web UI address). This is the explicit way to enable the planner — it does NOT auto-start. Planner operations are NOT code edits.",
6275
- parameters: Type.Object({}),
6276
- async execute(_id, _params, _signal, _onUpdate, ctx) {
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) {
6277
6188
  const st = await requirePlan(ctx);
6278
6189
  if (!st)
6279
6190
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
@@ -6305,7 +6216,20 @@ export default function planPiExtension(pi) {
6305
6216
  }
6306
6217
  if (preparation.changed)
6307
6218
  recap = `${preparation.legacyProjectContext.summary}\n\n${recap}`;
6308
- return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort, preparation: preparation.legacyProjectContext, plannerSkill: { status: plannerSkill.status, customized: plannerSkill.customized, message: plannerSkill.message } } };
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 } } };
6309
6233
  },
6310
6234
  });
6311
6235
  pi.registerTool({
@@ -6485,7 +6409,7 @@ export default function planPiExtension(pi) {
6485
6409
  "Operational rules:",
6486
6410
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
6487
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.",
6488
- "- 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.`,
6489
6413
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
6490
6414
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
6491
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).",