amberfork 0.9.1

The amberfork command: diff two agent-run traces and render where they fork.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
//! The `amberfork` command — the terminal surface over the diff pipeline.
//!
//! `amberfork diff <bad> --against <good>` wires the crates end-to-end:
//! `amberfork-ingest` loads both traces, `amberfork_align::diff` aligns and locates the fork,
//! and this crate renders the result. Side convention (the `DiffResult` contract): `<good>` is
//! the reference (side `a`), `<bad>` is the observed/failing run (side `b`).
//!
//! `amberfork demo` is the same pipeline on a pair embedded in the binary at compile time —
//! the zero-setup first contact: no files, no network, no particular working directory.
//!
//! `amberfork serve <bad> --against <good>` runs the same engine, then hands the fork to the
//! browser: a loopback-only server (amberfork-server) over the layout `Document`. It is a
//! sibling of `diff`, not a flag on it — a long-running server has different lifecycle
//! semantics than diff's exit-code contract. `amberfork serve --demo` is the zero-setup form:
//! the embedded pair `demo` renders in the terminal, handed to the browser instead of files.
//!
//! Exit codes follow the `diff(1)` precedent: **0** converged, **1** forked, **2** trouble
//! (unreadable input, bad usage — clap's own usage errors also exit 2). Errors go to stderr;
//! stdout carries only the result. `demo` keeps the same exit semantics: its pair forks by
//! design, so it exits 1. `serve` exits 2 on any startup failure (before the port binds) and
//! otherwise runs until interrupted.

use amberfork_align::{AlignParams, DiffParams, LexicalCost, diff};
use amberfork_ingest::Ingested;
use amberfork_judge::{ExplainContext, Judge, OllamaJudge, ground};
use amberfork_layout::{Document, ViewModel};
use amberfork_model::{DiffResult, Run, Warning};
use amberfork_record::{CaptureProxy, Cassette};
use amberfork_server::Server;
use clap::{Args, Parser, Subcommand};
use load::{LoadError, load_run};
use render::{RenderOpts, resolve_color_mode};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

mod html_export;
mod load;
mod render;
mod verify;

const EXIT_CONVERGED: u8 = 0;
const EXIT_FORKED: u8 = 1;
const EXIT_TROUBLE: u8 = 2;

/// `--judge local`'s provider (issue #10): a local Ollama server, no key, no cost — the same
/// local-provider convention `crates/amberfork/tests/verify_cli.rs` (#44) established.
const JUDGE_OLLAMA_URL: &str = "http://127.0.0.1:11434";
const JUDGE_DEFAULT_MODEL: &str = "smollm2:135m";
/// Neighbours either side of the fork step a judge is shown — enough content to narrate, never
/// the full trajectory (issue #10 guardrail #3).
const JUDGE_WINDOW_K: usize = 2;

/// The bundled demo pair (issue #5): a synthetic, hand-authored refund-triage divergence,
/// embedded so `demo` works before any trace of the user's own exists. Parse success and the
/// fork's location are locked by `tests/demo_cli.rs` against these same committed files.
const DEMO_GOOD: &str = include_str!("../assets/demo/good.json");
const DEMO_BAD: &str = include_str!("../assets/demo/bad.json");

/// The demo's hand-off line: its last job is teaching the real command.
const DEMO_HINT: &str =
    "  bundled sample pair · try your own runs:  amberfork diff <bad> --against <good>";

/// The `serve --demo` analog of `DEMO_HINT`: same teaching handoff, pointed at `serve`.
const DEMO_SERVE_HINT: &str =
    "  bundled sample pair · serve your own runs:  amberfork serve <bad> --against <good>";

#[derive(Parser)]
#[command(name = "amberfork", version, about)]
/// Diff two AI-agent run trajectories and find where they fork.
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Diff a failing run against a known-good reference and locate the fork.
    Diff(DiffArgs),

    /// Diff a bundled sample pair — see the fork with zero setup, no files needed.
    Demo(DemoArgs),

    /// Serve the fork in a local web view (127.0.0.1 only) — Ctrl-C to stop.
    Serve(ServeArgs),

    /// Record an agent behind a capture proxy, writing a full-content cassette.
    Record(RecordArgs),
}

