/**
 * Pipeline orchestration.
 *
 * Phase A  encoding            prompt -> feature-coded spec (tree of nodes)
 * Phase B  IR generation       per-feature frozen test suites (the IR)
 *          [gate 1: user approves the IR artifact]
 * Phase C  build+authenticate  per-feature implementation, each feature's
 *                              suite runs immediately (real-time auth)
 *          [gate 2: user approves the build]
 * Phase D  RHA                 automatic — see rha.ts
 *
 * Every phase writes typed nodes to the supergit ledger and commits with
 * node trailers. The artifact is never wholly regenerated: Phase C and D can
 * only write inside a feature node's ownership boundary, enforced here — not
 * by prompt discipline.
 */
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
import type {
  FeatureNode,
  FeatureProbe,
  EscalationRecord,
  FeatureSpec,
  GeneratedFile,
  IRManifest,
  IRTestCase,
  JobState,
  LlmClient,
  PromptAssessment,
} from './types';
import { converge } from './bounded';
import {
  classKeyOf,
  classifyTsc,
  deterministicFix,
  formatForRepair,
  namedMember,
  routeTarget,
} from './diagnostics';
import { assembleTestFile, assembleWithMap } from './irScaffold';
import type { IRParts } from './irScaffold';
import { Substrate } from './substrate';
import { runTsc, runVitest, scaffoldWorkspace, sha256, toFeatureResult } from './sandbox';
import type { TscDiagnostic } from './sandbox';

export type Emit = (kind: string, data?: Record<string, unknown>) => void;

import { getConfig, swarmTemperatures, temperatureLadder } from './config';
import { raiseSev1 } from './alerts';
import { isRoutingLlm, partitionFeatureIds } from './roleRouter';
import { ownerMap } from './workQueue';
import { resolveSerial, type FindingGroup } from './resolver';
import { appendLesson, checkedIdiomsText, loadLessons } from './lessons';

/** Max concurrent LLM calls during fan-out phases. */
export function concurrency(): number {
  const n = getConfig().concurrency;
  return Number.isInteger(n) && n > 0 ? n : 4;
}

/**
 * Run `fn` over `items` with at most `limit` in flight. Results keep the
 * input order, so ledger recording stays deterministic even though the
 * network calls overlap.
 */
export async function mapLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const results = new Array<R>(items.length);
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (true) {
      const i = next++;
      if (i >= items.length) return;
      results[i] = await fn(items[i]);
    }
  });
  await Promise.all(workers);
  return results;
}

// ---------------------------------------------------------------------------
// Feature-tree utilities + structural gates: pure implementations live in
// specGates.ts (shared with the Tier 1 browser runner); re-exported here so
// existing consumers keep importing from pipeline.
// ---------------------------------------------------------------------------

import {
  boundaryViolations,
  depthOf,
  extractCases,
  isSpecCollapse,
  mergeStubsByPath,
  normalizePath,
  offendingFeatureIds,
  preSanitizeSpec,
  resolveDependencyIds,
  specThinFeatures,
  topoOrder,
  validateSpec,
  validateSuiteShape,
} from './specGates';
export {
  boundaryViolations,
  dependentsOf,
  depthOf,
  extractCases,
  isSpecCollapse,
  mergeStubsByPath,
  offendingFeatureIds,
  preSanitizeSpec,
  resolveDependencyIds,
  specThinFeatures,
  topoOrder,
  validateSpec,
} from './specGates';

/** Resolve an IR import module to a workspace stub path (`src/foo.ts`). */
function stubPathFromImport(from: string): string {
  let m = normalizePath(from).replace(/\.ts$/, '');
  if (m.startsWith('../')) m = m.replace(/^(\.\.\/)+/, '');
  return m.endsWith('.ts') ? m : `${m}.ts`;
}

function findStubExact(stubs: GeneratedFile[], p: string): GeneratedFile | undefined {
  const key = normalizePath(p);
  const matches = stubs.filter(s => normalizePath(s.path) === key);
  if (matches.length > 1) throw new Error(`stub invariant violated: ${matches.length} stubs for ${key}`);
  return matches[0];
}

export function readFiles(dir: string, rels: string[]): GeneratedFile[] {
  const out: GeneratedFile[] = [];
  for (const rel of rels) {
    const abs = path.join(dir, rel);
    if (fs.existsSync(abs)) out.push({ path: rel, content: fs.readFileSync(abs, 'utf8') });
  }
  return out;
}

export function writeFiles(dir: string, files: GeneratedFile[]): void {
  for (const f of files) {
    const abs = path.join(dir, f.path);
    fs.mkdirSync(path.dirname(abs), { recursive: true });
    fs.writeFileSync(abs, f.content);
  }
}

/** LLM stub repair — fresh-only (broken body never sent). */
async function applyFixStub(
  llm: LlmClient,
  emit: Emit,
  stub: GeneratedFile,
  diagnostics: string,
  opts?: {
    temperature?: number;
    avoid?: string;
    callingLines?: string;
    contractText?: string;
    checkedIdioms?: string;
  },
): Promise<boolean> {
  const before = stub.content;
  const beforeLen = before.length;
  const next = await llm.fixStub(stub.path, {
    diagnostics,
    callingLines: opts?.callingLines,
    contractText: opts?.contractText,
    checkedIdioms: opts?.checkedIdioms ?? (opts?.avoid || undefined),
  }, { temperature: opts?.temperature });
  emit('fixStub', {
    path: stub.path,
    beforeLen,
    afterLen: next.length,
    noop: next === before,
  });
  stub.content = next;
  return next !== before;
}

/**
 * Spec for entropy-reset feature repair: known-good siblings stay intact;
 * offending features are reduced to id/name/description so the model never
 * re-anchors on the broken mechanical fields.
 */
export function specForFreshFeatureRepair(spec: FeatureSpec, offendingIds: string[]): FeatureSpec {
  const offend = new Set(offendingIds);
  return {
    title: spec.title,
    prompt: spec.prompt,
    features: spec.features.map(f => {
      if (!offend.has(f.id)) return f;
      return {
        id: f.id,
        name: f.name,
        description: f.description,
        parentId: f.parentId,
        deps: [],
        files: [],
        testFile: '',
        edgeCases: [],
        exports: [],
        envVars: [],
        stubs: [],
      };
    }),
  };
}

/** Cascade-exclude a feature and every dependent that cannot stand without it. */
/**
 * tsc's own "Did you mean 'X'?" suggestion (TS2551/2552/2820). The frozen
 * contract is truth — when the compiler names the conforming identifier, the
 * rename is mechanical. The oracle always knew this (did-you-mean-rename,
 * mechanical); test files were just exempt from the row. Zero tokens.
 */
export function extractRename(message: string): { wrong: string; right: string } | null {
  // In every suggestion-bearing shape (2551/2552/2820) the FIRST quoted token
  // is the offending identifier and the "Did you mean" token is the fix.
  const sug = /Did you mean '([^']+)'\?/.exec(message);
  const first = /'([^']+)'/.exec(message);
  if (!sug || !first || first[1] === sug[1]) return null;
  return { wrong: first[1], right: sug[1] };
}

export function cascadeExcludeFeatures(ids: Iterable<string>, features: FeatureNode[]): Set<string> {
  const drop = new Set(ids);
  let changed = true;
  while (changed) {
    changed = false;
    for (const f of features) {
      if (!drop.has(f.id) && f.deps.some(d => drop.has(d))) {
        drop.add(f.id);
        changed = true;
      }
    }
  }
  return drop;
}

/**
 * Whole-spec structural stall: ownership overlap / cycles / duplicate ids that
 * implicate most features (or cannot be localized). Localized mechanical
 * defects escalate per-feature instead of killing the job.
 */
export function isWholeSpecStructuralStall(
  problems: string[],
  offendingIds: string[],
  featureCount: number,
): boolean {
  if (!problems.length) return false;
  const structural = /owned by both|cycle|duplicate feature id/i;
  const hasStructural = problems.some(p => structural.test(p));
  if (!offendingIds.length) return true;
  if (!hasStructural) return false;
  return featureCount > 0 && offendingIds.length / featureCount > 0.5;
}

function mergeRepairedFeatures(
  spec: FeatureSpec,
  offending: string[],
  repaired: FeatureNode[],
): FeatureSpec | null {
  const priorCount = spec.features.length;
  const priorById = new Map(spec.features.map(f => [f.id, f]));
  for (const f of repaired) {
    if (priorById.has(f.id) && offending.includes(f.id)) {
      priorById.set(f.id, f);
    }
  }
  const next: FeatureSpec = {
    ...spec,
    features: spec.features.map(f => priorById.get(f.id)!),
  };
  preSanitizeSpec(next);
  if (next.features.length < priorCount) return null;
  return next;
}

// ---------------------------------------------------------------------------
// IR validation (Phase B gate)
// ---------------------------------------------------------------------------

export function validateTestFile(content: string): string[] {
  const problems: string[] = [...validateSuiteShape(content)];
  const out = ts.transpileModule(content, {
    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
    reportDiagnostics: true,
  });
  for (const d of out.diagnostics ?? []) {
    problems.push(`syntax: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`);
  }
  return problems;
}

// ---------------------------------------------------------------------------
// Phases A + B
// ---------------------------------------------------------------------------

