/**
 * Synthesize phase (v0.23) — conversation-to-brain pipeline.
 *
 * Reads transcripts from the configured corpus dir, runs a cheap Haiku
 * "is this worth processing?" verdict (cached in `dream_verdicts`), then
 * fans out one Sonnet subagent per worth-processing transcript with the
 * trusted-workspace `allowed_slug_prefixes` list. After children resolve,
 * the orchestrator queries `subagent_tool_executions` for the put_page
 * slugs each child wrote (codex finding #2: NOT a time-windowed pages
 * query — picks up unrelated writes), reverse-renders each new page from
 * DB to disk, and writes a deterministic summary index.
 *
 * Hard guarantees:
 *   - Subagent never gets fs-write access. Orchestrator holds the dual-write.
 *   - Allow-list is sourced from `skills/_brain-filing-rules.json` (single
 *     source of truth) and threaded as handler data; PROTECTED_JOB_NAMES
 *     prevents MCP from submitting `subagent` jobs, so the field is trusted.
 *   - Cooldown via `dream.synthesize.last_completion_ts` config key —
 *     written ONLY on success (codex finding #5 deferral: no auto git commit
 *     in v1).
 *   - Idempotency via `dream:synth:<file_path>:<content_hash>` job key.
 *   - Edited transcripts produce slugs with content-hash suffix → no overwrite.
 *
 * NOT in v1:
 *   - git auto-commit / push (deferred to v1.1, codex finding #5).
 *   - Daily token budget cap (cooldown bounds spend at v1 scale).
 */

import type Anthropic from '@anthropic-ai/sdk';
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { chat as gatewayChat, validateModelId, type ChatResult } from '../ai/gateway.ts';
import { AIConfigError } from '../ai/errors.ts';
import { normalizeModelId } from '../model-id.ts';
import { hasAnthropicKey } from '../ai/anthropic-key.ts';
import { basename, join, dirname, isAbsolute, resolve } from 'node:path';
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult, PhaseError } from '../cycle.ts';
import { MinionQueue } from '../minions/queue.ts';
import { waitForCompletion, TimeoutError } from '../minions/wait-for-completion.ts';
import { makeSubagentHandler } from '../minions/handlers/subagent.ts';
import type { MinionJobInput, MinionJobContext, MinionHandler, SubagentHandlerData } from '../minions/types.ts';
import { discoverTranscripts, type DiscoveredTranscript } from './transcript-discovery.ts';
import { serializeMarkdown, serializePageToMarkdown } from '../markdown.ts';
import type { Page, PageType } from '../types.ts';
import { validateSourceId } from '../utils.ts';
import { safeSplitIndex } from '../text-safe.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';

// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738).
// Used for the orchestrator-written summary index slug. `u` flag required
// by PAGE_SLUG_SEG's \p{...} classes (#3417).
const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'u');

// ── Model context budget (D1, D5, D7, D9) ─────────────────────────────

/**
 * Anthropic model id → input context window (tokens).
 * Unknown id (non-Anthropic alias, custom string) → safe 200K-token fallback
 * via `computeChunkCharBudget`. Codex finding #4: `resolveModel()` does not
 * canonicalize to Anthropic-only; this map keys on the exact strings the
 * resolver returns for known Anthropic aliases.
 */
const MODEL_CONTEXT_TOKENS: Record<string, number> = {
  'claude-opus-4-7': 1_000_000,
  'claude-opus-4-6': 1_000_000,
  'claude-sonnet-4-6': 200_000,
  'claude-sonnet-4-5': 200_000,
  'claude-haiku-4-5-20251001': 200_000,
};

/** Token-to-char ratio. 3.5 matches PR #748; conservative for English text. */
const CHARS_PER_TOKEN = 3.5;
/** Reserve 10% of context window for system prompt + tool defs + output. */
const HEADROOM_RATIO = 0.9;
/** Floor on user-overridable max_prompt_tokens (matches PR #748 minimum). */
const MIN_PROMPT_TOKENS = 100_000;
/** Default chunk-count cap; operator-configurable via dream.synthesize.max_chunks_per_transcript. */
const DEFAULT_MAX_CHUNKS = 24;
/** Conservative default budget when model is unknown (200K × HEADROOM_RATIO). */
const UNKNOWN_MODEL_BUDGET_TOKENS = 180_000;
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;

/**
 * Compute per-chunk character budget for the resolved model + config override.
 *
 * Resolution:
 *   - configMaxPromptTokens (already floored at MIN_PROMPT_TOKENS) wins when set.
 *   - Else the model's MODEL_CONTEXT_TOKENS entry × HEADROOM_RATIO.
 *   - Else (non-Anthropic alias / custom id) UNKNOWN_MODEL_BUDGET_TOKENS, with
 *     a once-per-process stderr warning.
 *
 * D7 scope: this bounds the INITIAL prompt size only. Tool-loop turn-N
 * accumulation is out of scope for v0.30.2 (terminal-error classification
 * catches turn-N blowups; per-turn budget guard is a v0.31+ follow-up).
 */
function computeChunkCharBudget(
  model: string,
  configMaxPromptTokens: number | null,
): number {
  if (configMaxPromptTokens !== null) {
    return Math.floor(configMaxPromptTokens * CHARS_PER_TOKEN);
  }
  const ctx = MODEL_CONTEXT_TOKENS[model];
  if (ctx === undefined) {
    warnUnknownModelOnce(model);
    return Math.floor(UNKNOWN_MODEL_BUDGET_TOKENS * CHARS_PER_TOKEN);
  }
  return Math.floor(ctx * HEADROOM_RATIO * CHARS_PER_TOKEN);
}

const _unknownModelWarned = new Set<string>();
function warnUnknownModelOnce(model: string): void {
  if (_unknownModelWarned.has(model)) return;
  _unknownModelWarned.add(model);
  process.stderr.write(
    `[dream] model "${model}" is not in MODEL_CONTEXT_TOKENS; ` +
    `using ${UNKNOWN_MODEL_BUDGET_TOKENS}-token fallback budget. ` +
    `Set dream.synthesize.max_prompt_tokens to override.\n`,
  );
}

// ── Hash-deterministic transcript chunker (D9) ────────────────────────

/**
 * Split content into chunks at most maxChars long, picking boundaries via a
 * 3-tier ladder lifted from PR #748:
 *   1. `## Topic:` separators (matches the daily-aggregated transcript shape)
 *   2. `---` markdown HR markers
 *   3. nearest `\n` newline
 *
 * D9 stable chunk identity: the back-half-of-budget search window is seeded
 * with a deterministic offset derived from contentHash so the same
 * (content, contentHash, maxChars) triple always produces identical chunks.
 * Closes the partial-progress ambiguity: chunk 2 of a transcript that
 * previously failed terminally produces byte-identical content on retry,
 * so the per-chunk idempotency key is durable across runs.
 *
 * The hash-derived offset jitters the search start within
 * [0.5×budget, 0.6×budget] so the back-half rule still holds.
 *
 * If no boundary fits, hard-split at maxChars (also deterministic in the
 * inputs).
 *
 * Pure function. Tested by `test/cycle/synthesize-chunker.test.ts`.
 */
export function splitTranscriptByBudget(
  content: string,
  contentHash: string,
  maxChars: number,
): string[] {
  if (maxChars <= 0) {
    throw new Error(`splitTranscriptByBudget: maxChars must be > 0, got ${maxChars}`);
  }
  if (content.length <= maxChars) return [content];

  const hashInt = parseHashOffset(contentHash);
  // Jitter window is the next 10% of budget after the 50% midpoint.
  const jitterRange = Math.max(1, Math.floor(maxChars * 0.1));
  const searchStart = Math.floor(maxChars * 0.5) + (hashInt % jitterRange);

  const out: string[] = [];
  let remaining = content;
  while (remaining.length > maxChars) {
    const split = findBoundary(remaining, maxChars, searchStart);
    out.push(remaining.slice(0, split));
    remaining = remaining.slice(split);
  }
  if (remaining.length > 0) out.push(remaining);
  return out;
}

function parseHashOffset(contentHash: string): number {
  // First 8 hex chars = 32 bits; plenty of entropy for the offset jitter.
  const hex = contentHash.slice(0, 8);
  const n = parseInt(hex, 16);
  return Number.isFinite(n) && n >= 0 ? n : 0;
}