/// `--judge`'s two states (issue #10). `Off` is the default and is byte-identical to a build
/// without this feature: no `Judge` is constructed, no network call is attempted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum JudgeMode {
    Off,
    Local,
}

#[derive(Args)]
struct DiffArgs {
    /// The failing/observed run trace (side `b` of the result).
    #[arg(value_name = "BAD")]
    bad: PathBuf,

    /// The known-good reference run trace (side `a` of the result).
    #[arg(long, value_name = "GOOD")]
    against: PathBuf,

    /// Refuse runs longer than this many steps — alignment memory and time grow with steps²,
    /// so bigger traces are a choice, not a surprise. Raise it to align them anyway.
    #[arg(long, value_name = "N", default_value_t = AlignParams::DEFAULT_MAX_STEPS)]
    max_steps: usize,

    /// Verify the fork by counterfactual re-execution: patch the fork step with the good run's
    /// response, re-drive the agent, and report whether the run recovered. Opt-in; both runs must
    /// be recorded cassettes, and it requires --upstream, --base-url-env, and a `-- <cmd>`.
    #[arg(long)]
    verify: bool,

    /// The real provider origin to relay post-branch cache-misses to, e.g.
    /// `https://api.openai.com`. Required with --verify.
    #[arg(long, value_name = "URL")]
    upstream: Option<String>,

    /// Env var pointing the re-driven agent's SDK at the replay listener, e.g. `OPENAI_BASE_URL`.
    /// Repeatable; at least one is required with --verify.
    #[arg(long = "base-url-env", value_name = "NAME")]
    base_url_env: Vec<String>,

    /// How many times to re-execute when verifying; the recovery verdict is a strict majority over
    /// these. Only meaningful with --verify.
    #[arg(long, value_name = "N", default_value_t = 3, value_parser = clap::value_parser!(u32).range(1..))]
    runs: u32,

    /// Narrate the fork with a local model (issue #10). `off` (default) is the untouched
    /// deterministic path — no network, no new dependency reached. `local` asks a local Ollama
    /// server (`http://127.0.0.1:11434`, model `smollm2:135m`) to describe the fork-region
    /// content, printed under `AI (unverified):` below the deterministic render. Never affects
    /// `--json`: the AI layer is not part of the machine contract.
    #[arg(long, value_enum, default_value_t = JudgeMode::Off)]
    judge: JudgeMode,

    /// Write a self-contained static HTML export of the fork view to this path (issue #29) —
    /// the same information the terminal renders, laid out with the web view's CSS so it opens
    /// cold from a CI artifact or a pasted attachment. No JS, no wasm, not interactive.
    /// Independent of --json/--verify/--judge; always attempted when given.
    #[arg(long, value_name = "FILE")]
    html: Option<PathBuf>,

    #[command(flatten)]
    output: OutputArgs,

    /// The agent command to re-drive, after `--` — e.g. `-- python agent.py`. Required with
    /// --verify; ignored otherwise.
    #[arg(value_name = "CMD", last = true, num_args = 1..)]
    command: Vec<String>,
}

#[derive(Args)]
struct DemoArgs {
    #[command(flatten)]
    output: OutputArgs,
}

#[derive(Args)]
struct ServeArgs {
    /// The failing/observed run trace (side `b` of the result). Omit with `--demo`.
    #[arg(
        value_name = "BAD",
        required_unless_present = "demo",
        conflicts_with = "demo"
    )]
    bad: Option<PathBuf>,

    /// The known-good reference run trace (side `a` of the result). Omit with `--demo`.
    #[arg(
        long,
        value_name = "GOOD",
        required_unless_present = "demo",
        conflicts_with = "demo"
    )]
    against: Option<PathBuf>,

    /// Serve the bundled sample pair — the fork in the browser with zero setup, no files
    /// needed (the same embedded pair `demo` renders in the terminal).
    #[arg(long)]
    demo: bool,

    /// Refuse runs longer than this many steps — same escape hatch as `diff` (alignment
    /// memory and time grow with steps²).
    #[arg(long, value_name = "N", default_value_t = AlignParams::DEFAULT_MAX_STEPS)]
    max_steps: usize,

    /// Pin the port. Default is an OS-assigned free port; a busy pinned port is an error,
    /// not a hunt for the next one.
    #[arg(long, value_name = "PORT")]
    port: Option<u16>,

    /// Open the browser once the server is up.
    #[arg(long)]
    open: bool,
}

