#!/usr/bin/env node
// contrast.mjs — CONTRASTIVE strategy-fossil miner. The signal is MODEL *minus* BASELINE.
// Does the expensive locating mechanically (cheap), so the deep-reasoning agent reads curated
// DELTAS, never whole docs. Answers "what to read / why / what's distinctive" without bloating context.
//
//   node contrast.mjs [--model claude-fable-5] [--baseline claude-opus-4-8] [--out ~/.claude/fable] [--top 8]
//
// Three fossil types (all contrastive, all secret-scrubbed):
//   1. REVISION DELTAS  — same .md Written N times => N-1 decision deltas (add/cut/reorder). The thinking is
//                         encrypted, but its CONSEQUENCES (the edits) are logged. Richest decision proxy.
//   2. MOVE GRAMMAR     — tool-call n-grams per turn. OBSERVED (logged) => [MEASURED], not inferred.
//                         Reports where MODEL's move-order diverges most from BASELINE.
//   3. ARCHETYPE PAIRS  — same-type docs (plan/spec/design/review) by each model => candidate diff pairs +
//                         falsification-marker density to rank what's load-bearing.
//
// Emits OUT/strategy/readlist.md — a ranked, curated read-list of deltas for the step-6 ultrathink agent.
// HONESTY: deltas/order/omissions are MEASURED; the strategy behind them stays [INFERRED] until reproduced.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; };
const MODEL = arg('--model', 'claude-fable-5');
const BASE = arg('--baseline', 'claude-opus-4-8');
const OUT = path.resolve(arg('--out', path.join(os.homedir(), '.claude/fable')).replace(/^~/, os.homedir()));
const TOP = parseInt(arg('--top', '8'), 10);
const PROJ_ROOT = path.join(os.homedir(), '.claude/projects');

const SECRET = [
  /[A-Fa-f0-9]{32,}/, /[A-Za-z0-9_\-]{40,}/, /\b(sk|pk|ghp|gho|xox[bap])[-_][A-Za-z0-9]{8,}/,
  /\b(api[_-]?key|secret|password|passwd|bearer|authorization|token)\b\s*[:=]/i,
  /-----BEGIN [A-Z ]+-----/, /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/,
];
const scrub = (s) => SECRET.some((re) => re.test(s)) ? '[scrubbed]' : s;
const clip = (s, n = 100) => { s = s.replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n) + '…' : s; };

