@mgiles/perk 3.0.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 (62) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/commitCompact.ts +98 -10
  3. package/extension/doors/draftReviewWaveTools.ts +43 -15
  4. package/extension/doors/dreamWaveTools.ts +475 -0
  5. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  6. package/extension/doors/objectiveStack.ts +1 -1
  7. package/extension/doors/planReviewBrowser.ts +30 -8
  8. package/extension/doors/prReview.ts +156 -49
  9. package/extension/doors/prReviewDynamic.ts +33 -13
  10. package/extension/doors/reviewWaveTools.ts +37 -14
  11. package/extension/factories/objectiveDraft.ts +95 -27
  12. package/extension/factories/objectiveDreamReport.ts +347 -0
  13. package/extension/factories/objectiveSave.ts +74 -1
  14. package/extension/factories/planReview.ts +173 -10
  15. package/extension/index.ts +62 -15
  16. package/extension/substrate/agentScratch.ts +171 -0
  17. package/extension/substrate/bindingDelivery.ts +9 -11
  18. package/extension/substrate/cache.ts +92 -2
  19. package/extension/substrate/command.ts +9 -6
  20. package/extension/substrate/config.ts +6 -1
  21. package/extension/substrate/git.ts +85 -2
  22. package/extension/substrate/result.ts +3 -2
  23. package/extension/substrate/sessionData.ts +6 -4
  24. package/extension/substrate/sessionPointers.ts +3 -4
  25. package/extension/substrate/toolGating.ts +9 -0
  26. package/extension/substrate/workflowState.ts +44 -2
  27. package/extension/surfaces/report.ts +38 -12
  28. package/extension/surfaces/surfaces.ts +129 -7
  29. package/extension/vendor/btw/btw.ts +38 -6
  30. package/extension/waves/adversarialReviewWave.ts +19 -2
  31. package/extension/waves/draftReviewWave.ts +17 -1
  32. package/extension/waves/dreamReducerWave.ts +700 -0
  33. package/extension/waves/dreamReport.ts +1494 -0
  34. package/extension/waves/dreamWave.ts +927 -0
  35. package/extension/waves/harvestWave.ts +1 -1
  36. package/extension/waves/ponytail.ts +104 -0
  37. package/extension/waves/prReviewDynamicWave.ts +115 -34
  38. package/extension/waves/prReviewWave.ts +122 -17
  39. package/extension/waves/reportWave.ts +103 -7
  40. package/extension/worker/readOnlySession.ts +2 -3
  41. package/package.json +6 -3
  42. package/prompts/_fixtures/live.yaml +49 -0
  43. package/prompts/commit-and-compact-continuation.md +13 -0
  44. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  45. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  46. package/prompts/stages/conflict-resolution.md +1 -1
  47. package/prompts/stages/learn-dream.md +10 -0
  48. package/prompts/stages/objective-review-browser.md +1 -1
  49. package/prompts/stages/plan-review-browser.md +1 -1
  50. package/prompts/stages/pr-review-browser/active.md +1 -1
  51. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  52. package/prompts/stages/pr-review-dynamic.md +5 -5
  53. package/prompts/stages/pr-review-terminal/active.md +1 -1
  54. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  55. package/prompts/stages/pr-review-terminal/local.md +1 -1
  56. package/prompts/stages/pr-review.md +5 -5
  57. package/shared/bindings.yaml +3 -0
  58. package/shared/contracts.md +2176 -500
  59. package/shared/registry.yaml +12 -12
  60. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  61. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  62. package/shared/schemas/outputs/pr-land.schema.json +3 -3
@@ -11,8 +11,9 @@
11
11
  // `objective_save`/`/objective-save` still persist the objective to GitHub.
12
12
  //
13
13
  // Format doctrine: JSON is the storage/transport format, NEVER the human review surface. The
14
- // artifact carries `{schema_version, title?, prose, roadmap}` the structured roadmap rides
15
- // verbatim (node-shape validation stays with the Python plane at save time, the
14
+ // artifact carries `{schema_version, title?, prose, roadmap}` (plus, in a perk learn dream
15
+ // session, the tool-written `dream_report` block contracts §8.63) the structured roadmap
16
+ // rides verbatim (node-shape validation stays with the Python plane at save time, the
16
17
  // `parse_structured_roadmap` path). The review surface reads the draft via
17
18
  // `readObjectiveDraft` (over `readSessionArtifact` — digest-validated, fail-open) and renders
18
19
  // markdown via `renderObjectiveDraft` (the prose + a roadmap table) — never raw JSON; the
@@ -20,9 +21,10 @@
20
21
  // back as structured JSON.
