// 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 } };
}