/**
 * codex-chunk-bridge.ts — lift codex-second-opinion output into a chunk.
 *
 * The `codex-second-opinion` UserPromptSubmit hook fires Codex in the
 * background and writes its adversarial analysis to
 * `/tmp/codex-adversarial-<claude-session-id>.txt` (see
 * `shared/engineering/rules/codex-second-opinion.md`). That text is injected
 * into the Claude Code context and then LOST on session end — it never reaches
 * the chunks substrate or the SPA.
 *
 * This bridge lifts that `/tmp` output into a single `actor_type:'codex'` chunk
 * via host-cp `POST /v1/chunks`, so the SPA's `<CodexPanel>` can render Codex's
 * analysis beside Claude's transcript (Track A of spa-harness-forge).
 *
 * Design decisions (from docs/plans/spa-harness-forge + scratch/ree-skill-forge/E2):
 *   - INVOKE ON COMPLETION, not a passive `fs.watch`. A late-starting watcher
 *     misses the write (T1). The caller invokes this once Codex's file lands.
 *   - actor_type='codex' is set in the POST BODY. host-cp's `principalFromBearer`
 *     honors a client-claimed `actor_type` in single-secret G1-demo mode
 *     (`plan-chat-service.mjs:199-217`) and `checkOperatorChunkGate` never gates
 *     non-operator writes — so no broker secret is needed (E2 §3).
 *   - message_id correlation (OQ-A2b lean B): when the caller supplies
 *     `resolveCritiquedMessageId`, the chunk reuses the critiqued Claude
 *     `message_id` with a high `seq` offset so the Claude↔Codex link is a
 *     substrate fact. When it returns null (no Claude turn yet), fall back to a
 *     fresh `codex-<uuid>` id with seq 0 (option A) — panel entry unlinked.
 *   - EMPTY / PENDING / MISSING file → no POST, no throw. The hook path must
 *     never break because Codex timed out (it timed out 120s×3 during design;
 *     success-signal A2). Errors are swallowed + reported in the result.
 *
 * OQ-A4 (load-bearing [assumed]): the `<claude-session-id>` in the /tmp filename
 * is NOT the plan-chat SPA `session_id`. The caller is responsible for resolving
 * which live `(world_id, session_id)` to target (the session the operator is
 * viewing; default world `_planning`). This bridge takes that scope as input —
 * it does not invent the mapping. See the plan's Assumptions log.
 */
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { makeHostCpChunkPoster, } from './agent-sdk-to-chunks.js';
/**
 * Best-effort parse of the leading verdict marker. The rule
 * (`codex-second-opinion.md`) defines agree/push-back/rethink semantics but not
 * a strict machine-parsable header (OQ-A6) — so this is a tolerant scan, not a
 * contract. Falls back to `'unknown'` (the panel renders a neutral badge).
 */
export function parseCodexVerdict(text) {
    const head = text.slice(0, 400).toLowerCase();
    // Order matters: 'rethink' and 'push-back' are stronger signals than 'agree'.
    if (/\brethink\b/.test(head))
        return 'rethink';
    if (/push[\s-]?back/.test(head))
        return 'push-back';
    if (/\bdisagree\b/.test(head))
        return 'push-back';
    if (/\bagree\b|\bapprove[sd]?\b/.test(head))
        return 'agree';
    return 'unknown';
}
/** Sentinel the hook writes while Codex is still running. */
const PENDING_SENTINEL = 'PENDING';
/**
 * Read the codex-second-opinion file and, if it carries real analysis, POST one
 * `actor_type:'codex'` chunk to host-cp. Never throws — the hook path stays
 * unbroken regardless of Codex's state.
 */
export async function liftCodexAnalysisToChunk(opts) {
    const { hostCpUrl, bearer, worldId, sessionId, filePath, resolveCritiquedMessageId, seqOffsetBase = 1000, readFileImpl = (p) => readFile(p, 'utf8'), postChunkImpl, idGen = randomUUID, now = () => new Date().toISOString(), } = opts;
    // 1. Read the file. Missing → no analysis to lift (hook may not have fired).
    let raw;
    try {
        raw = await readFileImpl(filePath);
    }
    catch {
        return { posted: false, reason: 'missing' };
    }
    const text = raw.trim();
    // 2. Empty or still-pending → nothing to surface yet (success-signal A2).
    if (text.length === 0)
        return { posted: false, reason: 'empty' };
    if (text === PENDING_SENTINEL || text.startsWith(`${PENDING_SENTINEL}\n`)) {
        return { posted: false, reason: 'pending' };
    }
    // 3. Derive correlation (option B) or fall back to a fresh id (option A).
    let critiquedId = null;
    if (resolveCritiquedMessageId) {
        try {
            critiquedId = await resolveCritiquedMessageId();
        }
        catch {
            critiquedId = null; // resolution failure → fresh-id fallback
        }
    }
    const correlated = typeof critiquedId === 'string' && critiquedId.length > 0;
    const messageId = correlated ? critiquedId : `codex-${idGen()}`;
    const seq = correlated ? seqOffsetBase : 0;
    const verdict = parseCodexVerdict(text);
    const row = {
        world_id: worldId,
        session_id: sessionId,
        message_id: messageId,
        seq,
        actor_type: 'codex',
        actor_id: 'codex',
        role: 'assistant',
        kind: 'text',
        chunk_type: 'text',
        chunk: text,
        created_at: now(),
    };
    const postChunk = postChunkImpl ?? makeHostCpChunkPoster({ sidecarUrl: hostCpUrl, bearer });
    // 4. POST. Swallow errors — a failed POST must not break the hook path.
    try {
        await postChunk(row);
    }
    catch (err) {
        // eslint-disable-next-line no-console
        console.error('[codex-chunk-bridge] POST /v1/chunks failed:', err);
        return { posted: false, reason: 'post-error' };
    }
    return { posted: true, messageId, seq, verdict, correlated };
}
//# sourceMappingURL=codex-chunk-bridge.js.map