21
22
  //
22
23
  // Vocabulary ownership: this module owns the shared draft/save param vocabulary
23
- // (`ObjectiveSaveParams`, `decodeObjectiveSaveParams`, `ROADMAP_PARAM_SCHEMA`) — objectiveDraft is
24
- // the LEAF (mirroring planDraft←planSave's direction); objectiveSave.ts consumes it, so it may
25
- // value-import `readObjectiveDraft` cycle-free for the approval→save orchestration.
24
+ // (`ObjectiveSaveParams`, `decodeObjectiveSaveParams`, `ROADMAP_PARAM_SCHEMA`,
25
+ // `DREAM_REPORT_PARAM_SCHEMA`) — objectiveDraft is the LEAF (mirroring planDraft←planSave's
26
+ // direction); objectiveSave.ts consumes it, so it may value-import `readObjectiveDraft`
27
+ // cycle-free for the approval→save orchestration.
26
28
  //
27
29
  // Imports stay node builtins + sibling seams (sessionData.ts, result.ts) so the module loads
28
30
  // under `node --test`; no manual `scratch`/`runs` path segments (cacheGuard.test.ts).
@@ -37,9 +39,15 @@ import {
37
39
  type SessionDataCtx,
38
40
  writeSessionArtifact,
39
41
  } from "../substrate/sessionData.ts";