#[derive(Args)]
struct RecordArgs {
    /// The real provider origin the proxy relays to, e.g. `https://api.openai.com`. Required and
    /// provider-agnostic — there is no baked-in default to go stale.
    #[arg(long, value_name = "URL")]
    upstream: String,

    /// Environment variable to point the agent's SDK at the proxy, e.g. `OPENAI_BASE_URL`.
    /// Repeatable; at least one is required — an SDK reading none would bypass the proxy and
    /// record nothing.
    #[arg(long = "base-url-env", value_name = "NAME", required = true)]
    base_url_env: Vec<String>,

    /// Where to write the cassette. Defaults to `<id>.cassette.json` in the working directory.
    #[arg(long, value_name = "FILE")]
    out: Option<PathBuf>,

    /// Recording id, stamped into the cassette and used for the default output name.
    #[arg(long, value_name = "ID", default_value = "recording")]
    id: String,

    /// The agent command to run, after `--` — e.g. `-- python agent.py`.
    #[arg(value_name = "CMD", last = true, required = true, num_args = 1..)]
    command: Vec<String>,
}

impl RecordArgs {
    /// The resolved cassette output path: the explicit `--out`, else `<id>.cassette.json`.
    fn out_path(&self) -> PathBuf {
        self.out
            .clone()
            .unwrap_or_else(|| PathBuf::from(format!("{}.cassette.json", self.id)))
    }
}

/// Output flags shared by every result-emitting subcommand, so `--json`/`--no-color` mean the
/// same thing everywhere.
#[derive(Args)]
struct OutputArgs {
    /// Emit the DiffResult as JSON on stdout — the machine contract.
    #[arg(long)]
    json: bool,

    /// Disable ANSI styling (also honored: a non-empty NO_COLOR, piped stdout, TERM=dumb).
    #[arg(long)]
    no_color: bool,
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match cli.command {
        Command::Diff(args) => run_diff(&args).unwrap_or_else(|err| {
            eprintln!("amberfork: {err}");
            ExitCode::from(EXIT_TROUBLE)
        }),
        Command::Demo(args) => run_demo(&args),
        Command::Serve(args) => run_serve(&args).unwrap_or_else(|err| {
            eprintln!("amberfork: {err}");
            ExitCode::from(EXIT_TROUBLE)
        }),
        Command::Record(args) => run_record(&args),
    }
}

fn run_diff(args: &DiffArgs) -> Result<ExitCode, LoadError> {
    // The verify flags are validated once, here: `None` is the untouched passive path, `Some` the
    // counterfactual path, `Err` an inconsistent request (maps to trouble, like a bad input).
    match verify::VerifyConfig::resolve(
        args.verify,
        args.upstream.as_deref(),
        &args.base_url_env,
        &args.command,
        args.runs,
    ) {
        Ok(None) => {
            let good = load_run(&args.against)?;
            let bad = load_run(&args.bad)?;
            Ok(diff_and_report(
                good,
                bad,
                args.max_steps,
                &args.output,
                None,
                args.judge,
                args.html.as_deref(),
            ))
        }
        Ok(Some(config)) => {
            // Not silently ignored: `--judge` has no effect on the `--verify` path yet (issue
            // #10's first slice scopes it to the passive `diff`), so an explicit request for it
            // is worth a warning rather than quiet nothing.
            if !matches!(args.judge, JudgeMode::Off) {
                eprintln!("amberfork: warning: --judge has no effect with --verify yet");
            }
            run_diff_verify(args, &config)
        }
        Err(message) => {
            eprintln!("amberfork: {message}");
            Ok(ExitCode::from(EXIT_TROUBLE))
        }
    }
}

