@agent-plan/core 0.2.28 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/accepted-decision-guard.d.ts +94 -0
  2. package/dist/accepted-decision-guard.d.ts.map +1 -0
  3. package/dist/accepted-decision-guard.js +110 -0
  4. package/dist/checklist.d.ts +28 -0
  5. package/dist/checklist.d.ts.map +1 -1
  6. package/dist/checklist.js +59 -0
  7. package/dist/feature-delete.d.ts +62 -0
  8. package/dist/feature-delete.d.ts.map +1 -0
  9. package/dist/feature-delete.js +49 -0
  10. package/dist/guard-decision.d.ts +118 -0
  11. package/dist/guard-decision.d.ts.map +1 -0
  12. package/dist/guard-decision.js +203 -0
  13. package/dist/handoff-context.d.ts +130 -1
  14. package/dist/handoff-context.d.ts.map +1 -1
  15. package/dist/handoff-context.js +167 -9
  16. package/dist/handoff-reply.d.ts +108 -0
  17. package/dist/handoff-reply.d.ts.map +1 -0
  18. package/dist/handoff-reply.js +279 -0
  19. package/dist/index.d.ts +15 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -1
  22. package/dist/mutation-reply.d.ts +105 -0
  23. package/dist/mutation-reply.d.ts.map +1 -0
  24. package/dist/mutation-reply.js +203 -0
  25. package/dist/naming.d.ts +1 -1
  26. package/dist/naming.d.ts.map +1 -1
  27. package/dist/naming.js +1 -1
  28. package/dist/pagination.d.ts +19 -0
  29. package/dist/pagination.d.ts.map +1 -0
  30. package/dist/pagination.js +15 -0
  31. package/dist/payload-fallback.js +1 -1
  32. package/dist/plan-store.d.ts +141 -8
  33. package/dist/plan-store.d.ts.map +1 -1
  34. package/dist/plan-store.js +459 -87
  35. package/dist/project-context-migration.d.ts +5 -0
  36. package/dist/project-context-migration.d.ts.map +1 -1
  37. package/dist/project-context-migration.js +13 -0
  38. package/dist/project-context.d.ts +395 -0
  39. package/dist/project-context.d.ts.map +1 -0
  40. package/dist/project-context.js +295 -0
  41. package/dist/recap.d.ts.map +1 -1
  42. package/dist/recap.js +27 -0
  43. package/dist/recommend-reply.d.ts +51 -0
  44. package/dist/recommend-reply.d.ts.map +1 -0
  45. package/dist/recommend-reply.js +84 -0
  46. package/dist/refs.d.ts +16 -0
  47. package/dist/refs.d.ts.map +1 -1
  48. package/dist/refs.js +38 -0
  49. package/dist/renderer.d.ts.map +1 -1
  50. package/dist/schema.d.ts +7 -2
  51. package/dist/schema.d.ts.map +1 -1
  52. package/dist/schema.js +8 -3
  53. package/dist/stranded-phase.d.ts +73 -0
  54. package/dist/stranded-phase.d.ts.map +1 -0
  55. package/dist/stranded-phase.js +93 -0
  56. package/dist/task-completion-gate.d.ts +84 -0
  57. package/dist/task-completion-gate.d.ts.map +1 -0
  58. package/dist/task-completion-gate.js +72 -0
  59. package/dist/task-context.d.ts +1 -0
  60. package/dist/task-context.d.ts.map +1 -1
  61. package/dist/task-context.js +31 -13
  62. package/dist/task-lifecycle.d.ts +12 -0
  63. package/dist/task-lifecycle.d.ts.map +1 -0
  64. package/dist/task-lifecycle.js +23 -0
  65. package/dist/task-order-context.d.ts +72 -0
  66. package/dist/task-order-context.d.ts.map +1 -0
  67. package/dist/task-order-context.js +82 -0
  68. package/dist/task-selection.d.ts.map +1 -1
  69. package/dist/task-selection.js +14 -12
  70. package/dist/task-start-outcome.d.ts +26 -0
  71. package/dist/task-start-outcome.d.ts.map +1 -1
  72. package/dist/task-start-outcome.js +23 -0
  73. package/dist/text-bounds.d.ts +32 -0
  74. package/dist/text-bounds.d.ts.map +1 -0
  75. package/dist/text-bounds.js +43 -0
  76. package/dist/write-coordination.d.ts +2 -2
  77. package/dist/write-coordination.js +2 -2
  78. package/package.json +1 -1
  79. package/planner-skill.md +30 -12
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { buildPhaseWorkMap } from "./task-context.js";
3
+ import { truncateAtSafeBoundary } from "./text-bounds.js";
3
4
  export const COMPLETION_SUMMARY_HEADING = "**Completion summary:**";
4
5
  export const HANDOFF_COMPLETENESS_AUDIT_VERSION = 1;
5
6
  export const HANDOFF_COLD_START_INVENTORY_VERSION = 1;
@@ -68,6 +69,121 @@ export class HandoffContractError extends Error {
68
69
  this.details = details;
69
70
  }
70
71
  }