function findBoundary(text: string, maxChars: number, searchStart: number): number {
  const window = text.slice(searchStart, maxChars);
  // Tier 1: "\n## Topic:" — last occurrence inside the search window.
  const topicIdx = window.lastIndexOf('\n## Topic:');
  if (topicIdx >= 0) return searchStart + topicIdx;
  // Tier 2: "\n---\n" markdown HR.
  const hrIdx = window.lastIndexOf('\n---\n');
  if (hrIdx >= 0) return searchStart + hrIdx;
  // Tier 3: any newline.
  const nlIdx = window.lastIndexOf('\n');
  if (nlIdx >= 0) return searchStart + nlIdx;
  // No boundary fits; hard-split at maxChars (deterministic).
  // v0.42.0.0: route through safeSplitIndex so a hard-split that lands
  // between a UTF-16 surrogate pair (emoji / non-BMP CJK / mathematical
  // alphanumerics) doesn't orphan the high surrogate — that would change
  // chunk byte-content vs the source and break the D9 stable-chunk-identity
  // invariant on the next retry.
  return safeSplitIndex(text, maxChars);
}

/**
 * D6: orchestrator-side deterministic slug rewrite. Zero Sonnet trust.
 *
 * Expected shape from `buildSynthesisPrompt` for a chunked child is already
 * `<base>-<hash6>-c<idx>`, but if Sonnet drops the chunk suffix this rewrite
 * enforces uniqueness post-hoc. Same hash AND same chunk idx → idempotent.
 *
 * Pure function. Cases:
 *   - already correctly suffixed (`...-<hash6>-c<idx>`) → return unchanged.
 *   - bare hash suffix (`...-<hash6>`) → append `-c<idx>`.
 *   - some other shape → pass through (orchestrator can't safely guess
 *     where to inject the chunk index; e2e test pins this).
 */
export function rewriteChunkedSlug(slug: string, hash6: string, idx: number): string {
  if (!slug) return slug;
  const expected = `${hash6}-c${idx}`;
  // Already correctly chunk-suffixed.
  if (slug === expected) return slug;
  if (slug.endsWith(`-${expected}`) || slug.endsWith(`/${expected}`)) return slug;
  // Bare hash6 at end of last path segment: rewrite.
  // Match either at start-of-slug, after a "/" path separator, or after a "-".
  const re = new RegExp(`(^|[/-])${hash6}$`);
  if (re.test(slug)) return `${slug}-c${idx}`;
  // Unknown shape — pass through; collision risk is now bounded by Sonnet's
  // per-chunk-prompt guidance and the existing slug-prefix allow-list.
  return slug;
}

// ── Public entry ──────────────────────────────────────────────────────

export interface SynthesizePhaseOpts {
  brainDir: string;
  dryRun: boolean;
  /** Generic in-cycle keepalive for cycle-lock TTL renewal during long waits. */
  yieldDuringPhase?: () => Promise<void>;
  /**
   * Override the corpus directory and other tunables. Primarily for the
   * `gbrain dream --input <file>` ad-hoc path; bypasses config reads.
   */
  inputFile?: string;
  date?: string;
  from?: string;
  to?: string;
  /**
   * Disable the self-consumption guard. Wired from the
   * `--unsafe-bypass-dream-guard` CLI flag. NOT auto-applied for `--input`
   * because that would allow any dream-generated page to silently re-enter
   * the synthesize loop. Caller must opt in explicitly.
   */
  bypassDreamGuard?: boolean;
  /**
   * #1586: the cycle's resolved brain source (cycleSourceId from cycle.ts —
   * explicit --source wins, else derived from the checkout dir). Threaded to
   * every subagent child as `source_id` so put_page writes land in this
   * source, and stamped onto collected refs so reverse-writes read the
   * correct (source_id, slug) row. Unset → legacy 'default'.
   */
  sourceId?: string;
  /**
   * issue #2860 — `gbrain dream --phase synthesize --once`. Bypasses the
   * `dream.synthesize.enabled` gate for THIS call only (does NOT bypass
   * the `session_corpus_dir` not-configured check — there's nothing to
   * run without a corpus). Never reads or writes config.
   */
  once?: boolean;
}

const INLINE_PGLITE_LOCK_MS = 30_000;

/**
 * PGLite cannot be served by a separate Minions worker process: the embedded
 * data-dir holds an exclusive file lock, so subagent children enqueued by the
 * synth parent would sit in 'waiting' until waitForCompletion times out.
 * Drive the same claim → run → complete/fail loop a worker would perform,
 * inline, against this phase's private child queue.
 *
 * `yieldDuringPhase` is ticked on a 60s interval while a child runs so the
 * 5-min cycle lock TTL keeps refreshing during long (up to 30-min) children.
 */
export async function runPgliteSubagentsInline(
  engine: BrainEngine,
  queue: MinionQueue,
  queueName: string,
  yieldDuringPhase?: () => Promise<void>,
  handler: MinionHandler = makeSubagentHandler({ engine }),
): Promise<void> {
  if (engine.kind !== 'pglite') return;

  while (true) {
    // Housekeeping a worker would normally perform, so child rows can reach
    // terminal states (delayed retries promoted, timeouts dead-lettered)
    // before the synth parent enters waitForCompletion polling.
    await queue.promoteDelayed();
    await queue.handleStalled();
    await queue.handleTimeouts();
    await queue.handleWallClockTimeouts(INLINE_PGLITE_LOCK_MS);

    const lockToken = randomUUID();
    const job = await queue.claim(lockToken, INLINE_PGLITE_LOCK_MS, queueName, ['subagent']);
    if (!job) return;

    const abort = new AbortController();
    const shutdown = new AbortController();
    const context: MinionJobContext = {
      id: job.id,
      name: job.name,
      data: job.data,
      attempts_made: job.attempts_made,
      signal: abort.signal,
      deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null,
      shutdownSignal: shutdown.signal,
      updateProgress: async (progress: unknown) => {
        await queue.updateProgress(job.id, lockToken, progress);
      },
      updateTokens: async (tokens) => {
        await queue.updateTokens(job.id, lockToken, tokens);
      },
      log: async (message) => {
        const value = typeof message === 'string' ? message : JSON.stringify(message);
        await engine.executeRaw(
          `UPDATE minion_jobs SET stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($1::text),
            updated_at = now()
           WHERE id = $2 AND status = 'active' AND lock_token = $3`,
          [value, job.id, lockToken],
        );
      },
      isActive: async () => {
        const rows = await engine.executeRaw<{ id: number }>(
          `SELECT id FROM minion_jobs WHERE id = $1 AND status = 'active' AND lock_token = $2`,
          [job.id, lockToken],
        );
        return rows.length > 0;
      },
      readInbox: async () => queue.readInbox(job.id, lockToken),
    };

    // Per-job deadline enforcement (worker.ts parity). While the drain loop
    // awaits the handler, the handleTimeouts sweep above can't run, so nothing
    // else can stop a child that blows past timeout_ms — the handler only
    // stops when ctx.signal fires. Derive the delay from the claim-time
    // timeout_at stamp so timer, DB sweeper, and deadlineAtMs agree.
    let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
    if (job.timeout_ms != null) {
      const delayMs = job.timeout_at != null
        ? Math.max(0, job.timeout_at.getTime() - Date.now())
        : job.timeout_ms;
      timeoutTimer = setTimeout(() => {
        if (!abort.signal.aborted) abort.abort(new Error('timeout'));
      }, delayMs);
    }

    // Cycle-lock keepalive while the child runs (best-effort, never throws).
    const keepalive = yieldDuringPhase
      ? setInterval(() => { yieldDuringPhase().catch(() => { /* best-effort */ }); }, 60_000)
      : null;
    try {
      const result = await handler(context);
      await queue.completeJob(
        job.id,
        lockToken,
        result != null ? (typeof result === 'object' ? result as Record<string, unknown> : { value: result }) : undefined,
      );
    } catch (e) {
      // Timeout is terminal (handleTimeouts parity: stall → retry,
      // timeout → dead), never a delayed retry.
      const timedOut = abort.signal.aborted;
      const errorText = timedOut ? 'timeout exceeded' : (e instanceof Error ? e.message : String(e));
      const attemptsExhausted = job.attempts_made + 1 >= job.max_attempts;
      await queue.failJob(
        job.id,
        lockToken,
        errorText,
        timedOut || attemptsExhausted ? 'dead' : 'delayed',
        0,
      );
    } finally {
      if (timeoutTimer) clearTimeout(timeoutTimer);
      if (keepalive) clearInterval(keepalive);
    }
  }
}