/// The `--verify` path: both inputs must be cassettes (re-execution replays their recorded
/// exchanges), so it loads them as cassettes, runs the same engine on their normalized runs, then
/// re-executes the fork to upgrade the attribution from static to counterfactual before reporting.
/// Default `diff` never reaches here — the passive path in [`run_diff`] is byte-for-byte untouched.
fn run_diff_verify(args: &DiffArgs, config: &verify::VerifyConfig) -> Result<ExitCode, LoadError> {
    let good = load::load_cassette(&args.against)?;
    let bad = load::load_cassette(&args.bad)?;
    let good_run = amberfork_record::normalize(&good);
    let bad_run = amberfork_record::normalize(&bad);

    let params = match engine_params(args.max_steps) {
        Ok(params) => params,
        Err(code) => return Ok(code),
    };
    let mut result = match diff(&good_run, &bad_run, &LexicalCost, &params) {
        Ok(result) => result,
        Err(err) => {
            eprintln!("amberfork: {err}");
            return Ok(ExitCode::from(EXIT_TROUBLE));
        }
    };
    // A cassette carries full content and raises no ingest diagnostics, so there are no per-run
    // warnings to merge — the passive path's `merged_warnings` reduces to an empty list here.
    result.warnings = Vec::new();

    match verify::verify_attribution(&result, &good, &bad, config, &params) {
        Ok(Some(attribution)) => result.attribution = Some(attribution),
        // Converged: verify found no fork to re-execute and leaves attribution as static analysis
        // left it (already `None` on a converged diff).
        Ok(None) => {}
        Err(err) => {
            eprintln!("amberfork: {err}");
            return Ok(ExitCode::from(EXIT_TROUBLE));
        }
    }
    Ok(report(
        &result,
        &good_run,
        &bad_run,
        &args.output,
        None,
        args.html.as_deref(),
    ))
}

fn run_demo(args: &DemoArgs) -> ExitCode {
    let (good, bad) = demo_pair();
    // `demo` has no `--judge`/`--html` flags of its own (both scope to `diff`, where a real
    // reference/observed pair exists — issue #10's precedent for narrowing a flag's first slice).
    diff_and_report(
        good,
        bad,
        AlignParams::DEFAULT_MAX_STEPS,
        &args.output,
        Some(DEMO_HINT),
        JudgeMode::Off,
        None,
    )
}

/// The single embed site for the bundled pair (design doc D7): `demo` and `serve --demo` both
/// source their traces here, so there is one copy that cannot drift, not two. Infallible by
/// construction — the files are committed next to this crate and `tests/demo_cli.rs` runs them
/// end-to-end, so a parse failure cannot survive CI.
fn demo_pair() -> (Ingested, Ingested) {
    let good = amberfork_ingest::from_json_str(DEMO_GOOD)
        .expect("embedded demo trace good.json parses (locked by demo_cli tests)");
    let bad = amberfork_ingest::from_json_str(DEMO_BAD)
        .expect("embedded demo trace bad.json parses (locked by demo_cli tests)");
    (good, bad)
}

/// The startup order is the contract (issue #25): ingest fails first with its typed errors,
/// then the engine, then the server's own checks (bundle, port) — all in the terminal,
/// before any port is bound. Only a running server produces stdout.
fn run_serve(args: &ServeArgs) -> Result<ExitCode, LoadError> {
    let (good, bad) = if args.demo {
        demo_pair()
    } else {
        // clap guarantees both are present unless `--demo` (required_unless_present), so these
        // `expect`s encode a parser invariant, not a runtime hope.
        let good = load_run(
            args.against
                .as_ref()
                .expect("clap requires --against unless --demo"),
        )?;
        let bad = load_run(args.bad.as_ref().expect("clap requires BAD unless --demo"))?;
        (good, bad)
    };
    let mut result = match run_engine(&good, &bad, args.max_steps) {
        Ok(result) => result,
        Err(code) => return Ok(code),
    };
    result.warnings = merged_warnings(good.warnings, bad.warnings);
    // Cloned before Document::new, which truncates its own copy in place: the server needs
    // BOTH the served (truncated) document and this untouched view, to answer an
    // expand-on-demand request for a slot the document cut (issue #30).
    let view = ViewModel::compute(&result, &good.run, &bad.run);
    let document = Document::new(view.clone());
    Ok(serve_document(&document, &view, args))
}