72
+ /** Single source of truth for the supportingDocuments recovery statement.
73
+ * Rendered in prepare output (before drafting) and attached to every
74
+ * agent-supplied-manifest failure of HANDOFF_SUPPORTING_DOCUMENT_INVALID
75
+ * (as details.recovery) so both adapters can render it without duplicating
76
+ * the wording. */
77
+ export const HANDOFF_SUPPORTING_DOCUMENTS_GUIDANCE = "supportingDocuments is optional and usually unnecessary: content above the target length is externalized automatically into .planner/docs/. Drop the field and retry unless you are linking a document you created yourself.";
78
+ /** Build a HANDOFF_SUPPORTING_DOCUMENT_INVALID error for a defect in an
79
+ * agent-supplied manifest entry, always carrying the droppable-field
80
+ * recovery statement. Do not use this for planner-owned failures (e.g. the
81
+ * auto-externalized document's own path) — those are not something dropping
82
+ * the agent's field would fix. */
83
+ export function supportingDocumentInvalidError(message, details = {}) {
84
+ return new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", message, { ...details, recovery: HANDOFF_SUPPORTING_DOCUMENTS_GUIDANCE });
85
+ }
86
+ /** Build a HANDOFF_SUPPORTING_DOCUMENT_INVALID error for a PlanStore-internal
87
+ * invariant (the count of requested vs. PlanStore-verified documents must
88
+ * always match; PlanStore either throws for a defective entry or returns one
89
+ * verified entry per requested document, in lockstep). This is unreachable
90
+ * through the documented tool flow, so unlike supportingDocumentInvalidError
91
+ * it must not carry the droppable-field recovery statement: dropping the
92
+ * field would not be the fix for an invariant violation, and advertising it
93
+ * would mislead the agent. */
94
+ function supportingDocumentInvariantError(message, details = {}) {
95
+ return new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", message, details);
96
+ }
97
+ /** Build a HANDOFF_RETAINED_CONTENT_NOT_FOUND error: `content` was omitted
98
+ * from a handoff_write call and PlanStore has no body retained for this
99
+ * phase + expectedHandoffUpdatedAt token (see PlanStore.retainHandoffDraft /
100
+ * getRetainedHandoffDraft). Reasons: no write against this token has ever
101
+ * failed, the retained draft aged out, the phase went terminal and its
102
+ * drafts were discarded, or the token itself is wrong/stale. Content is
103
+ * always required on the first write attempt for a token; it becomes
104
+ * optional only on a retry that reuses a retained one. */
105
+ export function retainedHandoffContentNotFoundError(phaseId, expectedHandoffUpdatedAt) {
106
+ return new HandoffContractError("HANDOFF_RETAINED_CONTENT_NOT_FOUND", "content was omitted but no retained draft was found for this phase and expectedHandoffUpdatedAt token.", {
107
+ phaseId,
108
+ expectedHandoffUpdatedAt,
109
+ recovery: "Provide content once. After a write fails, a retry against the exact same expectedHandoffUpdatedAt may omit content to reuse the body just submitted; a fresh token (from a new handoff_prepare) or a phase that has since gone terminal has nothing retained.",
110
+ });
111
+ }
112
+ /** The exact description externalizeOversizedHandoffContent stamps on the
113
+ * supporting-document entry it generates for an auto-externalized document.
114
+ * The single source of truth for recognizing one later: handoff_prepare
115
+ * matches on this instead of the timestamped file-naming convention, so
116
+ * detection and generation cannot drift apart. A document a human or agent
117
+ * linked deliberately (a different description) is already named in the
118
+ * persisted capsule's own "Supporting documents" bullet and is out of scope
119
+ * here — only content the tooling itself moved out without being asked. */
120
+ export const HANDOFF_AUTO_EXTERNALIZED_DOCUMENT_DESCRIPTION = "Full submitted handoff detail externalized automatically; required for cold resume and reconciliation.";
121
+ export function isAutoExternalizedHandoffDocument(document) {
122
+ return document.description === HANDOFF_AUTO_EXTERNALIZED_DOCUMENT_DESCRIPTION;
123
+ }
124
+ /** Level-2 (`##`) headings of a handoff document, in order. Used to name
125
+ * what an externalized document contains when its content cannot be
126
+ * inlined (budget, or the file is missing) and as a table of contents
127
+ * alongside content that is inlined. */
128
+ export function extractHandoffSectionHeadings(content) {
129
+ const headings = [];
130
+ for (const line of content.split(/\r?\n/)) {
131
+ const match = line.match(/^##\s+(.+?)\s*$/);
132
+ if (match)
133
+ headings.push(match[1]);
134
+ }
135
+ return headings;
136
+ }
137
+ /** Prior `##` headings with no counterpart heading in `newContent`. A
138
+ * section counts as carried forward only if its exact heading text
139
+ * (case-insensitive) still appears as a `##` heading somewhere in the new
140
+ * content — matching by heading, not by scanning prose for the words, keeps
141
+ * this a narrow structural check rather than a fuzzy content audit. Callers
142
+ * needing "did the author account for this" evidence use
143
+ * validateHandoffSectionReconciliation below, which turns this list into a
144
+ * typed refusal unless every entry has a disposition. */
145
+ function unaccountedHandoffSections(priorSectionHeadings, newContent) {
146
+ const newHeadings = new Set(extractHandoffSectionHeadings(newContent).map((heading) => heading.trim().toLowerCase()));
147
+ return priorSectionHeadings.filter((heading) => !newHeadings.has(heading.trim().toLowerCase()));
148
+ }
149
+ /**
150
+ * Make reconciliation evidenced rather than asserted (P104(F005)/T416).
151
+ *
152
+ * Narrow by design: this compares `##` heading text only, never section
153
+ * content or a fixed category list — it is not a return to the 16-section
154
+ * scaffold and 14-category cold-start inventory P100(F021)/T396 removed for
155
+ * making authoring verbose and failing late. A prior capsule with no
156
+ * headings (or a new submission with none) simply has nothing to name here;
157
+ * the check degrades to a no-op rather than blocking the write.
158
+ *
159
+ * A prior section vanishes from the new content in three ways an author
160
+ * might legitimately intend: dropped because it no longer matters,
161
+ * superseded by newer content, or folded into a different section (a rename
162
+ * reads the same way — the old heading is gone, the content lives under a
163
+ * new one). This function does not try to tell those apart; it only
164
+ * requires the author to say which happened, per named section, before the
165
+ * write proceeds. A deliberate full rewrite where every prior section is
166
+ * obsolete is handled the same way: every heading is named, and the author
167
+ * writes one disposition per heading.
168
+ */
169
+ export function validateHandoffSectionReconciliation(priorSectionHeadings, newContent, dispositions) {
170
+ const unaccounted = unaccountedHandoffSections(priorSectionHeadings, newContent);
171
+ if (unaccounted.length === 0)
172
+ return;
173
+ const bySection = new Map();
174
+ for (const entry of dispositions ?? []) {
175
+ const section = entry.section?.trim();
176
+ if (section)
177
+ bySection.set(section.toLowerCase(), entry.disposition ?? "");
178
+ }
179
+ const stillUnaccounted = unaccounted.filter((section) => !isSubstantive(bySection.get(section.trim().toLowerCase()) ?? ""));
180
+ if (stillUnaccounted.length > 0) {
181
+ throw new HandoffContractError("HANDOFF_SECTION_RECONCILIATION_REQUIRED", "Prior handoff sections have no counterpart in the new content. Supply sectionDispositions with one substantive entry per named section (dropped, superseded, or folded into another section) before writing.", {
182
+ unaccountedSections: stillUnaccounted,
183
+ recovery: "Retry against the same phaseRef and expectedHandoffUpdatedAt with sectionDispositions only; content may be omitted to reuse the body just submitted.",
184
+ });
185
+ }
186
+ }
71
187
  export function hasTaskCompletionEvidence(task) {
72
188
  if (task.status !== "done")
73
189
  return true;
@@ -132,6 +248,13 @@ export function auditPhaseHandoff(phase, feature) {
132
248
  coldStartInventoryCategories: HANDOFF_COLD_START_INVENTORY_CATEGORIES,
133
249
  existingCompletenessAudit: phase.handoffAudit,
134
250
  phaseWorkMap: buildPhaseWorkMap(phase, feature.number),
251
+ supportingDocumentsGuidance: HANDOFF_SUPPORTING_DOCUMENTS_GUIDANCE,
252
+ // Filled in by PlanStore.preparePhaseHandoff, which alone can read
253
+ // .planner/docs/; this pure function has no filesystem access.
254
+ externalizedHandoffDocuments: [],
255
+ // Compact-capsule headings only; PlanStore.preparePhaseHandoff overlays
256
+ // headings from auto-externalized documents once it has read them back.
257
+ priorSectionHeadings: phase.handoff.trim() ? extractHandoffSectionHeadings(phase.handoff) : [],
135
258
  };
136
259
  }
137
260
  function nonEmpty(value, field) {
@@ -339,11 +462,16 @@ function sectionBody(content, headings) {
339
462
  }
340
463
  return body.join("\n").trim();
341
464
  }
465
+ // truncateAtSafeBoundary / boundedContentForTransport moved to text-bounds.ts
466
+ // (P104(F005)/T419) so task-context.ts can share them without creating a
467
+ // cycle: this module already imports buildPhaseWorkMap from task-context.ts.
468
+ // Both are exported directly from text-bounds.js at the package root now.
342
469
  function boundedSection(value, fallback, maxChars) {
343
470
  const normalized = value.trim() || fallback;
344
471
  if (normalized.length <= maxChars)
345
472
  return normalized;
346
- return `${normalized.slice(0, Math.max(0, maxChars - 86)).trimEnd()}\n\n[Extended detail continues in the linked planner document.]`;
473
+ const safe = truncateAtSafeBoundary(normalized, Math.max(0, maxChars - 86));
474
+ return `${safe}\n\n[Extended detail continues in the linked planner document.]`;
347
475
  }
348
476
  /**
349
477
  * Mechanically compact oversized legacy or verbose handoffs while retaining the
@@ -390,7 +518,7 @@ export function externalizeOversizedHandoffContent(content, supportingDocumentPa
390
518
  extendedContent: `${firstHeading} — extended detail\n\n${base}\n`,
391
519
  supportingDocument: {
392
520
  path: supportingDocumentPath,
393
- description: "Full submitted handoff detail externalized automatically; required for cold resume and reconciliation.",
521
+ description: HANDOFF_AUTO_EXTERNALIZED_DOCUMENT_DESCRIPTION,
394
522
  },
395
523
  };
396
524
  }
@@ -456,20 +584,32 @@ export function validateHandoffContextSync(phase, feature, input) {
456
584
  const requestedDocuments = input.supportingDocuments ?? [];
457
585
  const verifiedDocuments = input.verifiedSupportingDocuments ?? [];
458
586
  if (requestedDocuments.length !== verifiedDocuments.length) {
459
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", "Every supporting document must be validated by PlanStore before the handoff is written.", { requestedCount: requestedDocuments.length, verifiedCount: verifiedDocuments.length });
587
+ throw supportingDocumentInvariantError("Every supporting document must be validated by PlanStore before the handoff is written.", { requestedCount: requestedDocuments.length, verifiedCount: verifiedDocuments.length });
460
588
  }
461
589
  const verifiedContents = input.verifiedSupportingDocumentContents ?? [];
462
590
  if (verifiedContents.length !== verifiedDocuments.length) {
463
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", "Validated supporting-document content is required for cold-start inventory coverage checks.", { verifiedDocumentCount: verifiedDocuments.length, verifiedContentCount: verifiedContents.length });
591
+ throw supportingDocumentInvariantError("Validated supporting-document content is required for cold-start inventory coverage checks.", { verifiedDocumentCount: verifiedDocuments.length, verifiedContentCount: verifiedContents.length });
464
592
  }
465
593
  for (let index = 0; index < requestedDocuments.length; index += 1) {
466
594
  const requested = requestedDocuments[index];
467
595
  const verified = verifiedDocuments[index];
468
596
  if (requested.path !== verified.path || !isSubstantive(requested.description) || requested.description.trim() !== verified.description) {
469
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document ${requested.path || `(index ${index})`} is not valid or lacks a substantive description.`, { index, path: requested.path });
597
+ throw supportingDocumentInvalidError(`Supporting document ${requested.path || `(index ${index})`} is not valid or lacks a substantive description.`, { index, path: requested.path });
470
598
  }
471
- if (!input.content.includes(requested.path)) {
472
- throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Canonical handoff content must link supporting document ${requested.path}.`, { index, path: requested.path });
599
+ // Body-dependent: the manifest is valid on its own, but the drafted content
600
+ // never linked it. This is the one check that cannot move to prepare, since
601
+ // prepare runs before the body exists.
602
+ //
603
+ // Check the submitted body as well as the persisted one. Auto-externalization
604
+ // rewrites `content` after the agent submitted it, moving whole sections into
605
+ // a .planner/docs/ file; a path the author linked inside one of those sections
606
+ // is no longer in `content` through no fault of theirs. The planner's own
607
+ // auto-externalized document is the mirror case — its path is generated during
608
+ // the rewrite, so it appears only in `content`. Accepting either body covers
609
+ // both without letting through a path the agent never linked anywhere.
610
+ const linkedBodies = [input.content, ...(input.submittedContent ? [input.submittedContent] : [])];
611
+ if (!linkedBodies.some((body) => body.includes(requested.path))) {
612
+ throw supportingDocumentInvalidError(`Canonical handoff content must link supporting document ${requested.path}. Checked the ${input.submittedContent ? "submitted body and the auto-externalized body" : "submitted body"}.`, { index, path: requested.path });
473
613
  }
474
614
  }
475
615
  renderVerifiedHandoffContent(input.content, input.completenessAudit, input.coldStartInventory, verifiedContents);
@@ -482,6 +622,15 @@ export function validateHandoffContextSync(phase, feature, input) {
482
622
  if (phase.handoff.trim() && !input.reconciledExistingHandoff) {
483
623
  throw new Error("An active handoff already exists. Reconcile its still-relevant content and set reconciledExistingHandoff=true.");
484
624
  }
625
+ if (phase.handoff.trim()) {
626
+ // Judge against the body the author actually wrote, not the planner's
627
+ // own auto-externalized compaction (mirrors the supporting-document link
628
+ // check above): when this write itself got compacted, submittedContent
629
+ // is the full pre-compaction text and is what a prior section's heading
630
+ // would still appear in.
631
+ const newContentForHeadings = input.submittedContent ?? input.content;
632
+ validateHandoffSectionReconciliation(input.priorSectionHeadings ?? [], newContentForHeadings, input.sectionDispositions);
633
+ }
485
634
  const sync = input.contextSync;
486
635
  const taskIds = sync.taskUpdates.map((update) => nonEmpty(update.taskId, "Task update taskId"));
487
636
  if (new Set(taskIds).size !== taskIds.length)
@@ -528,6 +677,14 @@ export function applyHandoffContextSync(phase, feature, input, timestamp) {
528
677
  for (const update of input.contextSync.taskUpdates) {
529
678
  const task = nextPhase.tasks.find((candidate) => candidate.id === update.taskId);
530
679
  const files = uniqueStrings(update.filesTouched ?? []);
680
+ // Decisions mentioned in a completion summary are prose, not decision
681
+ // authority (a completion summary is explicitly one of the places a new
682
+ // durable decision must not live only in). Render them into the section
683
+ // text with that caveat instead of appending to the legacy task.decisions
684
+ // array, which nothing treats as authoritative and which is otherwise
685
+ // read-only going forward (see accepted-decision-guard.ts). A decision
686
+ // worth keeping needs its own accepted_decision_create call on the right
687
+ // owner; this list is not a substitute for that.
531
688
  const decisions = uniqueStrings(update.decisions ?? []);
532
689
  const section = [
533
690
  COMPLETION_SUMMARY_HEADING,
@@ -539,24 +696,25 @@ export function applyHandoffContextSync(phase, feature, input, timestamp) {
539
696
  "**Remaining or unverified:**",
540
697
  update.remainingWork.trim(),
541
698
  ...(files.length > 0 ? ["", "**Files touched:**", ...files.map((file) => `- ${file}`)] : []),
699
+ ...(decisions.length > 0 ? ["", "**Decisions mentioned (not decision authority; record any durable decision with accepted_decision_create on its actual owner):**", ...decisions.map((decision) => `- ${decision}`)] : []),
542
700
  ].join("\n");
543
701
  task.description = appendSection(task.description, section);
544
702
  task.descriptionUpdatedAt = timestamp;
545
- task.decisions = uniqueStrings([...(task.decisions ?? []), ...decisions]);
546
703
  task.updatedAt = timestamp;
547
704
  updatedTaskIds.push(task.id);
548
705
  }
549
706
  const phaseUpdate = input.contextSync.phaseUpdate;
550
707
  if (phaseUpdate) {
708
+ const phaseDecisions = uniqueStrings(phaseUpdate.decisions ?? []);
551
709
  const section = [
552
710
  "**Handoff context update:**",
553
711
  phaseUpdate.progressSummary.trim(),
554
712
  "",
555
713
  "**Remaining work:**",
556
714
  phaseUpdate.remainingWork.trim(),
715
+ ...(phaseDecisions.length > 0 ? ["", "**Decisions mentioned (not decision authority; record any durable decision with accepted_decision_create on its actual owner):**", ...phaseDecisions.map((decision) => `- ${decision}`)] : []),
557
716
  ].join("\n");
558
717
  nextPhase.notes = appendSection(nextPhase.notes, section);
559
- nextPhase.decisions = uniqueStrings([...(nextPhase.decisions ?? []), ...(phaseUpdate.decisions ?? [])]);
560
718
  }
561
719
  const featureUpdate = input.contextSync.featureUpdate;
562
720
  if (featureUpdate) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared reply shaping for the handoff show/prepare tools.
3
+ *
4
+ * Every adapter (MCP, Pi, and any future harness) calls the same builders
5
+ * here instead of assembling its own text/structuredContent pair. Each
6
+ * builder returns exactly one copy of every piece of information: the
7
+ * human-readable channel (`text`) and the machine-readable channel
8
+ * (`structured`) never both carry the same field. Which channel is
9
+ * canonical for a given piece of content is decided per field below, based
10
+ * on what a structuredContent-only host needs in order to act (see the
11
+ * per-field comments) rather than by a single blanket rule — see AGENTS.md
12
+ * rule 4: this placement decision belongs in the core exactly once, not
13
+ * once per adapter.
14
+ */
15
+ import type { HandoffCompletenessAudit, Phase } from "./schema.js";
16
+ import type { PhaseWorkMap } from "./task-context.js";
17
+ import { type HandoffContractError, type PhaseHandoffAudit } from "./handoff-context.js";
18
+ import type { MutationReply } from "./mutation-reply.js";
19
+ /**
20
+ * Render a HandoffContractError (handoff refresh/prepare denial) as a
21
+ * text/structured pair. Both adapters carried an identical copy; kept here
22
+ * so the message and the `errorCode`/details shape can never drift between
23
+ * MCP and Pi. Each adapter still owns its own envelope (`isError` +
24
+ * `structuredContent` for MCP, `details` for Pi) — see AGENTS.md rule 4.
25
+ */
26
+ export declare function handoffContractFailureReply(error: HandoffContractError, action?: "refresh" | "preparation"): MutationReply;
27
+ export interface BoundedHandoffContent {
28
+ content: string;
29
+ fullLength: number;
30
+ truncated: boolean;
31
+ }
32
+ /** Bound raw handoff content to the absolute compatibility ceiling for
33
+ * transport. Legacy or externally-written content can exceed the ceiling;
34
+ * everything produced through the normal write path already respects it.
35
+ * Cuts at a safe boundary (paragraph, then line, then word — see
36
+ * boundedContentForTransport / truncateAtSafeBoundary in handoff-context.ts)
37
+ * rather than an arbitrary character offset, so the visible remainder never
38
+ * ends mid-word. */
39
+ export declare function boundedHandoffForTransport(content: string): BoundedHandoffContent;
40
+ /** A tool reply as one result: the human-readable body and the structured
41
+ * payload, with no field present in both. */
42
+ export interface HandoffReply {
43
+ text: string;
44
+ structured: Record<string, unknown>;
45
+ }
46
+ export type HandoffShowReplyInput = {
47
+ kind: "empty";
48
+ phaseRef: string;
49
+ phaseId: string;
50
+ handoffAudit: HandoffCompletenessAudit | null;
51
+ } | {
52
+ kind: "archived";
53
+ phaseRef: string;
54
+ phaseId: string;
55
+ /** Raw archived content; the builder bounds it for transport. */
56
+ content: string;
57
+ archiveReason: string;
58
+ archivedAt: string;
59
+ archiveFile: string;
60
+ handoffAudit: HandoffCompletenessAudit | null;
61
+ } | {
62
+ kind: "active";
63
+ phaseRef: string;
64
+ phase: Phase;
65
+ phaseWorkMap: PhaseWorkMap;
66
+ };
67
+ /**
68
+ * Build the reply for `planner-handoff-show` / `handoff_show`, covering the
69
+ * empty, archived, and active branches through one shared shape so no
70
+ * surface keeps its own construction.
71
+ *
72
+ * Placement: the resume capsule body is prose meant to be read, and every
73
+ * existing consumer reads it from `text` (never from `structured`), so
74
+ * `text` is its one home; `structured` carries only `fullLength`/
75
+ * `truncated` flags plus the identifiers, hash, and verification flags a
76
+ * structuredContent-only host needs to drive the candidate-then-verify
77
+ * contract (call verify with the right hash) without needing the prose
78
+ * itself.
79
+ */
80
+ export declare function buildHandoffShowReply(input: HandoffShowReplyInput): HandoffReply;
81
+ /** Single source of truth for the prepare-reply evidence-contract sentence,
82
+ * previously hardcoded identically in both adapters. */
83
+ export declare const HANDOFF_PREPARE_EVIDENCE_CONTRACT = "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.";
84
+ export interface HandoffPrepareReplyInput {
85
+ phaseRef: string;
86
+ audit: PhaseHandoffAudit;
87
+ }
88
+ /**
89
+ * Build the reply for `planner-handoff-prepare` / `handoff_prepare`.
90
+ *
91
+ * Placement: `draftTemplate` has no consumer in `text` (only the intro line
92
+ * pointing at it is read there) and every existing consumer reads it from
93
+ * `structured`, so `structured` is its one home; `text` gets a pointer
94
+ * instead of a second copy. The existing active handoff being reconciled is
95
+ * the opposite: nothing reads it from `structured`, so it stays prose in
96
+ * `text` only, bounded for transport, with `structured` carrying just its
97
+ * length/truncated flags. `missingCompletionTasks` duplicates the same
98
+ * titles already rendered into `text`; `structured` keeps only the task ids
99
+ * needed to address `taskUpdates`. The existing handoff's auto-externalized
100
+ * documents follow the same rule as the existing handoff itself: their
101
+ * bodies are prose for `text` (see renderExternalizedHandoffDocuments),
102
+ * `structured` keeps only path/headings/size/inlined/truncated per document
103
+ * (existingHandoffExternalizedDocuments) — see P104(F005)/T415's accepted
104
+ * decision for why inlining (budget permitting) was chosen over a
105
+ * separate-field or names-only reply.
106
+ */
107
+ export declare function buildHandoffPrepareReply(input: HandoffPrepareReplyInput): HandoffReply;
108
+ //# sourceMappingURL=handoff-reply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handoff-reply.d.ts","sourceRoot":"","sources":["../src/handoff-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,wBAAwB,EAA6B,KAAK,EAAE,MAAM,aAAa,CAAC;AAC9F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAGL,KAAK,oBAAoB,EAEzB,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,oBAAoB,EAAE,MAAM,GAAE,SAAS,GAAG,aAAyB,GAAG,aAAa,CAMrI;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;oBAMoB;AACpB,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,CAKjF;AA8CD;6CAC6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAmCD,MAAM,MAAM,qBAAqB,GAC7B;IACE,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,wBAAwB,GAAG,IAAI,CAAC;CAC/C,GACD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,wBAAwB,GAAG,IAAI,CAAC;CAC/C,GACD;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,YAAY,EAAE,YAAY,CAAC;CAC5B,CAAC;AAEN;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,qBAAqB,GAAG,YAAY,CAsEhF;AAED;wDACwD;AACxD,eAAO,MAAM,iCAAiC,0IAC2F,CAAC;AAE1I,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,iBAAiB,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,wBAAwB,GAAG,YAAY,CAkFtF"}
@@ -0,0 +1,279 @@
1
+ import { MAX_HANDOFF_CONTENT_CHARS, handoffContentHash, } from "./handoff-context.js";
2
+ import { boundedContentForTransport } from "./text-bounds.js";
3
+ /**
4
+ * Render a HandoffContractError (handoff refresh/prepare denial) as a
5
+ * text/structured pair. Both adapters carried an identical copy; kept here
6
+ * so the message and the `errorCode`/details shape can never drift between
7
+ * MCP and Pi. Each adapter still owns its own envelope (`isError` +
8
+ * `structuredContent` for MCP, `details` for Pi) — see AGENTS.md rule 4.
9
+ */
10
+ export function handoffContractFailureReply(error, action = "refresh") {
11
+ const recovery = typeof error.details.recovery === "string" ? ` Recovery: ${error.details.recovery}` : "";
12
+ return {
13
+ text: `❌ Handoff ${action} denied [${error.code}]: ${error.message}${recovery}`,
14
+ structured: { errorCode: error.code, ...error.details },
15
+ };
16
+ }
17
+ /** Bound raw handoff content to the absolute compatibility ceiling for
18
+ * transport. Legacy or externally-written content can exceed the ceiling;
19
+ * everything produced through the normal write path already respects it.
20
+ * Cuts at a safe boundary (paragraph, then line, then word — see
21
+ * boundedContentForTransport / truncateAtSafeBoundary in handoff-context.ts)
22
+ * rather than an arbitrary character offset, so the visible remainder never
23
+ * ends mid-word. */
24
+ export function boundedHandoffForTransport(content) {
25
+ const suffix = "\n\n[Legacy handoff truncated for transport safety. Move extended detail to a linked file under .planner/docs/ and refresh the handoff.]";
26
+ if (content.length <= MAX_HANDOFF_CONTENT_CHARS)
27
+ return { content, fullLength: content.length, truncated: false };
28
+ const bounded = boundedContentForTransport(content, Math.max(0, MAX_HANDOFF_CONTENT_CHARS - suffix.length));
29
+ return { content: `${bounded.content}${suffix}`, fullLength: content.length, truncated: true };
30
+ }
31
+ /**
32
+ * Render the existing handoff's auto-externalized documents (see
33
+ * HandoffExternalizedDocumentAudit) for the prepare text channel. Shares one
34
+ * budget across every document rather than bounding each independently to
35
+ * MAX_HANDOFF_CONTENT_CHARS, so several documents from successive rewrites
36
+ * cannot each spend the full ceiling — the reporting phase's own P082 capsule
37
+ * carried two linked documents at once, and a prior capsule's externalized
38
+ * document can by itself already be as large as the ceiling (a large draft
39
+ * is exactly what got externalized). Every document is still named — path,
40
+ * description, and heading table of contents — even when its content does
41
+ * not fit, so "elided" never again means "invisible": inlining degrades to
42
+ * naming, it never degrades to silence.
43
+ */
44
+ function renderExternalizedHandoffDocuments(documents) {
45
+ let remaining = MAX_HANDOFF_CONTENT_CHARS;
46
+ const blocks = [];
47
+ const structured = [];
48
+ for (const document of documents) {
49
+ const headingsNote = document.headings.length > 0 ? ` (sections: ${document.headings.join(", ")})` : "";
50
+ const header = `- ${document.path} — ${document.description}${headingsNote}`;
51
+ if (document.missing) {
52
+ blocks.push(`${header}\n [Not found on disk; its content is unavailable. Treat any facts it carried as unverified until confirmed another way.]`);
53
+ structured.push({ path: document.path, headings: document.headings, contentLength: document.contentLength, missing: true, inlined: false, truncated: false });
54
+ continue;
55
+ }
56
+ if (remaining <= 0) {
57
+ blocks.push(`${header}\n [Not inlined: the reconciliation budget for externalized detail is exhausted. Read this document directly before drafting.]`);
58
+ structured.push({ path: document.path, headings: document.headings, contentLength: document.contentLength, missing: false, inlined: false, truncated: false });
59
+ continue;
60
+ }
61
+ const bounded = boundedContentForTransport(document.content ?? "", remaining);
62
+ remaining -= bounded.content.length;
63
+ const continuation = bounded.truncated
64
+ ? "\n\n[Not fully inlined: remaining detail exceeds the reconciliation budget. Read this document directly for the rest.]"
65
+ : "";
66
+ blocks.push(`${header}\n${bounded.content}${continuation}`);
67
+ structured.push({ path: document.path, headings: document.headings, contentLength: document.contentLength, missing: false, inlined: true, truncated: bounded.truncated });
68
+ }
69
+ return { text: blocks.join("\n\n"), structured };
70
+ }
71
+ /**
72
+ * Transport shape of the phase work map. `entries` is the machine-readable
73
+ * twin of the rendered `content` — the same siblings spelled as JSON — and
74
+ * no caller reads it off a reply, so only the counts travel. Pass
75
+ * `includeContent` for the reply that renders the map in no other channel.
76
+ */
77
+ function workMapForTransport(map, includeContent) {
78
+ return {
79
+ ...(includeContent ? { content: map.content } : {}),
80
+ total: map.total,
81
+ truncated: map.truncated,
82
+ maxChars: map.maxChars,
83
+ };
84
+ }
85
+ /**
86
+ * Transport shape of the persisted audit. `entries`, `coldStartInventory`
87
+ * and `readBackSourceReviews` are planner-owned legacy evidence that agents
88
+ * are told not to reproduce (P100(F021)/T396) and that nothing reads back
89
+ * off a reply; only the verification facts travel.
90
+ */
91
+ function handoffAuditForTransport(audit) {
92
+ if (!audit)
93
+ return null;
94
+ return {
95
+ version: audit.version,
96
+ contentHash: audit.contentHash,
97
+ contentLength: audit.contentLength,
98
+ verifiedAt: audit.verifiedAt,
99
+ resumeReadyAt: audit.resumeReadyAt,
100
+ supportingDocuments: audit.supportingDocuments,
101
+ };
102
+ }
103
+ /**
104
+ * Build the reply for `planner-handoff-show` / `handoff_show`, covering the
105
+ * empty, archived, and active branches through one shared shape so no
106
+ * surface keeps its own construction.
107
+ *
108
+ * Placement: the resume capsule body is prose meant to be read, and every
109
+ * existing consumer reads it from `text` (never from `structured`), so
110
+ * `text` is its one home; `structured` carries only `fullLength`/
111
+ * `truncated` flags plus the identifiers, hash, and verification flags a
112
+ * structuredContent-only host needs to drive the candidate-then-verify
113
+ * contract (call verify with the right hash) without needing the prose
114
+ * itself.
115
+ */
116
+ export function buildHandoffShowReply(input) {
117
+ if (input.kind === "empty") {
118
+ return {
119
+ text: `No handoff set on ${input.phaseRef}.`,
120
+ structured: {
121
+ phaseRef: input.phaseRef,
122
+ phaseId: input.phaseId,
123
+ content: "",
124
+ empty: true,
125
+ resumeReady: false,
126
+ handoffAudit: handoffAuditForTransport(input.handoffAudit),
127
+ },
128
+ };
129
+ }
130
+ if (input.kind === "archived") {
131
+ const bounded = boundedHandoffForTransport(input.content);
132
+ const supportingDocuments = input.handoffAudit?.supportingDocuments ?? [];
133
+ const documentLines = supportingDocuments.map((document) => `- ${document.path} — ${document.description}`);
134
+ return {
135
+ text: [
136
+ `Archived terminal-phase handoff for ${input.phaseRef} (${input.archiveReason}; archived ${input.archivedAt})`,
137
+ ...(documentLines.length ? ["Supporting documents:", ...documentLines] : []),
138
+ "",
139
+ bounded.content,
140
+ ].join("\n"),
141
+ structured: {
142
+ phaseRef: input.phaseRef,
143
+ phaseId: input.phaseId,
144
+ active: false,
145
+ archived: true,
146
+ archiveReason: input.archiveReason,
147
+ archivedAt: input.archivedAt,
148
+ archiveFile: input.archiveFile,
149
+ fullLength: bounded.fullLength,
150
+ truncated: bounded.truncated,
151
+ supportingDocuments,
152
+ empty: false,
153
+ resumeReady: false,
154
+ handoffAudit: handoffAuditForTransport(input.handoffAudit),
155
+ },
156
+ };
157
+ }
158
+ const { phase, phaseRef, phaseWorkMap } = input;
159
+ const bounded = boundedHandoffForTransport(phase.handoff);
160
+ const contentHash = handoffContentHash(phase.handoff);
161
+ const persistenceVerified = Boolean(phase.handoffAudit
162
+ && phase.handoffAudit.contentHash === contentHash
163
+ && phase.handoffAudit.contentLength === phase.handoff.length);
164
+ const resumeReady = persistenceVerified && Boolean(phase.handoffAudit?.resumeReadyAt);
165
+ const status = resumeReady
166
+ ? "Resume-ready: persisted compact capsule read-back completed."
167
+ : "NOT resume-ready: after reading this persisted capsule, call planner-handoff-verify with its contentHash. The planner derives legacy evidence from persisted state; rewrite only if the capsule itself omits resume-critical context.";
168
+ return {
169
+ text: `Handoff for ${phaseRef}\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}`,
170
+ structured: {
171
+ phaseRef,
172
+ phaseId: phase.id,
173
+ // The rendered map is already in `text`, so only the counts travel.
174
+ phaseWorkMap: workMapForTransport(phaseWorkMap, false),
175
+ fullLength: bounded.fullLength,
176
+ truncated: bounded.truncated,
177
+ contentHash,
178
+ persistenceVerified,
179
+ resumeReady,
180
+ verificationRequired: !resumeReady,
181
+ handoffAudit: handoffAuditForTransport(phase.handoffAudit),
182
+ },
183
+ };
184
+ }
185
+ /** Single source of truth for the prepare-reply evidence-contract sentence,
186
+ * previously hardcoded identically in both adapters. */
187
+ export const HANDOFF_PREPARE_EVIDENCE_CONTRACT = "Planner-owned completeness and cold-start evidence is derived from persisted state; no category inventory is required from the agent.";
188
+ /**
189
+ * Build the reply for `planner-handoff-prepare` / `handoff_prepare`.
190
+ *
191
+ * Placement: `draftTemplate` has no consumer in `text` (only the intro line
192
+ * pointing at it is read there) and every existing consumer reads it from
193
+ * `structured`, so `structured` is its one home; `text` gets a pointer
194
+ * instead of a second copy. The existing active handoff being reconciled is
195
+ * the opposite: nothing reads it from `structured`, so it stays prose in
196
+ * `text` only, bounded for transport, with `structured` carrying just its
197
+ * length/truncated flags. `missingCompletionTasks` duplicates the same
198
+ * titles already rendered into `text`; `structured` keeps only the task ids
199
+ * needed to address `taskUpdates`. The existing handoff's auto-externalized
200
+ * documents follow the same rule as the existing handoff itself: their
201
+ * bodies are prose for `text` (see renderExternalizedHandoffDocuments),
202
+ * `structured` keeps only path/headings/size/inlined/truncated per document
203
+ * (existingHandoffExternalizedDocuments) — see P104(F005)/T415's accepted
204
+ * decision for why inlining (budget permitting) was chosen over a
205
+ * separate-field or names-only reply.
206
+ */
207
+ export function buildHandoffPrepareReply(input) {
208
+ const { phaseRef, audit } = input;
209
+ const { completenessCategories: _completenessCategories, coldStartSourceReviews: _coldStartSourceReviews, coldStartInventoryCategories: _coldStartInventoryCategories, handoff, missingCompletionTasks,
210
+ // Guidance is instruction the agent reads; the text channel renders it,
211
+ // so it must not travel again in the structured payload.
212
+ supportingDocumentsGuidance: _supportingDocumentsGuidance,
213
+ // Raw document bodies; never spread as-is into structured (see below —
214
+ // content stays text-only, structured keeps only the small facts).
215
+ externalizedHandoffDocuments, ...structuredAudit } = audit;
216
+ const missingLines = missingCompletionTasks
217
+ .map((task) => `- T${String(task.number).padStart(3, "0")} — ${task.title}`)
218
+ .join("\n") || "- None";
219
+ const existingBounded = boundedHandoffForTransport(handoff);
220
+ const existingText = existingBounded.content.trim() || "(none)";
221
+ const externalized = renderExternalizedHandoffDocuments(externalizedHandoffDocuments);
222
+ const text = [
223
+ `Handoff preparation for ${phaseRef}`,
224
+ `Base handoffUpdatedAt: ${audit.handoffUpdatedAt || "(empty)"}`,
225
+ "Required human inputs before drafting:",
226
+ ...audit.requiredHumanInputs.map((requiredInput) => `- ${requiredInput.id} — ${requiredInput.description}`),
227
+ "Planner-generated metadata (do not add these to Markdown): Created at, Updated at, Reason.",
228
+ "The exact drafting scaffold is provided in the structured result's draftTemplate field; replace every angle-bracket placeholder and retain every heading.",
229
+ "",
230
+ "Done tasks missing durable completion/verification evidence:",
231
+ missingLines,
232
+ "",
233
+ "Compact handoff contract: include only the exact focus/resume point, current or partial state, constraints, blockers, decisions, verification, and ordered next actions needed by the next agent.",
234
+ "No Markdown heading is mandatory: concise free-form resume prose is accepted. Use headings only when they make the capsule clearer.",
235
+ "Completeness audit and cold-start evidence are planner-owned metadata; do not copy their categories or source reviews into Markdown.",
236
+ `Inline target: ${audit.targetContentChars} characters; absolute compatibility ceiling: ${audit.maxContentChars}. Extended detail is externalized automatically when needed.`,
237
+ `Supporting documents: ${audit.supportingDocumentsGuidance}`,
238
+ "",
239
+ "Existing active handoff (reconcile all still-relevant content):",
240
+ existingText,
241
+ // Content this same active handoff previously moved out automatically.
242
+ // Reconciling "the existing handoff" means reconciling this too — it is
243
+ // not optional background reading, it is the part most likely to hold a
244
+ // fact no task holds (see AGENTS.md rule 4: this belongs here once, not
245
+ // rediscovered per adapter).
246
+ ...(externalizedHandoffDocuments.length > 0
247
+ ? [
248
+ "",
249
+ "Externalized detail from the existing handoff (auto-moved out when the capsule exceeded the inline target; reconcile before drafting — this is part of \"the existing handoff\", not optional background):",
250
+ externalized.text,
251
+ ]
252
+ : []),
253
+ ...(audit.priorSectionHeadings.length > 0
254
+ ? [
255
+ "",
256
+ `Prior sections to account for: ${audit.priorSectionHeadings.join(", ")}. Any of these missing as a "##" heading from the written content will be refused (HANDOFF_SECTION_RECONCILIATION_REQUIRED) unless you also submit sectionDispositions naming what happened to it (dropped, superseded, or folded into another section).`,
257
+ ]
258
+ : []),
259
+ ].join("\n");
260
+ return {
261
+ text,
262
+ structured: {
263
+ phaseRef,
264
+ ...structuredAudit,
265
+ // Prepare renders the map in no text channel, so it keeps `content`
266
+ // here — but not the JSON twin of the same siblings.
267
+ phaseWorkMap: workMapForTransport(structuredAudit.phaseWorkMap, true),
268
+ existingCompletenessAudit: handoffAuditForTransport(structuredAudit.existingCompletenessAudit),
269
+ existingHandoffLength: existingBounded.fullLength,
270
+ existingHandoffTruncated: existingBounded.truncated,
271
+ // Bodies are text-only (see externalized.text above); structured keeps
272
+ // only the facts a structuredContent-only host needs to know more
273
+ // exists than it can see: path, headings, size, and whether this call
274
+ // inlined it.
275
+ existingHandoffExternalizedDocuments: externalized.structured,
276
+ evidenceContract: HANDOFF_PREPARE_EVIDENCE_CONTRACT,
277
+ },
278
+ };
279
+ }