export async function runPhaseSynthesize(
  engine: BrainEngine,
  opts: SynthesizePhaseOpts,
): Promise<PhaseResult> {
  const start = Date.now();
  // Normalize brainDir to an absolute path BEFORE any reverse-write. Without
  // this, a relative or empty brainDir flows down to writeReversePages →
  // `join(brainDir, '${slug}.md')` → relative path → resolves against cwd at
  // writeFileSync time, spilling synthesize output into whatever directory
  // the cycle ran from (e.g., `companies/novamind.md` at the repo root).
  // Surfaced by the warm-narwhal wave when E2E test cleanup found orphan
  // synthesize pages at repo root from a `runCycle({brainDir: '.'})` call
  // chain. Throw on empty (silent cwd-resolution is worse than a loud
  // failure); resolve if relative (`.` / `./brain` / `../sibling` all valid
  // inputs but must canonicalize before the write).
  if (!opts.brainDir || opts.brainDir.trim() === '') {
    return failed(makeError('InternalError', 'BRAINDIR_EMPTY',
      'opts.brainDir is empty; refusing to run synthesize. Pass an absolute path.'));
  }
  if (!isAbsolute(opts.brainDir)) {
    opts.brainDir = resolve(opts.brainDir);
  }
  try {
    const config = await loadSynthConfig(engine);

    // Allow ad-hoc --input to run even when config is disabled.
    if (!opts.inputFile && !config.corpusDir) {
      return skipped('not_configured',
        'dream.synthesize.session_corpus_dir is unset');
    }
    if (!opts.inputFile && !config.enabled) {
      if (!opts.once) {
        return skipped('not_configured',
          'dream.synthesize.enabled is explicitly false');
      }
      process.stderr.write(
        '[dream] --once: dream.synthesize.enabled is false but ' +
        '--phase synthesize --once forces this run (config untouched)\n',
      );
    }

    // Cooldown check (skipped for explicit --input / --date / --from / --to runs).
    const explicitTarget = opts.inputFile || opts.date || opts.from || opts.to;
    if (!explicitTarget) {
      const cooldown = await checkCooldown(engine, config.cooldownHours);
      if (cooldown.active) {
        return skipped('cooldown_active',
          `synthesize cooled down until ${cooldown.expires_at} (${config.cooldownHours}h cooldown)`);
      }
    }

    if (opts.bypassDreamGuard) {
      process.stderr.write(
        '[dream] WARNING: --unsafe-bypass-dream-guard set; self-consumption guard disabled. ' +
        'Re-ingestion of dream output will incur Sonnet costs forever.\n',
      );
    }

    // v0.32.6 M2: pre-fetch prior contradictions from the most recent probe
    // run (if any). Surfaced as an informational block to the synthesize
    // subagent so it knows which slugs it should reconcile if it writes to
    // them. Best-effort — a probe that's never run is a normal early state.
    const priorContradictionsBlock = await loadPriorContradictionsBlock(engine);

    // Discover.
    const transcripts = opts.inputFile
      ? loadAdHocTranscript(opts.inputFile, config.minChars, config.excludePatterns, opts.bypassDreamGuard)
      : discoverTranscripts({
          corpusDir: config.corpusDir!,
          meetingTranscriptsDir: config.meetingTranscriptsDir ?? undefined,
          minChars: config.minChars,
          excludePatterns: config.excludePatterns,
          date: opts.date,
          from: opts.from,
          to: opts.to,
          bypassGuard: opts.bypassDreamGuard,
        });

    if (transcripts.length === 0) {
      return ok('no transcripts to process', { transcripts_processed: 0, pages_written: 0 });
    }

    // Significance verdicts (cached in dream_verdicts; Haiku on miss).
    const worthProcessing: DiscoveredTranscript[] = [];
    const verdicts: Array<{ filePath: string; worth: boolean; reasons: string[]; cached: boolean }> = [];
    // Provider-aware judge client routes through gateway.chat, so any
    // configured provider works (Anthropic, DeepSeek, OpenRouter, Voyage,
    // Ollama, llama-server, etc.). Returns null when the resolved verdict
    // model has no reachable provider (legacy "no API key" branch preserved
    // as the cheap pre-flight check).
    const judge = makeJudgeClient(config.verdictModel);
    for (const t of transcripts) {
      const cached = await engine.getDreamVerdict(t.filePath, t.contentHash);
      if (cached) {
        verdicts.push({ filePath: t.filePath, worth: cached.worth_processing, reasons: cached.reasons, cached: true });
        if (cached.worth_processing) worthProcessing.push(t);
        continue;
      }
      if (!judge) {
        // No configured provider for the verdict model — can't judge.
        // Skip with explicit reason; don't crash phase.
        verdicts.push({
          filePath: t.filePath,
          worth: false,
          reasons: [`no configured provider for verdict model: ${config.verdictModel}`],
          cached: false,
        });
        continue;
      }
      try {
        const verdict = await judgeSignificance(judge, t, config.verdictModel);
        await engine.putDreamVerdict(t.filePath, t.contentHash, verdict);
        verdicts.push({ filePath: t.filePath, worth: verdict.worth_processing, reasons: verdict.reasons, cached: false });
        if (verdict.worth_processing) worthProcessing.push(t);
      } catch (e) {
        // AIConfigError at chat time = provider auth/config went bad mid-run
        // (revoked key, recipe misconfig surfacing at first real call). Skip
        // this transcript with the gateway error message so the user sees the
        // shape of the problem in `gbrain dream --phase synthesize --dry-run`.
        if (e instanceof AIConfigError) {
          verdicts.push({
            filePath: t.filePath,
            worth: false,
            reasons: [`gateway error: ${e.message}`],
            cached: false,
          });
          continue;
        }
        throw e;
      }
    }

    // Dry-run stops here: significance filter ran (Haiku verdicts cached),
    // but no Sonnet synthesis. Codex finding #8: --dry-run does NOT mean
    // "zero LLM calls"; it means "skip Sonnet."
    if (opts.dryRun) {
      return ok(`dry-run: ${worthProcessing.length} of ${transcripts.length} transcripts would synthesize`, {
        transcripts_discovered: transcripts.length,
        transcripts_processed: 0,
        pages_written: 0,
        verdicts,
        dryRun: true,
      });
    }

    if (worthProcessing.length === 0) {
      // Even with verdicts, the cooldown timestamp is updated only on a
      // real successful run — not on "nothing worth processing." Lets a
      // re-run pick up if a new transcript lands later.
      return ok('all transcripts skipped by significance filter', {
        transcripts_discovered: transcripts.length,
        transcripts_processed: 0,
        pages_written: 0,
        verdicts,
      });
    }

    // Fan-out: submit one subagent per worth-processing transcript (or one
    // per chunk for transcripts that exceed the model's per-prompt budget).
    const allowedSlugPrefixes = await loadAllowedSlugPrefixes(config.outputRoot);
    if (allowedSlugPrefixes.length === 0) {
      return failed(makeError('InternalError', 'NO_ALLOWLIST',
        'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs'));
    }

    const queue = new MinionQueue(engine);
    // PGLite children drain inline (no separate worker can open the embedded
    // data-dir), so give them a private per-run queue: the inline drain must
    // never claim unrelated 'default'-queue jobs a Postgres worker owns.
    const childQueueName = engine.kind === 'pglite'
      ? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
      : 'default';
    const childIds: number[] = [];
    /** Map child job_id → chunk metadata for D6 orchestrator-side slug rewrite. */
    const chunkInfo = new Map<number, { idx: number; hash6: string }>();
    /** #1978: map child job_id → source transcript path so written pages get a raw_source stamp. */
    const jobRawSource = new Map<number, string>();
    /** Skip reasons for the cycle report (D5 cap hits, D8 legacy-key skips). */
    const skipReports: Array<{ filePath: string; reason: string }> = [];

    const maxCharsPerChunk = computeChunkCharBudget(config.model, config.maxPromptTokens);
    const successfulLegacyKeys = await loadSuccessfulLegacySynthesisKeys(
      engine,
      opts.sourceId ?? 'default',
    );

    for (const t of worthProcessing) {
      const hash16 = t.contentHash.slice(0, 16);
      const hash6 = t.contentHash.slice(0, 6);

      // D8: legacy-key migration safety. If this content hash already
      // completed under the pre-v2 path-based key family — single-chunk OR
      // a full chunked set — treat as already-synthesized and skip.
      // Prevents a full paid re-synthesis when the corpus root moves or
      // the chunking outcome changes across versions.
      const legacyCompletion = findLegacyCompletion(
        successfulLegacyKeys,
        t.filePath,
        hash16,
      );
      if (legacyCompletion) {
        skipReports.push({
          filePath: t.filePath,
          reason: legacyCompletion === 'chunked'
            ? 'already_synthesized_legacy_chunked'
            : 'already_synthesized_legacy_single_chunk',
        });
        continue;
      }

      const chunks = splitTranscriptByBudget(t.content, t.contentHash, maxCharsPerChunk);

      // D5 cap hit: log + skip; do NOT write to dream_verdicts. Closes the
      // poison-pill class — next cycle re-attempts under whatever budget
      // is then current.
      if (chunks.length > config.maxChunksPerTranscript) {
        process.stderr.write(
          `[dream] transcript ${t.basename} produced ${chunks.length} chunks at ` +
          `${maxCharsPerChunk}-char budget (cap=${config.maxChunksPerTranscript}); skipping. ` +
          `Increase dream.synthesize.max_chunks_per_transcript or use a larger-context model.\n`,
        );
        skipReports.push({
          filePath: t.filePath,
          reason: `oversize_after_split: ${chunks.length}/${config.maxChunksPerTranscript}`,
        });
        continue;
      }

      const isChunked = chunks.length > 1;
      // queue.add subagent validator (classifyCapabilities → resolveRecipe)
      // requires `provider:model`. resolveModel can return a bare id when
      // TIER_DEFAULTS / DEFAULT_ALIASES carry a bare value; ensure the
      // anthropic: prefix is present for known claude-* ids before passing
      // to the queue. Non-anthropic providers must already declare a colon.
      const subagentModel = config.model.includes(':')
        ? config.model
        : config.model.toLowerCase().startsWith('claude-')
          ? `anthropic:${config.model}`
          : config.model;
      for (let i = 0; i < chunks.length; i++) {
        const childData: SubagentHandlerData = {
          prompt: buildSynthesisPrompt(t, chunks[i], i, chunks.length, priorContradictionsBlock, config.outputRoot),
          model: subagentModel,
          max_turns: 30,
          allowed_slug_prefixes: allowedSlugPrefixes,
          // #1586: scope every child tool call to the cycle's resolved source
          // so put_page writes land there instead of the hardcoded 'default'.
          ...(opts.sourceId ? { source_id: opts.sourceId } : {}),
        };
        // Keep producer identity stable when the corpus root moves. Source and
        // complete filename remain explicit so equal bytes in different source
        // or filename namespaces do not collide.
        const synthesisKey =
          `dream:synth-v2:${encodeURIComponent(opts.sourceId ?? 'default')}` +
          `:filename:${encodeURIComponent(basename(t.filePath))}:${hash16}`;
        const idempotency_key = isChunked
          ? `${synthesisKey}:c${i}of${chunks.length}`
          : synthesisKey;
        const submitOpts: Partial<MinionJobInput> = {
          max_stalled: 3,
          on_child_fail: 'continue',
          idempotency_key,
          timeout_ms: config.subagentTimeoutMs,
          queue: childQueueName,
        };
        const child = await queue.add(
          'subagent',
          childData as unknown as Record<string, unknown>,
          submitOpts,
          { allowProtectedSubmit: true },
        );
        childIds.push(child.id);
        jobRawSource.set(child.id, t.filePath);
        if (isChunked) {
          chunkInfo.set(child.id, { idx: i, hash6 });
        }
      }
    }

    // PGLite cannot run a separate Minions worker because the embedded DB
    // holds an exclusive file lock. Drain this phase's private child queue
    // inline so the parent observes terminal child states instead of polling
    // waiters until subagentWaitTimeoutMs expires. No-op on Postgres.
    await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);

    // Wait for every child to reach a terminal state. Tick yieldDuringPhase
    // every 5 min so the cycle lock TTL refreshes.
    const childOutcomes: Array<{ jobId: number; status: string }> = [];
    for (const jobId of childIds) {
      try {
        const job = await waitForCompletion(queue, jobId, {
          timeoutMs: config.subagentWaitTimeoutMs,
          pollMs: 5 * 1000,
        });
        childOutcomes.push({ jobId, status: job.status });
      } catch (e) {
        if (e instanceof TimeoutError) {
          childOutcomes.push({ jobId, status: 'timeout' });
        } else {
          throw e;
        }
      }
      // After each child terminal, give the cycle lock + worker job lock a chance.
      if (opts.yieldDuringPhase) {
        try { await opts.yieldDuringPhase(); } catch { /* best-effort */ }
      }
    }

    // Collect slugs from put_page tool executions across the children
    // (codex finding #2: deterministic provenance, NOT pages.updated_at).
    // D6 orchestrator slug rewrite: chunkInfo drives post-hoc rewrite of
    // bare-hash slugs to `<hash6>-c<idx>` so chunked siblings can't collide
    // even if Sonnet drops the chunk suffix.
    // v0.32.8: refs carry source_id so reverseWriteRefs picks the correct
    // (source, slug) row. #1586: refs are stamped with the cycle's resolved
    // source (children write there via SubagentHandlerData.source_id).
    const cycleSourceId = opts.sourceId ?? 'default';
    const writtenRefs = await collectChildPutPageSlugs(engine, childIds, chunkInfo, cycleSourceId, jobRawSource);

    const summaryDate = opts.date ?? today();

    // #2569: persist the dream-output identity marker into the DB frontmatter
    // of every child-written page BEFORE reverse-rendering, so generated pages
    // are queryable (`frontmatter->>'dream_generated'`) and a later put_page
    // write-through (which re-renders from the DB row) can't erase the stamp.
    await stampDreamProvenance(engine, writtenRefs, summaryDate);

    // Dual-write: reverse-render each DB row → markdown file.
    const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs, cycleSourceId);

    // Summary index page (deterministic; orchestrator-written via direct
    // engine.putPage so no allow-list path needed).
    const summarySlug = `dream-cycle-summaries/${summaryDate}`;
    // Back-compat: writeSummaryPage takes string[] for display; map refs back to slugs.
    const writtenSlugs = writtenRefs.map(r => r.slug);
    if (SUMMARY_SLUG_RE.test(summarySlug)) {
      await writeSummaryPage(engine, opts.brainDir, summarySlug, summaryDate, writtenSlugs, childOutcomes, cycleSourceId);
    }

    // Write completion timestamp ON SUCCESS only.
    await engine.setConfig('dream.synthesize.last_completion_ts', new Date().toISOString());

    const ms = Date.now() - start;
    const submittedTranscripts = worthProcessing.length - skipReports.length;
    return ok(`${submittedTranscripts} transcript(s) synthesized in ${(ms / 1000).toFixed(1)}s`, {
      transcripts_discovered: transcripts.length,
      transcripts_processed: submittedTranscripts,
      pages_written: writtenSlugs.length,
      // v0.29: emit the slug list so the recompute_emotional_weight phase can
      // union with sync's pagesAffected and recompute weights for every page
      // synthesize wrote in this cycle.
      written_slugs: writtenSlugs,
      reverse_write_count: reverseWriteCount,
      child_outcomes: childOutcomes,
      // Children submitted (one per chunk for chunked transcripts; one per
      // transcript for single-chunk). Differs from transcripts_processed
      // when chunking is in play.
      children_submitted: childIds.length,
      // D5 cap hits + D8 legacy-key skips. Empty when nothing skipped.
      skips: skipReports,
      summary_slug: summarySlug,
      verdicts,
    });
  } catch (e) {
    return failed(makeError('InternalError', 'SYNTH_PHASE_FAIL',
      e instanceof Error ? (e.message || 'synthesize phase threw') : String(e)));
  }
}