/// The human label for what a `serve` invocation is showing: the bundled pair for `--demo`,
/// or the two trace paths otherwise (clap guarantees both are present in that branch).
fn serve_source(args: &ServeArgs) -> String {
    if args.demo {
        return "the bundled demo pair".to_string();
    }
    let bad = args.bad.as_ref().expect("clap requires BAD unless --demo");
    let against = args
        .against
        .as_ref()
        .expect("clap requires --against unless --demo");
    format!("{} vs {}", bad.display(), against.display())
}

/// The ONE async edge in this crate: a current-thread runtime wrapping the server's
/// lifetime, so the engine path above it stays sync (design doc: tokio is quarantined to
/// I/O edges).
fn serve_document(document: &Document, view: &ViewModel, args: &ServeArgs) -> ExitCode {
    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_io()
        .build()
    {
        Ok(runtime) => runtime,
        Err(err) => {
            eprintln!("amberfork: cannot start the async runtime: {err}");
            return ExitCode::from(EXIT_TROUBLE);
        }
    };
    runtime.block_on(async {
        let server = match Server::bind(document, view, args.port.unwrap_or(0)).await {
            Ok(server) => server,
            Err(err) => {
                eprintln!("amberfork: {err}");
                return ExitCode::from(EXIT_TROUBLE);
            }
        };
        // The pinned handoff (#25 amendment): the verdict lands in the terminal BEFORE any
        // browser opens — the web view elaborates the answer, it never gates it.
        let url = format!("http://{}", server.local_addr());
        println!("{}", document.view.headline());
        println!("serving {} → {url}  (Ctrl-C to stop)", serve_source(args));
        if args.demo {
            // The demo's last job is teaching the real command — the serve analog of DEMO_HINT.
            println!("{DEMO_SERVE_HINT}");
        }
        for warning in &document.view.warnings {
            eprintln!("amberfork: warning: {}", warning.msg);
        }
        if args.open
            && let Err(err) = webbrowser::open(&url)
        {
            // The browser is convenience; the server it points at must outlive its absence.
            eprintln!("amberfork: warning: cannot open a browser: {err}");
        }
        match server.serve().await {
            Ok(()) => ExitCode::SUCCESS,
            Err(err) => {
                eprintln!("amberfork: {err}");
                ExitCode::from(EXIT_TROUBLE)
            }
        }
    })
}

/// `amberfork record -- <cmd>`: run the agent behind the capture proxy and write a cassette.
///
/// Exit contract differs from `diff`'s on purpose: `record` is a transparent wrapper, so it
/// propagates the *agent's* exit code (a `record`ed CI run behaves like the command it wraps).
/// Only `record`'s own setup/teardown failures — proxy bind, agent not spawnable, cassette not
/// writable — map to exit 2, the same trouble code the rest of the CLI uses.
fn run_record(args: &RecordArgs) -> ExitCode {
    // The async edge, like `serve`: a current-thread runtime wrapping the proxy's lifetime while
    // the agent runs, so nothing above it touches the runtime.
    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_io()
        .build()
    {
        Ok(runtime) => runtime,
        Err(err) => {
            eprintln!("amberfork: cannot start the async runtime: {err}");
            return ExitCode::from(EXIT_TROUBLE);
        }
    };
    runtime.block_on(record_session(args))
}

