@agent-plan/core 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/accepted-decision-guard.d.ts +94 -0
- package/dist/accepted-decision-guard.d.ts.map +1 -0
- package/dist/accepted-decision-guard.js +110 -0
- package/dist/checklist.d.ts +28 -0
- package/dist/checklist.d.ts.map +1 -1
- package/dist/checklist.js +59 -0
- package/dist/feature-delete.d.ts +62 -0
- package/dist/feature-delete.d.ts.map +1 -0
- package/dist/feature-delete.js +49 -0
- package/dist/guard-decision.d.ts +118 -0
- package/dist/guard-decision.d.ts.map +1 -0
- package/dist/guard-decision.js +203 -0
- package/dist/handoff-context.d.ts +130 -1
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +167 -9
- package/dist/handoff-reply.d.ts +108 -0
- package/dist/handoff-reply.d.ts.map +1 -0
- package/dist/handoff-reply.js +279 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/mutation-reply.d.ts +105 -0
- package/dist/mutation-reply.d.ts.map +1 -0
- package/dist/mutation-reply.js +203 -0
- package/dist/naming.d.ts +1 -1
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +1 -1
- package/dist/pagination.d.ts +19 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +15 -0
- package/dist/payload-fallback.js +1 -1
- package/dist/plan-store.d.ts +141 -8
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +459 -87
- package/dist/project-context-migration.d.ts +5 -0
- package/dist/project-context-migration.d.ts.map +1 -1
- package/dist/project-context-migration.js +13 -0
- package/dist/project-context.d.ts +395 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +295 -0
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +27 -0
- package/dist/recommend-reply.d.ts +51 -0
- package/dist/recommend-reply.d.ts.map +1 -0
- package/dist/recommend-reply.js +84 -0
- package/dist/refs.d.ts +16 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +38 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/schema.d.ts +7 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +8 -3
- package/dist/stranded-phase.d.ts +73 -0
- package/dist/stranded-phase.d.ts.map +1 -0
- package/dist/stranded-phase.js +93 -0
- package/dist/task-completion-gate.d.ts +84 -0
- package/dist/task-completion-gate.d.ts.map +1 -0
- package/dist/task-completion-gate.js +72 -0
- package/dist/task-context.d.ts +1 -0
- package/dist/task-context.d.ts.map +1 -1
- package/dist/task-context.js +31 -13
- package/dist/task-lifecycle.d.ts +12 -0
- package/dist/task-lifecycle.d.ts.map +1 -0
- package/dist/task-lifecycle.js +23 -0
- package/dist/task-order-context.d.ts +72 -0
- package/dist/task-order-context.d.ts.map +1 -0
- package/dist/task-order-context.js +82 -0
- package/dist/task-selection.d.ts.map +1 -1
- package/dist/task-selection.js +14 -12
- package/dist/task-start-outcome.d.ts +26 -0
- package/dist/task-start-outcome.d.ts.map +1 -1
- package/dist/task-start-outcome.js +23 -0
- package/dist/text-bounds.d.ts +32 -0
- package/dist/text-bounds.d.ts.map +1 -0
- package/dist/text-bounds.js +43 -0
- package/dist/write-coordination.d.ts +2 -2
- package/dist/write-coordination.js +2 -2
- package/package.json +1 -1
- package/planner-skill.md +30 -12
package/dist/handoff-context.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
597
|
+
throw supportingDocumentInvalidError(`Supporting document ${requested.path || `(index ${index})`} is not valid or lacks a substantive description.`, { index, path: requested.path });
|
|
470
598
|
}
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
}
|