// ── Config ────────────────────────────────────────────────────────────

interface SynthConfig {
  enabled: boolean;
  corpusDir: string | null;
  meetingTranscriptsDir: string | null;
  minChars: number;
  excludePatterns: string[];
  model: string;
  verdictModel: string;
  cooldownHours: number;
  /**
   * D1: Override the per-chunk token budget (model_context × HEADROOM_RATIO
   * by default). Floor MIN_PROMPT_TOKENS, no upper cap (model context wins).
   * Surface name follows PR #748: `dream.synthesize.max_prompt_tokens`.
   * `null` means use the model-context lookup.
   */
  maxPromptTokens: number | null;
  /**
   * D5/D10: Cap on chunks produced from a single transcript. On cap hit, the
   * transcript is logged + skipped (NOT cached in dream_verdicts — closes the
   * cache-poisoning class). Operator override:
   * `dream.synthesize.max_chunks_per_transcript`.
   */
  maxChunksPerTranscript: number;
  /**
   * #2415: top-level namespace for synthesized output (reflections, originals,
   * patterns). Config key `dream.synthesize.output_root`; default 'wiki' —
   * zero behavior change unless set. No trailing slash. Must satisfy the slug
   * grammar; invalid values fall back to 'wiki' with a stderr warning.
   */
  outputRoot: string;
  subagentTimeoutMs: number;
  subagentWaitTimeoutMs: number;
}

