// The `dream_report` gate for the objective draft/review/save path (contracts.md §8.63). // // ONE resolver implements the whole gate matrix — `writeObjectiveDraft` (objectiveDraft.ts) // and `saveObjective` (objectiveSave.ts) both consume its typed outcome, so no parallel // branch/message implementation can drift. "Dream session" is detected structurally, exactly // like `run_dream_wave` (doors/dreamWaveTools.ts): the session's claimed `run_id` plus the // existence of the run-scoped dream manifest (no claimed run counts as non-dream). The gate is // fail-closed in BOTH directions: a dream session refuses a report-less objective (the // objective and its report review as ONE bundle — an approval is always savable), and a // `dream_report` outside a dream session refuses rather than being silently dropped. Absence // on a non-dream path is byte-identical no-op behavior. // // Trusted-context recovery follows the session-artifacts digest-pointer doctrine: the bare // run-scratch bundle is never trusted — the `dream_bundle_digest` workflow-state marker // (cleared at wave entry, set to the finalized bytes' digest after a successful finalize) is // the freshness/integrity authority, and the bundle is strictly re-decoded through // `decodeFinalizedDreamBundle` on every recovery read (untrusted-at-rest posture). After a // successful recovery the revalidation bracket (contracts.md §8.65) re-proves HEAD-unchanged + // tree-clean against the manifest's stamped `commit_sha` — at draft-write AND save, since both // consumers flow through the one resolver; drift refuses `bad_state` (the analysis is stale). // // Imports only the dream wave siblings, the substrate seams, and node builtins — cycle-free // (nothing in `waves/` imports factories) and loadable under `node --test`. import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { runScratchDir } from "../substrate/cache.ts"; import { revalidationBracket } from "../substrate/git.ts"; import { digestSessionData, type SessionDataCtx } from "../substrate/sessionData.ts"; import { branchOf, rebuildWorkflowState, type WorkflowState } from "../substrate/workflowState.ts"; import { DREAM_ANALYSES_FILENAME, decodeFinalizedDreamBundle } from "../waves/dreamReducerWave.ts"; import { buildDreamReport, type DreamReportContext } from "../waves/dreamReport.ts"; import { codePointLength, DREAM_MANIFEST_FILENAME, decodeDreamManifest, } from "../waves/dreamWave.ts"; /** * The shared part-invariance + size rule's comment-body cap (contracts §8.64) — the full * rendered companion comment (marker + blank line + part) must fit with margin under GitHub's * 65,536-char issue-comment limit. The Python twin is * `perk.learn.dream_companion.COMPANION_COMMENT_MAX_CHARS` (parity-pinned fixtures). */ export const COMPANION_COMMENT_MAX_CHARS = 65_000; // The invariance shapes (the exact shapes the Linear transcoder `to_linear_markdown` // rewrites/drops — derived locally by the same rule, mirroring the Python twin). const MARKER_TEXT = "perk:learn-dream-report"; const PERK_HTML_MARKER_RE = //; const DETAILS_OPEN_RE = /^
[^<]*<\/code><\/summary>$/; const DETAILS_CLOSE = "
"; // Every line boundary Python's `str.splitlines()` recognizes EXCEPT `\n` — the Linear // transcoder splits on all of them and rejoins with `\n`, so any other boundary form would be // normalized in the stored body and defeat the persistence-side byte comparison forever. const NON_CANONICAL_LINE_BOUNDARIES = [ "\r", "\v", "\f", "\u001c", "\u001d", "\u001e", "\u0085", "\u2028", "\u2029", ]; /** * The TS mirror of Python's `validate_report_parts` (contracts §8.64) — run over the freshly * rendered parts at draft-write AND save (both flow through `resolveDreamReportGate`), so an * approved report is always Python-savable: no empty/blank part, no perk HTML-comment marker, * no literal companion marker text, no perk-rendered `
` wrapper line (the shapes the * Linear transcoder rewrites/drops — transcode-invariance keeps the persistence-side * dual-candidate byte comparison exact), and every rendered comment body (marker + blank line + * part) within `COMPANION_COMMENT_MAX_CHARS` code points. Returns named violations (`[]` = * valid). Parity-pinned against the Python twin by the shared fixture set. */ export function reportPartInvarianceViolations(parts: string[], runId: string): string[] { const violations: string[] = []; parts.forEach((part, i) => { const index = i + 1; const where = `part ${index}`; if (part.trim() === "") { violations.push(`${where}: empty part`); return; } if (part.includes(MARKER_TEXT)) { violations.push(`${where}: carries the literal '${MARKER_TEXT}' marker text`); } if (PERK_HTML_MARKER_RE.test(part)) { violations.push( `${where}: carries a perk HTML-comment marker ( is rewritten by the ` + "Linear transcoder)", ); } if ( part.split(/\r\n|\r|\n/).some((line) => DETAILS_OPEN_RE.test(line) || line === DETAILS_CLOSE) ) { violations.push( `${where}: carries a perk-rendered
wrapper line (dropped by the Linear ` + "transcoder)", ); } if (NON_CANONICAL_LINE_BOUNDARIES.some((boundary) => part.includes(boundary))) { violations.push( `${where}: carries a line boundary other than \\n (normalized by the Linear transcoder)`, ); } const bodyLength = codePointLength(`\n\n${part}`); if (bodyLength > COMPANION_COMMENT_MAX_CHARS) { violations.push( `${where}: rendered comment body is ${bodyLength} chars (cap ${COMPANION_COMMENT_MAX_CHARS})`, ); } }); if (parts.length === 0) violations.push("parts: empty list"); return violations; } /** * The `dream_report` block the objective-draft artifact carries (tool-written only — the model * never writes the artifact): the validated model input, the ONE `generated_at` stamp that * keeps re-rendering deterministic across review and save, and the stored CANONICAL parts the * review surface renders and the save byte-compares. */ export interface ObjectiveDreamReportBlock { input: unknown; generated_at: string; parts: string[]; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * The artifact-side shape check `readObjectiveDraft` uses: a plain object carrying a * plain-object `input`, a non-blank string `generated_at`, and a non-empty all-string `parts`. * Deep validation stays with the resolver (the save re-runs the full gate); `null` = malformed. */ export function decodeDreamReportBlock(value: unknown): ObjectiveDreamReportBlock | null { if (!isRecord(value)) return null; if (!isRecord(value.input)) return null; if (typeof value.generated_at !== "string" || !value.generated_at.trim()) return null; if (!Array.isArray(value.parts) || value.parts.length === 0) return null; const parts: string[] = []; for (const part of value.parts) { if (typeof part !== "string") return null; parts.push(part); } return { input: value.input, generated_at: value.generated_at, parts }; } /** * Recover the trusted `DreamReportContext` from the claimed run's scratch state — every arm * fail-closed with a named detail (the caller maps them to `bad_state`): * * 1. the run-scoped manifest: read + parse + `decodeDreamManifest` (the strict §8.60 decoder, * path bound at decode time). No `verifyDocContainment` — this path reads no doc files; * the lexical decode suffices (resolved containment is the wave tool's pre-spawn concern); * 2. the freshness check: the `dream_bundle_digest` marker (read from the caller's ONE * workflow-state snapshot) must be present, non-empty, and equal the digest of the bundle * bytes just read — a bare file is never trusted (missing marker = no finalized wave; * empty = invalidated by a newer attempt, including a cleanup-failure residue; mismatch = * stale/tampered bytes); * 3. the strict finalized decode (`decodeFinalizedDreamBundle` over the digest of the * manifest bytes just read — the marker authenticates the bundle bytes and the bundle's * `manifest_digest` extends that authority to the manifest, so an at-rest manifest edit * refuses; the analyses-only mid-wave shape refuses here too). */ function recoverDreamReportContext( ctx: SessionDataCtx, runId: string, marker: string | undefined, generatedAt: string, ): { ok: true; context: DreamReportContext } | { ok: false; detail: string } { const manifestPath = join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME); let manifestBytes: string; let rawManifest: unknown; try { manifestBytes = readFileSync(manifestPath, "utf8"); rawManifest = JSON.parse(manifestBytes); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { ok: false, detail: `dream manifest unreadable at '${manifestPath}': ${detail}` }; } const manifest = decodeDreamManifest(rawManifest, manifestPath); if (!manifest.ok) { return { ok: false, detail: `dream manifest invalid: ${manifest.detail}` }; } const bundlePath = join(dirname(manifestPath), DREAM_ANALYSES_FILENAME); let bundleBytes: string; try { bundleBytes = readFileSync(bundlePath, "utf8"); } catch { return { ok: false, detail: `no dream bundle at '${bundlePath}' — re-run the dream wave`, }; } if (marker === undefined || marker === "") { return { ok: false, detail: "no finalized dream wave for this session — re-run the dream wave", }; } if (digestSessionData(bundleBytes) !== marker) { return { ok: false, detail: "the dream bundle does not match the session's finalized digest — re-run the dream wave", }; } let rawBundle: unknown; try { rawBundle = JSON.parse(bundleBytes); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { ok: false, detail: `dream bundle is not valid JSON: ${detail}` }; } const decoded = decodeFinalizedDreamBundle( rawBundle, manifest.manifest, digestSessionData(manifestBytes), ); if (!decoded.ok) { return { ok: false, detail: `${decoded.detail} — re-run the dream wave` }; } return { ok: true, context: { manifest: manifest.manifest, analyses: decoded.analyses, reducers: decoded.reducers, run_id: runId, generated_at: generatedAt, }, }; } /** The typed gate outcome both consumers branch on — the whole matrix, one vocabulary. */ export type DreamReportGateOutcome = | { kind: "absent" } | { kind: "block"; block: ObjectiveDreamReportBlock } | { kind: "refuse"; errorType: "invalid_input" | "bad_state"; detail: string }; /** * The ONE gate resolver (contracts §8.63) — identical at draft-write and save. `input` is the * model-supplied `dream_report` value, or `undefined` for "no dream_report" (callers pass the * value only when present; an `{input: undefined}` carrier is never constructed). The matrix: * * | session | `dream_report` | outcome | * | --------- | -------------- | ------- | * | non-dream | absent | `absent` — unchanged, byte-identical behavior | * | non-dream | present | refuse `invalid_input` (never silently dropped) | * | dream | absent | refuse `invalid_input` (one approval bundle) | * | dream | present | recover context → `buildDreamReport` → refuse or `block` | * * An UNREADABLE workflow state (a throwing branch read) refuses `bad_state` BEFORE the matrix — * it is never conflated with a confirmed non-dream session (the `activeSessionRunId` * null-on-throw sentinel would otherwise let a transient read failure surface as `absent`). * * Failure taxonomy: gate violations + `buildDreamReport` refusals → `invalid_input` (the * bounded ≤25 named details newline-joined); an unreadable workflow state and * context-recovery failures → `bad_state`. */ export function resolveDreamReportGate( ctx: SessionDataCtx, input: unknown, generatedAt: string, bracket: ( cwd: string, expectedSha: string, ) => { ok: boolean; detail: string | null } = revalidationBracket, ): DreamReportGateOutcome { // ONE workflow-state snapshot for the whole gate (run identity + the freshness marker), // read with error distinction: unreadable state fails closed, never "non-dream". let state: WorkflowState; try { state = rebuildWorkflowState(branchOf(ctx)); } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { kind: "refuse", errorType: "bad_state", detail: `session workflow state is unreadable — cannot resolve the dream_report gate: ${detail}`, }; } const runId = typeof state.run_id === "string" && state.run_id.length > 0 ? state.run_id : null; const dream = runId !== null && existsSync(join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME)); if (runId === null || !dream) { if (input === undefined) return { kind: "absent" }; return { kind: "refuse", errorType: "invalid_input", detail: "dream_report is only valid inside a perk learn dream session — refusing rather than " + "silently dropping it", }; } if (input === undefined) { return { kind: "refuse", errorType: "invalid_input", detail: "this dream session's objective must carry dream_report — the objective and its " + "report review as one bundle", }; } const recovered = recoverDreamReportContext(ctx, runId, state.dream_bundle_digest, generatedAt); if (!recovered.ok) { return { kind: "refuse", errorType: "bad_state", detail: recovered.detail }; } // The revalidation-bracket re-check (contracts.md §8.65): the manifest — with its stamped // commit_sha — is now decoded and authenticated, so re-prove HEAD-unchanged + tree-clean // against it. Both `writeObjectiveDraft` and `saveObjective` flow through this resolver, so // the bracket re-fires at draft-write AND save; non-dream paths never reach it (the matrix // above returned already). The parameter default is the production bracket — tests inject // stubs. const drift = bracket(ctx.cwd, recovered.context.manifest.commit_sha); if (!drift.ok) { return { kind: "refuse", errorType: "bad_state", detail: `the repository moved since the dream snapshot (${drift.detail}) — the analysis is ` + "stale; re-run perk learn dream", }; } const built = buildDreamReport(input, recovered.context); if (!built.ok) { return { kind: "refuse", errorType: "invalid_input", detail: built.details.join("\n") }; } // The §8.64 invariance mirror, at draft-write AND save (this resolver is both), so an // approved report is always savable by the Python door's identical rule. const violations = reportPartInvarianceViolations(built.parts, runId); if (violations.length > 0) { return { kind: "refuse", errorType: "invalid_input", detail: `dream report parts violate the invariance rule: ${violations.join("; ")}`, }; } return { kind: "block", block: { input, generated_at: generatedAt, parts: built.parts } }; }