const walk = (d) => {
  let acc = []; let ents;
  try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return acc; }
  for (const e of ents) {
    const p = path.join(d, e.name);
    if (e.isDirectory()) acc = acc.concat(walk(p));
    else if (e.name.endsWith('.jsonl')) acc.push(p);
  }
  return acc;
};
const archetype = (fp) => {
  const b = path.basename(fp).toLowerCase();
  if (/memory/.test(b)) return 'memory';
  if (/plan/.test(b)) return 'plan';
  if (/spec|data-model|schema/.test(b)) return 'spec';
  if (/design|adr|rfc|architect/.test(b)) return 'design';
  if (/review|audit|scan/.test(b)) return 'review';
  if (/readme|index/.test(b)) return 'index';
  return 'other';
};
const MARKERS = [
  [/\bassert\b|\bexpect\(/i, 'assert'], [/delete this|remove this|breaks? if|would break/i, 'falsification'],
  [/acceptance|done when|definition of done|✅|\[ \]/i, 'acceptance'],
  [/verify (with|by|before)|grep |curl |before claiming/i, 'verify-before-claim'],
  [/\bD\d+\b|\bINV-\d+\b|decision:/i, 'decision-id'],
];

if (!fs.existsSync(PROJ_ROOT)) { console.error(`no transcripts at ${PROJ_ROOT}`); process.exit(2); }
const files = walk(PROJ_ROOT);

// per-model accumulators
const versions = new Map();          // `${model}\t${fp}` -> [{ts, content}]
const grams = { [MODEL]: new Map(), [BASE]: new Map() };  // "A>B>C" -> count
const macros = { [MODEL]: { readsBeforeWrite: [], verifyAfterWrite: 0, writes: 0 },
                 [BASE]: { readsBeforeWrite: [], verifyAfterWrite: 0, writes: 0 } };
const docs = { [MODEL]: new Map(), [BASE]: new Map() };    // fp -> {arch, chars, markers:Set}

const READ = new Set(['Read', 'Grep', 'Glob', 'LS']);
const inc = (m, k) => m.set(k, (m.get(k) || 0) + 1);

for (const f of files) {
  let lines; try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { continue; }
  const stream = { [MODEL]: [], [BASE]: [] };   // per-SESSION ordered tool stream (moves span turns)
  for (const ln of lines) {
    if (!ln.includes(MODEL) && !ln.includes(BASE)) continue;
    let o; try { o = JSON.parse(ln); } catch { continue; }
    const msg = o.message || {};
    if (msg.role !== 'assistant') continue;
    const model = msg.model || '';
    if (model !== MODEL && model !== BASE) continue;
    const c = msg.content;
    if (!Array.isArray(c)) continue;
    const ts = o.timestamp || '';

    for (const b of c) {
      if (!b || b.type !== 'tool_use') continue;
      const name = b.name || '?';
      stream[model].push(name);
      if (name === 'Write') {
        const fp = (b.input || {}).file_path || '';
        if (fp.endsWith('.md')) {
          if (model === MODEL) {           // revision chain only for target
            const key = `${model}\t${fp}`;
            (versions.get(key) || versions.set(key, []).get(key)).push({ ts, content: (b.input || {}).content || '' });
          }
          const content = (b.input || {}).content || '';
          const mk = new Set(); for (const [re, tag] of MARKERS) if (re.test(content)) mk.add(tag);
          docs[model].set(fp, { arch: archetype(fp), chars: content.length, markers: mk });
        }
      }
    }
  }
  // process this session's streams (move-order crosses turn boundaries)
  for (const mdl of [MODEL, BASE]) {
    const s = stream[mdl];
    for (let i = 0; i + 2 < s.length; i++) inc(grams[mdl], `${s[i]}>${s[i + 1]}>${s[i + 2]}`);
    for (let i = 0; i < s.length; i++) {
      if (s[i] !== 'Write') continue;
      macros[mdl].writes++;
      let r = 0, j = i - 1; while (j >= 0 && READ.has(s[j])) { r++; j--; }   // reads immediately before this write
      macros[mdl].readsBeforeWrite.push(r);
      let k = i + 1; let verified = false;                                   // any verify (Bash/Read) before next write
      for (; k < s.length && s[k] !== 'Write'; k++) if (s[k] === 'Bash' || READ.has(s[k])) { verified = true; break; }
      if (verified) macros[mdl].verifyAfterWrite++;
    }
  }
}

// ---- fossil 1: revision deltas (rank by versions × total churn) ----
const lineset = (s) => new Set(s.split('\n').map((x) => x.trim()).filter(Boolean));
const headings = (s) => s.split('\n').filter((x) => /^#{1,6}\s/.test(x.trim())).map((x) => x.trim());
const revFossils = [];
for (const [key, vs] of versions) {
  if (vs.length < 2) continue;                         // ≥2 versions = ≥1 decision delta
  vs.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
  const fp = key.split('\t')[1];
  let churn = 0; const steps = [];
  for (let i = 1; i < vs.length; i++) {
    const a = lineset(vs[i - 1].content), b = lineset(vs[i].content);
    const added = [...b].filter((x) => !a.has(x)), removed = [...a].filter((x) => !b.has(x));
    if (!added.length && !removed.length) continue;
    churn += added.length + removed.length;
    const newH = headings(vs[i].content).filter((h) => !headings(vs[i - 1].content).includes(h));
    steps.push({ d: vs[i].content.length - vs[i - 1].content.length, add: added.length, del: removed.length,
                 heads: newH.slice(0, 3).map((h) => scrub(clip(h, 60))),
                 sample: added.find((x) => x.length > 20) ? scrub(clip(added.find((x) => x.length > 20))) : '' });
  }
  if (steps.length) revFossils.push({ fp, arch: archetype(fp), versions: vs.length, churn, steps });
}
revFossils.sort((a, b) => b.versions * b.churn - a.versions * a.churn);

// ---- fossil 2: most-divergent move-grammar (MODEL freq / BASE freq) ----
const tot = (m) => [...m.values()].reduce((a, b) => a + b, 0) || 1;
const tM = tot(grams[MODEL]), tB = tot(grams[BASE]);
const allG = new Set([...grams[MODEL].keys(), ...grams[BASE].keys()]);
const divergent = [...allG].map((g) => {
  const pM = (grams[MODEL].get(g) || 0) / tM, pB = (grams[BASE].get(g) || 0) / tB;
  return { g, pM, pB, lift: (pM + 1e-6) / (pB + 1e-6), nM: grams[MODEL].get(g) || 0 };
}).filter((x) => x.nM >= 5).sort((a, b) => b.lift - a.lift);

// ---- fossil 3: archetype pairs + marker density ----
const archMap = {};
for (const mdl of [MODEL, BASE]) for (const [fp, d] of docs[mdl]) {
  (archMap[d.arch] ||= { [MODEL]: [], [BASE]: [] })[mdl].push({ fp, ...d });
}
const markerRank = [...docs[MODEL].entries()].map(([fp, d]) => ({ fp, arch: d.arch, n: d.markers.size, tags: [...d.markers] }))
  .filter((x) => x.n).sort((a, b) => b.n - a.n);

const avg = (a) => a.length ? (a.reduce((x, y) => x + y, 0) / a.length) : 0;
// ---- emit readlist ----
fs.mkdirSync(path.join(OUT, 'strategy'), { recursive: true });
let md = `# ${MODEL} strategy fossils — contrastive read-list (baseline: ${BASE})

audience: AI coding agents. This is the CURATED input for the step-6 ultrathink pass. Read THESE deltas, not
whole docs. Everything here is MEASURED (edits, move-order, omissions). The strategy you infer from it is
[INFERRED] until a matched-pair run reproduces it. The signal is **${MODEL} minus ${BASE}** — where it diverges
from the Opus baseline is what's distinctive ("what did it do that the baseline never would").

## A. Move-order grammar — OBSERVED, [MEASURED]. Where ${MODEL}'s tool-sequence diverges most from ${BASE}.
Each row: a 3-move pattern over-used by ${MODEL} vs baseline (lift = how much more often, per-turn-normalized).

| over-used move-pattern | ${MODEL} share | ${BASE} share | lift |
|---|---|---|---|
${divergent.slice(0, 12).map((x) => `| \`${x.g}\` | ${(x.pM * 100).toFixed(2)}% | ${(x.pB * 100).toFixed(2)}% | ${x.lift.toFixed(1)}× |`).join('\n')}