/** #2415: shared output-root resolution (synthesize + patterns phases). */
export async function loadOutputRoot(engine: BrainEngine): Promise<string> {
  const raw = await engine.getConfig('dream.synthesize.output_root');
  if (!raw) return 'wiki';
  const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
  if (SUMMARY_SLUG_RE.test(trimmed)) return trimmed;
  process.stderr.write(
    `[dream] dream.synthesize.output_root "${raw}" is not a valid slug prefix; falling back to "wiki".\n`,
  );
  return 'wiki';
}

async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
  const enabledRaw = await engine.getConfig('dream.synthesize.enabled');
  const corpusDir = await engine.getConfig('dream.synthesize.session_corpus_dir');
  // v2: enabled defaults to true when corpus dir is configured, false otherwise.
  // Explicit enabled=false still wins for pausing synthesis without removing corpus config.
  const enabled = enabledRaw === 'false' ? false : (enabledRaw === 'true' || !!corpusDir);
  const meetingTranscriptsDir = await engine.getConfig('dream.synthesize.meeting_transcripts_dir');
  const excludeStr = await engine.getConfig('dream.synthesize.exclude_patterns');
  // v0.28: resolveModel() unifies CLI flag > new key > deprecated key > models.default > env > fallback
  const { resolveModel } = await import('../model-config.ts');
  const model = await resolveModel(engine, {
    configKey: 'models.dream.synthesize',
    deprecatedConfigKey: 'dream.synthesize.model',
    tier: 'reasoning',
    fallback: 'sonnet',
  });
  const verdictModel = await resolveModel(engine, {
    configKey: 'models.dream.synthesize_verdict',
    deprecatedConfigKey: 'dream.synthesize.verdict_model',
    tier: 'utility',
    fallback: 'haiku',
  });
  // getNumberConfig (not `parseInt(str, 10) || N`) so a configured 0 is honored — a bare
  // `|| N` coerces an explicit 0 back to the default (cooldown 0 = "no cooldown").
  const cooldownHours = Math.max(0, await getNumberConfig(engine, 'dream.synthesize.cooldown_hours', 12));
  const minChars = Math.max(0, await getNumberConfig(engine, 'dream.synthesize.min_chars', 2000));
  const maxPromptTokensStr = await engine.getConfig('dream.synthesize.max_prompt_tokens');
  const maxChunksStr = await engine.getConfig('dream.synthesize.max_chunks_per_transcript');
  const subagentTimeoutMs = await getNumberConfig(
    engine,
    'dream.synthesize.subagent_timeout_ms',
    DEFAULT_SUBAGENT_TIMEOUT_MS,
  );
  const subagentWaitTimeoutMs = await getNumberConfig(
    engine,
    'dream.synthesize.subagent_wait_timeout_ms',
    DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS,
  );

  let excludePatterns: string[] = ['medical', 'therapy'];
  if (excludeStr) {
    try {
      const parsed = JSON.parse(excludeStr);
      if (Array.isArray(parsed)) excludePatterns = parsed.filter(p => typeof p === 'string');
    } catch { /* keep default */ }
  }

  // D1: max_prompt_tokens floored at MIN_PROMPT_TOKENS; null → use model lookup.
  let maxPromptTokens: number | null = null;
  if (maxPromptTokensStr) {
    const parsed = parseInt(maxPromptTokensStr, 10);
    if (Number.isFinite(parsed) && parsed > 0) {
      maxPromptTokens = Math.max(MIN_PROMPT_TOKENS, parsed);
    }
  }
  // D10: max_chunks default 24, floor 1.
  let maxChunksPerTranscript = DEFAULT_MAX_CHUNKS;
  if (maxChunksStr) {
    const parsed = parseInt(maxChunksStr, 10);
    if (Number.isFinite(parsed) && parsed >= 1) {
      maxChunksPerTranscript = parsed;
    }
  }

  return {
    enabled,
    corpusDir: corpusDir ?? null,
    meetingTranscriptsDir: meetingTranscriptsDir ?? null,
    minChars,
    excludePatterns,
    model,
    verdictModel,
    cooldownHours,
    maxPromptTokens,
    maxChunksPerTranscript,
    outputRoot: await loadOutputRoot(engine),
    subagentTimeoutMs,
    subagentWaitTimeoutMs,
  };
}

async function getNumberConfig(
  engine: BrainEngine,
  key: string,
  fallback: number,
): Promise<number> {
  const raw = await engine.getConfig(key);
  if (raw === undefined || raw === null) return fallback;
  const value = Number(raw);
  return Number.isNaN(value) ? fallback : value;
}

async function checkCooldown(
  engine: BrainEngine,
  hours: number,
): Promise<{ active: boolean; expires_at?: string }> {
  if (hours <= 0) return { active: false };
  const last = await engine.getConfig('dream.synthesize.last_completion_ts');
  if (!last) return { active: false };
  const lastMs = Date.parse(last);
  if (Number.isNaN(lastMs)) return { active: false };
  const expiresMs = lastMs + hours * 60 * 60 * 1000;
  if (Date.now() >= expiresMs) return { active: false };
  return { active: true, expires_at: new Date(expiresMs).toISOString() };
}

// ── Allow-list source of truth ───────────────────────────────────────

/**
 * #2415: `outputRoot` remaps the canonical `wiki/`-rooted globs to the
 * configured namespace (e.g. `notes/personal/reflections/*`). Default 'wiki'
 * returns the globs verbatim. Shared by the patterns phase (imported there —
 * the two phases must enforce the same allow-list).
 */
export async function loadAllowedSlugPrefixes(outputRoot = 'wiki'): Promise<string[]> {
  // Search a few known locations relative to the binary / repo. The first
  // hit wins; if none found, return [].
  const candidates = [
    join(process.cwd(), 'skills', '_brain-filing-rules.json'),
    join(__dirname, '..', '..', '..', 'skills', '_brain-filing-rules.json'),
  ];
  for (const path of candidates) {
    if (!existsSync(path)) continue;
    try {
      const raw = readFileSync(path, 'utf8');
      const parsed = JSON.parse(raw) as { dream_synthesize_paths?: { globs?: unknown } };
      const globs = parsed?.dream_synthesize_paths?.globs;
      if (Array.isArray(globs) && globs.every(g => typeof g === 'string')) {
        if (outputRoot === 'wiki') return globs as string[];
        return (globs as string[]).map(g =>
          g.startsWith('wiki/') ? `${outputRoot}/${g.slice('wiki/'.length)}` : g,
        );
      }
    } catch { /* try next */ }
  }
  return [];
}

// ── Significance judge (gateway-routed; provider-agnostic) ──────────────
//
// The JudgeClient interface is unchanged for test-seam stability — existing
// tests that pass a mock client to judgeSignificance keep working byte-
// identically. Only the construction path moved from `new Anthropic()` to
// `gateway.chat()` so any provider with a registered recipe (Anthropic,
// DeepSeek, OpenRouter, Voyage, Ollama, llama-server, etc.) is reachable
// via `gbrain config set models.dream.synthesize_verdict <provider>:<model>`.
//
// This mirrors v0.35.5.0's `tryBuildGatewayClient` in src/core/think/index.ts
// (which closed #952 for runThink). Same pattern, same trade-offs:
// construction-time provider/key probe returns null on a clear miss (cheap
// pre-flight), and the verdict loop wraps the actual chat call in try/catch
// for AIConfigError surfacing mid-run.

export interface JudgeClient {
  create: (params: Anthropic.MessageCreateParamsNonStreaming) => Promise<Anthropic.Message>;
}

/**
 * Build a gateway-routed JudgeClient for the resolved verdict model.
 * Returns null when no chat provider is reachable for `verdictModel`:
 *   - Unknown provider id (resolveRecipe throws AIConfigError).
 *   - Anthropic provider with no key (env or config) — preserves the legacy
 *     "no ANTHROPIC_API_KEY" cheap-skip semantics.
 * On null, the verdict loop short-circuits each transcript with an explicit
 * "no configured provider" reason and continues the phase.
 *
 * For non-Anthropic providers (deepseek, openrouter, voyage, ollama,
 * llama-server, ...), we delegate auth probing to the gateway's own
 * recipe `auth_env.required` machinery — AIConfigError at gateway.chat()
 * time is caught by the verdict loop and surfaced per-transcript.
 */