export async function runEncodingAndIR(
  job: JobState,
  llm: LlmClient,
  emit: Emit,
  opts: { skipGate?: boolean; overrideBy?: 'human' | 'autopilot' } = {},
): Promise<{ substrate: Substrate }> {
  job.phase = 'encoding';
  emit('phase', { phase: job.phase });
  loadLessons(); // standing taxonomy from prior runs

  fs.mkdirSync(job.workspaceDir, { recursive: true });
  scaffoldWorkspace(job.workspaceDir, job.title || 'artifact');
  // Gate re-entry (reassess / override / autopilot) re-invokes this phase on an
  // EXISTING workspace. init() truncates the append-only ledger and commits a
  // second genesis — destroying the record of prior gate rounds. Reopen when a
  // ledger exists; initialize only on first entry.
  const substrate = fs.existsSync(path.join(job.workspaceDir, '.supergit', 'ledger.csv'))
    ? Substrate.open(job.workspaceDir)
    : Substrate.init(job.workspaceDir);

  // ---- Phase 0: the input gate (BOUNDED, per-feature) ---------------------
  // Deficient input is a legitimate terminal OUTCOME, not a retry trigger. But
  // the JUDGMENT itself is bounded: rather than one holistic "is this spec
  // sufficient?" call (which makes a small model incoherent — the same feature
  // came back both clear and deficient), we enumerate features once, then run
  // an isolated verdict per feature on its own text. Each probe is small
  // enough that a 2B model is reliable, and cheap enough to RESAMPLE: a
  // genuinely-clear feature that occasionally misfires is rescued by another
  // draw; a genuinely-ambiguous one keeps asking → honest deficiency. No
  // stronger model — bound the spot and loop the weak one.
  //
  // The gate is a HARD block, but rounds are MONOTONIC (cleared features are
  // cached across reassess) and BOUNDED (RHA_GATE_ROUNDS at the job level,
  // matching the per-feature probe bound). The human operator holds the
  // levers: re-evaluate (resample uncleared features), clarify (new compile
  // with revised prompt — resets the round counter), or override (skipGate)
  // — proceeding on operator authentication, recorded in the ledger. Since
  // the whole artifact is authenticated downstream anyway, the operator's
  // spot-check is the economical final say.
  /** Floor for Part P collapse guard: enumerated count when the gate ran. */
  let enumeratedFloor: number | null = null;
  if (opts.skipGate) {
    const by = opts.overrideBy ?? 'human';
    const ov = substrate.recordNode({
      type: 'decision',
      status: 'info',
      summary: by === 'autopilot'
        ? 'input gate: AUTOPILOT override — unsupervised E2E, recorded for post-hoc human authentication'
        : 'input gate: human operator override — proceeding past the gate on manual authentication',
    });
    substrate.commit(
      ov,
      by === 'autopilot'
        ? 'input gate: autopilot override (unsupervised)'
        : 'input gate: human override (operator authenticated)',
    );
    emit('gate-override', { by });
    job.promptAssessment = null;
  } else {
  const PROBES = Number(process.env.RHA_GATE_PROBES || 3);
  // Judge the ARTIFACT, not the model's opinion. A weak model asked "is this
  // clear?" in isolation leans "unclear" (it lost the surrounding context) and
  // over-flags. But a weak model asked to WRITE one assertion usually can when
  // the feature really is testable — and genuinely can't only when it isn't.
  // So a feature is clear iff the probe produced a concrete assertion; its
  // self-reported `clear` bool is ignored. This is the scaffolding principle
  // applied to judgment: check a produced artifact, not a fuzzy verdict.
  const NON_ASSERTION = /\b(cannot|can't|unclear|unknown|unspecified|undefined|ambiguous|depends|need(s)? more|not enough|insufficient|n\/a|to be determined|tbd)\b/i;
  const isAssertion = (a: string) => {
    const s = a.trim();
    return s.length >= 15 && !NON_ASSERTION.test(s);
  };
  const normKey = (name: string, scope: string) =>
    `${name} ${scope}`.toLowerCase().replace(/\s+/g, ' ').trim();
  const productCtx = job.prompt.trim().replace(/\s+/g, ' ').slice(0, 240);
  const rawCandidates = await llm.enumerateFeatures(job.prompt);
  // Dedupe by normalized name — first occurrence wins (duplicate enum draws
  // otherwise re-probe the same feature N times per round).
  const seenNames = new Set<string>();
  const candidates = rawCandidates.filter(c => {
    const k = c.name.toLowerCase().replace(/\s+/g, ' ').trim();
    if (seenNames.has(k)) return false;
    seenNames.add(k);
    return true;
  });
  emit('prompt-gate-enum', { features: candidates.length, enumerated: rawCandidates.length });
  if (candidates.length === 0) {
    raiseSev1(job, 'zero-features', 'enumeration produced 0 features', { enumerated: rawCandidates.length }, emit);
  }
  enumeratedFloor = candidates.length;

  const prior = new Map(
    (job.promptAssessment?.verdicts ?? [])
      .filter(v => v.clear)
      .map(v => [normKey(v.name, v.scope), v] as const),
  );

  const verdicts = await mapLimit(candidates, concurrency(), async c => {
    const key = normKey(c.name, c.scope);
    const cached = prior.get(key);
    if (cached) {
      emit('prompt-gate-probe', { feature: c.name, clear: true, cached: true });
      return {
        c,
        clear: true,
        probe: { clear: true, assertion: cached.assertion, question: '' } as FeatureProbe,
      };
    }
    // Enrich the isolated span with a sliver of product context so the model
    // isn't judging a bare fragment.
    const enriched = { name: c.name, scope: `Product context: ${productCtx}\n\nFeature: ${c.name}\nText: ${c.scope}` };
    let clearProbe: FeatureProbe | null = null;
    let lastProbe: FeatureProbe | null = null;
    // Decorrelate probe retries: identical draws at one temperature turn
    // occasional stochastic misfires into false "intent unclear".
    const probeTemps = temperatureLadder();
    for (let t = 0; t < PROBES && !clearProbe; t++) {
      const p = await llm.probeFeature(enriched, { temperature: probeTemps[t % probeTemps.length] });
      lastProbe = p;
      if (isAssertion(p.assertion)) clearProbe = p;
    }
    const probe = clearProbe ?? lastProbe ?? { clear: false, assertion: '', question: '' };
    emit('prompt-gate-probe', { feature: c.name, clear: clearProbe !== null, cached: false });
    return { c, clear: clearProbe !== null, probe };
  });

  const deficiencies = verdicts
    .filter(v => !v.clear)
    .map(v => ({
      aspect: v.c.name,
      issue: `No observable assertion could be derived for "${v.c.name}" across ${PROBES} bounded probes without guessing intent.`,
      question: v.probe.question.trim() ||
        `What is the observable behavior of "${v.c.name}" — what input produces what output or state change?`,
    }));
  if (candidates.length === 0) {
    deficiencies.push({
      aspect: 'no features',
      issue: 'The prompt did not resolve into any distinct features.',
      question: 'What are the concrete features this product should have?',
    });
  }

  const assessment: PromptAssessment = {
    sufficient: deficiencies.length === 0,
    featuresIdentified: verdicts.filter(v => v.clear).map(v => v.c.name),
    deficiencies,
    suggestedRewrite: deficiencies.length === 0
      ? ''
      : verdicts
          .map(v => v.clear
            ? `Feature: ${v.c.name} — ${v.probe.assertion}`
            : `Feature: ${v.c.name} — [DECIDE: ${v.probe.question.trim() || 'clarify the observable behavior'}]`)
          .join('\n'),
    verdicts: verdicts.map(v => ({
      name: v.c.name,
      scope: v.c.scope,
      clear: v.clear,
      assertion: v.probe.assertion,
    })),
  };
  emit('prompt-gate', {
    sufficient: assessment.sufficient,
    features: assessment.featuresIdentified.length,
    deficiencies: assessment.deficiencies.length,
  });
  if (!assessment.sufficient) {
    job.promptAssessment = assessment;
    const gate = substrate.recordNode({
      type: 'decision',
      status: 'fail',
      summary: `input gate: prompt is not feature-demarcated (${assessment.deficiencies.length} deficiencies) — surfacing to author, no synthesis attempted`,
    });
    substrate.commit(gate, 'input gate: insufficient demarcation — analysis recorded');
    job.phase = 'insufficient-input';
    emit('phase', { phase: job.phase });
    return { substrate };
  }
  } // end input gate

  const decisionA = substrate.recordNode({
    type: 'decision',
    status: 'info',
    summary: `encode prompt into feature-coded spec`,
  });

  // SET 32a — pin encode to temperature 0. Deterministic spec carve is a
  // pre-condition for longitudinal comparison / bisectability.
  let spec = await llm.encodeFeatures(job.prompt, undefined, { temperature: 0 });
  // Part P2: pre-sanitize BEFORE the first validate — mechanical fixes never
  // trigger a re-encode. Part P3: the gate never calls encodeFeatures again.
  preSanitizeSpec(spec);
  // Part P1 floor: never accept fewer features than enumerated (or, on
  // skipGate, than this first encode produced).
  const collapseFloor = Math.max(enumeratedFloor ?? 0, spec.features.length);
  if (enumeratedFloor != null && isSpecCollapse(enumeratedFloor, spec.features.length)) {
    raiseSev1(
      job,
      'spec-collapse',
      `spec gate collapse: ${enumeratedFloor}→${spec.features.length} features (first encode)`,
      { from: enumeratedFloor, to: spec.features.length },
      emit,
    );
  }

  // Early per-feature escalations (spec/stub gates) — merged into irEscalations.
  const earlyEscalations: EscalationRecord[] = [];
  const specTemps = temperatureLadder();

  const tryAdoptRepairedSpec = (
    next: FeatureSpec | null,
    pass: number,
    priorCount: number,
  ): boolean => {
    if (!next) {
      emit('encoding-retry', {
        reason: 'spec-collapse-rejected',
        pass,
        from: priorCount,
        to: priorCount,
        floor: collapseFloor,
      });
      return false;
    }
    if (isSpecCollapse(collapseFloor, next.features.length) || next.features.length < priorCount) {
      emit('encoding-retry', {
        reason: 'spec-collapse-rejected',
        pass,
        from: priorCount,
        to: next.features.length,
        floor: collapseFloor,
      });
      return false;
    }
    spec = next;
    return true;
  };

  const specOutcome = await converge<string>({
    measure: () => {
      preSanitizeSpec(spec);
      return validateSpec(spec);
    },
    signature: ps => ps.join('|'),
    // Ceiling is generous; the real stop is stall + failed entropy reset.
    maxPasses: getConfig().maxPasses,
    progressStall: getConfig().progressStall,
    repair: async (ps, pass) => {
      const offending = offendingFeatureIds(ps, spec.features.map(f => f.id));
      emit('encoding-retry', {
        reason: 'spec-validation',
        pass,
        findings: ps.length,
        features: offending,
        mode: 'per-feature',
        temperature: specTemps[(pass - 1) % specTemps.length] ?? null,
      });
      if (!offending.length) {
        // Nothing to target — leave the spec alone (no whole-spec regen).
        return;
      }
      const priorCount = spec.features.length;
      let repaired: FeatureNode[];
      try {
        repaired = await llm.repairFeatures(spec, offending, ps, {
          temperature: specTemps[(pass - 1) % specTemps.length],
        });
      } catch (e) {
        emit('encoding-retry', {
          reason: 'spec-repair-failed',
          pass,
          error: e instanceof Error ? e.message : String(e),
        });
        return; // keep prior — converge counts as no-progress
      }
      tryAdoptRepairedSpec(mergeRepairedFeatures(spec, offending, repaired), pass, priorCount);
    },
    onStall: async (ps, pass) => {
      const offending = offendingFeatureIds(ps, spec.features.map(f => f.id));
      if (!offending.length) return false;
      const baseline = ps.length;
      const width = Math.max(1, getConfig().swarmWidth);
      const TEMPS = swarmTemperatures();
      const clean = specForFreshFeatureRepair(spec, offending);
      emit('spec-fresh-take', { features: offending, width, baseline, pass });
      const draws = await mapLimit(Array.from({ length: width }, (_, i) => i), concurrency(), (i: number) =>
        llm.repairFeatures(clean, offending, ps, { temperature: TEMPS[i % TEMPS.length] }));
      let best: FeatureSpec | null = null;
      let bestErrors = baseline;
      const priorCount = spec.features.length;
      for (const repaired of draws) {
        const trial = mergeRepairedFeatures(spec, offending, repaired);
        if (!trial) continue;
        if (isSpecCollapse(collapseFloor, trial.features.length) || trial.features.length < priorCount) continue;
        const errs = validateSpec(trial);
        if (errs.length < bestErrors) {
          bestErrors = errs.length;
          best = trial;
        }
        if (errs.length === 0) break;
      }
      if (best && bestErrors < baseline) {
        spec = best;
        emit('spec-fresh-take-kept', { features: offending, baseline, now: bestErrors });
        return true;
      }
      return false;
    },
  });
  if (isSpecCollapse(collapseFloor, spec.features.length)) {
    raiseSev1(
      job,
      'spec-collapse',
      `spec gate collapse: ${collapseFloor}→${spec.features.length} features`,
      { from: collapseFloor, to: spec.features.length, passes: specOutcome.passes },
      emit,
    );
  }
  if (specOutcome.errors.length) {
    const offending = offendingFeatureIds(specOutcome.errors, spec.features.map(f => f.id));
    if (isWholeSpecStructuralStall(specOutcome.errors, offending, spec.features.length)) {
      throw new Error(
        `spec gate ${specOutcome.stalled ? 'stalled' : 'hit the pass ceiling'} after ${specOutcome.passes} attempt(s) — whole-spec structural failure:\n- ${specOutcome.errors.join('\n- ')}`,
      );
    }
    // LOCALIZE: escalate only the offending features (and dependents), continue.
    // With edge-case demotion and deterministic dep resolution, everything that
    // survives to here is contract/structural — the cascade is legitimate by
    // construction. But provenance stays honest: seeds carry their own
    // diagnostics; dependents are labeled COLLATERAL, never defects.
    const seeds = new Set(offending);
    const drop = cascadeExcludeFeatures(offending.length ? offending : [], spec.features);
    if (!drop.size) {
      throw new Error(
        `spec gate ${specOutcome.stalled ? 'stalled' : 'hit the pass ceiling'} after ${specOutcome.passes} attempt(s) — escalating:\n- ${specOutcome.errors.join('\n- ')}`,
      );
    }
    // Proportionality alarm BEFORE excluding: a cascade consuming ≥ the SEV1
    // fraction of the artifact is an algorithm-level event — halt loudly with
    // honest accounting (offenders vs collateral), don't quietly amputate.
    const cascadeFrac = drop.size / Math.max(1, spec.features.length);
    if (cascadeFrac >= getConfig().sev1IrFraction && drop.size > seeds.size) {
      raiseSev1(
        job,
        'spec-cascade',
        `spec cascade: ${seeds.size} offender(s) would exclude ${drop.size}/${spec.features.length} features (${drop.size - seeds.size} collateral, no defect of their own)`,
        {
          offenders: [...seeds],
          collateral: [...drop].filter(id => !seeds.has(id)),
          errors: specOutcome.errors,
        },
        emit,
      );
    }
    for (const f of spec.features.filter(x => drop.has(x.id))) {
      const isSeed = seeds.has(f.id);
      const via = isSeed ? undefined : f.deps.find(d => drop.has(d)) ?? [...seeds][0];
      earlyEscalations.push({
        featureId: f.id,
        featureName: f.name,
        gate: 'spec',
        collateralOf: via,
        diagnostics: isSeed
          ? specOutcome.errors.filter(e => e.includes(f.id))
          : [`excluded as a dependent of ${via} — no defect of its own`],
      });
      emit('spec-escalated', {
        featureId: f.id,
        findings: isSeed ? specOutcome.errors.filter(e => e.includes(f.id)).length : 0,
        collateralOf: via ?? null,
      });
      const escNode = substrate.recordNode({
        type: 'decision',
        featureId: f.id,
        status: 'fail',
        summary: isSeed
          ? `spec escalated: ${f.id} could not pass validateSpec — excluded, run continues`
          : `spec cascade: ${f.id} excluded as dependent of ${via} — no defect of its own`,
      });
      substrate.commit(escNode, `spec(${f.id}): ${isSeed ? 'escalated' : `collateral of ${via}`} — excluded from run`);
    }
    spec = {
      ...spec,
      features: spec.features
        .filter(f => !drop.has(f.id))
        .map(f => ({ ...f, deps: f.deps.filter(d => !drop.has(d)) })),
    };
    preSanitizeSpec(spec);
    const residual = validateSpec(spec);
    if (residual.length) {
      throw new Error(
        `spec gate residual after per-feature escalation:\n- ${residual.join('\n- ')}`,
      );
    }
    if (!spec.features.length) {
      throw new Error('every feature escalated at the spec gate — nothing left to build');
    }
  }
  job.spec = spec;
  job.title = spec.title;

  // Thin-IR warning (demoted from a gate-blocking error): record, never block.
  const thin = specThinFeatures(spec);
  if (thin.length) {
    emit('spec-warning', { kind: 'thin-ir', features: thin });
    substrate.recordNode({
      type: 'decision',
      status: 'info',
      summary: `thin IR warning: ${thin.join(', ')} declare no edge cases — suites will be behavior-only`,
    });
  }

  // spec nodes + forward dependency pointers
  const specNode = new Map<string, string>();
  for (const f of topoOrder(spec.features)) {
    const parentSpec = f.parentId ? specNode.get(f.parentId) ?? decisionA : decisionA;
    const id = substrate.recordNode({
      type: 'spec',
      featureId: f.id,
      parentId: parentSpec,
      causeId: decisionA,
      status: 'info',
      summary: `${f.id} ${f.name}: ${f.description.slice(0, 120)}`,
    });
    specNode.set(f.id, id);
    emit('feature-encoded', { featureId: f.id, name: f.name });
  }
  for (const f of spec.features) {
    for (const d of f.deps) {
      substrate.recordEdge(specNode.get(d)!, specNode.get(f.id)!, `${d} is depended on by ${f.id}`);
    }
  }
  fs.writeFileSync(
    path.join(job.workspaceDir, '.supergit', 'spec.json'),
    JSON.stringify(spec, null, 2),
  );
  substrate.commit(decisionA, `spec: feature-coded specification (${spec.features.length} nodes)`);

  // ---- Header files: emit interface stubs and gate them alone -------------
  // Bounded convergence: repair ONLY the files tsc flags, iterate while each
  // pass makes progress, escalate on stall. The spec is never re-encoded —
  // whole-artifact regeneration is the failure mode this architecture exists
  // to prevent, at every gate, not just after build.
  // Invariant: ONE stub entry per path. The spec gate flags duplicates (T2.1),
  // but repair is stochastic — merge deterministically so a duplicate draw can
  // never put two writers on one file. Merged content may re-trip the stub gate
  // (duplicate imports); that gate then has a SINGLE stub object per path to
  // repair, which is the case it handles correctly.
  for (const f of spec.features) {
    f.stubs = mergeStubsByPath(f.stubs);
  }
  for (const f of spec.features) writeFiles(job.workspaceDir, f.stubs);
  const findStub = (p: string) => findStubExact(spec.features.flatMap(f => f.stubs), p);
  const stubDepth = depthOf(spec.features);
  const ownerOfStub = (file: string) =>
    spec.features.find(f => f.stubs.some(s => normalizePath(s.path) === normalizePath(file)));

  const groupTsc = (errs: TscDiagnostic[]): FindingGroup<TscDiagnostic>[] => {
    const m = new Map<string, FindingGroup<TscDiagnostic>>();
    for (const d of errs) {
      const ck = classKeyOf(d);
      const target = routeTarget(d).file;
      if (!m.has(ck)) {
        m.set(ck, {
          classKey: { key: ck, bugClass: classifyTsc(d), target, member: namedMember(d) },
          findings: [],
        });
      }
      m.get(ck)!.findings.push(d);
    }
    return [...m.values()];
  };

  // Serial substrate-mediated stub resolution (ONE group at a time).
  const stubOutcome = await resolveSerial<TscDiagnostic>({
    measure: () => runTsc(job.workspaceDir),
    group: groupTsc,
    depthOf: (target) => {
      const o = ownerOfStub(target);
      return o ? (stubDepth.get(o.id) ?? 0) : 0;
    },
    applyLessons: (groups) => {
      let applied = false;
      for (const g of groups) {
        for (const d of g.findings) {
          const stub = findStub(g.classKey.target);
          if (!stub) continue;
          const next = deterministicFix(d, stub.content);
          if (next && next !== stub.content) {
            stub.content = next;
            writeFiles(job.workspaceDir, [stub]);
            applied = true;
          }
        }
      }
      return applied;
    },
    repairFresh: async (g, temperature) => {
      const stub = findStub(g.classKey.target);
      if (!stub) return;
      const owner = ownerOfStub(g.classKey.target);
      const idioms = checkedIdiomsText([g.classKey.key, g.classKey.bugClass]);
      emit('stub-repair', {
        pass: 0,
        files: [g.classKey.target],
        findings: g.findings.length,
        deterministic: 0,
        temperature: temperature ?? null,
      });
      await applyFixStub(llm, emit, stub, g.findings.map(formatForRepair).join('\n'), {
        temperature,
        checkedIdioms: idioms || undefined,
        contractText: owner
          ? `exports: ${owner.exports.map(e => e.symbol).join(', ')}\nfiles: ${owner.files.join(', ')}`
          : undefined,
      });
      writeFiles(job.workspaceDir, [stub]);
    },
    scopedRemaining: async (g) => {
      const all = runTsc(job.workspaceDir);
      return all.filter(d => classKeyOf(d) === g.classKey.key);
    },
    onLesson: async (g) => {
      const member = g.classKey.member ?? undefined;
      const idiom = `${g.classKey.bugClass}: ${member ? `member ${member} ` : ''}on ${g.classKey.target}`;
      const resolution = member
        ? `additive widen, signature inferred from calling lines / diagnostics`
        : `fresh stub regeneration from diagnostics`;
      const rec = {
        classKey: g.classKey.key,
        idiom,
        resolution,
        member,
        target: g.classKey.target,
        signature: member
          ? `export function ${member}(..._args: unknown[]): never { throw new Error('stub: not implemented'); }`
          : undefined,
        ts: new Date().toISOString(),
      };
      appendLesson(rec);
      const n = substrate.recordNode({
        type: 'lesson',
        featureId: ownerOfStub(g.classKey.target)?.id ?? null,
        bugClass: g.classKey.bugClass,
        status: 'pass',
        summary: `lesson: ${idiom} → ${resolution}`,
      });
      substrate.commit(n, `lesson: ${g.classKey.key}`);
      emit('lesson', { classKey: g.classKey.key, idiom });
    },
    escalateFeature: async (featureId, g) => {
      const f = spec.features.find(x => x.id === featureId);
      if (!f) return;
      const drop = cascadeExcludeFeatures([featureId], spec.features);
      for (const id of drop) {
        const feat = spec.features.find(x => x.id === id);
        if (!feat) continue;
        const isSeed = id === featureId;
        const via = isSeed ? undefined : feat.deps.find(d => drop.has(d)) ?? featureId;
        earlyEscalations.push({
          featureId: feat.id,
          featureName: feat.name,
          gate: 'stub',
          collateralOf: via,
          diagnostics: isSeed
            ? g.findings.map(d => `${d.file}:${d.line} TS${d.code}: ${d.message}`)
            : [`excluded as a dependent of ${via} — no defect of its own`],
        });
        emit('stub-escalated', { featureId: feat.id, findings: isSeed ? g.findings.length : 0, collateralOf: via ?? null });
        const escNode = substrate.recordNode({
          type: 'decision',
          featureId: feat.id,
          status: 'fail',
          summary: isSeed
            ? `stub escalated: ${feat.id} — excluded, run continues`
            : `stub cascade: ${feat.id} excluded as dependent of ${via} — no defect of its own`,
        });
        substrate.commit(escNode, `stub(${feat.id}): ${isSeed ? 'escalated' : `collateral of ${via}`}`);
        for (const s of feat.stubs) {
          try { fs.rmSync(path.join(job.workspaceDir, s.path), { force: true }); } catch { /* ignore */ }
        }
      }
      spec = {
        ...spec,
        features: spec.features
          .filter(x => !drop.has(x.id))
          .map(x => ({ ...x, deps: x.deps.filter(d => !drop.has(d)) })),
      };
    },
    owningFeature: (target) => ownerOfStub(target)?.id ?? null,
  });
  if (stubOutcome.errors.length && !spec.features.length) {
    throw new Error('every feature escalated at the stub gate — nothing left to build');
  }
  if (stubOutcome.errors.length) {
    // Residual after per-feature escalation — structural failure
    const residual = runTsc(job.workspaceDir);
    if (residual.length) {
      throw new Error(
        `stub gate residual after serial resolution:\n- ${residual
          .map(d => `${d.file}:${d.line} TS${d.code}: ${d.message}`)
          .join('\n- ')}`,
      );
    }
  }
  const stubNode = substrate.recordNode({
    type: 'decision',
    status: 'pass',
    summary: `interface stubs emitted and compile clean (${spec.features.reduce((n, f) => n + f.stubs.length, 0)} header files)`,
  });
  substrate.commit(stubNode, 'spec: interface stubs (header files)');
  emit('stubs-ready', { files: spec.features.flatMap(f => f.stubs.map(s => s.path)) });
  fs.writeFileSync(
    path.join(job.workspaceDir, '.supergit', 'spec.json'),
    JSON.stringify(spec, null, 2),
  );

  // ---- Phase B: IR generation --------------------------------------------
  job.phase = 'generating-ir';
  emit('phase', { phase: job.phase });

  const cases: IRTestCase[] = [];
  const freezeHashes: Record<string, string> = {};

  // Features whose IR cannot be made to converge escalate individually — the
  // rest of the run proceeds. Suites are independent given the frozen stubs,
  // so one unfixable feature no longer kills the whole job.
  const irEscalations: EscalationRecord[] = [
    ...earlyEscalations,
  ];
  const irTemps = temperatureLadder();

  // Suites are independent by construction (strict input isolation: each is
  // derived from the spec alone), so generation fans out. Only the network
  // calls overlap — validation runs per task, and everything that touches
  // the ledger or git happens in the serial pass below, in topo order.
  type GenOk = { f: FeatureNode; content: string; parts: IRParts | undefined };
  const genResults = await mapLimit(topoOrder(spec.features), concurrency(), async (f): Promise<GenOk | { f: FeatureNode; escalated: string[] }> => {
    let { content, parts } = await llm.generateTests(spec, f);
    // Bounded unit: this ONE feature's suite. Regenerate with the gate's
    // findings as feedback while passes make progress; a broken IR must never
    // freeze, so stall = escalate THIS feature (not the whole job).
    const irOutcome = await converge<string>({
      measure: () => validateTestFile(content),
      signature: ps => ps.join('|'),
      maxPasses: getConfig().maxPasses,
      progressStall: getConfig().progressStall,
      repair: async (ps, pass) => {
        const temp = irTemps[(pass - 1) % irTemps.length];
        emit('ir-repair', { featureId: f.id, pass, findings: ps.length, temperature: temp ?? null });
        const r = await llm.generateTests(
          spec,
          f,
          `Deterministic gate findings on the previous attempt:\n- ${ps.join('\n- ')}`,
          { temperature: temp },
        );
        content = r.content;
        parts = r.parts;
      },
      onStall: async (ps, pass) => {
        // One fresh-draw pass: no feedback, higher temp — before escalating.
        const TEMPS = swarmTemperatures();
        const temp = TEMPS[TEMPS.length - 1];
        const baseline = ps.length;
        emit('ir-fresh-take', { featureId: f.id, width: 1, baseline, pass, mode: 'per-feature' });
        const snap = { content, parts };
        const r = await llm.generateTests(spec, f, undefined, { temperature: temp });
        const now = validateTestFile(r.content).length;
        if (now < baseline) {
          content = r.content;
          parts = r.parts;
          emit('ir-fresh-take-kept', { featureId: f.id, baseline, now });
          return true;
        }
        content = snap.content;
        parts = snap.parts;
        return false;
      },
    });
    if (irOutcome.errors.length) {
      if (irOutcome.errors.some(e => /has no test cases/i.test(e))) {
        raiseSev1(
          job,
          'no-test-cases',
          `suite for ${f.id} (${f.name}) has no test cases`,
          { featureId: f.id, feature: f.name, diagnostics: irOutcome.errors },
          emit,
        );
      }
      emit('ir-escalated', { featureId: f.id, findings: irOutcome.errors.length });
      return { f, escalated: irOutcome.errors };
    }
    emit('ir-generated', { featureId: f.id });
    return { f, content, parts };
  });
  const generated: GenOk[] = [];
  for (const r of genResults) {
    if ('escalated' in r) irEscalations.push({ featureId: r.f.id, featureName: r.f.name, gate: 'ir', diagnostics: r.escalated });
    else generated.push(r);
  }

  // ---- Cross-suite consistency gate ---------------------------------------
  // All suites + all stubs typecheck together BEFORE anything freezes. A
  // suite that calls beyond a declared interface fails here — at gate 1 —
  // instead of surfacing as an unhealable integration failure after build.
  for (const { f, content } of generated) {
    writeFiles(job.workspaceDir, [{ path: f.testFile, content }]);
  }
  // Bounded convergence, RHA doctrine at the IR level. The failure this
  // replaces: regenerating a whole suite per pass resamples ~N fresh errors
  // for every N it clears, so the count plateaus and coupled features never
  // converge. Here the unit is the failing CASE — repair only the cases tsc
  // flags, preserve every passing case byte-for-byte, and RATCHET (revert any
  // file a pass made worse). That makes the error count monotonically
  // non-increasing, so progress-stall means genuinely stuck, not oscillating.
  const testFileToEntry = new Map(generated.map(e => [normalizePath(e.f.testFile), e]));
  const fmtDiag = (d: TscDiagnostic) => `${normalizePath(d.file)}:${d.line} TS${d.code}: ${d.message}`;
  const excerptAround = (content: string, line: number, radius = 6) =>
    content.split('\n').slice(Math.max(0, line - 1 - radius), line + radius).join('\n');
  // Per-suite escalation loop: run the bounded gate; if it cannot clear,
  // escalate the WORST surviving suite and re-run on the rest with a fresh
  // budget. Removing one hopeless suite lets the independent others converge.
  // Serial resolver doctrine: one class-group at a time; no exhaustedStubs bench.
  const SYNTAX_CODES = new Set([1002, 1003, 1005, 1109, 1128, 1131, 1155, 1183, 1434]);
  // Contract-first scheduling state: fingerprint of the stubs that residual
  // findings route to. While that fingerprint keeps changing, the contract is
  // still growing and NO suite may burn escalation budget (the F11 casualty
  // class: excluded in 20s while routes.ts was still being widened, then the
  // contract quadrupled). Quiescence (unchanged across 2 rounds with routed
  // findings persisting) = the contract genuinely cannot grow → escalate the
  // stub's OWNER, never innocent suites.
  let lastStubFp = '';
  let stubQuiet = 0;
  let gateRounds = 0;
  // eslint-disable-next-line no-constant-condition
  while (true) {
    const gateOutcome = await converge<TscDiagnostic>({
    measure: () => runTsc(job.workspaceDir),
    signature: ds => ds.map(d => `${normalizePath(d.file)}:${d.line}:${d.code}`).sort().join('|'),
    metric: ds => ds.length,
    repair: async (ds, pass) => {
      const touchedThisPass = new Set<string>();
      // SERIAL: lessons/deterministic first, then ONE upstream-most class group.
      let lessonHit = false;
      for (const d of ds) {
        const target = routeTarget(d).file;
        const stub = findStubExact(spec.features.flatMap(f => f.stubs), target);
        if (!stub) continue;
        const next = deterministicFix(d, stub.content);
        if (next && next !== stub.content) {
          stub.content = next;
          writeFiles(job.workspaceDir, [stub]);
          lessonHit = true;
        }
      }
      if (lessonHit) return;

      const groups = new Map<string, TscDiagnostic[]>();
      for (const d of ds) {
        const ck = classKeyOf(d);
        if (!groups.has(ck)) groups.set(ck, []);
        groups.get(ck)!.push(d);
      }
      const depthMap = depthOf(spec.features);
      const ranked = [...groups.entries()].sort((a, b) => {
        const ta = routeTarget(a[1][0]).file;
        const tb = routeTarget(b[1][0]).file;
        const oa = spec.features.find(f => f.stubs.some(s => normalizePath(s.path) === normalizePath(ta)) || normalizePath(f.testFile) === normalizePath(ta));
        const ob = spec.features.find(f => f.stubs.some(s => normalizePath(s.path) === normalizePath(tb)) || normalizePath(f.testFile) === normalizePath(tb));
        const da = oa ? (depthMap.get(oa.id) ?? 0) : 99;
        const db = ob ? (depthMap.get(ob.id) ?? 0) : 99;
        return da - db || a[0].localeCompare(b[0]);
      });
      const pick = ranked[0]?.[1] ?? ds;
      const byFile = new Map<string, TscDiagnostic[]>();
      for (const d of pick) {
        const key = normalizePath(d.file);
        if (!byFile.has(key)) byFile.set(key, []);
        byFile.get(key)!.push(d);
      }
      emit('consistency-gate', { pass: false, findings: ds.length, files: [...byFile.keys()], repairPass: pass, serialGroup: ranked[0]?.[0] ?? null });

      // Snapshot each failing test file for the ratchet, then per-case repair.
      const snapshots = new Map<string, { parts: IRParts | undefined; content: string }>();
      const stubSnapshots = new Map<string, string>(); // path -> pre-pass content (T4 ratchet)
      for (const [key, diags] of byFile) {
        const entry = testFileToEntry.get(key);
        if (!entry) continue; // a stub file — handled below
        snapshots.set(key, { parts: entry.parts ? structuredClone(entry.parts) : undefined, content: entry.content });

        if (!entry.parts) {
          // No structured parts (mock fixtures): fall back to whole-suite regen.
          const r = await llm.generateTests(spec, entry.f, diags.map(fmtDiag).join('\n'));
          if (!validateTestFile(r.content).length) {
            entry.content = r.content;
            entry.parts = r.parts;
            writeFiles(job.workspaceDir, [{ path: entry.f.testFile, content: r.content }]);
          }
          continue;
        }

        // Missing-member errors are REPORTED by the suite but OWNED by the
        // contract (the cross-module lie the oracle's route column encodes).
        // Pre-freeze, growth is safe: ADD the member the suite needs. TS2305
        // names its definer module; TS2304 (bare name) belongs to THIS
        // feature's own stubs — the suite calls something nobody exports.
        const missing = diags.filter(d => d.code === 2305 || d.code === 2304);
        // BATCH per target stub: one widening call carries EVERY missing member
        // plus each one's calling evidence. One-diagnostic-at-a-time widening
        // (193→209→287→…, eleven calls) is the trickle that lost the F11 race —
        // the contract must reach the suites' full demand in one draw.
        const widenByStub = new Map<string, { stub: GeneratedFile; evidence: string[] }>();
        for (const d of missing) {
          const targetPath = d.code === 2305 ? routeTarget(d).file : null;
          const stubs = targetPath
            ? spec.features.flatMap(f => f.stubs).filter(s => normalizePath(s.path) === targetPath)
            : entry.f.stubs;
          for (const stub of stubs) {
            const p = normalizePath(stub.path);
            if (!widenByStub.has(p)) widenByStub.set(p, { stub, evidence: [] });
            widenByStub.get(p)!.evidence.push(
              fmtDiag(d) + `\nCalling lines (suite ${entry.f.testFile}):\n` + excerptAround(entry.content, d.line),
            );
          }
        }
        for (const [p, { stub, evidence }] of widenByStub) {
          if (touchedThisPass.has(p)) continue;
          stubSnapshots.set(p, stub.content); // T4 ratchet
          await applyFixStub(
            llm,
            emit,
            stub,
            `Dependent TEST suites need ${evidence.length} member(s) this contract does not provide — ADD ALL of them (pre-freeze, additive-only):\n\n`
            + evidence.join('\n\n'),
          );
          touchedThisPass.add(p);
          writeFiles(job.workspaceDir, [stub]);
        }

        // Arity: only "got MORE than declared" is contract-side (TS2554 with
        // M > N). TS2555 ("expected at least N, got M") is a caller defect and
        // stays test-side. Widen ONLY the stub that declares the called symbol,
        // with the calling LINES as context — not the whole suite.
        const arity = diags.filter(d => {
          if (d.code !== 2554) return false;
          const m = /Expected (\d+) arguments?, but got (\d+)/.exec(d.message);
          return !!(m && Number(m[2]) > Number(m[1]));
        });
        if (arity.length) {
          const line = entry.content.split('\n')[arity[0].line - 1] ?? '';
          const ctor = /new\s+([A-Z]\w*)\s*\(/.exec(line)?.[1]; // missing-constructor case
          const sym = ctor ?? /(?:^|[^\w.])(\w+)\s*\(/.exec(line)?.[1];
          const imp = sym && entry.parts?.imports.find(i => i.symbols.includes(sym));
          const target = imp ? stubPathFromImport(imp.from) : null;
          const stubs = target
            ? entry.f.stubs.filter(s => normalizePath(s.path) === target)
            : entry.f.stubs; // unresolved symbol: last-resort fan-out, logged
          if (!target) emit('stub-repair', { pass, files: ['<unresolved arity target>'], findings: arity.length, deterministic: 0 });
          for (const stub of stubs) {
            const p = normalizePath(stub.path);
            if (touchedThisPass.has(p)) continue;
            stubSnapshots.set(p, stub.content); // for P2c
            const changed = await applyFixStub(
              llm,
              emit,
              stub,
              `Dependent TESTS call this contract with more arguments than declared — WIDEN (pre-freeze):\n`
              + arity.map(fmtDiag).join('\n')
              + (ctor ? `\nThe error is on \`new ${ctor}(...)\` and the stub declares no constructor — ADD one.` : '')
              + `\n\nCalling lines (suite ${entry.f.testFile}):\n` + excerptAround(entry.content, arity[0].line),
            );
            touchedThisPass.add(p);
            writeFiles(job.workspaceDir, [stub]);
          }
          // do NOT continue past the file's other errors — fall through on testSide
        }

        const contractOwned = new Set([...missing, ...arity].map(fmtDiag));
        let testSide = diags.filter(d => !contractOwned.has(fmtDiag(d)));
        if (!testSide.length) continue; // everything routed to stubs

        // Mechanical renames FIRST (zero tokens): TS2551/2552/2820 carry the
        // fix in the message. Apply to the owning case body (or the import
        // block) and drop the diagnostic — no draw, no escalation budget.
        // F1 once died on `RouteMap` vs `routeMap` with 3 LLM draws; this row
        // makes that class extinct for suites, as it already was for stubs.
        const renamable = testSide
          .map(d => ({ d, r: [2551, 2552, 2820].includes(d.code) ? extractRename(d.message) : null }))
          .filter((x): x is { d: TscDiagnostic; r: { wrong: string; right: string } } => !!x.r);
        if (renamable.length) {
          const preMap = assembleWithMap(entry.f, entry.parts.imports, entry.parts.cases);
          const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          for (const { d, r } of renamable) {
            const re = new RegExp(`\\b${esc(r.wrong)}\\b`, 'g');
            const cr = preMap.caseRanges.find(x => d.line >= x.start && d.line <= x.end);
            if (cr) {
              const c = entry.parts.cases[cr.index];
              entry.parts.cases[cr.index] = { ...c, body: c.body.replace(re, r.right) };
            } else {
              entry.parts.imports = entry.parts.imports.map(im => ({
                ...im,
                symbols: im.symbols.map(s => (s === r.wrong ? r.right : s)),
              }));
            }
          }
          const renamed = assembleTestFile(entry.f, entry.parts.imports, entry.parts.cases);
          if (!validateTestFile(renamed).length) {
            entry.content = renamed;
            writeFiles(job.workspaceDir, [{ path: entry.f.testFile, content: renamed }]);
          }
          const dropped = new Set(renamable.map(x => fmtDiag(x.d)));
          testSide = testSide.filter(d => !dropped.has(fmtDiag(d)));
          if (!testSide.length) continue; // renames may be the whole story — re-measure next pass
        }

        // Map each remaining diagnostic line to the case (or import block) that owns it.
        const map = assembleWithMap(entry.f, entry.parts.imports, entry.parts.cases);
        const caseErrors = new Map<number, string[]>();
        const importErrors: string[] = [];
        for (const d of testSide) {
          const cr = map.caseRanges.find(r => d.line >= r.start && d.line <= r.end);
          if (cr) {
            if (!caseErrors.has(cr.index)) caseErrors.set(cr.index, []);
            caseErrors.get(cr.index)!.push(fmtDiag(d));
          } else {
            importErrors.push(fmtDiag(d)); // import block or structural line
          }
        }
        const brokenIdx = [...caseErrors.keys()];
        if (!brokenIdx.length && !importErrors.length) continue;

        const brokenCases = brokenIdx.map(i => ({
          name: entry.parts!.cases[i].name,
          kind: entry.parts!.cases[i].kind,
          errors: caseErrors.get(i)!,
        }));
        // Swarm best-of-N (verify, don't vote): fire RHA_SWARM_WIDTH candidates
        // and keep the first whose reassembly clears the cheap deterministic
        // verifier (validateTestFile). Width 1 = single call, identical to
        // before. Wider helps a weak model; a stronger model needs width ~1.
        // Walk temperatures up the ladder across passes (and across swarm slots)
        // so retries are decorrelated.
        const swarmWidth = Math.max(1, getConfig().swarmWidth);
        const caseTemps = temperatureLadder();
        const candidates = await mapLimit(
          Array.from({ length: swarmWidth }, (_, i) => i),
          concurrency(),
          (i: number) => llm.fixTestCases(
            spec,
            entry.f,
            brokenCases,
            importErrors,
            entry.parts!.imports,
            { temperature: caseTemps[(pass - 1 + i) % caseTemps.length] },
          ),
        );
        let fix = candidates[0];
        for (const cand of candidates) {
          const trialCases = entry.parts!.cases.slice();
          brokenIdx.forEach((idx, i) => { const nc = cand.cases[i]; if (nc) trialCases[idx] = { name: nc.name, kind: nc.kind, body: nc.body }; });
          const trialImports = (importErrors.length && cand.imports?.length) ? cand.imports : entry.parts!.imports;
          if (!validateTestFile(assembleTestFile(entry.f, trialImports, trialCases)).length) { fix = cand; break; }
        }
        // Splice corrected cases back by position (returned in brokenIdx order).
        brokenIdx.forEach((idx, i) => {
          const nc = fix.cases[i];
          if (nc) entry.parts!.cases[idx] = { name: nc.name, kind: nc.kind, body: nc.body };
        });
        if (importErrors.length && fix.imports?.length) entry.parts!.imports = fix.imports;

        const reassembled = assembleTestFile(entry.f, entry.parts.imports, entry.parts.cases);
        if (validateTestFile(reassembled).length) {
          entry.parts = snapshots.get(key)!.parts; // structural regression → revert parts
          continue;
        }
        entry.content = reassembled;
        writeFiles(job.workspaceDir, [{ path: entry.f.testFile, content: reassembled }]);
      }

      // Stub files implicated here get the same oracle treatment: try the
      // deterministic fix first (zero tokens), then a hint-scoped fixStub (which
      // may now GROW the contract additively — bidirectional repair). Process
      // in dependency order — root stubs first — so an upstream contract that
      // grows is on disk before dependents are re-measured next pass.
      const stubDepth = depthOf(spec.features);
      const ownerDepth = (p: string) => {
        const owner = spec.features.find(f => f.stubs.some(s => normalizePath(s.path) === p));
        return owner ? (stubDepth.get(owner.id) ?? 0) : 0;
      };
      const stubKeys = [...byFile.keys()].filter(k => k.startsWith('src/')).sort((a, b) => ownerDepth(a) - ownerDepth(b));
      for (const key of stubKeys) {
        const diags = byFile.get(key)!;
        const stub = findStubExact(spec.features.flatMap(f => f.stubs), key);
        if (!stub) continue;
        if (!stubSnapshots.has(key)) stubSnapshots.set(key, stub.content);
        let handled = false;
        for (const d of diags) {
          const next = deterministicFix(d, stub.content);
          if (next && next !== stub.content) { stub.content = next; handled = true; }
        }
        if (handled) { writeFiles(job.workspaceDir, [stub]); continue; }
        if (touchedThisPass.has(key)) continue;
        const changed = await applyFixStub(llm, emit, stub, diags.map(formatForRepair).join('\n'));
        touchedThisPass.add(key);
        writeFiles(job.workspaceDir, [stub]);
      }

      // Ratchet: re-typecheck; revert any test file whose error count grew.
      const after = runTsc(job.workspaceDir);
      const afterCount = new Map<string, number>();
      for (const d of after) {
        const key = normalizePath(d.file);
        afterCount.set(key, (afterCount.get(key) ?? 0) + 1);
      }
      for (const [key, snap] of snapshots) {
        const grew = (afterCount.get(key) ?? 0) > (byFile.get(key)?.length ?? 0);
        if (!grew) continue;
        const entry = testFileToEntry.get(key)!;
        entry.parts = snap.parts;
        entry.content = snap.content;
        writeFiles(job.workspaceDir, [{ path: entry.f.testFile, content: snap.content }]);
      }
      // Per-stub, syntax-hard ratchet: a repair may grow a contract additively but
      // must NEVER make a stub unparseable or raise its own error count. Revert and
      // bench any stub that did — this is what stops the 569→569 corruption thrash.
      const afterByFile = new Map<string, number>();
      const afterSyntax = new Set<string>();
      for (const d of after) {
        const k = normalizePath(d.file);
        afterByFile.set(k, (afterByFile.get(k) ?? 0) + 1);
        if (SYNTAX_CODES.has(d.code)) afterSyntax.add(k);
      }
      for (const [p, content] of stubSnapshots) {
        const stub = findStubExact(spec.features.flatMap(f => f.stubs), p);
        if (!stub || stub.content === content) continue;
        const grew = (afterByFile.get(p) ?? 0) > (byFile.get(p)?.length ?? 0);
        if (afterSyntax.has(p) || grew) {
          stub.content = content;             // restore last-parseable contract
          writeFiles(job.workspaceDir, [stub]);
        }
      }
      stubSnapshots.clear();
    },
    });
    if (!gateOutcome.errors.length) break; // survivors all typecheck against the stubs

    if (++gateRounds > 60) {
      throw new Error(`cross-suite gate: round cap (60) exceeded with ${gateOutcome.errors.length} residual finding(s)`);
    }
    // CONTRACT-FIRST: findings the oracle routes into src/ belong to stubs.
    // While their target stubs are still changing, take another widening round
    // — suite escalation is frozen until the contract stops moving.
    const contractRouted = gateOutcome.errors.filter(d => routeTarget(d).file.startsWith('src/'));
    let contractStuck = false;
    if (contractRouted.length) {
      const allStubs = spec.features.flatMap(f => f.stubs);
      const fp = [...new Set(contractRouted.map(d => routeTarget(d).file))].sort()
        .map(p => `${p}:${sha256(findStubExact(allStubs, p)?.content ?? '')}`)
        .join('|');
      if (fp !== lastStubFp) {
        lastStubFp = fp;
        stubQuiet = 0;
        continue; // contract still moving — widen more, no suite burns budget
      }
      stubQuiet++;
      if (stubQuiet < 2) continue;
      contractStuck = true; // quiescent + still failing: the contract cannot grow
    } else {
      lastStubFp = '';
      stubQuiet = 0;
    }

    const byFile = new Map<string, TscDiagnostic[]>();
    for (const d of gateOutcome.errors) {
      const key = normalizePath(d.file);
      if (!byFile.has(key)) byFile.set(key, []);
      byFile.get(key)!.push(d);
    }
    // Dependency-aware escalation: give up on LEAVES first, fight hardest for
    // the ROOT. A root's contract is the shared cause of its dependents'
    // failures (and bidirectional stub-growth may still rescue it), so escalate
    // the DEEPEST failing suite first; tie-break by most residual errors.
    const escDepth = depthOf(spec.features);
    let worst: GenOk | null = null;
    let worstDiags: TscDiagnostic[] = [];
    let worstDepth = -1;
    for (const [key, entry] of testFileToEntry) {
      const arr = byFile.get(key) ?? [];
      if (!arr.length) continue;
      const dep = escDepth.get(entry.f.id) ?? 0;
      if (dep > worstDepth || (dep === worstDepth && arr.length > worstDiags.length)) {
        worst = entry; worstDiags = arr; worstDepth = dep;
      }
    }
    // A stuck contract escalates its OWNING feature (the stub-owner path
    // below), never a suite that was only blocked by it.
    if (!worst || contractStuck) {
      // Residual errors belong to CONTRACT files (reported there, or routed
      // there by the oracle when the contract went quiescent). DROP the
      // owning feature(s) — the party whose contract cannot be satisfied —
      // never the suites that were only blocked by it.
      const ownerOfPath = (p: string) =>
        spec.features.find(f => f.stubs.some(s => normalizePath(s.path) === normalizePath(p)));
      const owners = new Set<string>();
      for (const d of gateOutcome.errors) {
        const owner = ownerOfPath(d.file) ?? (contractStuck ? ownerOfPath(routeTarget(d).file) : undefined);
        if (owner) owners.add(owner.id);
      }
      if (!owners.size) {
        throw new Error(`cross-suite gate: residual errors in no known file:\n- ${gateOutcome.errors.map(fmtDiag).join('\n- ')}`);
      }
      let dropped = 0;
      for (const fid of owners) {
        const gi = generated.findIndex(g => g.f.id === fid);
        if (gi < 0) continue;
        const g = generated[gi];
        const fdiags = gateOutcome.errors.filter(d =>
          (ownerOfPath(d.file) ?? (contractStuck ? ownerOfPath(routeTarget(d).file) : undefined))?.id === fid);
        irEscalations.push({
          featureId: g.f.id,
          featureName: g.f.name,
          gate: 'stub',
          diagnostics: fdiags.map(fmtDiag),
        });
        emit('ir-escalated', { featureId: g.f.id, findings: fdiags.length, gate: 'stub' });
        const escNode = substrate.recordNode({
          type: 'decision',
          featureId: g.f.id,
          status: 'fail',
          summary: `stub escalated: ${g.f.id}'s contract could not grow to satisfy its callers (${fdiags.length} findings) — excluded, run continues`,
        });
        substrate.commit(escNode, `stub(${g.f.id}): contract quiescent + failing — excluded`);
        try { fs.rmSync(path.join(job.workspaceDir, g.f.testFile), { force: true }); } catch { /* ignore */ }
        // Drop the residual stub files too — otherwise the next measure still
        // sees stub-level errors, re-enters !worst, and finds no suite left to
        // drop (infinite continue / hard throw). Dependents that imported them
        // surface as suite-side missing-module errors and escalate normally.
        for (const d of fdiags) {
          for (const p of [normalizePath(d.file), normalizePath(routeTarget(d).file)]) {
            if (!p.startsWith('src/')) continue;
            try { fs.rmSync(path.join(job.workspaceDir, p), { force: true }); } catch { /* ignore */ }
          }
        }
        testFileToEntry.delete(normalizePath(g.f.testFile));
        generated.splice(gi, 1);
        dropped++;
      }
      lastStubFp = '';
      stubQuiet = 0; // contract set changed — fresh quiescence window
      if (!dropped) {
        throw new Error(
          `cross-suite gate: residual stub errors for already-escalated features:\n- ${gateOutcome.errors.map(fmtDiag).join('\n- ')}`,
        );
      }
      if (!generated.length) throw new Error('every feature escalated — no suite could be frozen against the stubs');
      continue; // re-run the gate without the dropped feature(s)
    }

    // ENTROPY RESET at the IR gate (same anti-anchoring as RHA build-repair).
    // The per-case repair kept editing the SAME broken suite — correlated draws
    // that plateau (the 30,30,30 grind). Before escalating, take FRESH whole-
    // suite draws from the frozen contract ALONE (spec + stubs, no broken cases
    // in context) — a swarm — and keep the one the verifier says conforms best.
    // Grinding one basin ⇒ branch. Only escalate if fresh draws can't beat it.
    {
      const wkey = normalizePath(worst.f.testFile);
      const width = Math.max(1, getConfig().swarmWidth);
      const suiteFindings = () => runTsc(job.workspaceDir).filter(x => normalizePath(x.file) === wkey).length;
      const baseline = worstDiags.length;
      const snapContent = worst.content;
      const snapParts = worst.parts ? structuredClone(worst.parts) : undefined;
      emit('ir-fresh-take', { featureId: worst.f.id, width, baseline });
      // Temperature ladder: decorrelate the fresh draws so they don't all
      // sample the wrong basin the anchored suite was stuck in.
      const IR_TEMPS = swarmTemperatures();
      const draws = await mapLimit(Array.from({ length: width }, (_, i) => i), concurrency(), (i: number) =>
        llm.generateTests(spec, worst!.f, undefined, { temperature: IR_TEMPS[i % IR_TEMPS.length] }));
      let bestContent: string | null = null;
      let bestParts: IRParts | undefined;
      let bestFindings = baseline;
      for (const r of draws) {
        if (validateTestFile(r.content).length) continue; // structurally invalid → skip
        writeFiles(job.workspaceDir, [{ path: worst.f.testFile, content: r.content }]);
        const fc = suiteFindings();
        if (fc < bestFindings) { bestFindings = fc; bestContent = r.content; bestParts = r.parts; }
        if (fc === 0) break;
      }
      if (bestContent && bestFindings < baseline) {
        worst.content = bestContent;
        worst.parts = bestParts;
        writeFiles(job.workspaceDir, [{ path: worst.f.testFile, content: bestContent }]);
        emit('ir-fresh-take-kept', { featureId: worst.f.id, baseline, now: bestFindings });
        continue; // re-run the gate with the fresher suite instead of escalating
      }
      worst.content = snapContent; // fresh draws did not beat the anchored suite — restore and escalate
      worst.parts = snapParts;
      writeFiles(job.workspaceDir, [{ path: worst.f.testFile, content: snapContent }]);
    }

    irEscalations.push({ featureId: worst.f.id, featureName: worst.f.name, gate: 'ir', diagnostics: worstDiags.map(fmtDiag) });
    emit('ir-escalated', { featureId: worst.f.id, findings: worstDiags.length, gate: 'ir' });
    const escNode = substrate.recordNode({
      type: 'decision',
      featureId: worst.f.id,
      status: 'fail',
      summary: `IR escalated: ${worst.f.id} suite could not typecheck against the stubs (${worstDiags.length} findings) — excluded from freeze, run continues`,
    });
    substrate.commit(escNode, `ir(${worst.f.id}): escalated — suite not frozen`);
    try { fs.rmSync(path.join(job.workspaceDir, worst.f.testFile), { force: true }); } catch { /* ignore */ }
    testFileToEntry.delete(normalizePath(worst.f.testFile));
    const gi = generated.indexOf(worst);
    if (gi >= 0) generated.splice(gi, 1);
    if (generated.length === 0) {
      throw new Error('every feature\'s IR escalated — no suite could be frozen against the stubs');
    }
  }
  const gateNode = substrate.recordNode({
    type: 'decision',
    status: 'pass',
    summary: 'cross-suite consistency gate: all suites typecheck against the interface stubs',
  });
  substrate.commit(gateNode, 'ir: cross-suite consistency gate passed');
  emit('consistency-gate', { pass: true });
  fs.writeFileSync(
    path.join(job.workspaceDir, '.supergit', 'spec.json'),
    JSON.stringify(spec, null, 2),
  );

  for (const { f, content } of generated) {
    const fCases = extractCases(f.id, content);
    cases.push(...fCases);
    freezeHashes[f.testFile] = sha256(content);
    const testNode = substrate.recordNode({
      type: 'test',
      featureId: f.id,
      parentId: specNode.get(f.id),
      causeId: specNode.get(f.id),
      artifactPath: f.testFile,
      status: 'info',
      summary: `IR suite for ${f.id}: ${fCases.length} cases (${fCases.filter(c => c.kind === 'edge').length} edge)`,
    });
    substrate.commit(testNode, `ir(${f.id}): freeze candidate test suite`);
    emit('ir-feature', { featureId: f.id, cases: fCases.length, edge: fCases.filter(c => c.kind === 'edge').length });
  }

  // The manifest carries only the SURVIVING features — the ones with a frozen,
  // stub-conformant suite. Escalated features are excluded from freeze/build.
  const survivors = generated.map(e => e.f);
  const manifest: IRManifest = {
    version: 1,
    title: spec.title,
    prompt: spec.prompt,
    features: survivors,
    cases,
    freezeHashes,
    frozenAt: null,
  };
  job.manifest = manifest;
  job.irEscalations = irEscalations;
  if (cases.length === 0) {
    raiseSev1(
      job,
      'zero-ir-cases',
      'IR produced 0 cases across all surviving features',
      { survivors: survivors.length, escalated: irEscalations.length },
      emit,
    );
  }
  // Honest flood accounting: count only TRUE IR/stub-gate escalations against
  // the population that actually reached this phase. Spec-gate exclusions have
  // their own alarm (spec-cascade) and collateral is never a defect — counting
  // either here once inflated 4 real failures into "25/15 flood".
  const trueIr = irEscalations.filter(e => (e.gate ?? 'ir') !== 'spec' && !e.collateralOf);
  const totalFeatures = Math.max(1, spec.features.length);
  const frac = getConfig().sev1IrFraction;
  if (trueIr.length / totalFeatures >= frac && frac < 1) {
    raiseSev1(
      job,
      'ir-escalation-flood',
      `IR escalations ${trueIr.length}/${totalFeatures} ≥ fraction ${frac} (${irEscalations.length - trueIr.length} spec/collateral exclusions counted separately)`,
      { escalated: trueIr.length, total: totalFeatures, fraction: frac, specOrCollateral: irEscalations.length - trueIr.length },
      emit,
    );
  }
  fs.writeFileSync(
    path.join(job.workspaceDir, '.supergit', 'ir-manifest.json'),
    JSON.stringify(manifest, null, 2),
  );
  const irDone = substrate.recordNode({
    type: 'decision',
    status: irEscalations.length ? 'fail' : 'info',
    summary: `IR artifact complete: ${cases.length} cases across ${survivors.length} features`
      + (irEscalations.length ? ` — ${irEscalations.length} feature(s) escalated and excluded` : '')
      + ` — awaiting approval`,
  });
  substrate.commit(irDone, 'ir: manifest complete, awaiting approval');
  if (irEscalations.length) emit('ir-escalations', { count: irEscalations.length, features: irEscalations.map(e => e.featureId) });

  job.phase = 'awaiting-ir-approval';
  emit('phase', { phase: job.phase });
  return { substrate };
}

// ---------------------------------------------------------------------------
// Freeze + Phase C
// ---------------------------------------------------------------------------

export function verifyFreeze(dir: string, manifest: IRManifest): void {
  for (const [file, hash] of Object.entries(manifest.freezeHashes)) {
    const abs = path.join(dir, file);
    const actual = fs.existsSync(abs) ? sha256(fs.readFileSync(abs, 'utf8')) : '(missing)';
    if (actual !== hash) {
      throw new Error(`frozen IR tampered: ${file} hash mismatch — refusing to proceed`);
    }
  }
}

export async function runBuild(
  job: JobState,
  substrate: Substrate,
  llm: LlmClient,
  emit: Emit,
): Promise<void> {
  const spec = job.spec!;
  const manifest = job.manifest!;

  manifest.frozenAt = new Date().toISOString();
  fs.writeFileSync(
    path.join(job.workspaceDir, '.supergit', 'ir-manifest.json'),
    JSON.stringify(manifest, null, 2),
  );
  const freezeNode = substrate.recordNode({
    type: 'decision',
    status: 'info',
    summary: 'IR approved and frozen — deterministic contract assigned to development',
  });
  substrate.commit(freezeNode, 'ir: frozen (user approved)');

  job.phase = 'building';
  emit('phase', { phase: job.phase });
  job.buildResults = [];

  // Group features by dependency depth: everything in a level is mutually
  // independent, so implementation generation fans out within a level while
  // levels themselves run in order (a dependent always finds its deps'
  // files on disk). Writes, ledger nodes, commits and authentication stay
  // serial per level, in topo order, for a deterministic record.
  // Only build features that have a frozen suite in the manifest — IR-escalated
  // features are excluded. Depth/topo are computed over the FULL spec graph so
  // ordering stays correct even when an escalated feature sits in the DAG.
  const built = new Set(manifest.features.map(f => f.id));
  const depth = depthOf(spec.features);
  const levels: FeatureNode[][] = [];
  for (const f of topoOrder(spec.features)) {
    if (!built.has(f.id)) continue;
    const d = depth.get(f.id)!;
    (levels[d] ??= []).push(f);
  }

  // N5: when RoutingLlm has two operators, assign each feature to a partition
  // owner so the two never serialize on each other's work.
  const dual = isRoutingLlm(llm) && llm.operatorAt(1) !== llm.operatorAt(0);
  const owners = dual
    ? ownerMap(...partitionFeatureIds(spec.features))
    : null;
  if (dual && owners) {
    emit('workqueue-partition', {
      a: [...owners.entries()].filter(([, o]) => o === 0).map(([id]) => id),
      b: [...owners.entries()].filter(([, o]) => o === 1).map(([id]) => id),
    });
  }

  for (const level of levels) {
    if (!level) continue;
    verifyFreeze(job.workspaceDir, manifest);

    const generated = await mapLimit(level, concurrency(), async f => {
      const frozenTest = fs.readFileSync(path.join(job.workspaceDir, f.testFile), 'utf8');
      const depFiles = f.deps.flatMap(d => spec.features.find(x => x.id === d)!.files);
      const depContext = readFiles(job.workspaceDir, depFiles);
      const builder = dual && owners
        ? (isRoutingLlm(llm) ? llm.operatorAt(owners.get(f.id) ?? 0) : llm)
        : llm;

      emit('build-feature', { featureId: f.id, name: f.name, operator: owners?.get(f.id) ?? 0 });
      let files = await builder.generateImplementation(spec, f, frozenTest, depContext);
      let violations = boundaryViolations(files, f);
      if (violations.length) {
        files = await builder.generateImplementation(spec, f, frozenTest, depContext);
        violations = boundaryViolations(files, f);
        if (violations.length) {
          throw new Error(`build for ${f.id} violated the write boundary:\n- ${violations.join('\n- ')}`);
        }
      }
      return { f, files };
    });

    for (const { f, files } of generated) {
    await new Promise<void>(r => setImmediate(r)); // let SSE flush between features
    writeFiles(job.workspaceDir, files);

    const testNode = substrate.latest('test', f.id);
    const codeNode = substrate.recordNode({
      type: 'code',
      featureId: f.id,
      parentId: testNode?.id ?? null,
      causeId: testNode?.id ?? null,
      artifactPath: f.files.join(';'),
      status: 'info',
      summary: `implementation of ${f.id} ${f.name} (${files.length} files)`,
    });
    substrate.commit(codeNode, `feat(${f.id}): implement ${f.name}`);

    // real-time authentication against the frozen corpus
    const run = runVitest(job.workspaceDir, [f.testFile]);
    const declared = (job.manifest?.cases ?? []).filter(c => c.featureId === f.id).length;
    if (declared > 0 && run.passed + run.failed === 0) {
      raiseSev1(
        job,
        'zero-executed',
        `${f.testFile}: declares ${declared} cases but 0 executed (collection mismatch)`,
        { featureId: f.id, testFile: f.testFile, declared, collected: run.collected },
        emit,
      );
    }
    const result = toFeatureResult(f.id, run);
    job.buildResults.push(result);
    substrate.recordNode({
      type: 'result',
      featureId: f.id,
      parentId: codeNode,
      causeId: codeNode,
      status: run.failed === 0 && run.passed > 0 ? 'pass' : 'fail',
      summary: `authentication: ${run.passed} passed, ${run.failed} failed`,
    });
    emit('auth-result', {
      featureId: f.id,
      passed: result.passed,
      failed: result.failed,
      failures: result.failures.slice(0, 5),
    });
    }
  }

  job.phase = 'awaiting-build-approval';
  emit('phase', { phase: job.phase });
}