/// Bind the proxy, run the agent against it, and write what was captured. The agent runs as an
/// async child (`tokio::process`) so the proxy keeps serving concurrently — a blocking wait on the
/// current-thread runtime would starve the relay and deadlock the very call being recorded.
async fn record_session(args: &RecordArgs) -> ExitCode {
    let proxy = match CaptureProxy::bind(&args.id, &args.upstream).await {
        Ok(proxy) => proxy,
        Err(err) => {
            eprintln!("amberfork: {err}");
            return ExitCode::from(EXIT_TROUBLE);
        }
    };

    // Printed before the agent runs, on its own line: the agent's own stdout/stderr is about to
    // start flowing and would bury a warning appended later. Only headers are redacted (an
    // allowlist, per cassette.rs) — bodies are captured in full, on purpose, so this is not a
    // gap to close quietly but a standing fact to repeat every time (issue #43).
    eprintln!(
        "amberfork: recording unredacted request/response bodies — do not share a cassette \
         without scrubbing it first (see docs/cassette-format.md#privacy-contract)"
    );

    // clap guarantees at least the program name (`num_args = 1..`).
    let (program, program_args) = args
        .command
        .split_first()
        .expect("clap requires a command after --");
    let mut child = tokio::process::Command::new(program);
    child.args(program_args);
    // The agent inherits the parent environment (its credential lives there); we only override the
    // base-URL var(s) so its SDK talks to the proxy instead of the provider directly.
    for name in &args.base_url_env {
        child.env(name, proxy.base_url());
    }

    let status = match child.status().await {
        Ok(status) => status,
        Err(err) => {
            eprintln!("amberfork: cannot run agent `{program}`: {err}");
            return ExitCode::from(EXIT_TROUBLE);
        }
    };

    // Write the cassette even when the agent failed — a failed run is precisely the one worth
    // recording, so capture is unconditional and only the write itself can still fail here.
    let cassette = proxy.cassette();
    let out = args.out_path();
    if let Err(code) = write_cassette(&cassette, &out) {
        return code;
    }
    eprintln!(
        "amberfork: recorded {} exchange(s) → {}",
        cassette.exchanges.len(),
        out.display()
    );

    ExitCode::from(exit_code_of(&status))
}

/// Serialize a cassette to its output file. Serialization itself is infallible (bodies are
/// `serde_json::Value`, no non-string map keys); only the filesystem write can fail, and that is
/// a `record` trouble (exit 2), not the agent's.
fn write_cassette(cassette: &Cassette, out: &Path) -> Result<(), ExitCode> {
    let json = serde_json::to_string_pretty(cassette)
        .expect("cassette serialization is infallible (Value bodies, string keys)");
    std::fs::write(out, json).map_err(|err| {
        eprintln!(
            "amberfork: cannot write cassette to {}: {err}",
            out.display()
        );
        ExitCode::from(EXIT_TROUBLE)
    })
}

/// The exit code `record` returns for a finished agent. A normal exit propagates its code (already
/// a byte at the OS boundary); a signal-terminated agent has no code, so it maps to a generic
/// failure — the transparent-wrapper contract means the agent's fate is the command's fate.
fn exit_code_of(status: &std::process::ExitStatus) -> u8 {
    status.code().map_or(1, |code| code as u8)
}

/// Params + engine, shared by every subcommand. The single decision point for engine params:
/// anything user-supplied (--max-steps today, future --tau style flags) routes through
/// validated() here, and both param and engine refusals (today: the size guard) map to exit
/// 2 with the same stderr shape as an unreadable input — the engine itself never asserts.
fn engine_params(max_steps: usize) -> Result<DiffParams, ExitCode> {
    let params = DiffParams {
        align: AlignParams {
            max_steps,
            ..AlignParams::default()
        },
        ..DiffParams::default()
    };
    params.validated().map_err(|err| {
        eprintln!("amberfork: {err}");
        ExitCode::from(EXIT_TROUBLE)
    })
}

fn run_engine(good: &Ingested, bad: &Ingested, max_steps: usize) -> Result<DiffResult, ExitCode> {
    let params = engine_params(max_steps)?;
    diff(&good.run, &bad.run, &LexicalCost, &params).map_err(|err| {
        eprintln!("amberfork: {err}");
        ExitCode::from(EXIT_TROUBLE)
    })
}