export function makeJudgeClient(verdictModel: string): JudgeClient | null {
  // Normalize: ensure provider:model shape (and slash→colon — #1698). resolveModel
  // returns bare anthropic ids (e.g. `claude-haiku-4-5`); gateway.chat needs `anthropic:...`.
  const modelStr = normalizeModelId(verdictModel);

  // #1698 (C1): id-validity via the shared `validateModelId` core (resolveRecipe +
  // assertTouchpoint) — catches unknown provider AND typo'd native model. We do NOT
  // use the full `probeChatModel` here: its `isAvailable` layer would reject
  // non-Anthropic-no-key providers and an unconfigured gateway, breaking the
  // deliberate per-transcript-degrade contract (and test A9). validateModelId reads
  // the recipe registry, not gateway _config, so it works pre-configureGateway().
  const v = validateModelId(modelStr);
  if (!v.ok) return null;

  // Anthropic key probe (legacy behavior preserved verbatim). Other providers' key
  // checks happen lazily at chat call time and surface as AIConfigError, which the
  // verdict loop catches per-transcript.
  if (v.parsed.providerId === 'anthropic' && !hasAnthropicKey()) return null;

  return {
    create: async (params): Promise<Anthropic.Message> => {
      // Map Anthropic.MessageCreateParamsNonStreaming → gateway.ChatOpts.
      // `judgeSignificance` always sends string content + string system,
      // and the adapter only TEXT-flattens the array-of-blocks shape —
      // `tool_use`, `tool_result`, image, and other non-text blocks become
      // empty strings. If a future caller wires tool-use or image content
      // through this client, extend the mapping instead of relying on the
      // current silent drop. Same pattern as think/index.ts:607-615.
      const messages = params.messages.map(m => ({
        role: m.role,
        content: typeof m.content === 'string'
          ? m.content
          : (Array.isArray(m.content)
              ? m.content.map(b => ('text' in b ? b.text : '')).join('')
              : ''),
      }));
      const system = typeof params.system === 'string'
        ? params.system
        : (Array.isArray(params.system)
            ? params.system.map(b => ('text' in b ? b.text : '')).join('')
            : undefined);

      const result: ChatResult = await gatewayChat({
        model: modelStr,
        system,
        messages,
        maxTokens: params.max_tokens,
      });

      // Map gateway.ChatResult → Anthropic.Message shape. judgeSignificance
      // reads `.content[0].type === 'text'` and `.content[0].text`; other
      // fields are best-effort for downstream telemetry parity.
      return {
        id: '',
        type: 'message',
        role: 'assistant',
        model: modelStr,
        content: [{ type: 'text', text: result.text }],
        stop_reason: 'end_turn',
        stop_sequence: null,
        usage: {
          input_tokens: result.usage.input_tokens,
          output_tokens: result.usage.output_tokens,
        },
      } as unknown as Anthropic.Message;
    },
  };
}

interface VerdictResult {
  worth_processing: boolean;
  reasons: string[];
}

export async function judgeSignificance(
  client: JudgeClient,
  t: DiscoveredTranscript,
  verdictModel = 'claude-haiku-4-5-20251001',
): Promise<VerdictResult> {
  // Truncate the transcript at 8K chars for cost control. Haiku's verdict
  // doesn't need the full body; the opening + closing sections are usually
  // representative of significance.
  //
  // v0.41.13 surrogate-safety (supersedes PRs #1559+#1561's safeSliceEnd
  // helper; see text-safe.ts:18-21 module docstring for why that helper
  // re-introduces the case-3 bug the canonical safeSplitIndex was written
  // to fix). Routes head + tail slicing through safeSplitIndex so an emoji
  // at offset 4000 (or length-4000) never produces a lone surrogate that
  // Anthropic's JSON parser rejects ("no low surrogate in string", caught
  // 2026-05-24 on telegram).
  //
  // Contract: this branch only runs when content.length > 8000, so
  // length - 4000 > 4000 > 0 — safeSplitIndex never sees an out-of-range
  // maxChars here. (Codex C-10 documented contract.)
  let trimmed: string;
  if (t.content.length > 8000) {
    const headEnd = safeSplitIndex(t.content, 4000);
    const tailStart = safeSplitIndex(t.content, t.content.length - 4000);
    trimmed = t.content.slice(0, headEnd) + '\n[...truncated...]\n' + t.content.slice(tailStart);
  } else {
    trimmed = t.content;
  }

  const sys = `You judge whether a conversation transcript is worth synthesizing into a personal knowledge brain.

WORTH PROCESSING (return worth_processing=true):
- The user articulates a new idea, frame, mental model, or thesis
- The user reflects on themselves, names patterns, processes emotion
- The user discusses specific people, companies, or decisions in depth
- The user makes a strategic call worth remembering

NOT WORTH PROCESSING (return worth_processing=false):
- Routine ops ("check my email", "schedule X")
- Pure code debugging without user reflection
- Short message exchanges with no original thought
- Repetitive content the brain already has

Respond as JSON: {"worth_processing": <bool>, "reasons": ["<short>", "<short>"]}.
Two reasons max, one phrase each.`;

  const msg = await client.create({
    model: verdictModel,
    max_tokens: 200,
    system: sys,
    messages: [{ role: 'user', content: `Transcript ${t.basename}:\n\n${trimmed}` }],
  });

  for (const block of msg.content) {
    if (block.type === 'text') {
      const text = block.text.trim();
      const m = /\{[\s\S]*\}/.exec(text);
      if (!m) continue;
      try {
        const parsed = JSON.parse(m[0]) as { worth_processing?: unknown; reasons?: unknown };
        const worth = parsed.worth_processing === true;
        const reasons = Array.isArray(parsed.reasons)
          ? parsed.reasons.filter((r): r is string => typeof r === 'string').slice(0, 4)
          : [];
        return { worth_processing: worth, reasons };
      } catch { /* fall through */ }
    }
  }
  // Couldn't parse — default to NOT processing (cheap fallback).
  return { worth_processing: false, reasons: ['judge response unparseable'] };
}

// ── Subagent prompt ──────────────────────────────────────────────────

/**
 * Build the prompt for one subagent. When `chunkTotal > 1`, the slug seed
 * gains a `-c<idx>` suffix and the prompt names which chunk this is.
 *
 * D6 enforcement is orchestrator-side (rewriteChunkedSlug runs at slug-
 * collection time). Sonnet still gets the chunked seed via the prompt's
 * `USE THIS in slugs` rule for the happy path.
 */
/**
 * v0.32.6 M2 — Load prior probe findings into an informational block.
 * Returns '' if no probe runs exist or the engine doesn't know how (pre-v33
 * brain that hasn't applied migrations). Best-effort and silent on failure.
 */
async function loadPriorContradictionsBlock(engine: BrainEngine): Promise<string> {
  try {
    const rows = await engine.loadContradictionsTrend(30);
    if (!rows || rows.length === 0) return '';
    const latest = rows[0];
    const report = latest.report_json as Record<string, unknown> | null;
    const perQuery = (report?.per_query as Array<{
      contradictions: Array<{
        severity: 'low' | 'medium' | 'high';
        axis: string;
        a: { slug: string };
        b: { slug: string };
      }>;
    }> | undefined) ?? [];
    const findings: Array<{ severity: string; axis: string; a: string; b: string }> = [];
    for (const q of perQuery) {
      for (const c of q.contradictions) {
        findings.push({ severity: c.severity, axis: c.axis, a: c.a.slug, b: c.b.slug });
      }
    }
    if (findings.length === 0) return '';
    // Sort by severity DESC (high first); take top 5 to keep prompt bounded.
    const rank: Record<string, number> = { high: 3, medium: 2, low: 1 };
    findings.sort((x, y) => (rank[y.severity] ?? 0) - (rank[x.severity] ?? 0));
    const top = findings.slice(0, 5);
    const lines = top.map((f) => `  - [${f.severity}] ${f.a} vs ${f.b}${f.axis ? ' — ' + f.axis : ''}`);
    return [
      '',
      'PRIOR DETECTED CONTRADICTIONS (latest probe run, severity DESC, top 5):',
      ...lines,
      '',
      'If your synthesis writes to any of these slugs, reconcile the contradiction',
      'in the compiled_truth instead of recreating it. Either update to the newer/',
      'correct value, mark the older claim as historical, or note the conflict',
      'explicitly. Ignore findings irrelevant to what this transcript covers.',
    ].join('\n');
  } catch {
    return '';
  }
}