40
- import { arrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
42
+ import { arrayParam, objectParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
41
43
  import type { EntrySink } from "../substrate/workflowState.ts";
42
44
  import type { ReportTarget } from "../surfaces/report.ts";
45
+ import { DREAM_REPORT_INPUT_SCHEMA } from "../waves/dreamReport.ts";
46
+ import {
47
+ decodeDreamReportBlock,
48
+ type ObjectiveDreamReportBlock,
49
+ resolveDreamReportGate,
50
+ } from "./objectiveDreamReport.ts";
43
51
 
44
52
  /** The reviewed objective delivery choice (contracts §8.45). */
45
53
  export type DeliveryChoice = "incremental" | "stacked";
@@ -53,6 +61,9 @@ export interface ObjectiveSaveParams {
53
61
  base?: string;
54
62
  // The reviewed delivery choice; omitted ⇒ incremental (the §8.42 absence rule).
55
63
  delivery?: DeliveryChoice;
64
+ // The dream-report input (perk learn dream only — §8.63); deep validation is the gate
65
+ // resolver's, so the decode keeps it opaque beyond the plain-object shape.
66
+ dream_report?: unknown;
56
67
  }
57
68
 
58
69
  /**
@@ -69,6 +80,20 @@ export const DELIVERY_PARAM_SCHEMA = {
69
80
  "one atomic PR train — capability-checked at save).",
70
81
  } as const;
71
82
 
83
+ /**
84
+ * The `dream_report` property, shared between `objective_save` and `objective_draft` so the
85
+ * two tools' dream contracts cannot drift: the §8.62 `DREAM_REPORT_INPUT_SCHEMA` embedded by
86
+ * identifier (the `DELIVERY_PARAM_SCHEMA`/`ROADMAP_PARAM_SCHEMA` shared-schema pattern) plus
87
+ * the gate description. Structurally reachable only inside a `perk learn dream` session (the
88
+ * resolver refuses it outside one).
89
+ */
90
+ export const DREAM_REPORT_PARAM_SCHEMA = {
91
+ ...DREAM_REPORT_INPUT_SCHEMA,
92
+ description:
93
+ "The perk learn dream session's final report input (the parent's decisions only) — " +
94
+ "required inside a dream session, refused outside one.",
95
+ } as const;
96
+
72
97
  /**
73
98
  * The roadmap-node items JSON schema, shared between `objective_save` and `objective_draft`
74
99
  * so the two tools' roadmap contracts cannot drift.
@@ -116,12 +141,22 @@ export function decodeObjectiveSaveParams(params: unknown): ObjectiveSaveParams
116
141
  const roadmap = arrayParam(p, "roadmap");
117
142
  const base = stringParam(p, "base");
118
143
  const delivery = stringParam(p, "delivery");
119
- if (prose === null || title === null || roadmap === null || base === null || delivery === null) {
144
+ // `dream_report` must be a plain object when present (absent undefined); deep validation
145
+ // stays with the gate resolver (resolveDreamReportGate).
146
+ const dreamReport = objectParam(p, "dream_report");
147
+ if (
148
+ prose === null ||
149
+ title === null ||
150
+ roadmap === null ||
151
+ base === null ||
152
+ delivery === null ||
153
+ dreamReport === null
154
+ ) {
120
155
  return null;
121
156
  }
122
157
  // The delivery enum is strict beyond `string`: an off-enum value is present-but-mistyped.
123
158
  if (delivery !== undefined && delivery !== "incremental" && delivery !== "stacked") return null;
124
- return { prose: prose ?? "", title, roadmap, base, delivery };
159
+ return { prose: prose ?? "", title, roadmap, base, delivery, dream_report: dreamReport };
125
160
  }
126
161
 
127
162
  /** The fixed working-objective artifact name (one JSON file: prose + the structured roadmap). */
@@ -144,7 +179,9 @@ export type ObjectiveDraftResult = Result<ObjectiveDraftOk>;
144
179
  * structured roadmap, verbatim — the draft never validates node shapes) as one JSON artifact and
145
180
  * write it through the accessor seam (file + `session_artifacts` provenance pointer). Soft
146
181
  * result, never throws — failure taxonomy: empty prose → `invalid_input`; no session run_id →
147
- * `no_run_id`; file-or-pointer write failure → `write_failed` (the seam already warned).
182
+ * `no_run_id`; a `dream_report` gate refusal → `invalid_input`/`bad_state` (the §8.63 matrix
183
+ * validated at write time so a report-less dream bundle can never reach review);
184
+ * file-or-pointer write failure → `write_failed` (the seam already warned).
148
185
  */
149
186
  export function writeObjectiveDraft(
150
187
  sink: EntrySink,
@@ -155,6 +192,7 @@ export function writeObjectiveDraft(
155
192
  roadmap?: unknown[];
156
193
  base?: string;
157
194
  delivery?: DeliveryChoice;
195
+ dream_report?: unknown;
158
196
  },
159
197
  ): ObjectiveDraftResult {
160
198
  const fail = failFor(ctx, "objective-draft");
@@ -168,6 +206,13 @@ export function writeObjectiveDraft(
168
206
  return fail("session has no run_id — cannot write the objective-draft artifact", "no_run_id");
169
207
  }
170
208
 
209
+ // The §8.63 gate: validated at draft-write time via buildDreamReport, the ONE stamp stored
210
+ // with the block; `absent` keeps the payload byte-identical (every non-dream path unchanged).
211
+ const gate = resolveDreamReportGate(ctx, opts.dream_report, new Date().toISOString());
212
+ if (gate.kind === "refuse") {
213
+ return fail(gate.detail, gate.errorType);
214
+ }
215
+
171
216
  // Deterministic key order via the explicit literal; `title`/`base`/`delivery` are omitted
172
217
  // when blank/absent (schema_version stays 1 — an additive optional field, fail-open readers).
173
218
  const title = opts.title?.trim();
@@ -179,6 +224,7 @@ export function writeObjectiveDraft(
179
224
  ...(title ? { title } : {}),
180
225
  ...(base ? { base } : {}),
181
226
  ...(delivery ? { delivery } : {}),
227
+ ...(gate.kind === "block" ? { dream_report: gate.block } : {}),
182
228
  prose: opts.prose,
183
229
  roadmap,
184
230
  };
@@ -216,6 +262,8 @@ export interface ObjectiveDraft {
216
262
  base?: string;
217
263
  // The reviewed delivery choice; kept only when exactly the enum (junk → absent, like `base`).
218
264
  delivery?: DeliveryChoice;
265
+ // The dream-report block (§8.63); a present-but-malformed block refuses the WHOLE draft.
266
+ dream_report?: ObjectiveDreamReportBlock;
219
267
  }
220
268
 
221
269
  /**
@@ -223,7 +271,10 @@ export interface ObjectiveDraft {
223
271
  * `readSessionArtifact`'s loud tier): no pointer/file/digest → `null` (the seam already spoke);
224
272
  * malformed JSON, a non-object payload, an unsupported `schema_version`, or blank prose → a
225
273
  * stderr warning + `null`. `roadmap` defaults to `[]` when absent/non-array; `title` is kept
226
- * only when a non-blank string. Never throws.
274
+ * only when a non-blank string. A present-but-malformed `dream_report` block refuses the WHOLE
275
+ * draft (warn + `null`) — deliberately stricter than the lenient junk→absent handling of
276
+ * `base`/`delivery`, because silently dropping a malformed report is exactly what §8.63
277
+ * forbids. Never throws.
227
278
  */
228
279
  export function readObjectiveDraft(ctx: SessionDataCtx): ObjectiveDraft | null {
229
280
  const artifact = readSessionArtifact(ctx, OBJECTIVE_DRAFT_ARTIFACT);
@@ -258,10 +309,19 @@ export function readObjectiveDraft(ctx: SessionDataCtx): ObjectiveDraft | null {
258
309
  payload.delivery === "incremental" || payload.delivery === "stacked"
259
310
  ? payload.delivery
260
311
  : undefined;
312
+ let dreamReport: ObjectiveDreamReportBlock | undefined;
313
+ if ("dream_report" in payload) {
314
+ const block = decodeDreamReportBlock(payload.dream_report);
315
+ if (block === null) {
316
+ return refuse("carries a malformed dream_report block");
317
+ }
318
+ dreamReport = block;
319
+ }
261
320
  return {
262
321
  ...(title !== undefined ? { title } : {}),
263
322
  ...(base !== undefined ? { base } : {}),
264
323
  ...(delivery !== undefined ? { delivery } : {}),
324
+ ...(dreamReport !== undefined ? { dream_report: dreamReport } : {}),
265
325
  prose,
266
326
  roadmap,
267
327
  };
@@ -294,7 +354,10 @@ function nodeDependsOn(node: unknown): string {
294
354
  * a `## Roadmap` section with ONE markdown table. A prominent `**Delivery:**` line renders
295
355
  * directly under the title unconditionally (the reviewed choice must be visible either way —
296
356
  * contracts §8.45). The `Phase` column appears only when some node carries a non-blank string
297
- * `phase`. Pure; never throws.
357
+ * `phase`. When the draft carries a `dream_report` block, the stored CANONICAL parts append as
358
+ * the final section — the review surface IS the approval bundle: the objective and its report
359
+ * review (and are approved or denied) together (§8.63); the parts carry their own
360
+ * `# Dream report — <run_id>` headers. Pure; never throws.
298
361
  */
299
362
  /** The always-present prominent `**Delivery:**` review line (contracts §8.45). */
300
363
  function deliveryLine(draft: ObjectiveDraft): string {
@@ -313,23 +376,27 @@ export function renderObjectiveDraft(draft: ObjectiveDraft): string {
313
376
  out += `${deliveryLine(draft)}\n\n`;
314
377
  out += draft.prose;
315
378
 
316
- if (draft.roadmap.length === 0) return out;
379
+ if (draft.roadmap.length > 0) {
380
+ const withPhase = draft.roadmap.some((node) => nodeString(node, "phase").trim().length > 0);
381
+ const header = withPhase
382
+ ? "| Node | Phase | Description | Depends On | Status |\n| --- | --- | --- | --- | --- |"
383
+ : "| Node | Description | Depends On | Status |\n| --- | --- | --- | --- |";
384
+ const rows = draft.roadmap.map((node) => {
385
+ const cells = [
386
+ tableCell(nodeString(node, "id")),
387
+ ...(withPhase ? [tableCell(nodeString(node, "phase"))] : []),
388
+ tableCell(nodeString(node, "description")),
389
+ tableCell(nodeDependsOn(node)),
390
+ tableCell(nodeString(node, "status") || "pending"),
391
+ ];
392
+ return `| ${cells.join(" | ")} |`;
393
+ });
394
+ out = `${out.trimEnd()}\n\n## Roadmap\n\n${header}\n${rows.join("\n")}\n`;
395
+ }
317
396
 
318
- const withPhase = draft.roadmap.some((node) => nodeString(node, "phase").trim().length > 0);
319
- const header = withPhase
320
- ? "| Node | Phase | Description | Depends On | Status |\n| --- | --- | --- | --- | --- |"
321
- : "| Node | Description | Depends On | Status |\n| --- | --- | --- | --- |";
322
- const rows = draft.roadmap.map((node) => {
323
- const cells = [
324
- tableCell(nodeString(node, "id")),
325
- ...(withPhase ? [tableCell(nodeString(node, "phase"))] : []),
326
- tableCell(nodeString(node, "description")),
327
- tableCell(nodeDependsOn(node)),
328
- tableCell(nodeString(node, "status") || "pending"),
329
- ];
330
- return `| ${cells.join(" | ")} |`;
331
- });
332
- return `${out.trimEnd()}\n\n## Roadmap\n\n${header}\n${rows.join("\n")}\n`;
397
+ if (draft.dream_report === undefined) return out;
398
+ // The approval bundle: objective first, then the stored CANONICAL report parts.
399
+ return `${out.trimEnd()}\n\n${draft.dream_report.parts.join("\n\n")}\n`;
333
400
  }
334
401
 
335
402
  const TOOL_GUIDELINES = [
@@ -371,6 +438,7 @@ export function registerObjectiveDraft(pi: ExtensionAPI): void {
371
438
  "Optional target branch for this objective's plans (omit to use the repo default).",
372
439
  },
373
440
  delivery: DELIVERY_PARAM_SCHEMA,
441
+ dream_report: DREAM_REPORT_PARAM_SCHEMA,
374
442
  roadmap: {
375
443
  type: "array",
376
444
  description:
@@ -0,0 +1,347 @@
1
+ // The `dream_report` gate for the objective draft/review/save path (contracts.md §8.63).
2
+ //
3
+ // ONE resolver implements the whole gate matrix — `writeObjectiveDraft` (objectiveDraft.ts)
4
+ // and `saveObjective` (objectiveSave.ts) both consume its typed outcome, so no parallel
5
+ // branch/message implementation can drift. "Dream session" is detected structurally, exactly
6
+ // like `run_dream_wave` (doors/dreamWaveTools.ts): the session's claimed `run_id` plus the
7
+ // existence of the run-scoped dream manifest (no claimed run counts as non-dream). The gate is
8
+ // fail-closed in BOTH directions: a dream session refuses a report-less objective (the
9
+ // objective and its report review as ONE bundle — an approval is always savable), and a
10
+ // `dream_report` outside a dream session refuses rather than being silently dropped. Absence
11
+ // on a non-dream path is byte-identical no-op behavior.
12
+ //
13
+ // Trusted-context recovery follows the session-artifacts digest-pointer doctrine: the bare
14
+ // run-scratch bundle is never trusted — the `dream_bundle_digest` workflow-state marker
15
+ // (cleared at wave entry, set to the finalized bytes' digest after a successful finalize) is
16
+ // the freshness/integrity authority, and the bundle is strictly re-decoded through
17
+ // `decodeFinalizedDreamBundle` on every recovery read (untrusted-at-rest posture). After a
18
+ // successful recovery the revalidation bracket (contracts.md §8.65) re-proves HEAD-unchanged +
19
+ // tree-clean against the manifest's stamped `commit_sha` — at draft-write AND save, since both
20
+ // consumers flow through the one resolver; drift refuses `bad_state` (the analysis is stale).
21
+ //
22
+ // Imports only the dream wave siblings, the substrate seams, and node builtins — cycle-free
23
+ // (nothing in `waves/` imports factories) and loadable under `node --test`.
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { runScratchDir } from "../substrate/cache.ts";
28
+ import { revalidationBracket } from "../substrate/git.ts";
29
+ import { digestSessionData, type SessionDataCtx } from "../substrate/sessionData.ts";
30
+ import { branchOf, rebuildWorkflowState, type WorkflowState } from "../substrate/workflowState.ts";
31
+ import { DREAM_ANALYSES_FILENAME, decodeFinalizedDreamBundle } from "../waves/dreamReducerWave.ts";
32
+ import { buildDreamReport, type DreamReportContext } from "../waves/dreamReport.ts";
33
+ import {
34
+ codePointLength,
35
+ DREAM_MANIFEST_FILENAME,
36
+ decodeDreamManifest,
37
+ } from "../waves/dreamWave.ts";
38
+
39
+ /**
40
+ * The shared part-invariance + size rule's comment-body cap (contracts §8.64) — the full
41
+ * rendered companion comment (marker + blank line + part) must fit with margin under GitHub's
42
+ * 65,536-char issue-comment limit. The Python twin is
43
+ * `perk.learn.dream_companion.COMPANION_COMMENT_MAX_CHARS` (parity-pinned fixtures).
44
+ */
45
+ export const COMPANION_COMMENT_MAX_CHARS = 65_000;
46
+
47
+ // The invariance shapes (the exact shapes the Linear transcoder `to_linear_markdown`
48
+ // rewrites/drops — derived locally by the same rule, mirroring the Python twin).
49
+ const MARKER_TEXT = "perk:learn-dream-report";
50
+ const PERK_HTML_MARKER_RE = /<!--\s*\/?perk:[^>]+?\s*-->/;
51
+ const DETAILS_OPEN_RE = /^<details><summary><code>[^<]*<\/code><\/summary>$/;
52
+ const DETAILS_CLOSE = "</details>";
53
+ // Every line boundary Python's `str.splitlines()` recognizes EXCEPT `\n` — the Linear
54
+ // transcoder splits on all of them and rejoins with `\n`, so any other boundary form would be
55
+ // normalized in the stored body and defeat the persistence-side byte comparison forever.
56
+ const NON_CANONICAL_LINE_BOUNDARIES = [
57
+ "\r",
58
+ "\v",
59
+ "\f",
60
+ "\u001c",
61
+ "\u001d",
62
+ "\u001e",
63
+ "\u0085",
64
+ "\u2028",
65
+ "\u2029",
66
+ ];
67
+
68
+ /**
69
+ * The TS mirror of Python's `validate_report_parts` (contracts §8.64) — run over the freshly
70
+ * rendered parts at draft-write AND save (both flow through `resolveDreamReportGate`), so an
71
+ * approved report is always Python-savable: no empty/blank part, no perk HTML-comment marker,
72
+ * no literal companion marker text, no perk-rendered `<details>` wrapper line (the shapes the
73
+ * Linear transcoder rewrites/drops — transcode-invariance keeps the persistence-side
74
+ * dual-candidate byte comparison exact), and every rendered comment body (marker + blank line +
75
+ * part) within `COMPANION_COMMENT_MAX_CHARS` code points. Returns named violations (`[]` =
76
+ * valid). Parity-pinned against the Python twin by the shared fixture set.
77
+ */
78
+ export function reportPartInvarianceViolations(parts: string[], runId: string): string[] {
79
+ const violations: string[] = [];
80
+ parts.forEach((part, i) => {
81
+ const index = i + 1;
82
+ const where = `part ${index}`;
83
+ if (part.trim() === "") {
84
+ violations.push(`${where}: empty part`);
85
+ return;
86
+ }
87
+ if (part.includes(MARKER_TEXT)) {
88
+ violations.push(`${where}: carries the literal '${MARKER_TEXT}' marker text`);
89
+ }
90
+ if (PERK_HTML_MARKER_RE.test(part)) {
91
+ violations.push(
92
+ `${where}: carries a perk HTML-comment marker (<!-- perk:… --> is rewritten by the ` +
93
+ "Linear transcoder)",
94
+ );
95
+ }
96
+ if (
97
+ part.split(/\r\n|\r|\n/).some((line) => DETAILS_OPEN_RE.test(line) || line === DETAILS_CLOSE)
98
+ ) {
99
+ violations.push(
100
+ `${where}: carries a perk-rendered <details> wrapper line (dropped by the Linear ` +
101
+ "transcoder)",
102
+ );
103
+ }
104
+ if (NON_CANONICAL_LINE_BOUNDARIES.some((boundary) => part.includes(boundary))) {
105
+ violations.push(
106
+ `${where}: carries a line boundary other than \\n (normalized by the Linear transcoder)`,
107
+ );
108
+ }
109
+ const bodyLength = codePointLength(`<!-- ${MARKER_TEXT}:${runId}:${index} -->\n\n${part}`);
110
+ if (bodyLength > COMPANION_COMMENT_MAX_CHARS) {
111
+ violations.push(
112
+ `${where}: rendered comment body is ${bodyLength} chars (cap ${COMPANION_COMMENT_MAX_CHARS})`,
113
+ );
114
+ }
115
+ });
116
+ if (parts.length === 0) violations.push("parts: empty list");
117
+ return violations;
118
+ }
119
+
120
+ /**
121
+ * The `dream_report` block the objective-draft artifact carries (tool-written only — the model
122
+ * never writes the artifact): the validated model input, the ONE `generated_at` stamp that
123
+ * keeps re-rendering deterministic across review and save, and the stored CANONICAL parts the
124
+ * review surface renders and the save byte-compares.
125
+ */
126
+ export interface ObjectiveDreamReportBlock {
127
+ input: unknown;
128
+ generated_at: string;
129
+ parts: string[];
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ /**
137
+ * The artifact-side shape check `readObjectiveDraft` uses: a plain object carrying a
138
+ * plain-object `input`, a non-blank string `generated_at`, and a non-empty all-string `parts`.
139
+ * Deep validation stays with the resolver (the save re-runs the full gate); `null` = malformed.
140
+ */
141
+ export function decodeDreamReportBlock(value: unknown): ObjectiveDreamReportBlock | null {
142
+ if (!isRecord(value)) return null;
143
+ if (!isRecord(value.input)) return null;
144
+ if (typeof value.generated_at !== "string" || !value.generated_at.trim()) return null;
145
+ if (!Array.isArray(value.parts) || value.parts.length === 0) return null;
146
+ const parts: string[] = [];
147
+ for (const part of value.parts) {
148
+ if (typeof part !== "string") return null;
149
+ parts.push(part);
150
+ }
151
+ return { input: value.input, generated_at: value.generated_at, parts };
152
+ }
153
+
154
+ /**
155
+ * Recover the trusted `DreamReportContext` from the claimed run's scratch state — every arm
156
+ * fail-closed with a named detail (the caller maps them to `bad_state`):
157
+ *
158
+ * 1. the run-scoped manifest: read + parse + `decodeDreamManifest` (the strict §8.60 decoder,
159
+ * path bound at decode time). No `verifyDocContainment` — this path reads no doc files;
160
+ * the lexical decode suffices (resolved containment is the wave tool's pre-spawn concern);
161
+ * 2. the freshness check: the `dream_bundle_digest` marker (read from the caller's ONE
162
+ * workflow-state snapshot) must be present, non-empty, and equal the digest of the bundle
163
+ * bytes just read — a bare file is never trusted (missing marker = no finalized wave;
164
+ * empty = invalidated by a newer attempt, including a cleanup-failure residue; mismatch =
165
+ * stale/tampered bytes);
166
+ * 3. the strict finalized decode (`decodeFinalizedDreamBundle` over the digest of the
167
+ * manifest bytes just read — the marker authenticates the bundle bytes and the bundle's
168
+ * `manifest_digest` extends that authority to the manifest, so an at-rest manifest edit
169
+ * refuses; the analyses-only mid-wave shape refuses here too).
170
+ */
171
+ function recoverDreamReportContext(
172
+ ctx: SessionDataCtx,
173
+ runId: string,
174
+ marker: string | undefined,
175
+ generatedAt: string,
176
+ ): { ok: true; context: DreamReportContext } | { ok: false; detail: string } {
177
+ const manifestPath = join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME);
178
+ let manifestBytes: string;
179
+ let rawManifest: unknown;
180
+ try {
181
+ manifestBytes = readFileSync(manifestPath, "utf8");
182
+ rawManifest = JSON.parse(manifestBytes);
183
+ } catch (error) {
184
+ const detail = error instanceof Error ? error.message : String(error);
185
+ return { ok: false, detail: `dream manifest unreadable at '${manifestPath}': ${detail}` };
186
+ }
187
+ const manifest = decodeDreamManifest(rawManifest, manifestPath);
188
+ if (!manifest.ok) {
189
+ return { ok: false, detail: `dream manifest invalid: ${manifest.detail}` };
190
+ }
191
+
192
+ const bundlePath = join(dirname(manifestPath), DREAM_ANALYSES_FILENAME);
193
+ let bundleBytes: string;
194
+ try {
195
+ bundleBytes = readFileSync(bundlePath, "utf8");
196
+ } catch {
197
+ return {
198
+ ok: false,
199
+ detail: `no dream bundle at '${bundlePath}' — re-run the dream wave`,
200
+ };
201
+ }
202
+ if (marker === undefined || marker === "") {
203
+ return {
204
+ ok: false,
205
+ detail: "no finalized dream wave for this session — re-run the dream wave",
206
+ };
207
+ }
208
+ if (digestSessionData(bundleBytes) !== marker) {
209
+ return {
210
+ ok: false,
211
+ detail:
212
+ "the dream bundle does not match the session's finalized digest — re-run the dream wave",
213
+ };
214
+ }
215
+ let rawBundle: unknown;
216
+ try {
217
+ rawBundle = JSON.parse(bundleBytes);
218
+ } catch (error) {
219
+ const detail = error instanceof Error ? error.message : String(error);
220
+ return { ok: false, detail: `dream bundle is not valid JSON: ${detail}` };
221
+ }
222
+ const decoded = decodeFinalizedDreamBundle(
223
+ rawBundle,
224
+ manifest.manifest,
225
+ digestSessionData(manifestBytes),
226
+ );
227
+ if (!decoded.ok) {
228
+ return { ok: false, detail: `${decoded.detail} — re-run the dream wave` };
229
+ }
230
+ return {
231
+ ok: true,
232
+ context: {
233
+ manifest: manifest.manifest,
234
+ analyses: decoded.analyses,
235
+ reducers: decoded.reducers,
236
+ run_id: runId,
237
+ generated_at: generatedAt,
238
+ },
239
+ };
240
+ }
241
+
242
+ /** The typed gate outcome both consumers branch on — the whole matrix, one vocabulary. */
243
+ export type DreamReportGateOutcome =
244
+ | { kind: "absent" }
245
+ | { kind: "block"; block: ObjectiveDreamReportBlock }
246
+ | { kind: "refuse"; errorType: "invalid_input" | "bad_state"; detail: string };
247
+
248
+ /**
249
+ * The ONE gate resolver (contracts §8.63) — identical at draft-write and save. `input` is the
250
+ * model-supplied `dream_report` value, or `undefined` for "no dream_report" (callers pass the
251
+ * value only when present; an `{input: undefined}` carrier is never constructed). The matrix:
252
+ *
253
+ * | session | `dream_report` | outcome |
254
+ * | --------- | -------------- | ------- |
255
+ * | non-dream | absent | `absent` — unchanged, byte-identical behavior |
256
+ * | non-dream | present | refuse `invalid_input` (never silently dropped) |
257
+ * | dream | absent | refuse `invalid_input` (one approval bundle) |
258
+ * | dream | present | recover context → `buildDreamReport` → refuse or `block` |
259
+ *
260
+ * An UNREADABLE workflow state (a throwing branch read) refuses `bad_state` BEFORE the matrix —
261
+ * it is never conflated with a confirmed non-dream session (the `activeSessionRunId`
262
+ * null-on-throw sentinel would otherwise let a transient read failure surface as `absent`).
263
+ *
264
+ * Failure taxonomy: gate violations + `buildDreamReport` refusals → `invalid_input` (the
265
+ * bounded ≤25 named details newline-joined); an unreadable workflow state and
266
+ * context-recovery failures → `bad_state`.
267
+ */
268
+ export function resolveDreamReportGate(
269
+ ctx: SessionDataCtx,
270
+ input: unknown,
271
+ generatedAt: string,
272
+ bracket: (
273
+ cwd: string,
274
+ expectedSha: string,
275
+ ) => { ok: boolean; detail: string | null } = revalidationBracket,
276
+ ): DreamReportGateOutcome {
277
+ // ONE workflow-state snapshot for the whole gate (run identity + the freshness marker),
278
+ // read with error distinction: unreadable state fails closed, never "non-dream".
279
+ let state: WorkflowState;
280
+ try {
281
+ state = rebuildWorkflowState(branchOf(ctx));
282
+ } catch (error) {
283
+ const detail = error instanceof Error ? error.message : String(error);
284
+ return {
285
+ kind: "refuse",
286
+ errorType: "bad_state",
287
+ detail: `session workflow state is unreadable — cannot resolve the dream_report gate: ${detail}`,
288
+ };
289
+ }
290
+ const runId = typeof state.run_id === "string" && state.run_id.length > 0 ? state.run_id : null;
291
+ const dream =
292
+ runId !== null && existsSync(join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME));
293
+ if (runId === null || !dream) {
294
+ if (input === undefined) return { kind: "absent" };
295
+ return {
296
+ kind: "refuse",
297
+ errorType: "invalid_input",
298
+ detail:
299
+ "dream_report is only valid inside a perk learn dream session — refusing rather than " +
300
+ "silently dropping it",
301
+ };
302
+ }
303
+ if (input === undefined) {
304
+ return {
305
+ kind: "refuse",
306
+ errorType: "invalid_input",
307
+ detail:
308
+ "this dream session's objective must carry dream_report — the objective and its " +
309
+ "report review as one bundle",
310
+ };
311
+ }
312
+ const recovered = recoverDreamReportContext(ctx, runId, state.dream_bundle_digest, generatedAt);
313
+ if (!recovered.ok) {
314
+ return { kind: "refuse", errorType: "bad_state", detail: recovered.detail };
315
+ }
316
+ // The revalidation-bracket re-check (contracts.md §8.65): the manifest — with its stamped
317
+ // commit_sha — is now decoded and authenticated, so re-prove HEAD-unchanged + tree-clean
318
+ // against it. Both `writeObjectiveDraft` and `saveObjective` flow through this resolver, so
319
+ // the bracket re-fires at draft-write AND save; non-dream paths never reach it (the matrix
320
+ // above returned already). The parameter default is the production bracket — tests inject
321
+ // stubs.
322
+ const drift = bracket(ctx.cwd, recovered.context.manifest.commit_sha);
323
+ if (!drift.ok) {
324
+ return {
325
+ kind: "refuse",
326
+ errorType: "bad_state",
327
+ detail:
328
+ `the repository moved since the dream snapshot (${drift.detail}) — the analysis is ` +
329
+ "stale; re-run perk learn dream",
330
+ };
331
+ }
332
+ const built = buildDreamReport(input, recovered.context);
333
+ if (!built.ok) {
334
+ return { kind: "refuse", errorType: "invalid_input", detail: built.details.join("\n") };
335
+ }
336
+ // The §8.64 invariance mirror, at draft-write AND save (this resolver is both), so an
337
+ // approved report is always savable by the Python door's identical rule.
338
+ const violations = reportPartInvarianceViolations(built.parts, runId);
339
+ if (violations.length > 0) {
340
+ return {
341
+ kind: "refuse",
342
+ errorType: "invalid_input",
343
+ detail: `dream report parts violate the invariance rule: ${violations.join("; ")}`,
344
+ };
345
+ }
346
+ return { kind: "block", block: { input, generated_at: generatedAt, parts: built.parts } };
347
+ }