@mgiles/perk 2.3.0 → 3.1.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 (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -0,0 +1,1494 @@
1
+ // The learn-dream report layer — the pure interior module that turns the two-level dream wave
2
+ // outcome into ONE checkable, savable final report (contracts.md §8.62). Three pieces: the
3
+ // structured dream-report model (the trust split — the model supplies ONLY the parent's
4
+ // decisions; everything factual is injected from trusted caller context), the validation that
5
+ // proves the parent's judgment obeys the pinned curation policy (downgrade-only, the
6
+ // destructive evidence bar, merge-target survival, the exact unit partition, surviving
7
+ // follow-up destinations), and the deterministic Markdown renderer that owns the CANONICAL
8
+ // report bytes, split into parts under a backend-neutral size budget. Pure domain code: no fs,
9
+ // no tool registration, no `ExtensionAPI` — imports only the two dream siblings plus nothing
10
+ // else. The input is untrusted DATA, never instructions.
11
+
12
+ import {
13
+ DREAM_REDUCER_ANGLES,
14
+ type DreamReducerAnalysis,
15
+ type DreamReducerAngle,
16
+ } from "./dreamReducerWave.ts";
17
+ import {
18
+ codePointLength,
19
+ DREAM_DISPOSITIONS,
20
+ type DreamDisposition,
21
+ type DreamDocAssessment,
22
+ type DreamLaneAnalysis,
23
+ type DreamManifest,
24
+ } from "./dreamWave.ts";
25
+
26
+ /** The dream report's own schema version line (independent of the manifest's). */
27
+ export const DREAM_REPORT_SCHEMA_VERSION = "1";
28
+
29
+ /** The cap on DISTINCT `roadmap_node` values across the selected units — the ≤12-node
30
+ * selection cap checked at review time (many-to-one unit→node mapping is allowed). */
31
+ export const DREAM_REPORT_MAX_ROADMAP_NODES = 12;
32
+
33
+ /**
34
+ * The per-part cap on the rendered report, measured in Unicode CODE POINTS (matching Python's
35
+ * `len()` in the `journal.py` `JOURNAL_EVENT_MAX_CHARS` precedent) — under GitHub's 65,536-char
36
+ * comment limit with margin for the persistence-side storage markers (the renderer never emits
37
+ * marker HTML).
38
+ */
39
+ export const DREAM_REPORT_PART_MAX_CHARS = 60_000;
40
+
41
+ /** The fixed per-part packing allowance for the part header line. */
42
+ export const DREAM_REPORT_PART_HEADER_RESERVE = 200;
43
+
44
+ /** The bounded semantic-detail collection cap: overflow appends ONE synthetic count detail. */
45
+ export const DREAM_REPORT_MAX_VALIDATION_DETAILS = 25;
46
+
47
+ /**
48
+ * The SSOT for EVERY capped model-supplied field: the input schema's `maxItems`/`maxLength`
49
+ * and the structural decode both read from this one object (the `DREAM_ANALYST_CAPS` pattern).
50
+ * String caps are measured in Unicode code points (JSON Schema `maxLength` semantics — the
51
+ * shared `codePointLength`). `rows`/`selectedUnits`/`overflowUnits` are static schema bounds;
52
+ * the real gates are the validator's exact path-set equality, the ≤12-distinct-node cap, and
53
+ * the exact unit partition.
54
+ */
55
+ export const DREAM_REPORT_CAPS = {
56
+ rows: 512,
57
+ rowRationaleChars: 300,
58
+ fallbackReasonChars: 300,
59
+ uncertainties: 12,
60
+ uncertaintyChars: 300,
61
+ selectedUnits: 64,
62
+ overflowUnits: 64,
63
+ unitTitleChars: 150,
64
+ unitDocs: 32,
65
+ unitRationaleChars: 400,
66
+ unitNodeChars: 32,
67
+ harvestFollowups: 12,
68
+ followupTitleChars: 150,
69
+ followupPointerChars: 250,
70
+ followupEvidenceChars: 250,
71
+ followupDestinationChars: 400,
72
+ predictedNoteChars: 300,
73
+ } as const;
74
+
75
+ // ------------------------------------------------------------------ the model-facing input
76
+
77
+ /** One final per-doc disposition row (model-supplied). */
78
+ export interface DreamReportInputRow {
79
+ path: string;
80
+ disposition: DreamDisposition;
81
+ merge_target: string | null;
82
+ rationale: string;
83
+ /** Required non-empty exactly when the final disposition differs from the analyst proposal. */
84
+ fallback_reason: string | null;
85
+ }
86
+
87
+ /** One selected curation unit (rank = input position; several units MAY share a node). */
88
+ export interface DreamReportSelectedUnit {
89
+ title: string;
90
+ roadmap_node: string;
91
+ docs: string[];
92
+ rationale: string;
93
+ }
94
+
95
+ /** One overflow curation unit (rank = input position; carries no roadmap node). */
96
+ export interface DreamReportOverflowUnit {
97
+ title: string;
98
+ docs: string[];
99
+ rationale: string;
100
+ }
101
+
102
+ /** One harvest follow-up citing a SURVIVING destination (a keep/revise doc or its cluster). */
103
+ export interface DreamReportFollowup {
104
+ title: string;
105
+ destination: string;
106
+ pointer: string;
107
+ evidence: string;
108
+ }
109
+
110
+ /** The model-supplied prediction — TYPE sanity only, deliberately no directional/quota rule. */
111
+ export interface DreamReportPredictedEffectsInput {
112
+ docs_after: number;
113
+ bytes_after: number;
114
+ note: string | null;
115
+ }
116
+
117
+ /** The untrusted, model-supplied input: ONLY the decisions the design assigns to the parent. */
118
+ export interface DreamReportInput {
119
+ rows: DreamReportInputRow[];
120
+ uncertainties: string[];
121
+ selected_units: DreamReportSelectedUnit[];
122
+ overflow_units: DreamReportOverflowUnit[];
123
+ harvest_followups: DreamReportFollowup[];
124
+ predicted_effects: DreamReportPredictedEffectsInput;
125
+ }
126
+
127
+ /** The trusted, caller-supplied context the report is composed FROM — everything factual
128
+ * (snapshot identity, coverage, analyst evidence, reducer stances, uncertainties, counters)
129
+ * is injected from here, never accepted from the model. */
130
+ export interface DreamReportContext {
131
+ manifest: DreamManifest;
132
+ analyses: DreamLaneAnalysis[];
133
+ reducers: DreamReducerAnalysis[];
134
+ run_id: string;
135
+ generated_at: string;
136
+ }
137
+
138
+ /**
139
+ * The model-facing input schema (exported for the review path's param embedding — the
140
+ * `DREAM_ANALYST_REPORT_SCHEMA` discipline): closed shape at every level, all fields required
141
+ * (optional semantics via `null`), enums, every `maxItems`/`maxLength` read from the ONE
142
+ * `DREAM_REPORT_CAPS` SSOT, no if/then, no `pattern` — the composed defensive validation
143
+ * enforces everything the schema cannot (the single-line rule included).
144
+ */
145
+ export const DREAM_REPORT_INPUT_SCHEMA = {
146
+ type: "object",
147
+ additionalProperties: false,
148
+ required: [
149
+ "rows",
150
+ "uncertainties",
151
+ "selected_units",
152
+ "overflow_units",
153
+ "harvest_followups",
154
+ "predicted_effects",
155
+ ],
156
+ properties: {
157
+ rows: {
158
+ type: "array",
159
+ maxItems: DREAM_REPORT_CAPS.rows,
160
+ items: {
161
+ type: "object",
162
+ additionalProperties: false,
163
+ required: ["path", "disposition", "merge_target", "rationale", "fallback_reason"],
164
+ properties: {
165
+ path: { type: "string" },
166
+ disposition: { type: "string", enum: [...DREAM_DISPOSITIONS] },
167
+ merge_target: { type: ["string", "null"] },
168
+ rationale: { type: "string", maxLength: DREAM_REPORT_CAPS.rowRationaleChars },
169
+ fallback_reason: {
170
+ type: ["string", "null"],
171
+ maxLength: DREAM_REPORT_CAPS.fallbackReasonChars,
172
+ },
173
+ },
174
+ },
175
+ },
176
+ uncertainties: {
177
+ type: "array",
178
+ maxItems: DREAM_REPORT_CAPS.uncertainties,
179
+ items: { type: "string", maxLength: DREAM_REPORT_CAPS.uncertaintyChars },
180
+ },
181
+ selected_units: {
182
+ type: "array",
183
+ maxItems: DREAM_REPORT_CAPS.selectedUnits,
184
+ items: {
185
+ type: "object",
186
+ additionalProperties: false,
187
+ required: ["title", "roadmap_node", "docs", "rationale"],
188
+ properties: {
189
+ title: { type: "string", maxLength: DREAM_REPORT_CAPS.unitTitleChars },
190
+ roadmap_node: { type: "string", maxLength: DREAM_REPORT_CAPS.unitNodeChars },
191
+ docs: {
192
+ type: "array",
193
+ maxItems: DREAM_REPORT_CAPS.unitDocs,
194
+ items: { type: "string" },
195
+ },
196
+ rationale: { type: "string", maxLength: DREAM_REPORT_CAPS.unitRationaleChars },
197
+ },
198
+ },
199
+ },
200
+ overflow_units: {
201
+ type: "array",
202
+ maxItems: DREAM_REPORT_CAPS.overflowUnits,
203
+ items: {
204
+ type: "object",
205
+ additionalProperties: false,
206
+ required: ["title", "docs", "rationale"],
207
+ properties: {
208
+ title: { type: "string", maxLength: DREAM_REPORT_CAPS.unitTitleChars },
209
+ docs: {
210
+ type: "array",
211
+ maxItems: DREAM_REPORT_CAPS.unitDocs,
212
+ items: { type: "string" },
213
+ },
214
+ rationale: { type: "string", maxLength: DREAM_REPORT_CAPS.unitRationaleChars },
215
+ },
216
+ },
217
+ },
218
+ harvest_followups: {
219
+ type: "array",
220
+ maxItems: DREAM_REPORT_CAPS.harvestFollowups,
221
+ items: {
222
+ type: "object",
223
+ additionalProperties: false,
224
+ required: ["title", "destination", "pointer", "evidence"],
225
+ properties: {
226
+ title: { type: "string", maxLength: DREAM_REPORT_CAPS.followupTitleChars },
227
+ destination: {
228
+ type: "string",
229
+ maxLength: DREAM_REPORT_CAPS.followupDestinationChars,
230
+ },
231
+ pointer: { type: "string", maxLength: DREAM_REPORT_CAPS.followupPointerChars },
232
+ evidence: { type: "string", maxLength: DREAM_REPORT_CAPS.followupEvidenceChars },
233
+ },
234
+ },
235
+ },
236
+ predicted_effects: {
237
+ type: "object",
238
+ additionalProperties: false,
239
+ required: ["docs_after", "bytes_after", "note"],
240
+ properties: {
241
+ docs_after: { type: "integer", minimum: 0 },
242
+ bytes_after: { type: "integer", minimum: 0 },
243
+ note: { type: ["string", "null"], maxLength: DREAM_REPORT_CAPS.predictedNoteChars },
244
+ },
245
+ },
246
+ },
247
+ };
248
+
249
+ // ------------------------------------------------------------------- the composed report
250
+
251
+ /** One injected reducer stance record, joined onto its doc's row (angle order). */
252
+ export interface DreamReportStanceRecord {
253
+ angle: DreamReducerAngle;
254
+ stance: "endorse" | "challenge";
255
+ reason: string;
256
+ evidence_checked: string[];
257
+ }
258
+
259
+ /** One composed per-doc row: the model's final decision joined with the injected analyst
260
+ * evidence and reducer stances (manifest lane/doc order). */
261
+ export interface DreamReportRow {
262
+ path: string;
263
+ lane: string;
264
+ cluster: string | null;
265
+ analyst_disposition: DreamDisposition;
266
+ analyst_merge_target: string | null;
267
+ analyst_rationale: string;
268
+ analyst_preserve: string[];
269
+ analyst_evidence_checked: string[];
270
+ analyst_confidence: "high" | "medium" | "low";
271
+ final_disposition: DreamDisposition;
272
+ final_merge_target: string | null;
273
+ rationale: string;
274
+ fallback_reason: string | null;
275
+ stances: DreamReportStanceRecord[];
276
+ }
277
+
278
+ /** One analyst lane's coverage line (complete by construction — rendered for verification). */
279
+ export interface DreamReportAnalystCoverage {
280
+ lane: string;
281
+ docs: number;
282
+ overlap_signals_omitted: number;
283
+ harvest_followups_omitted: number;
284
+ uncertainties_omitted: number;
285
+ }
286
+
287
+ /** One reducer angle's coverage line. */
288
+ export interface DreamReportReducerCoverage {
289
+ angle: DreamReducerAngle;
290
+ stances: number;
291
+ stances_omitted: number;
292
+ angle_findings_omitted: number;
293
+ uncertainties_omitted: number;
294
+ }
295
+
296
+ /** The composed dream report — a plain JSON-serializable join of validated input with
297
+ * injected trusted context. The renderer is a pure function of this value. */
298
+ export interface DreamReport {
299
+ snapshot: {
300
+ schema_version: string;
301
+ run_id: string;
302
+ generated_at: string;
303
+ commit_sha: string;
304
+ registry_mode: "clusters" | "categories";
305
+ doc_count: number;
306
+ total_bytes: number;
307
+ };
308
+ findings: {
309
+ structural: { family: string; count: number }[];
310
+ advisory: { family: string; count: number }[];
311
+ };
312
+ coverage: {
313
+ analysts: DreamReportAnalystCoverage[];
314
+ reducers: DreamReportReducerCoverage[];
315
+ };
316
+ rows: DreamReportRow[];
317
+ uncertainties: {
318
+ parent: string[];
319
+ analysts: { lane: string; items: string[] }[];
320
+ reducers: { angle: DreamReducerAngle; items: string[] }[];
321
+ };
322
+ reducer_findings: { angle: DreamReducerAngle; items: string[] }[];
323
+ selected_units: DreamReportSelectedUnit[];
324
+ overflow_units: DreamReportOverflowUnit[];
325
+ harvest_followups: DreamReportFollowup[];
326
+ predicted_effects: {
327
+ docs_before: number;
328
+ bytes_before: number;
329
+ docs_after: number;
330
+ bytes_after: number;
331
+ note: string | null;
332
+ };
333
+ }
334
+
335
+ // --------------------------------------------------------------------------- shared bits
336
+
337
+ function isRecord(value: unknown): value is Record<string, unknown> {
338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
339
+ }
340
+
341
+ function nonNegativeInteger(value: unknown): value is number {
342
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
343
+ }
344
+
345
+ function isDisposition(value: unknown): value is DreamDisposition {
346
+ return typeof value === "string" && (DREAM_DISPOSITIONS as readonly string[]).includes(value);
347
+ }
348
+
349
+ /** Destructiveness levels for the downgrade-only rule — the two destructive dispositions
350
+ * share a level and are INCOMPARABLE (a merge↔retire swap is never a downgrade). */
351
+ const DISPOSITION_LEVEL: Record<DreamDisposition, number> = {
352
+ keep: 0,
353
+ revise: 1,
354
+ "merge-into": 2,
355
+ retire: 2,
356
+ };
357
+
358
+ function isDestructive(disposition: DreamDisposition): boolean {
359
+ return DISPOSITION_LEVEL[disposition] === 2;
360
+ }
361
+
362
+ /** Name the first line-structure violation in a model-supplied string, or null. The renderer
363
+ * places these strings in table cells and bullets — line structure stays renderer-owned, so
364
+ * `\r`/`\n` and every other C0 control character refuse with a named detail. */
365
+ function singleLineViolation(s: string): string | null {
366
+ for (const ch of s) {
367
+ const cp = ch.codePointAt(0) as number;
368
+ if (cp < 0x20) {
369
+ if (cp === 0x0a) return "a newline";
370
+ if (cp === 0x0d) return "a carriage return";
371
+ return `a C0 control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")})`;
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /** `merge-into → docs/x.md` / `retire` — the proposal spelling used in named details. */
378
+ function describeProposal(disposition: DreamDisposition, target: string | null): string {
379
+ return target === null ? disposition : `${disposition} → ${target}`;
380
+ }
381
+
382
+ type Refusal = { ok: false; details: string[] };
383
+
384
+ function refuse(detail: string): Refusal {
385
+ return { ok: false, details: [detail] };
386
+ }
387
+
388
+ // ------------------------------------------------------------- the structural input decode
389
+
390
+ type StringField = { ok: true; value: string } | { ok: false; detail: string };
391
+
392
+ /** Decode one capped single-line model string (code-point cap + the single-line rule). */
393
+ function decodeReportString(value: unknown, maxChars: number | null, what: string): StringField {
394
+ if (typeof value !== "string") {
395
+ return { ok: false, detail: `${what} is not a string` };
396
+ }
397
+ if (maxChars !== null && codePointLength(value) > maxChars) {
398
+ return { ok: false, detail: `${what} exceeds ${maxChars} code points` };
399
+ }
400
+ const violation = singleLineViolation(value);
401
+ if (violation !== null) {
402
+ return {
403
+ ok: false,
404
+ detail: `${what} contains ${violation} — model-supplied strings must be single-line`,
405
+ };
406
+ }
407
+ return { ok: true, value };
408
+ }
409
+
410
+ /** Decode one capped array of single-line model strings. */
411
+ function decodeReportStringArray(
412
+ raw: unknown,
413
+ maxItems: number,
414
+ maxChars: number | null,
415
+ what: string,
416
+ ): { ok: true; items: string[] } | { ok: false; detail: string } {
417
+ if (!Array.isArray(raw)) {
418
+ return { ok: false, detail: `${what} is not an array` };
419
+ }
420
+ if (raw.length > maxItems) {
421
+ return { ok: false, detail: `${what} carries more than ${maxItems} items (${raw.length})` };
422
+ }
423
+ const items: string[] = [];
424
+ for (const item of raw) {
425
+ const decoded = decodeReportString(item, maxChars, `a ${what} item`);
426
+ if (!decoded.ok) return decoded;
427
+ items.push(decoded.value);
428
+ }
429
+ return { ok: true, items };
430
+ }
431
+
432
+ /**
433
+ * The fail-fast structural decode (whitelisted construction — an extra input key never
434
+ * survives; the first named detail wins): the schema-equivalent shape plus the code-enforced
435
+ * caps and the single-line rule. Relational/semantic rules (path sets, downgrade-only, the
436
+ * evidence bar, survival, partition, destinations) are collected afterwards — construction
437
+ * cannot proceed over malformed input, so those misses fail fast here.
438
+ */
439
+ function decodeDreamReportInput(
440
+ raw: unknown,
441
+ ): { ok: true; input: DreamReportInput } | { ok: false; detail: string } {
442
+ if (!isRecord(raw)) {
443
+ return { ok: false, detail: "dream report input is not an object" };
444
+ }
445
+ const caps = DREAM_REPORT_CAPS;
446
+
447
+ if (!Array.isArray(raw.rows)) {
448
+ return { ok: false, detail: "input rows is not an array" };
449
+ }
450
+ if (raw.rows.length > caps.rows) {
451
+ return {
452
+ ok: false,
453
+ detail: `input rows carries more than ${caps.rows} rows (${raw.rows.length})`,
454
+ };
455
+ }
456
+ const rows: DreamReportInputRow[] = [];
457
+ for (const rawRow of raw.rows) {
458
+ if (!isRecord(rawRow)) {
459
+ return { ok: false, detail: "a disposition row is not an object" };
460
+ }
461
+ const path = decodeReportString(rawRow.path, null, "a disposition row path");
462
+ if (!path.ok) return path;
463
+ const what = `row '${path.value}'`;
464
+ if (!isDisposition(rawRow.disposition)) {
465
+ return {
466
+ ok: false,
467
+ detail: `${what} disposition ${JSON.stringify(rawRow.disposition)} is outside the vocabulary`,
468
+ };
469
+ }
470
+ let mergeTarget: string | null = null;
471
+ if (rawRow.merge_target !== null) {
472
+ const decoded = decodeReportString(rawRow.merge_target, null, `${what} merge_target`);
473
+ if (!decoded.ok) return decoded;
474
+ mergeTarget = decoded.value;
475
+ }
476
+ const rationale = decodeReportString(
477
+ rawRow.rationale,
478
+ caps.rowRationaleChars,
479
+ `${what} rationale`,
480
+ );
481
+ if (!rationale.ok) return rationale;
482
+ let fallbackReason: string | null = null;
483
+ if (rawRow.fallback_reason !== null) {
484
+ const decoded = decodeReportString(
485
+ rawRow.fallback_reason,
486
+ caps.fallbackReasonChars,
487
+ `${what} fallback_reason`,
488
+ );
489
+ if (!decoded.ok) return decoded;
490
+ fallbackReason = decoded.value;
491
+ }
492
+ rows.push({
493
+ path: path.value,
494
+ disposition: rawRow.disposition,
495
+ merge_target: mergeTarget,
496
+ rationale: rationale.value,
497
+ fallback_reason: fallbackReason,
498
+ });
499
+ }
500
+
501
+ const uncertainties = decodeReportStringArray(
502
+ raw.uncertainties,
503
+ caps.uncertainties,
504
+ caps.uncertaintyChars,
505
+ "input uncertainties",
506
+ );
507
+ if (!uncertainties.ok) return uncertainties;
508
+
509
+ if (!Array.isArray(raw.selected_units)) {
510
+ return { ok: false, detail: "input selected_units is not an array" };
511
+ }
512
+ if (raw.selected_units.length > caps.selectedUnits) {
513
+ return {
514
+ ok: false,
515
+ detail:
516
+ `input selected_units carries more than ${caps.selectedUnits} units ` +
517
+ `(${raw.selected_units.length})`,
518
+ };
519
+ }
520
+ const selectedUnits: DreamReportSelectedUnit[] = [];
521
+ for (const [index, rawUnit] of raw.selected_units.entries()) {
522
+ const what = `selected unit ${index + 1}`;
523
+ if (!isRecord(rawUnit)) {
524
+ return { ok: false, detail: `${what} is not an object` };
525
+ }
526
+ const title = decodeReportString(rawUnit.title, caps.unitTitleChars, `${what} title`);
527
+ if (!title.ok) return title;
528
+ const node = decodeReportString(
529
+ rawUnit.roadmap_node,
530
+ caps.unitNodeChars,
531
+ `${what} roadmap_node`,
532
+ );
533
+ if (!node.ok) return node;
534
+ const docs = decodeReportStringArray(rawUnit.docs, caps.unitDocs, null, `${what} docs`);
535
+ if (!docs.ok) return docs;
536
+ const rationale = decodeReportString(
537
+ rawUnit.rationale,
538
+ caps.unitRationaleChars,
539
+ `${what} rationale`,
540
+ );
541
+ if (!rationale.ok) return rationale;
542
+ selectedUnits.push({
543
+ title: title.value,
544
+ roadmap_node: node.value,
545
+ docs: docs.items,
546
+ rationale: rationale.value,
547
+ });
548
+ }
549
+
550
+ if (!Array.isArray(raw.overflow_units)) {
551
+ return { ok: false, detail: "input overflow_units is not an array" };
552
+ }
553
+ if (raw.overflow_units.length > caps.overflowUnits) {
554
+ return {
555
+ ok: false,
556
+ detail:
557
+ `input overflow_units carries more than ${caps.overflowUnits} units ` +
558
+ `(${raw.overflow_units.length})`,
559
+ };
560
+ }
561
+ const overflowUnits: DreamReportOverflowUnit[] = [];
562
+ for (const [index, rawUnit] of raw.overflow_units.entries()) {
563
+ const what = `overflow unit ${index + 1}`;
564
+ if (!isRecord(rawUnit)) {
565
+ return { ok: false, detail: `${what} is not an object` };
566
+ }
567
+ const title = decodeReportString(rawUnit.title, caps.unitTitleChars, `${what} title`);
568
+ if (!title.ok) return title;
569
+ const docs = decodeReportStringArray(rawUnit.docs, caps.unitDocs, null, `${what} docs`);
570
+ if (!docs.ok) return docs;
571
+ const rationale = decodeReportString(
572
+ rawUnit.rationale,
573
+ caps.unitRationaleChars,
574
+ `${what} rationale`,
575
+ );
576
+ if (!rationale.ok) return rationale;
577
+ overflowUnits.push({ title: title.value, docs: docs.items, rationale: rationale.value });
578
+ }
579
+
580
+ if (!Array.isArray(raw.harvest_followups)) {
581
+ return { ok: false, detail: "input harvest_followups is not an array" };
582
+ }
583
+ if (raw.harvest_followups.length > caps.harvestFollowups) {
584
+ return {
585
+ ok: false,
586
+ detail:
587
+ `input harvest_followups carries more than ${caps.harvestFollowups} follow-ups ` +
588
+ `(${raw.harvest_followups.length})`,
589
+ };
590
+ }
591
+ const harvestFollowups: DreamReportFollowup[] = [];
592
+ for (const [index, rawFollowup] of raw.harvest_followups.entries()) {
593
+ const what = `harvest follow-up ${index + 1}`;
594
+ if (!isRecord(rawFollowup)) {
595
+ return { ok: false, detail: `${what} is not an object` };
596
+ }
597
+ const title = decodeReportString(rawFollowup.title, caps.followupTitleChars, `${what} title`);
598
+ if (!title.ok) return title;
599
+ const destination = decodeReportString(
600
+ rawFollowup.destination,
601
+ caps.followupDestinationChars,
602
+ `${what} destination`,
603
+ );
604
+ if (!destination.ok) return destination;
605
+ const pointer = decodeReportString(
606
+ rawFollowup.pointer,
607
+ caps.followupPointerChars,
608
+ `${what} pointer`,
609
+ );
610
+ if (!pointer.ok) return pointer;
611
+ const evidence = decodeReportString(
612
+ rawFollowup.evidence,
613
+ caps.followupEvidenceChars,
614
+ `${what} evidence`,
615
+ );
616
+ if (!evidence.ok) return evidence;
617
+ harvestFollowups.push({
618
+ title: title.value,
619
+ destination: destination.value,
620
+ pointer: pointer.value,
621
+ evidence: evidence.value,
622
+ });
623
+ }
624
+
625
+ if (!isRecord(raw.predicted_effects)) {
626
+ return { ok: false, detail: "input predicted_effects is not an object" };
627
+ }
628
+ const effects = raw.predicted_effects;
629
+ if (!nonNegativeInteger(effects.docs_after)) {
630
+ return { ok: false, detail: "predicted_effects docs_after is not a non-negative integer" };
631
+ }
632
+ if (!nonNegativeInteger(effects.bytes_after)) {
633
+ return { ok: false, detail: "predicted_effects bytes_after is not a non-negative integer" };
634
+ }
635
+ let note: string | null = null;
636
+ if (effects.note !== null) {
637
+ const decoded = decodeReportString(
638
+ effects.note,
639
+ caps.predictedNoteChars,
640
+ "predicted_effects note",
641
+ );
642
+ if (!decoded.ok) return decoded;
643
+ note = decoded.value;
644
+ }
645
+
646
+ return {
647
+ ok: true,
648
+ input: {
649
+ rows,
650
+ uncertainties: uncertainties.items,
651
+ selected_units: selectedUnits,
652
+ overflow_units: overflowUnits,
653
+ harvest_followups: harvestFollowups,
654
+ predicted_effects: {
655
+ docs_after: effects.docs_after,
656
+ bytes_after: effects.bytes_after,
657
+ note,
658
+ },
659
+ },
660
+ };
661
+ }
662
+
663
+ // --------------------------------------------------------------- the context re-verification
664
+
665
+ /**
666
+ * Re-verify the trusted context BEFORE any input judgment: a report can only be built from
667
+ * COMPLETE waves — `analyses` must cover the manifest's lanes exactly (one per lane, manifest
668
+ * order, each covering its lane's docs exactly) and `reducers` must carry exactly the three
669
+ * `DREAM_REDUCER_ANGLES` in fixed order. Incomplete coverage is never described as complete.
670
+ */
671
+ function contextViolation(context: DreamReportContext): string | null {
672
+ if (typeof context.run_id !== "string" || context.run_id === "") {
673
+ return "context run_id must be a non-empty string";
674
+ }
675
+ if (singleLineViolation(context.run_id) !== null) {
676
+ return "context run_id must be a single-line string";
677
+ }
678
+ if (typeof context.generated_at !== "string" || context.generated_at === "") {
679
+ return "context generated_at must be a non-empty string";
680
+ }
681
+ if (singleLineViolation(context.generated_at) !== null) {
682
+ return "context generated_at must be a single-line string";
683
+ }
684
+ const expectedLanes = context.manifest.lanes.map((lane) => lane.id);
685
+ const gotLanes = context.analyses.map((analysis) => analysis.lane);
686
+ if (
687
+ expectedLanes.length !== gotLanes.length ||
688
+ expectedLanes.some((id, i) => id !== gotLanes[i])
689
+ ) {
690
+ return (
691
+ "context analyses must cover the manifest's lanes exactly in manifest order " +
692
+ `(expected: ${expectedLanes.join(", ")}; got: ${gotLanes.join(", ") || "none"})`
693
+ );
694
+ }
695
+ for (const [index, lane] of context.manifest.lanes.entries()) {
696
+ const analysis = context.analyses[index] as DreamLaneAnalysis;
697
+ const expectedDocs = lane.docs.map((doc) => doc.path);
698
+ const gotDocs = analysis.report.docs.map((doc) => doc.path);
699
+ if (expectedDocs.length !== gotDocs.length || expectedDocs.some((p, i) => p !== gotDocs[i])) {
700
+ return `context analysis for lane '${lane.id}' does not cover the lane's docs exactly`;
701
+ }
702
+ }
703
+ const gotAngles = context.reducers.map((reducer) => reducer.angle);
704
+ if (
705
+ gotAngles.length !== DREAM_REDUCER_ANGLES.length ||
706
+ DREAM_REDUCER_ANGLES.some((angle, i) => angle !== gotAngles[i])
707
+ ) {
708
+ return (
709
+ "context reducers must carry exactly the three reducer angles in fixed order " +
710
+ `(expected: ${DREAM_REDUCER_ANGLES.join(", ")}; got: ${gotAngles.join(", ") || "none"})`
711
+ );
712
+ }
713
+ return null;
714
+ }
715
+
716
+ // ------------------------------------------------------------------------- the validation
717
+
718
+ /** The gate angles whose explicit endorsement a destructive final row requires. */
719
+ const EVIDENCE_BAR_GATE_ANGLES: readonly DreamReducerAngle[] = [
720
+ "consolidation-preservation",
721
+ "currency-accuracy",
722
+ ];
723
+
724
+ interface JoinedRow {
725
+ input: DreamReportInputRow;
726
+ analyst: DreamDocAssessment;
727
+ /** The downgrade rule passed — later phases (evidence bar, survival) only run then. */
728
+ downgradeOk: boolean;
729
+ }
730
+
731
+ /**
732
+ * Validate the model-supplied input against the pinned curation policy and compose the full
733
+ * typed `DreamReport` by joining it with the injected trusted context. Two-stage error
734
+ * reporting (the interactive-redraft deviation from the fail-fast decoder posture): context
735
+ * re-verification and structural decode fail FAST (first named detail, one-element `details`);
736
+ * the semantic rules collect up to `DREAM_REPORT_MAX_VALIDATION_DETAILS` named details in
737
+ * deterministic order (validation phase order, then manifest doc order within a phase),
738
+ * overflow appending one final synthetic detail counting the omitted violations.
739
+ */
740
+ export function validateDreamReport(
741
+ input: unknown,
742
+ context: DreamReportContext,
743
+ ): { ok: true; report: DreamReport } | Refusal {
744
+ const contextDetail = contextViolation(context);
745
+ if (contextDetail !== null) return refuse(contextDetail);
746
+ const decoded = decodeDreamReportInput(input);
747
+ if (!decoded.ok) return refuse(decoded.detail);
748
+ const typed = decoded.input;
749
+
750
+ const manifest = context.manifest;
751
+ const manifestPaths: string[] = manifest.lanes.flatMap((lane) =>
752
+ lane.docs.map((doc) => doc.path),
753
+ );
754
+ const corpusSet = new Set(manifestPaths);
755
+ const assessmentByPath = new Map<string, DreamDocAssessment>();
756
+ for (const analysis of context.analyses) {
757
+ for (const doc of analysis.report.docs) assessmentByPath.set(doc.path, doc);
758
+ }
759
+
760
+ const details: string[] = [];
761
+
762
+ // Phase 1 (D4) — exact path-set equality: no duplicate, no extra, no missing rows.
763
+ const rowByPath = new Map<string, DreamReportInputRow>();
764
+ for (const row of typed.rows) {
765
+ if (rowByPath.has(row.path)) {
766
+ details.push(`duplicate disposition row for '${row.path}'`);
767
+ continue;
768
+ }
769
+ if (!corpusSet.has(row.path)) {
770
+ details.push(`disposition row path '${row.path}' is not an authored doc in the manifest`);
771
+ continue;
772
+ }
773
+ rowByPath.set(row.path, row);
774
+ }
775
+ for (const path of manifestPaths) {
776
+ if (!rowByPath.has(path)) {
777
+ details.push(`missing disposition row for authored doc '${path}'`);
778
+ }
779
+ }
780
+
781
+ // Phase 2 (D4) — per-row rules in manifest doc order: rationale non-empty, the
782
+ // merge-target shape, downgrade-only against the analyst proposal, the fallback rule.
783
+ const joined = new Map<string, JoinedRow>();
784
+ for (const path of manifestPaths) {
785
+ const row = rowByPath.get(path);
786
+ if (row === undefined) continue;
787
+ const analyst = assessmentByPath.get(path) as DreamDocAssessment;
788
+ const state: JoinedRow = { input: row, analyst, downgradeOk: true };
789
+ joined.set(path, state);
790
+ if (row.rationale === "") {
791
+ details.push(`row '${path}' rationale must be non-empty`);
792
+ }
793
+ if (row.disposition === "merge-into") {
794
+ if (row.merge_target === null) {
795
+ details.push(`row '${path}' has disposition 'merge-into' but a null merge_target`);
796
+ state.downgradeOk = false;
797
+ continue;
798
+ }
799
+ } else if (row.merge_target !== null) {
800
+ details.push(
801
+ `row '${path}' carries a merge_target on a '${row.disposition}' disposition (must be null)`,
802
+ );
803
+ state.downgradeOk = false;
804
+ continue;
805
+ }
806
+ // Downgrade-only: final level ≤ analyst level; a destructive final must match the analyst
807
+ // proposal EXACTLY (same disposition AND byte-identical merge_target — the reducers
808
+ // stanced that specific proposal; anything else is an unendorsed new action).
809
+ if (isDestructive(row.disposition)) {
810
+ if (!isDestructive(analyst.disposition)) {
811
+ details.push(
812
+ `row '${path}' escalates the analyst proposal '${analyst.disposition}' to ` +
813
+ `'${row.disposition}' — the parent never resolves upward`,
814
+ );
815
+ state.downgradeOk = false;
816
+ } else if (
817
+ row.disposition !== analyst.disposition ||
818
+ row.merge_target !== analyst.merge_target
819
+ ) {
820
+ details.push(
821
+ `row '${path}' final '${describeProposal(row.disposition, row.merge_target)}' does ` +
822
+ `not match the analyst proposal ` +
823
+ `'${describeProposal(analyst.disposition, analyst.merge_target)}' exactly — an ` +
824
+ "unendorsed destructive action is refused (downgrade to 'revise' or 'keep' with a " +
825
+ "fallback_reason instead)",
826
+ );
827
+ state.downgradeOk = false;
828
+ }
829
+ } else if (row.disposition === "revise" && analyst.disposition === "keep") {
830
+ details.push(
831
+ `row '${path}' escalates the analyst proposal 'keep' to 'revise' — the parent never ` +
832
+ "resolves upward",
833
+ );
834
+ state.downgradeOk = false;
835
+ }
836
+ if (!state.downgradeOk) continue;
837
+ const changed = row.disposition !== analyst.disposition;
838
+ if (changed && (row.fallback_reason === null || row.fallback_reason === "")) {
839
+ details.push(
840
+ `row '${path}' departs from the analyst proposal ('${analyst.disposition}' → ` +
841
+ `'${row.disposition}') and requires a non-empty fallback_reason`,
842
+ );
843
+ } else if (!changed && row.fallback_reason !== null) {
844
+ details.push(
845
+ `row '${path}' matches the analyst proposal and must carry fallback_reason: null`,
846
+ );
847
+ }
848
+ }
849
+
850
+ // Phase 3 (D5) — the destructive evidence bar, computed ONLY from context stances: an
851
+ // explicit endorse from BOTH gate angles and no challenge from ANY angle; silence counts as
852
+ // non-endorsement. The bar is necessary, not sufficient — an eligible proposal MAY still be
853
+ // downgraded by parent judgment.
854
+ const stancesByDoc = new Map<string, DreamReportStanceRecord[]>();
855
+ for (const reducer of context.reducers) {
856
+ for (const stance of reducer.report.stances) {
857
+ const records = stancesByDoc.get(stance.doc) ?? [];
858
+ records.push({
859
+ angle: reducer.angle,
860
+ stance: stance.stance,
861
+ reason: stance.reason,
862
+ evidence_checked: stance.evidence_checked,
863
+ });
864
+ stancesByDoc.set(stance.doc, records);
865
+ }
866
+ }
867
+ for (const path of manifestPaths) {
868
+ const state = joined.get(path);
869
+ if (state === undefined || !state.downgradeOk) continue;
870
+ if (!isDestructive(state.input.disposition)) continue;
871
+ const records = stancesByDoc.get(path) ?? [];
872
+ const blockers: string[] = [];
873
+ for (const gate of EVIDENCE_BAR_GATE_ANGLES) {
874
+ const endorsed = records.some((r) => r.angle === gate && r.stance === "endorse");
875
+ if (!endorsed) blockers.push(`no '${gate}' endorsement (silence is non-endorsement)`);
876
+ }
877
+ for (const record of records) {
878
+ if (record.stance === "challenge") blockers.push(`a '${record.angle}' challenge`);
879
+ }
880
+ if (blockers.length > 0) {
881
+ details.push(
882
+ `destructive row '${path}' ('${state.input.disposition}') fails the evidence bar: ` +
883
+ `${blockers.join("; ")} — the only legal moves are downgrading to 'revise' or ` +
884
+ "'keep' with a fallback_reason",
885
+ );
886
+ }
887
+ }
888
+
889
+ // Phase 4 (D6) — merge-target existence + survival over FINAL dispositions. Survival
890
+ // structurally forbids merge chains and cycles (a merge-into doc can never be a target).
891
+ for (const path of manifestPaths) {
892
+ const state = joined.get(path);
893
+ if (state === undefined || !state.downgradeOk) continue;
894
+ if (state.input.disposition !== "merge-into") continue;
895
+ const target = state.input.merge_target as string;
896
+ if (!corpusSet.has(target)) {
897
+ details.push(`row '${path}' merge_target '${target}' is not a member of the manifest corpus`);
898
+ continue;
899
+ }
900
+ const targetRow = rowByPath.get(target);
901
+ if (targetRow === undefined) continue; // the missing-row detail already covers the target
902
+ if (targetRow.disposition !== "keep" && targetRow.disposition !== "revise") {
903
+ details.push(
904
+ `row '${path}' merge_target '${target}' does not survive (final disposition ` +
905
+ `'${targetRow.disposition}') — a merge target must end keep or revise`,
906
+ );
907
+ }
908
+ }
909
+
910
+ // Phase 5 (D7) — curation units: corpus membership, no doc in two units, no empty unit, no
911
+ // final-keep doc in a unit, the exact partition over the final non-keep set, non-empty
912
+ // roadmap nodes, and the ≤12-distinct-node cap (many-to-one node mapping is allowed).
913
+ const claimedBy = new Set<string>();
914
+ const allUnits: { what: string; docs: string[] }[] = [
915
+ ...typed.selected_units.map((unit, i) => ({
916
+ what: `selected unit ${i + 1} ('${unit.title}')`,
917
+ docs: unit.docs,
918
+ })),
919
+ ...typed.overflow_units.map((unit, i) => ({
920
+ what: `overflow unit ${i + 1} ('${unit.title}')`,
921
+ docs: unit.docs,
922
+ })),
923
+ ];
924
+ for (const unit of allUnits) {
925
+ if (unit.docs.length === 0) {
926
+ details.push(`${unit.what} has no docs — empty units are refused`);
927
+ }
928
+ for (const doc of unit.docs) {
929
+ if (!corpusSet.has(doc)) {
930
+ details.push(`${unit.what} doc '${doc}' is not a member of the manifest corpus`);
931
+ continue;
932
+ }
933
+ if (claimedBy.has(doc)) {
934
+ details.push(`doc '${doc}' appears in more than one curation unit`);
935
+ continue;
936
+ }
937
+ claimedBy.add(doc);
938
+ const row = rowByPath.get(doc);
939
+ if (row !== undefined && row.disposition === "keep") {
940
+ details.push(
941
+ `${unit.what} doc '${doc}' has final disposition 'keep' — final-keep docs appear ` +
942
+ "in no unit",
943
+ );
944
+ }
945
+ }
946
+ }
947
+ for (const path of manifestPaths) {
948
+ const row = rowByPath.get(path);
949
+ if (row === undefined || row.disposition === "keep") continue;
950
+ if (!claimedBy.has(path)) {
951
+ details.push(
952
+ `non-keep doc '${path}' is not covered by any curation unit — selected + overflow ` +
953
+ "units must partition the non-keep docs exactly",
954
+ );
955
+ }
956
+ }
957
+ const distinctNodes = new Set<string>();
958
+ for (const [index, unit] of typed.selected_units.entries()) {
959
+ if (unit.roadmap_node === "") {
960
+ details.push(`selected unit ${index + 1} ('${unit.title}') roadmap_node must be non-empty`);
961
+ continue;
962
+ }
963
+ distinctNodes.add(unit.roadmap_node);
964
+ }
965
+ if (distinctNodes.size > DREAM_REPORT_MAX_ROADMAP_NODES) {
966
+ details.push(
967
+ `selected units name ${distinctNodes.size} distinct roadmap nodes — the cap is ` +
968
+ `${DREAM_REPORT_MAX_ROADMAP_NODES}`,
969
+ );
970
+ }
971
+
972
+ // Phase 6 (D8) — harvest follow-ups cite SURVIVING destinations: a keep/revise corpus doc,
973
+ // or a cluster named by at least one keep/revise doc.
974
+ const survivingDocs = new Set<string>();
975
+ for (const path of manifestPaths) {
976
+ const row = rowByPath.get(path);
977
+ if (row !== undefined && (row.disposition === "keep" || row.disposition === "revise")) {
978
+ survivingDocs.add(path);
979
+ }
980
+ }
981
+ const allClusters = new Set<string>();
982
+ const survivingClusters = new Set<string>();
983
+ for (const lane of manifest.lanes) {
984
+ for (const doc of lane.docs) {
985
+ if (doc.cluster === null) continue;
986
+ allClusters.add(doc.cluster);
987
+ if (survivingDocs.has(doc.path)) survivingClusters.add(doc.cluster);
988
+ }
989
+ }
990
+ for (const [index, followup] of typed.harvest_followups.entries()) {
991
+ const what = `harvest follow-up ${index + 1} ('${followup.title}')`;
992
+ if (followup.pointer === "") {
993
+ details.push(`${what} pointer must be non-empty`);
994
+ }
995
+ if (corpusSet.has(followup.destination)) {
996
+ if (!survivingDocs.has(followup.destination)) {
997
+ const row = rowByPath.get(followup.destination);
998
+ details.push(
999
+ `${what} destination '${followup.destination}' is a corpus doc that does not ` +
1000
+ `survive (final disposition '${row?.disposition ?? "missing"}') — repoint the ` +
1001
+ "follow-up at a survivor",
1002
+ );
1003
+ }
1004
+ } else if (allClusters.has(followup.destination)) {
1005
+ if (!survivingClusters.has(followup.destination)) {
1006
+ details.push(
1007
+ `${what} destination '${followup.destination}' is a cluster with no surviving ` +
1008
+ "keep/revise member",
1009
+ );
1010
+ }
1011
+ } else {
1012
+ details.push(
1013
+ `${what} destination '${followup.destination}' is neither a surviving corpus doc ` +
1014
+ "nor a cluster named by a surviving doc",
1015
+ );
1016
+ }
1017
+ }
1018
+
1019
+ // Phase 7 (D9) — predicted effects carry deliberately NO directional/quota rule: a growth
1020
+ // prediction is valid (type sanity was structural).
1021
+
1022
+ if (details.length > 0) {
1023
+ if (details.length > DREAM_REPORT_MAX_VALIDATION_DETAILS) {
1024
+ const omitted = details.length - DREAM_REPORT_MAX_VALIDATION_DETAILS;
1025
+ return {
1026
+ ok: false,
1027
+ details: [
1028
+ ...details.slice(0, DREAM_REPORT_MAX_VALIDATION_DETAILS),
1029
+ `…and ${omitted} more validation detail(s) omitted ` +
1030
+ `(cap ${DREAM_REPORT_MAX_VALIDATION_DETAILS})`,
1031
+ ],
1032
+ };
1033
+ }
1034
+ return { ok: false, details };
1035
+ }
1036
+
1037
+ // Compose — whitelisted construction, manifest lane/doc order throughout.
1038
+ const rows: DreamReportRow[] = [];
1039
+ for (const lane of manifest.lanes) {
1040
+ for (const doc of lane.docs) {
1041
+ const state = joined.get(doc.path) as JoinedRow;
1042
+ rows.push({
1043
+ path: doc.path,
1044
+ lane: lane.id,
1045
+ cluster: doc.cluster,
1046
+ analyst_disposition: state.analyst.disposition,
1047
+ analyst_merge_target: state.analyst.merge_target,
1048
+ analyst_rationale: state.analyst.rationale,
1049
+ analyst_preserve: state.analyst.preserve,
1050
+ analyst_evidence_checked: state.analyst.evidence_checked,
1051
+ analyst_confidence: state.analyst.confidence,
1052
+ final_disposition: state.input.disposition,
1053
+ final_merge_target: state.input.merge_target,
1054
+ rationale: state.input.rationale,
1055
+ fallback_reason: state.input.fallback_reason,
1056
+ stances: stancesByDoc.get(doc.path) ?? [],
1057
+ });
1058
+ }
1059
+ }
1060
+ const report: DreamReport = {
1061
+ snapshot: {
1062
+ schema_version: DREAM_REPORT_SCHEMA_VERSION,
1063
+ run_id: context.run_id,
1064
+ generated_at: context.generated_at,
1065
+ commit_sha: manifest.commit_sha,
1066
+ registry_mode: manifest.registry_mode,
1067
+ doc_count: manifest.doc_count,
1068
+ total_bytes: manifest.total_bytes,
1069
+ },
1070
+ findings: {
1071
+ structural: [
1072
+ { family: "stale_pointers", count: manifest.findings.structural.stale_pointers.length },
1073
+ {
1074
+ family: "broken_doc_paths",
1075
+ count: manifest.findings.structural.broken_doc_paths.length,
1076
+ },
1077
+ { family: "duplicate_cues", count: manifest.findings.structural.duplicate_cues.length },
1078
+ {
1079
+ family: "missing_frontmatter",
1080
+ count: manifest.findings.structural.missing_frontmatter.length,
1081
+ },
1082
+ ],
1083
+ advisory: [
1084
+ {
1085
+ family: "distillation_issues",
1086
+ count: manifest.findings.advisory.distillation_issues.length,
1087
+ },
1088
+ {
1089
+ family: "source_code_blocks",
1090
+ count: manifest.findings.advisory.source_code_blocks.length,
1091
+ },
1092
+ { family: "overlong_cues", count: manifest.findings.advisory.overlong_cues.length },
1093
+ { family: "cue_hazards", count: manifest.findings.advisory.cue_hazards.length },
1094
+ { family: "empty_clusters", count: manifest.findings.advisory.empty_clusters.length },
1095
+ ],
1096
+ },
1097
+ coverage: {
1098
+ analysts: manifest.lanes.map((lane, index) => {
1099
+ const analysis = context.analyses[index] as DreamLaneAnalysis;
1100
+ return {
1101
+ lane: lane.id,
1102
+ docs: lane.docs.length,
1103
+ overlap_signals_omitted: analysis.report.overlap_signals_omitted,
1104
+ harvest_followups_omitted: analysis.report.harvest_followups_omitted,
1105
+ uncertainties_omitted: analysis.report.uncertainties_omitted,
1106
+ };
1107
+ }),
1108
+ reducers: context.reducers.map((reducer) => ({
1109
+ angle: reducer.angle,
1110
+ stances: reducer.report.stances.length,
1111
+ stances_omitted: reducer.report.stances_omitted,
1112
+ angle_findings_omitted: reducer.report.angle_findings_omitted,
1113
+ uncertainties_omitted: reducer.report.uncertainties_omitted,
1114
+ })),
1115
+ },
1116
+ rows,
1117
+ uncertainties: {
1118
+ parent: typed.uncertainties,
1119
+ analysts: context.analyses.map((analysis) => ({
1120
+ lane: analysis.lane,
1121
+ items: analysis.report.uncertainties,
1122
+ })),
1123
+ reducers: context.reducers.map((reducer) => ({
1124
+ angle: reducer.angle,
1125
+ items: reducer.report.uncertainties,
1126
+ })),
1127
+ },
1128
+ reducer_findings: context.reducers.map((reducer) => ({
1129
+ angle: reducer.angle,
1130
+ items: reducer.report.angle_findings,
1131
+ })),
1132
+ selected_units: typed.selected_units,
1133
+ overflow_units: typed.overflow_units,
1134
+ harvest_followups: typed.harvest_followups,
1135
+ predicted_effects: {
1136
+ docs_before: manifest.doc_count,
1137
+ bytes_before: manifest.total_bytes,
1138
+ docs_after: typed.predicted_effects.docs_after,
1139
+ bytes_after: typed.predicted_effects.bytes_after,
1140
+ note: typed.predicted_effects.note,
1141
+ },
1142
+ };
1143
+ return { ok: true, report };
1144
+ }
1145
+
1146
+ // --------------------------------------------------------------------------- the renderer
1147
+
1148
+ /** One packable Markdown block: same-group consecutive blocks join with `\n` (consecutive
1149
+ * table or bullet lines); a table-row block carries its table's header for re-emission after
1150
+ * a mid-table split (`null` for non-table groups — bullet lines split without re-emission). */
1151
+ interface RenderBlock {
1152
+ text: string;
1153
+ groupId: number | null;
1154
+ tableHeader: string | null;
1155
+ }
1156
+
1157
+ /** Deterministic table-cell/bullet sanitization: `|` escaped, internal newline runs collapsed
1158
+ * to a single space — injected (analyst/reducer) prose may carry newlines/pipes; model strings
1159
+ * are single-line by validation, so the collapse is idempotent for them. Line structure stays
1160
+ * renderer-owned; the typed report retains the exact strings. */
1161
+ function sanitize(s: string): string {
1162
+ return s.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
1163
+ }
1164
+
1165
+ /** `a; b; c` or an em-dash for an empty list (inside a table cell or bullet). */
1166
+ function joinOrDash(items: string[]): string {
1167
+ return items.length === 0 ? "—" : items.map(sanitize).join("; ");
1168
+ }
1169
+
1170
+ /**
1171
+ * Render the composed report to CANONICAL Markdown bytes in parts — a pure function of the
1172
+ * report (no clock, no locale, no environment): an ordered stream of blocks greedily packed
1173
+ * under `DREAM_REPORT_PART_MAX_CHARS − DREAM_REPORT_PART_HEADER_RESERVE` code points, splits
1174
+ * only at block boundaries (bullet-list sections pack per bullet line, so a block group
1175
+ * splits at line boundaries), a table split re-emitting the table header row in the next part,
1176
+ * every part prefixed with its header after packing. A single block exceeding the budget is a
1177
+ * defensive refusal (structurally unreachable under the caps arithmetic — named, never
1178
+ * truncated).
1179
+ */
1180
+ export function renderDreamReport(
1181
+ report: DreamReport,
1182
+ ): { ok: true; parts: string[] } | { ok: false; detail: string } {
1183
+ const blocks: RenderBlock[] = [];
1184
+ let nextGroupId = 0;
1185
+ const push = (text: string): void => {
1186
+ blocks.push({ text, groupId: null, tableHeader: null });
1187
+ };
1188
+ /** Push each line as its own block sharing ONE fresh group: unsplit output joins with `\n`
1189
+ * (byte-identical to a single joined block); an oversized section splits at line
1190
+ * boundaries with no header re-emission. */
1191
+ const pushLines = (lines: string[]): void => {
1192
+ const groupId = nextGroupId;
1193
+ nextGroupId += 1;
1194
+ for (const line of lines) {
1195
+ blocks.push({ text: line, groupId, tableHeader: null });
1196
+ }
1197
+ };
1198
+ const pushTable = (headerCells: string[], rows: string[][]): void => {
1199
+ const groupId = nextGroupId;
1200
+ nextGroupId += 1;
1201
+ const header =
1202
+ `| ${headerCells.join(" | ")} |\n` + `| ${headerCells.map(() => "---").join(" | ")} |`;
1203
+ blocks.push({ text: header, groupId, tableHeader: null });
1204
+ for (const row of rows) {
1205
+ blocks.push({
1206
+ text: `| ${row.map(sanitize).join(" | ")} |`,
1207
+ groupId,
1208
+ tableHeader: header,
1209
+ });
1210
+ }
1211
+ };
1212
+ const stanceBullets = (stances: DreamReportStanceRecord[]): string =>
1213
+ stances.length === 0
1214
+ ? "- No reducer stances recorded (silence is non-endorsement)."
1215
+ : stances
1216
+ .map(
1217
+ (s) =>
1218
+ `- ${s.angle}: ${s.stance} — ${sanitize(s.reason)} ` +
1219
+ `(checked: ${joinOrDash(s.evidence_checked)})`,
1220
+ )
1221
+ .join("\n");
1222
+
1223
+ // 1 — Snapshot.
1224
+ push("## Snapshot");
1225
+ push(
1226
+ [
1227
+ `- Run: ${sanitize(report.snapshot.run_id)}`,
1228
+ `- Report schema version: ${sanitize(report.snapshot.schema_version)}`,
1229
+ `- Commit: ${sanitize(report.snapshot.commit_sha)}`,
1230
+ `- Generated at: ${sanitize(report.snapshot.generated_at)}`,
1231
+ `- Registry mode: ${report.snapshot.registry_mode}`,
1232
+ `- Docs: ${report.snapshot.doc_count}`,
1233
+ `- Total bytes: ${report.snapshot.total_bytes}`,
1234
+ ].join("\n"),
1235
+ );
1236
+
1237
+ // 2 — Findings summary.
1238
+ push("## Findings summary");
1239
+ pushTable(
1240
+ ["Family", "Count"],
1241
+ [
1242
+ ...report.findings.structural.map((f) => [`structural.${f.family}`, String(f.count)]),
1243
+ ...report.findings.advisory.map((f) => [`advisory.${f.family}`, String(f.count)]),
1244
+ ],
1245
+ );
1246
+
1247
+ // 3 — Wave coverage (complete by construction; rendered so a reviewer can verify it).
1248
+ push("## Wave coverage");
1249
+ push("### Analyst lanes");
1250
+ pushTable(
1251
+ [
1252
+ "Lane",
1253
+ "Docs",
1254
+ "Overlap signals omitted",
1255
+ "Harvest follow-ups omitted",
1256
+ "Uncertainties omitted",
1257
+ ],
1258
+ report.coverage.analysts.map((c) => [
1259
+ c.lane,
1260
+ String(c.docs),
1261
+ String(c.overlap_signals_omitted),
1262
+ String(c.harvest_followups_omitted),
1263
+ String(c.uncertainties_omitted),
1264
+ ]),
1265
+ );
1266
+ push("### Reducer angles");
1267
+ pushTable(
1268
+ ["Angle", "Stances", "Stances omitted", "Angle findings omitted", "Uncertainties omitted"],
1269
+ report.coverage.reducers.map((c) => [
1270
+ c.angle,
1271
+ String(c.stances),
1272
+ String(c.stances_omitted),
1273
+ String(c.angle_findings_omitted),
1274
+ String(c.uncertainties_omitted),
1275
+ ]),
1276
+ );
1277
+
1278
+ // 4 — Dispositions: ONE table, one row per authored doc in manifest order.
1279
+ push("## Dispositions");
1280
+ pushTable(
1281
+ ["Doc", "Cluster", "Analyst", "Final", "Merge target", "Confidence", "Rationale"],
1282
+ report.rows.map((row) => [
1283
+ row.path,
1284
+ row.cluster ?? "—",
1285
+ describeProposal(row.analyst_disposition, row.analyst_merge_target),
1286
+ row.final_disposition,
1287
+ row.final_merge_target ?? "—",
1288
+ row.analyst_confidence,
1289
+ row.rationale,
1290
+ ]),
1291
+ );
1292
+
1293
+ // 5 — Non-keep evidence: one subsection per FINAL non-keep doc (manifest order) — the
1294
+ // injected analyst evidence plus every injected reducer stance for that doc's proposal.
1295
+ push("## Non-keep evidence");
1296
+ const nonKeepRows = report.rows.filter((row) => row.final_disposition !== "keep");
1297
+ if (nonKeepRows.length === 0) {
1298
+ push("_None._");
1299
+ }
1300
+ for (const row of nonKeepRows) {
1301
+ push(`### ${row.path}`);
1302
+ push(
1303
+ [
1304
+ `- Analyst rationale: ${sanitize(row.analyst_rationale)}`,
1305
+ `- Preserve: ${joinOrDash(row.analyst_preserve)}`,
1306
+ `- Evidence checked: ${joinOrDash(row.analyst_evidence_checked)}`,
1307
+ ].join("\n"),
1308
+ );
1309
+ push(stanceBullets(row.stances));
1310
+ }
1311
+
1312
+ // 6 — Fallbacks: the recorded non-destructive departures, rendered directly from the rows
1313
+ // carrying a non-null fallback_reason (manifest order). A fallback doc whose final is keep
1314
+ // renders its injected stances HERE (its proposal never reaches §5), so every reducer
1315
+ // stance renders exactly once — §5 (final non-keep) or §6 (final keep).
1316
+ push("## Fallbacks");
1317
+ const fallbackRows = report.rows.filter((row) => row.fallback_reason !== null);
1318
+ if (fallbackRows.length === 0) {
1319
+ push("_None._");
1320
+ }
1321
+ for (const row of fallbackRows) {
1322
+ push(`### ${row.path}`);
1323
+ push(
1324
+ [
1325
+ `- Analyst proposal: ${sanitize(
1326
+ describeProposal(row.analyst_disposition, row.analyst_merge_target),
1327
+ )}`,
1328
+ `- Final: ${row.final_disposition}`,
1329
+ `- Reason: ${sanitize(row.fallback_reason as string)}`,
1330
+ ].join("\n"),
1331
+ );
1332
+ if (row.final_disposition === "keep") {
1333
+ push(stanceBullets(row.stances));
1334
+ }
1335
+ }
1336
+
1337
+ // 7 — Uncertainties, labeled by source: the parent's input first, then the injected
1338
+ // analyst (by lane) and reducer (by angle) uncertainties.
1339
+ push("## Uncertainties");
1340
+ const uncertaintyLines: string[] = [
1341
+ ...report.uncertainties.parent.map((item) => `- Parent: ${sanitize(item)}`),
1342
+ ...report.uncertainties.analysts.flatMap((entry) =>
1343
+ entry.items.map((item) => `- Analyst ${sanitize(entry.lane)}: ${sanitize(item)}`),
1344
+ ),
1345
+ ...report.uncertainties.reducers.flatMap((entry) =>
1346
+ entry.items.map((item) => `- Reducer ${entry.angle}: ${sanitize(item)}`),
1347
+ ),
1348
+ ];
1349
+ if (uncertaintyLines.length === 0) {
1350
+ push("_None._");
1351
+ } else {
1352
+ pushLines(uncertaintyLines);
1353
+ }
1354
+
1355
+ // 8 — Reducer findings: the injected per-angle `angle_findings` (bounded cross-corpus
1356
+ // value — a deliberate minor addition beyond the node's section list).
1357
+ push("## Reducer findings");
1358
+ const findingLines: string[] = report.reducer_findings.flatMap((entry) =>
1359
+ entry.items.map((item) => `- ${entry.angle}: ${sanitize(item)}`),
1360
+ );
1361
+ if (findingLines.length === 0) {
1362
+ push("_None._");
1363
+ } else {
1364
+ pushLines(findingLines);
1365
+ }
1366
+
1367
+ // 9 — Selected curation units (rank = position).
1368
+ push("## Selected curation units");
1369
+ if (report.selected_units.length === 0) {
1370
+ push("_None._");
1371
+ }
1372
+ for (const [index, unit] of report.selected_units.entries()) {
1373
+ push(
1374
+ [
1375
+ `**${index + 1}. ${sanitize(unit.title)}** — node \`${sanitize(unit.roadmap_node)}\``,
1376
+ `- Docs: ${joinOrDash(unit.docs)}`,
1377
+ `- Rationale: ${sanitize(unit.rationale)}`,
1378
+ ].join("\n"),
1379
+ );
1380
+ }
1381
+
1382
+ // 10 — Overflow (rank = position, no node).
1383
+ push("## Overflow");
1384
+ if (report.overflow_units.length === 0) {
1385
+ push("_None._");
1386
+ }
1387
+ for (const [index, unit] of report.overflow_units.entries()) {
1388
+ push(
1389
+ [
1390
+ `**${index + 1}. ${sanitize(unit.title)}**`,
1391
+ `- Docs: ${joinOrDash(unit.docs)}`,
1392
+ `- Rationale: ${sanitize(unit.rationale)}`,
1393
+ ].join("\n"),
1394
+ );
1395
+ }
1396
+
1397
+ // 11 — Harvest follow-ups.
1398
+ push("## Harvest follow-ups");
1399
+ if (report.harvest_followups.length === 0) {
1400
+ push("_None._");
1401
+ } else {
1402
+ pushTable(
1403
+ ["Title", "Destination", "Pointer", "Evidence"],
1404
+ report.harvest_followups.map((f) => [f.title, f.destination, f.pointer, f.evidence]),
1405
+ );
1406
+ }
1407
+
1408
+ // 12 — Predicted effects (with the explicit not-quotas line).
1409
+ push("## Predicted effects");
1410
+ push(
1411
+ [
1412
+ `- Docs: ${report.predicted_effects.docs_before} → ${report.predicted_effects.docs_after}`,
1413
+ `- Bytes: ${report.predicted_effects.bytes_before} → ` +
1414
+ `${report.predicted_effects.bytes_after}`,
1415
+ `- Note: ${report.predicted_effects.note === null ? "—" : sanitize(report.predicted_effects.note)}`,
1416
+ ].join("\n"),
1417
+ );
1418
+ push("_Predictions are not quotas._");
1419
+
1420
+ // Greedy block packing under the reserve-adjusted budget.
1421
+ const budget = DREAM_REPORT_PART_MAX_CHARS - DREAM_REPORT_PART_HEADER_RESERVE;
1422
+ const oversize = (length: number): { ok: false; detail: string } => ({
1423
+ ok: false,
1424
+ detail:
1425
+ `a single rendered block of ${length} code points exceeds the part packing budget ` +
1426
+ `(${budget}) — refusing to truncate`,
1427
+ });
1428
+ const bodies: string[] = [];
1429
+ let current = "";
1430
+ let prevGroupId: number | null = null;
1431
+ for (const block of blocks) {
1432
+ if (current === "") {
1433
+ const length = codePointLength(block.text);
1434
+ if (length > budget) return oversize(length);
1435
+ current = block.text;
1436
+ } else {
1437
+ const separator = block.groupId !== null && block.groupId === prevGroupId ? "\n" : "\n\n";
1438
+ const candidate = current + separator + block.text;
1439
+ if (codePointLength(candidate) <= budget) {
1440
+ current = candidate;
1441
+ } else {
1442
+ bodies.push(current);
1443
+ const opener =
1444
+ block.tableHeader === null ? block.text : `${block.tableHeader}\n${block.text}`;
1445
+ const length = codePointLength(opener);
1446
+ if (length > budget) return oversize(length);
1447
+ current = opener;
1448
+ }
1449
+ }
1450
+ prevGroupId = block.groupId;
1451
+ }
1452
+ if (current !== "") bodies.push(current);
1453
+
1454
+ const total = bodies.length;
1455
+ const parts = bodies.map((body, index) => {
1456
+ const header =
1457
+ index === 0
1458
+ ? `# Dream report — ${report.snapshot.run_id}`
1459
+ : `# Dream report — ${report.snapshot.run_id} (continued, part ${index + 1} of ${total})`;
1460
+ return `${header}\n\n${body}\n`;
1461
+ });
1462
+ for (const part of parts) {
1463
+ const length = codePointLength(part);
1464
+ if (length > DREAM_REPORT_PART_MAX_CHARS) {
1465
+ // Defensive: reachable only when the run id outgrows the fixed header reserve.
1466
+ return {
1467
+ ok: false,
1468
+ detail:
1469
+ `a rendered part of ${length} code points exceeds the part cap ` +
1470
+ `(${DREAM_REPORT_PART_MAX_CHARS}) — refusing to truncate`,
1471
+ };
1472
+ }
1473
+ }
1474
+ return { ok: true, parts };
1475
+ }
1476
+
1477
+ // ------------------------------------------------------------------------ the entry point
1478
+
1479
+ /**
1480
+ * Validate, compose, render, and enforce the part budget in ONE call — the draft path
1481
+ * validates BEFORE review, so an approved report is always savable. `validateDreamReport`'s
1482
+ * two-stage `details` shape passes through; the renderer's single-detail defensive arm is
1483
+ * wrapped into a one-element `details`.
1484
+ */
1485
+ export function buildDreamReport(
1486
+ input: unknown,
1487
+ context: DreamReportContext,
1488
+ ): { ok: true; report: DreamReport; parts: string[] } | Refusal {
1489
+ const validated = validateDreamReport(input, context);
1490
+ if (!validated.ok) return validated;
1491
+ const rendered = renderDreamReport(validated.report);
1492
+ if (!rendered.ok) return refuse(rendered.detail);
1493
+ return { ok: true, report: validated.report, parts: rendered.parts };
1494
+ }