/// The shared back half of `diff`/`demo`: run the engine on a loaded pair, emit the result
/// (render or `--json`), and map it to the `diff(1)` exit code. `footer` is an optional
/// trailing line for the human render only — `--json` stays the pure machine contract. `judge`
/// prints the AI explain layer (issue #10) after the deterministic result; it never changes
/// `report`'s own output or the exit code.
fn diff_and_report(
    good: Ingested,
    bad: Ingested,
    max_steps: usize,
    output: &OutputArgs,
    footer: Option<&str>,
    judge: JudgeMode,
    html: Option<&Path>,
) -> ExitCode {
    let mut result = match run_engine(&good, &bad, max_steps) {
        Ok(result) => result,
        Err(code) => return code,
    };
    result.warnings = merged_warnings(good.warnings, bad.warnings);
    let code = report(&result, &good.run, &bad.run, output, footer, html);
    print_judge_explanation(judge, &result, &good.run, &bad.run, output);
    code
}

/// `--judge local`'s CLI surface: build the fork-region window, ask a local Ollama server to
/// narrate it, and print the grounded result under an `AI (unverified):` label. A no-op for
/// `off` and for `--json` (the AI layer never joins the machine contract — issue #10's Approach
/// A→C growth note names the schema change that would be needed, deliberately not made here).
/// Any failure (provider unreachable, an ungrounded claim) degrades to a stderr warning — the
/// diff already succeeded before this runs, and stays succeeded (issue #10 edge cases).
fn print_judge_explanation(
    mode: JudgeMode,
    result: &DiffResult,
    good: &Run,
    bad: &Run,
    output: &OutputArgs,
) {
    if matches!(mode, JudgeMode::Off) || output.json {
        return;
    }
    match judge_explanation(result, good, bad) {
        Ok(grounded) => {
            let color = resolve_color_mode(
                output.no_color,
                std::io::stdout().is_terminal(),
                std::env::var("NO_COLOR").ok().as_deref(),
                std::env::var("TERM").ok().as_deref(),
                std::env::var("COLORTERM").ok().as_deref(),
            );
            println!(
                "{}",
                color.dim(&format!("AI (unverified): {}", grounded.narrative))
            );
            if let Some(fix) = grounded.speculative_fix {
                println!("{}", color.dim(&format!("AI (speculative fix): {fix}")));
            }
        }
        Err(message) => eprintln!("amberfork: warning: judge: {message}"),
    }
}

/// The one async edge `--judge local` needs: a current-thread runtime wraps one Ollama call, the
/// same tokio quarantine `--verify`/`serve` observe.
fn judge_explanation(
    result: &DiffResult,
    good: &Run,
    bad: &Run,
) -> Result<amberfork_judge::Grounded, String> {
    // `enable_time` is required by `connect_timeout` below (reqwest's connector schedules a
    // timer on the runtime) — omitting it doesn't fail to build, it panics on the first
    // connection attempt, so this pairing is load-bearing, not decorative.
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_io()
        .enable_time()
        .build()
        .map_err(|err| format!("cannot start the async runtime: {err}"))?;
    let client = reqwest::Client::builder()
        // A firewall silently dropping the connection would otherwise hang `diff` indefinitely;
        // "server not running" (the common case — Ollama is opt-in) fails fast either way.
        .connect_timeout(std::time::Duration::from_secs(2))
        .build()
        .map_err(|err| err.to_string())?;
    let judge = OllamaJudge::new(client, JUDGE_OLLAMA_URL, JUDGE_DEFAULT_MODEL);
    let ctx = ExplainContext::windowed(result, good, bad, JUDGE_WINDOW_K);
    runtime.block_on(async {
        let explanation = judge.explain(&ctx).await.map_err(|err| err.to_string())?;
        ground(result, explanation).map_err(|err| err.to_string())
    })
}