function buildSynthesisPrompt(
  t: DiscoveredTranscript,
  chunkText: string,
  chunkIdx: number,
  chunkTotal: number,
  priorContradictionsBlock = '',
  outputRoot = 'wiki',
): string {
  const dateHint = t.inferredDate ?? today();
  const baseSlugSegment = sanitizeForSlug(t.basename) || `session-${dateHint}`;
  const isChunked = chunkTotal > 1;
  const hashSuffix = isChunked
    ? `${t.contentHash.slice(0, 6)}-c${chunkIdx}`
    : t.contentHash.slice(0, 6);
  const chunkBanner = isChunked
    ? `\n- This is CHUNK ${chunkIdx + 1} of ${chunkTotal} from the same transcript. Different chunks process different sections; do not assume continuity with other chunks.`
    : '';
  const transcriptHeader = isChunked
    ? `${t.filePath} (chunk ${chunkIdx + 1}/${chunkTotal})`
    : t.filePath;
  return `You are synthesizing a conversation transcript into the user's personal knowledge brain.

CONTEXT
- Today's date: ${dateHint}
- Transcript hash suffix (USE THIS in slugs): ${hashSuffix}
- Source file basename: ${baseSlugSegment}${chunkBanner}${priorContradictionsBlock}

OUTPUT POLICY (ALL of these are required)
1. Quote the user verbatim. Do not paraphrase memorable phrasings.
2. Cross-reference compulsively: every new page MUST contain at least one wikilink (e.g., \`[ref](people/jane-doe)\` or \`[[people/jane-doe]]\`) to existing brain content. Use the search tool to find existing pages first.
3. Do NOT write to any path outside the allow-list shown in the put_page schema.
4. Slug discipline: lowercase alphanumeric and hyphens only, slash-separated segments. NO underscores, NO file extensions.
5. Self-contained opening: begin every new page's body with a 2-3 sentence summary that a reader unfamiliar with this transcript could understand on its own, before any quotes or detail. Do not assume the reader has the source conversation for context.

TASKS
A. Reflections (self-knowledge, pattern recognition, emotional processing):
   slug: \`${outputRoot}/personal/reflections/${dateHint}-<topic-slug>-${hashSuffix}\`

B. Originals (new ideas, frames, theses, mental models):
   slug: \`${outputRoot}/originals/ideas/${dateHint}-<idea-slug>-${hashSuffix}\`

C. People mentions: search first; if a page exists, do not put_page over it (the orchestrator handles people enrichment via timeline entries — your job is the reflection/original synthesis, NOT modifying existing person pages).

D. If nothing in this transcript meets the bar (significance filter already passed but the content is still routine), return without writing anything.

TRANSCRIPT (${transcriptHeader})
---
${chunkText}
---

When done, briefly list the slugs you wrote in your final message so the orchestrator can audit.`;
}

function sanitizeForSlug(s: string): string {
  return s
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 60);
}

// ── Slug collection from child put_page calls (codex #2 + D6) ────────

/**
 * D6 (orchestrator-side deterministic slug rewrite, zero Sonnet trust):
 * two-stage path — raw fetch (no DISTINCT, preserves duplicate evidence) →
 * in-memory chunk-suffix rewrite via `rewriteChunkedSlug` for chunked
 * children → return distinct rewritten set.
 *
 * Closes Codex finding #2 ("collision detection via SELECT DISTINCT was
 * fake"): we no longer need detection because the rewrite enforces
 * uniqueness at slug-write time.
 *
 * `chunkInfo` maps child job_id → { chunk_index, hash6 }. Single-chunk
 * children are absent from the map and pass through unchanged.
 */
async function collectChildPutPageSlugs(
  engine: BrainEngine,
  childIds: number[],
  chunkInfo: Map<number, { idx: number; hash6: string }>,
  sourceId = 'default',
  jobRawSource?: Map<number, string>,
): Promise<Array<{ slug: string; source_id: string; raw_source?: string }>> {
  if (childIds.length === 0) return [];
  // Raw fetch — NO SELECT DISTINCT. Preserves per-child slug duplicates so
  // the orchestrator sees what each child wrote. COALESCE handles both
  // properly-stored jsonb objects (input->>'slug') and double-encoded jsonb
  // strings from pre-fix data ((input #>> '{}')::jsonb->>'slug').
  //
  // v0.32.8: returns Array<{slug, source_id}> instead of string[]. Subagent
  // put_page tool schema doesn't expose source_id (subagents are scoped to
  // a single source). #1586: the orchestrator scopes each child to the
  // cycle's resolved source via SubagentHandlerData.source_id, and stamps
  // the SAME source here so reverseWriteRefs / provenance reads target the
  // correct (source_id, slug) row. Unset → legacy 'default'.
  const rows = await engine.executeRaw<{ job_id: number | bigint; slug: string }>(
    `SELECT job_id,
            COALESCE(input->>'slug', (input #>> '{}')::jsonb->>'slug') AS slug
       FROM subagent_tool_executions
      WHERE job_id = ANY($1::int[])
        AND tool_name = 'brain_put_page'
        AND status = 'complete'`,
    [childIds],
  );
  // #1978: slug → source transcript path (first writer wins) so the
  // provenance stamp can record WHERE the synthesized content came from.
  const rewritten = new Map<string, string | undefined>();
  for (const r of rows) {
    if (typeof r.slug !== 'string' || r.slug.length === 0) continue;
    // Postgres decodes the BIGINT FK as bigint; both metadata maps are keyed
    // by the INTEGER minion job id represented as a JavaScript number.
    const jobId = Number(r.job_id);
    const ci = chunkInfo.get(jobId);
    const slug = ci ? rewriteChunkedSlug(r.slug, ci.hash6, ci.idx) : r.slug;
    if (!rewritten.has(slug) || rewritten.get(slug) === undefined) {
      rewritten.set(slug, jobRawSource?.get(jobId));
    }
  }
  return Array.from(rewritten.keys()).sort().map(slug => {
    const raw_source = rewritten.get(slug);
    return { slug, source_id: sourceId, ...(raw_source ? { raw_source } : {}) };
  });
}

/**
 * D8: load every `completed` legacy job key in the pre-v2 path-based
 * family `dream:synth:<filePath>:<hash16>[:c<i>of<n>]`. Used at fan-out
 * time to detect transcripts already synthesized under an old key shape;
 * those should NOT be re-submitted under v2 keys. (v2 keys start with
 * `dream:synth-v2:` and don't match the LIKE prefix — the queue's own
 * idempotency dedupe already covers them.)
 *
 * Plain `status = 'completed'` deliberately mirrors the queue-level
 * idempotency semantics the legacy keys relied on: a completed job blocks
 * re-submission regardless of `result.stop_reason` (pinned in
 * test/minions.test.ts). Filtering on stop_reason here would re-pay for
 * transcripts the old code path never re-ran, and reading `result` at all
 * would need the `(result #>> '{}')` double-encoded-jsonb defense.
 *
 * Loads source-scoped completions once per phase; no schema additions
 * and no repeated history scan for each transcript.
 */
async function loadSuccessfulLegacySynthesisKeys(
  engine: BrainEngine,
  sourceId: string,
): Promise<string[]> {
  const rows = await engine.executeRaw<{ idempotency_key: string }>(
    `SELECT idempotency_key
       FROM minion_jobs
      WHERE name = 'subagent'
        AND status = 'completed'
        AND COALESCE(NULLIF(data->>'source_id', ''), 'default') = $1
        AND idempotency_key LIKE 'dream:synth:%'`,
    [sourceId],
  );
  return rows.map(row => row.idempotency_key);
}

/**
 * Match a transcript (by filename + content hash) against completed legacy
 * keys. `'single'` when a `dream:synth:<path>:<hash16>` completion exists;
 * `'chunked'` when a FULL chunk set `:c0of<n>`..`:c<n-1>of<n>` completed
 * (chunk indices are 0-based). Partial chunk sets return null so the
 * transcript gets a fresh v2 synthesis instead of shipping with holes.
 */
function findLegacyCompletion(
  successfulKeys: string[],
  filePath: string,
  hash16: string,
): 'single' | 'chunked' | null {
  const filename = basename(filePath);
  const hashSuffix = `:${hash16}`;
  /** total chunk count n → completed 0-based chunk indices */
  const chunkSets = new Map<number, Set<number>>();
  for (const key of successfulKeys) {
    const chunk = /:c(\d+)of(\d+)$/.exec(key);
    const base = chunk ? key.slice(0, -chunk[0].length) : key;
    if (!base.endsWith(hashSuffix)) continue;
    const historicalPath = base.slice('dream:synth:'.length, -hashSuffix.length);
    if (basename(historicalPath) !== filename) continue;
    if (!chunk) return 'single';
    const i = Number(chunk[1]);
    const n = Number(chunk[2]);
    if (n < 1 || i < 0 || i >= n) continue;
    let seen = chunkSets.get(n);
    if (!seen) chunkSets.set(n, seen = new Set());
    seen.add(i);
  }
  for (const [n, seen] of chunkSets) {
    if (seen.size === n) return 'chunked';
  }
  return null;
}

// ── Dream-provenance DB stamp (#2569) ────────────────────────────────

