#!/usr/bin/env node
// auto-handoff — PreCompact/SessionEnd: write deterministic handoff for cwd;
// SessionEnd additionally spawns detached codex (spark/low, fallback mini/low) rich handoff.
// --announce (SessionStart): print freshest handoff pointer for cwd.
// Fail-open: every path exits 0; a handoff bug must never break compaction or session close.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawn } from 'node:child_process';
import {
  LEDGER_NAME,
  appendLedger,
  ledgerFor,
  openFacts,
  readLedger,
  reportForCwd,
  renderReport
} from '../lib/session-durability.mjs';

const HANDOFF_ROOT = path.join(os.homedir(), '.claude', 'handoffs');
const MIN_USER_TURNS = 5;
const DEDUPE_MS = 5 * 60 * 1000;

const slugOf = (c) =>
  String(c).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(-80) || 'root';

function git(cwd, args) {
  try {
    return execFileSync('git', args, { cwd, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
      .toString()
      .trim();
  } catch {
    return '';
  }
}

function parseTranscript(tp) {
  const userPrompts = [];
  let todos = null;
  let lastAssistant = '';
  const lines = fs.readFileSync(tp, 'utf8').split('\n');
  for (const line of lines) {
    if (!line) continue;
    let o;
    try {
      o = JSON.parse(line);
    } catch {
      continue;
    }
    const m = o.message;
    if (!m) continue;
    if (o.type === 'user') {
      let t = '';
      if (typeof m.content === 'string') t = m.content;
      else if (Array.isArray(m.content))
        t = m.content.filter((c) => c && c.type === 'text').map((c) => c.text).join('\n');
      t = (t || '').trim();
      if (t && !/^(<|Caveat:|Base directory for this skill|This session is being continued)/.test(t))
        userPrompts.push(t.replace(/\s+/g, ' ').slice(0, 300));
    } else if (o.type === 'assistant' && Array.isArray(m.content)) {
      for (const c of m.content) {
        if (!c) continue;
        if (c.type === 'tool_use' && c.name === 'TodoWrite' && c.input && Array.isArray(c.input.todos))
          todos = c.input.todos;
        if (c.type === 'text' && c.text && c.text.trim()) lastAssistant = c.text.trim().slice(0, 1500);
      }
    }
  }
  return { userPrompts, todos, lastAssistant };
}

function writeDeterministic({ dir, cwd, sessionId, event, parsed }) {
  const { userPrompts, todos, lastAssistant } = parsed;
  const branch = git(cwd, ['branch', '--show-current']);
  const status = git(cwd, ['status', '--short']).split('\n').slice(0, 40).join('\n');
  const log = git(cwd, ['log', '--oneline', '-10']);
  const diffstat = git(cwd, ['diff', '--stat']).split('\n').slice(-1)[0] || '';
  let plans = '';
  try {
    const pd = path.join(cwd, 'docs', 'plans');
    plans = fs
      .readdirSync(pd)
      .filter((f) => f.endsWith('.jsonl'))
      .map((f) => ({ f, m: fs.statSync(path.join(pd, f)).mtimeMs }))
      .sort((a, b) => b.m - a.m)
      .slice(0, 5)
      .map((x) => `- docs/plans/${x.f}`)
      .join('\n');
  } catch {}

  const todoMd = Array.isArray(todos)
    ? todos.map((t) => `- [${t.status === 'completed' ? 'x' : ' '}] (${t.status}) ${t.content || t.subject || ''}`).join('\n')
    : '(no TodoWrite state captured)';

  const md = `# Auto-handoff — ${path.basename(cwd)}

- **cwd:** ${cwd}
- **session:** ${sessionId || 'unknown'}
- **event:** ${event}
- **written:** ${new Date().toISOString()}
- **branch:** ${branch || '(none/not a repo)'} ${diffstat ? `— uncommitted: ${diffstat}` : ''}

## Reader instructions (fresh agent)
Deterministic snapshot, machine-written at compaction/session-end. It describes session \`${sessionId || 'unknown'}\` ONLY — if the SessionStart durability report names a different session, this file is STALE, ignore it. Ground truth for progress = git + run-plan journal, NOT this file. Use via \`pickup\` skill. A richer codex-authored handoff may exist beside this file as \`${sessionId || '<session>'}-rich.md\` (SessionEnd only).

## TODO state (last TodoWrite)
${todoMd}

## Git status (short)
\`\`\`
${status || '(clean)'}
\`\`\`

## Recent commits
\`\`\`
${log || '(none)'}
\`\`\`

## Recent plan files
${plans || '(none found)'}

## Last ${Math.min(userPrompts.length, 10)} user prompts (oldest→newest)
${userPrompts.slice(-10).map((p) => `- ${p}`).join('\n')}

## Last assistant message (truncated)
${lastAssistant || '(none)'}
`;
  fs.mkdirSync(dir, { recursive: true });
  const latest = path.join(dir, 'latest.md');
  fs.writeFileSync(latest, md);
  if (sessionId)
    fs.writeFileSync(path.join(dir, `${new Date().toISOString().slice(0, 10)}-${String(sessionId).slice(0, 8)}.md`), md);
  return latest;
}

function spawnRich({ dir, cwd, tp, sessionId }) {
  const dest = path.join(dir, `${sessionId}-rich.md`);
  const prompt =
    `Read the Claude Code session transcript at ${tp} (JSONL; skip tool_result noise). ` +
    `Author a continuation handoff in markdown for a fresh coding agent: goal, methodology, ` +
    `completed work, current state, precise next actions, blockers gated on the user, standing user instructions (verbatim), key file paths. ` +
    `Be complete but terse. Output ONLY the handoff markdown.`;
  const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
  const sh =
    `logf=$(cdx exec --skip-git-repo-check -m gpt-5.3-codex-spark -c model_reasoning_effort=low ${q(prompt)}) ` +
    `|| logf=$(cdx exec --skip-git-repo-check -m gpt-5.4-mini -c model_reasoning_effort=low ${q(prompt)}); ` +
    `[ -n "$logf" ] && [ -f "$logf" ] && cp "$logf" ${q(dest)}`;
  spawn('sh', ['-c', sh], { detached: true, stdio: 'ignore', cwd }).unref();
}

function main() {
  let inp = {};
  try {
    inp = JSON.parse(fs.readFileSync(0, 'utf8'));
  } catch {}
  const cwd = inp.cwd || process.cwd();
  const dir = path.join(HANDOFF_ROOT, slugOf(cwd));
  const ledger = path.join(dir, LEDGER_NAME);

  const sessionId = inp.session_id || (inp.transcript_path ? path.basename(inp.transcript_path, '.jsonl') : null);

  if (process.argv.includes('--announce')) {
    // `--resume` reuses the session id and appends to the same transcript, so a session
    // that already has an open record is its own previous life: report on it, and report
    // before appending the new open or the fresh pid would read that life as still alive.
    let resumed = false;
    try {
      resumed = Boolean(sessionId && ledgerFor(readLedger(ledger), sessionId).open);
    } catch {}
    let report;
    try {
      report = renderReport(reportForCwd({ cwd, excludeSessionId: resumed ? null : sessionId }));
    } catch (err) {
      report =
        '=== session durability ===\n' +
        `state: UNDETERMINED — the durability reader itself failed (${err.message}).\n` +
        'Treat any handoff file for this directory as unverified; read the transcript directly.';
    }
    try {
      appendLedger(ledger, {
        k: 'open',
        session: sessionId,
        ts: new Date().toISOString(),
        cwd,
        transcript: inp.transcript_path || null,
        ...openFacts()
      });
    } catch {}
    console.log(report);
    return;
  }

  if (inp.hook_event_name === 'SessionEnd') {
    try {
      appendLedger(ledger, {
        k: 'close',
        session: sessionId,
        ts: new Date().toISOString(),
        reason: inp.reason || null
      });
    } catch {}
  }

  const tp = inp.transcript_path;
  if (!tp || !fs.existsSync(tp)) return;

  // dedupe: repeated auto-compacts within window rewrite nothing
  try {
    const st = fs.statSync(path.join(dir, 'latest.md'));
    if (Date.now() - st.mtimeMs < DEDUPE_MS) return;
  } catch {}

  let parsed;
  try {
    parsed = parseTranscript(tp);
  } catch {
    return;
  }
  if (parsed.userPrompts.length < MIN_USER_TURNS) return;

  const event = inp.hook_event_name || 'unknown';
  try {
    writeDeterministic({ dir, cwd, sessionId, event, parsed });
  } catch {
    return;
  }
  if (event === 'SessionEnd' && sessionId) {
    try {
      spawnRich({ dir, cwd, tp, sessionId });
    } catch {}
  }
}

main();
process.exit(0);