/// Emit an already-computed result — `--json` machine contract, or the rendered view — and map it
/// to the `diff(1)` exit code. Shared by the passive `diff`/`demo` path and `diff --verify`, so
/// every surface reports identically; the verify path's only difference is the attribution it
/// hands in. `result.warnings` is set by the caller (ingest warnings, or none for a cassette).
fn report(
    result: &DiffResult,
    good_run: &Run,
    bad_run: &Run,
    output: &OutputArgs,
    footer: Option<&str>,
    html: Option<&Path>,
) -> ExitCode {
    if output.json {
        let json = serde_json::to_string_pretty(result)
            .expect("DiffResult serialization is infallible (no non-string map keys)");
        println!("{json}");
    } else {
        let color = resolve_color_mode(
            output.no_color,
            std::io::stdout().is_terminal(),
            std::env::var("NO_COLOR").ok().as_deref(),
            std::env::var("TERM").ok().as_deref(),
            std::env::var("COLORTERM").ok().as_deref(),
        );
        let width = terminal_size::terminal_size().map_or(100, |(w, _)| usize::from(w.0));
        let opts = RenderOpts {
            color,
            width: width.max(60),
        };
        // The seam in one line: semantics from the layout crate, styling from the painter.
        let view = ViewModel::compute(result, good_run, bad_run);
        print!("{}", render::render(&view, &opts));
        if let Some(footer) = footer {
            // Chrome, not result: dim so it never competes with the amber fork.
            println!("{}", color.dim(footer));
        }
        // Diagnostics stay off stdout: stdout is the result, stderr is the channel for
        // everything about producing it.
        for warning in &result.warnings {
            eprintln!("amberfork: warning: {}", warning.msg);
        }
    }

    if let Some(path) = html {
        // A second, independent `ViewModel::compute` — not the one just rendered above. Routing
        // that one through `Document::new` would apply the browser wire envelope's 4 KiB-per-slot
        // truncation to the terminal's input, which must stay byte-identical to before (`ui.md`'s
        // "two unrelated truncation mechanisms" note). The export gets its own, freshly enveloped
        // like `serve`'s document — appropriate here too, since this file is exactly the kind of
        // shareable artifact the envelope guards against blowing up.
        let document = Document::new(ViewModel::compute(result, good_run, bad_run));
        if let Err(err) = std::fs::write(path, html_export::render(&document)) {
            eprintln!("amberfork: --html {}: {err}", path.display());
            return ExitCode::from(EXIT_TROUBLE);
        }
    }

    let code = if result.fork.is_some() {
        EXIT_FORKED
    } else {
        EXIT_CONVERGED
    };
    ExitCode::from(code)
}

/// Merge per-run ingest warnings into the result's flat list, each message prefixed with its
/// side (`a` = reference, `b` = observed) so the flat list stays attributable to a run.
fn merged_warnings(reference: Vec<Warning>, observed: Vec<Warning>) -> Vec<Warning> {
    fn tag(side: char, warnings: Vec<Warning>) -> impl Iterator<Item = Warning> {
        warnings.into_iter().map(move |w| Warning {
            code: w.code,
            msg: format!("run {side}: {}", w.msg),
        })
    }
    tag('a', reference).chain(tag('b', observed)).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The D7 identity anchor: `demo` and `serve --demo` both source their pair from this one
    /// `demo_pair()` — there is a single embed site, not two that can drift. This locks the
    /// infallibility both call sites `.expect()` on, and proves the shared bytes are the real
    /// authored divergence (a forking pair), not an empty or degenerate one.
    #[test]
    fn demo_pair_is_the_single_forking_source() {
        let (good, bad) = demo_pair();
        let params = DiffParams::default()
            .validated()
            .expect("default params validate");
        let result =
            diff(&good.run, &bad.run, &LexicalCost, &params).expect("the demo pair aligns");
        assert!(
            result.fork.is_some(),
            "the embedded demo pair must encode a real divergence"
        );
    }

    fn record_args(out: Option<PathBuf>, id: &str) -> RecordArgs {
        RecordArgs {
            upstream: "https://api.example.com".to_string(),
            base_url_env: vec!["OPENAI_BASE_URL".to_string()],
            out,
            id: id.to_string(),
            command: vec!["python".to_string(), "agent.py".to_string()],
        }
    }

    #[test]
    fn record_out_defaults_to_the_id_cassette_when_unset() {
        assert_eq!(
            record_args(None, "run-42").out_path(),
            PathBuf::from("run-42.cassette.json")
        );
    }

    #[test]
    fn record_explicit_out_wins_over_the_default() {
        let out = PathBuf::from("/tmp/custom.cassette.json");
        assert_eq!(record_args(Some(out.clone()), "run-42").out_path(), out);
    }
}