/**
 * Persist the dream-output identity marker (`dream_generated: true` +
 * `dream_cycle_date`) into the `pages.frontmatter` JSONB row for every page
 * a synthesize child wrote. Render-time `frontmatterOverrides` alone only
 * reach the markdown FILE — the DB row stayed unstamped, so DB consumers
 * couldn't enumerate generated pages and a later put_page write-through
 * (which re-renders from the DB row) silently erased the marker.
 *
 * Plain UPDATE through executeRawJsonb (raw object bound to $3::jsonb —
 * never JSON.stringify into a ::jsonb cast; engine-parity safe, no new
 * engine method). Best-effort per row: a stamp failure never kills the
 * phase (the render-time override still covers the file).
 */
async function stampDreamProvenance(
  engine: BrainEngine,
  refs: Array<{ slug: string; source_id: string; raw_source?: string }>,
  cycleDate: string,
): Promise<void> {
  if (refs.length === 0) return;
  const { executeRawJsonb } = await import('../sql-query.ts');
  for (const { slug, source_id, raw_source } of refs) {
    try {
      await executeRawJsonb(
        engine,
        `UPDATE pages
            SET frontmatter = COALESCE(frontmatter, '{}'::jsonb) || $3::jsonb
          WHERE slug = $1 AND source_id = $2`,
        [slug, source_id],
        // #1978 raw-source persistence: record the transcript path the
        // synthesis was derived from, so `gbrain doctor` (raw_provenance
        // check) can verify every generated page carries a raw trace.
        [{
          dream_generated: true,
          dream_cycle_date: cycleDate,
          ...(raw_source ? { raw_source } : {}),
        }],
      );
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      process.stderr.write(`[dream] provenance stamp ${slug}@${source_id} failed: ${msg}\n`);
    }
  }
}

// ── Reverse-write DB rows → markdown files ───────────────────────────

async function reverseWriteRefs(
  engine: BrainEngine,
  brainDir: string,
  refs: Array<{ slug: string; source_id: string }>,
  nativeSourceId = 'default',
): Promise<number> {
  let count = 0;
  for (const { slug, source_id } of refs) {
    // v0.32.8 F6: validate source_id is filesystem-safe before any join().
    validateSourceId(source_id);
    const page = await engine.getPage(slug, { sourceId: source_id });
    if (!page) continue;
    const tags = await engine.getTags(slug, { sourceId: source_id });
    try {
      const md = renderPageToMarkdown(page, tags);
      // v0.32.8 F6: foreign-source pages land at brainDir/.sources/<id>/<slug>.md
      // so same-slug-different-source pages don't collide. Pages belonging to
      // the cycle's own source (#1586: brainDir IS that source's checkout —
      // legacy 'default' when unscoped) stay at brainDir/<slug>.md.
      const filePath = source_id === nativeSourceId
        ? join(brainDir, `${slug}.md`)
        : join(brainDir, '.sources', source_id, `${slug}.md`);
      mkdirSync(dirname(filePath), { recursive: true });
      writeFileSync(filePath, md, 'utf8');
      count++;
    } catch (e) {
      // Per-slug failures are non-fatal — phase continues.
      const msg = e instanceof Error ? e.message : String(e);
      process.stderr.write(`[dream] reverse-write ${slug}@${source_id} failed: ${msg}\n`);
    }
  }
  return count;
}

/**
 * Render a Page to markdown, stamping the dream-output identity marker into
 * frontmatter. This stamp is the explicit identity surface checked by
 * `isDreamOutput` in transcript-discovery.ts. Stamping at render time covers
 * every reverse-write path (subagent reflections + originals + summary) with
 * one funnel; the prior content-pattern guard could miss real output because
 * `serializeMarkdown` does not embed the page slug in the body.
 */
export function renderPageToMarkdown(page: Page, tags: string[]): string {
  // v0.38 DRY: the dream-output identity stamp (dream_generated +
  // dream_cycle_date) is the ONLY thing that differs from the v0.38
  // put_page write-through renderer. Both call the shared
  // serializePageToMarkdown helper in markdown.ts; this wrapper passes
  // the dream-specific overrides. Future markdown-shape changes happen
  // in one place.
  return serializePageToMarkdown(page, tags, {
    frontmatterOverrides: {
      dream_generated: true,
      dream_cycle_date: today(),
    },
  });
}

// ── Summary index page ───────────────────────────────────────────────

async function writeSummaryPage(
  engine: BrainEngine,
  brainDir: string,
  summarySlug: string,
  summaryDate: string,
  writtenSlugs: string[],
  childOutcomes: Array<{ jobId: number; status: string }>,
  sourceId = 'default',
): Promise<void> {
  const completed = childOutcomes.filter(c => c.status === 'completed').length;
  const failed = childOutcomes.length - completed;

  const lines: string[] = [];
  lines.push(`# Dream cycle ${summaryDate}`);
  lines.push('');
  lines.push(`**Children:** ${completed} completed, ${failed} failed/timeout.`);
  lines.push(`**Pages written:** ${writtenSlugs.length}.`);
  lines.push('');
  if (writtenSlugs.length > 0) {
    lines.push('## Pages');
    lines.push('');
    for (const s of writtenSlugs) {
      lines.push(`- [[${s}]]`);
    }
    lines.push('');
  }

  const body = lines.join('\n');
  // Stamp the dream-output identity marker into the summary's frontmatter.
  // parseMarkdown below round-trips it into the DB-stored frontmatter, so the
  // marker survives any later reverse-render of the summary page.
  const fullMarkdown = serializeMarkdown(
    {
      dream_generated: true,
      dream_cycle_date: summaryDate,
      // #1978: deterministic index page — no source document of its own;
      // raw traces live on the listed pages. Explicit exemption keeps the
      // doctor raw_provenance check quiet.
      raw_trace_exempt: true,
      raw_trace_exempt_reason: 'deterministic dream-cycle index; raw traces live on listed pages',
    } as Record<string, unknown>,
    body,
    '',
    { type: 'note' as string, title: `Dream cycle ${summaryDate}`, tags: ['dream-cycle'] },
  );

  // Direct engine.putPage — orchestrator write, no subagent context, no
  // allow-list check (server-side viaSubagent=false). The summary slug is
  // pre-validated against SUMMARY_SLUG_RE in the caller.
  // Importing put_page via operations.ts would re-run namespace logic
  // unnecessarily; we go straight to the engine.
  const { parseMarkdown } = await import('../markdown.ts');
  const parsed = parseMarkdown(fullMarkdown);
  // #1586: summary lands in the cycle's resolved source too — otherwise the
  // children live in the named source while the index drifts to 'default'.
  await engine.putPage(summarySlug, {
    type: parsed.type,
    title: parsed.title,
    compiled_truth: parsed.compiled_truth,
    timeline: parsed.timeline,
    frontmatter: parsed.frontmatter,
  }, { sourceId });

  // Also write to disk (orchestrator dual-write).
  try {
    const filePath = join(brainDir, `${summarySlug}.md`);
    mkdirSync(dirname(filePath), { recursive: true });
    writeFileSync(filePath, fullMarkdown, 'utf8');
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    process.stderr.write(`[dream] summary file-write failed: ${msg}\n`);
  }
}

// ── Helpers ──────────────────────────────────────────────────────────

function loadAdHocTranscript(
  filePath: string,
  minChars: number,
  excludePatterns: string[],
  bypassGuard?: boolean,
): DiscoveredTranscript[] {
  const { readSingleTranscript } = require('./transcript-discovery.ts') as typeof import('./transcript-discovery.ts');
  const t = readSingleTranscript(filePath, { minChars, excludePatterns, bypassGuard });
  return t ? [t] : [];
}

function today(): string {
  return new Date().toISOString().slice(0, 10);
}

function ok(summary: string, details: Record<string, unknown> = {}): PhaseResult {
  return { phase: 'synthesize', status: 'ok', duration_ms: 0, summary, details };
}

function skipped(reason: string, summary: string): PhaseResult {
  return {
    phase: 'synthesize',
    status: 'skipped',
    duration_ms: 0,
    summary,
    details: { reason },
  };
}

function failed(error: PhaseError): PhaseResult {
  return {
    phase: 'synthesize',
    status: 'fail',
    duration_ms: 0,
    summary: 'synthesize phase failed',
    details: {},
    error,
  };
}

function makeError(cls: string, code: string, message: string, hint?: string): PhaseError {
  return hint ? { class: cls, code, message, hint } : { class: cls, code, message };
}

// ── Test-only export ───────────────────────────────────────
// `__testing` re-exports otherwise-private helpers so unit tests can pin
// behavior at function granularity (e.g., #745 collectChildPutPageSlugs
// double-encoded jsonb regression). Not part of the runtime contract.
export const __testing = {
  collectChildPutPageSlugs,
  buildSynthesisPrompt,
  stampDreamProvenance,
  reverseWriteRefs,
  runPgliteSubagentsInline,
  loadSynthConfig,
};
