@agent-plan/core 0.2.29 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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 +128 -0
  11. package/dist/guard-decision.d.ts.map +1 -0
  12. package/dist/guard-decision.js +269 -0
  13. package/dist/guard-state.d.ts +14 -0
  14. package/dist/guard-state.d.ts.map +1 -0
  15. package/dist/guard-state.js +35 -0
  16. package/dist/handoff-context.d.ts +130 -1
  17. package/dist/handoff-context.d.ts.map +1 -1
  18. package/dist/handoff-context.js +167 -9
  19. package/dist/handoff-reply.d.ts +113 -0
  20. package/dist/handoff-reply.d.ts.map +1 -0
  21. package/dist/handoff-reply.js +279 -0
  22. package/dist/index.d.ts +18 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +18 -1
  25. package/dist/load-reply.d.ts +63 -0
  26. package/dist/load-reply.d.ts.map +1 -0
  27. package/dist/load-reply.js +64 -0
  28. package/dist/mutation-reply.d.ts +105 -0
  29. package/dist/mutation-reply.d.ts.map +1 -0
  30. package/dist/mutation-reply.js +203 -0
  31. package/dist/naming.d.ts +1 -1
  32. package/dist/naming.d.ts.map +1 -1
  33. package/dist/naming.js +1 -1
  34. package/dist/on-hold.d.ts +55 -0
  35. package/dist/on-hold.d.ts.map +1 -0
  36. package/dist/on-hold.js +123 -0
  37. package/dist/package-version.d.ts +13 -0
  38. package/dist/package-version.d.ts.map +1 -1
  39. package/dist/package-version.js +32 -0
  40. package/dist/pagination.d.ts +19 -0
  41. package/dist/pagination.d.ts.map +1 -0
  42. package/dist/pagination.js +15 -0
  43. package/dist/payload-fallback.js +1 -1
  44. package/dist/plan-store.d.ts +141 -8
  45. package/dist/plan-store.d.ts.map +1 -1
  46. package/dist/plan-store.js +459 -87
  47. package/dist/project-context-migration.d.ts +5 -0
  48. package/dist/project-context-migration.d.ts.map +1 -1
  49. package/dist/project-context-migration.js +13 -0
  50. package/dist/project-context.d.ts +397 -0
  51. package/dist/project-context.d.ts.map +1 -0
  52. package/dist/project-context.js +294 -0
  53. package/dist/recap.d.ts.map +1 -1
  54. package/dist/recap.js +33 -0
  55. package/dist/recommend-reply.d.ts +51 -0
  56. package/dist/recommend-reply.d.ts.map +1 -0
  57. package/dist/recommend-reply.js +93 -0
  58. package/dist/refs.d.ts +16 -0
  59. package/dist/refs.d.ts.map +1 -1
  60. package/dist/refs.js +38 -0
  61. package/dist/renderer.d.ts.map +1 -1
  62. package/dist/schema.d.ts +7 -2
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +8 -3
  65. package/dist/stranded-phase.d.ts +73 -0
  66. package/dist/stranded-phase.d.ts.map +1 -0
  67. package/dist/stranded-phase.js +93 -0
  68. package/dist/task-completion-gate.d.ts +84 -0
  69. package/dist/task-completion-gate.d.ts.map +1 -0
  70. package/dist/task-completion-gate.js +72 -0
  71. package/dist/task-context.d.ts +1 -0
  72. package/dist/task-context.d.ts.map +1 -1
  73. package/dist/task-context.js +42 -14
  74. package/dist/task-lifecycle.d.ts +12 -0
  75. package/dist/task-lifecycle.d.ts.map +1 -0
  76. package/dist/task-lifecycle.js +23 -0
  77. package/dist/task-order-context.d.ts +72 -0
  78. package/dist/task-order-context.d.ts.map +1 -0
  79. package/dist/task-order-context.js +82 -0
  80. package/dist/task-selection.d.ts +4 -1
  81. package/dist/task-selection.d.ts.map +1 -1
  82. package/dist/task-selection.js +36 -14
  83. package/dist/task-start-outcome.d.ts +26 -0
  84. package/dist/task-start-outcome.d.ts.map +1 -1
  85. package/dist/task-start-outcome.js +23 -0
  86. package/dist/text-bounds.d.ts +32 -0
  87. package/dist/text-bounds.d.ts.map +1 -0
  88. package/dist/text-bounds.js +43 -0
  89. package/dist/write-coordination.d.ts +2 -2
  90. package/dist/write-coordination.js +2 -2
  91. package/package.json +1 -1
  92. 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,113 @@
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
+ * Transport note: Claude Code shows the agent only structuredContent when a
16
+ * result has one, so the MCP server folds `text` into the structured part
17
+ * for every tool (foldTextIntoStructured in plan-mcp). The rule above still
18
+ * holds for what these builders return; the fold is the MCP transport's job.
19
+ */
20
+ import type { HandoffCompletenessAudit, Phase } from "./schema.js";
21
+ import type { PhaseWorkMap } from "./task-context.js";
22
+ import { type HandoffContractError, type PhaseHandoffAudit } from "./handoff-context.js";
23
+ import type { MutationReply } from "./mutation-reply.js";
24
+ /**
25
+ * Render a HandoffContractError (handoff refresh/prepare denial) as a
26
+ * text/structured pair. Both adapters carried an identical copy; kept here
27
+ * so the message and the `errorCode`/details shape can never drift between
28
+ * MCP and Pi. Each adapter still owns its own envelope (`isError` +
29
+ * `structuredContent` for MCP, `details` for Pi) — see AGENTS.md rule 4.
30
+ */
31
+ export declare function handoffContractFailureReply(error: HandoffContractError, action?: "refresh" | "preparation"): MutationReply;
32
+ export interface BoundedHandoffContent {
33
+ content: string;
34
+ fullLength: number;
35
+ truncated: boolean;
36
+ }
37
+ /** Bound raw handoff content to the absolute compatibility ceiling for
38
+ * transport. Legacy or externally-written content can exceed the ceiling;
39
+ * everything produced through the normal write path already respects it.
40
+ * Cuts at a safe boundary (paragraph, then line, then word — see
41
+ * boundedContentForTransport / truncateAtSafeBoundary in handoff-context.ts)
42
+ * rather than an arbitrary character offset, so the visible remainder never
43
+ * ends mid-word. */
44
+ export declare function boundedHandoffForTransport(content: string): BoundedHandoffContent;
45
+ /** A tool reply as one result: the human-readable body and the structured
46
+ * payload, with no field present in both. */
47
+ export interface HandoffReply {
48
+ text: string;
49
+ structured: Record<string, unknown>;
50
+ }
51
+ export type HandoffShowReplyInput = {
52
+ kind: "empty";
53
+ phaseRef: string;
54
+ phaseId: string;
55
+ handoffAudit: HandoffCompletenessAudit | null;
56
+ } | {
57
+ kind: "archived";
58
+ phaseRef: string;
59
+ phaseId: string;
60
+ /** Raw archived content; the builder bounds it for transport. */
61
+ content: string;
62
+ archiveReason: string;
63
+ archivedAt: string;
64
+ archiveFile: string;
65
+ handoffAudit: HandoffCompletenessAudit | null;
66
+ } | {
67
+ kind: "active";
68
+ phaseRef: string;
69
+ phase: Phase;
70
+ phaseWorkMap: PhaseWorkMap;
71
+ };
72
+ /**
73
+ * Build the reply for `planner-handoff-show` / `handoff_show`, covering the
74
+ * empty, archived, and active branches through one shared shape so no
75
+ * surface keeps its own construction.
76
+ *
77
+ * Placement: the resume capsule body is prose meant to be read, and every
78
+ * existing consumer reads it from `text` (never from `structured`), so
79
+ * `text` is its one home; `structured` carries only `fullLength`/
80
+ * `truncated` flags plus the identifiers, hash, and verification flags a
81
+ * structuredContent-only host needs to drive the candidate-then-verify
82
+ * contract (call verify with the right hash) without needing the prose
83
+ * itself.
84
+ */
85
+ export declare function buildHandoffShowReply(input: HandoffShowReplyInput): HandoffReply;
86
+ /** Single source of truth for the prepare-reply evidence-contract sentence,
87
+ * previously hardcoded identically in both adapters. */
88
+ 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.";
89
+ export interface HandoffPrepareReplyInput {
90
+ phaseRef: string;
91
+ audit: PhaseHandoffAudit;
92
+ }
93
+ /**
94
+ * Build the reply for `planner-handoff-prepare` / `handoff_prepare`.
95
+ *
96
+ * Placement: `draftTemplate` has no consumer in `text` (only the intro line
97
+ * pointing at it is read there) and every existing consumer reads it from
98
+ * `structured`, so `structured` is its one home; `text` gets a pointer
99
+ * instead of a second copy. The existing active handoff being reconciled is
100
+ * the opposite: nothing reads it from `structured`, so it stays prose in
101
+ * `text` only, bounded for transport, with `structured` carrying just its
102
+ * length/truncated flags. `missingCompletionTasks` duplicates the same
103
+ * titles already rendered into `text`; `structured` keeps only the task ids
104
+ * needed to address `taskUpdates`. The existing handoff's auto-externalized
105
+ * documents follow the same rule as the existing handoff itself: their
106
+ * bodies are prose for `text` (see renderExternalizedHandoffDocuments),
107
+ * `structured` keeps only path/headings/size/inlined/truncated per document
108
+ * (existingHandoffExternalizedDocuments) — see P104(F005)/T415's accepted
109
+ * decision for why inlining (budget permitting) was chosen over a
110
+ * separate-field or names-only reply.
111
+ */
112
+ export declare function buildHandoffPrepareReply(input: HandoffPrepareReplyInput): HandoffReply;
113
+ //# 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;;;;;;;;;;;;;;;;;;GAkBG;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"}