Macro contrasts (per turn that writes a doc):
- reads immediately before a write — ${MODEL}: ${avg(macros[MODEL].readsBeforeWrite).toFixed(1)} · ${BASE}: ${avg(macros[BASE].readsBeforeWrite).toFixed(1)}
- verify-after-write rate — ${MODEL}: ${(100 * macros[MODEL].verifyAfterWrite / (macros[MODEL].writes || 1)).toFixed(1)}% · ${BASE}: ${(100 * macros[BASE].verifyAfterWrite / (macros[BASE].writes || 1)).toFixed(1)}%

## B. Revision-delta fossils — the decision trace. Top ${TOP} most-revised docs (versions × churn).
The thinking is encrypted; these EDITS are its logged consequences. Read the step sequence as decisions: what
got added, cut, reordered. Ask "why this edit, and would the baseline have made it?"

`;
for (const r of revFossils.slice(0, TOP)) {
  md += `### ${path.basename(r.fp)}  ·  ${r.arch}  ·  ${r.versions} versions, churn ${r.churn}\n`;
  md += `\`${r.fp.replace(os.homedir(), '~')}\`\n\n`;
  r.steps.slice(0, 6).forEach((s, i) => {
    md += `- step ${i + 1}: ${s.d >= 0 ? '+' : ''}${s.d}c (+${s.add}/−${s.del} lines)`;
    if (s.heads.length) md += ` · new headings: ${s.heads.map((h) => `"${h}"`).join(', ')}`;
    if (s.sample) md += ` · e.g. \`${s.sample}\``;
    md += '\n';
  });
  md += '\n';
}
md += `## C. Contrastive archetype pairs — diff ${MODEL} vs ${BASE} of the SAME type; note what ${MODEL} OMITS.
`;
for (const arch of Object.keys(archMap).sort()) {
  const a = archMap[arch]; if (!a[MODEL].length || !a[BASE].length) continue;
  const top = (xs) => xs.sort((p, q) => q.chars - p.chars).slice(0, 2).map((x) => path.basename(x.fp)).join(', ');
  md += `- **${arch}** — ${MODEL}: ${top(a[MODEL])} · ${BASE}: ${top(a[BASE])}\n`;
}
md += `\n## D. Falsification-marker density — ${MODEL}'s load-bearing docs (read these for acceptance structure).
*Marker "types" below are FUZZY families (regex-matched), not literal strings — e.g. "acceptance" fires on \`[ ]\`
checkboxes, "verify-before-claim" on grep/curl patterns. Confirm the actual gate mechanism in-doc; don't quote the label.*
`;
for (const m of markerRank.slice(0, TOP)) md += `- \`${path.basename(m.fp)}\` (${m.arch}) — ${m.n} marker types: ${m.tags.join(', ')}\n`;

md += `\n---
## How the step-6 agent uses this
1. Read §A — the move-patterns ${MODEL} uses and ${BASE} doesn't. Each is a candidate strategy rule (OBSERVED).
2. Read §B — for the top revision chains, infer the DECISION each edit encodes. Cite the step.
3. Read §C/§D — open at most 1–2 named docs ONLY to confirm an omission or acceptance pattern; do not bulk-read.
4. Emit an [INFERRED] strategy model; for each rule, the reproduction test that would kill it (matched-pair).
`;
fs.writeFileSync(path.join(OUT, 'strategy/readlist.md'), md);

console.log(`model: ${MODEL}  baseline: ${BASE}`);
console.log(`revision chains (≥3 versions): ${revFossils.length}  ·  divergent move-patterns: ${divergent.length}`);
console.log(`archetypes paired: ${Object.values(archMap).filter((a) => a[MODEL].length && a[BASE].length).length}  ·  marker-dense docs: ${markerRank.length}`);
console.log(`out: ${path.join(OUT, 'strategy/readlist.md').replace(os.homedir(), '~')}`);
