kranz 0.2.0

Git-native mission control for governed, validated AI coding-agent work.
Documentation
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
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
//! `kranz exec -f <mission.md>` — fully headless missions for CI (roadmap M5).
//!
//! Plan file in, exit code out. There is no human on the other end: the
//! mission.md is a ticket-shaped markdown that must be self-sufficient, and
//! approval is automatic. The one place a headless run can't proceed is when
//! the orchestrator answers `request_plan` with clarifying questions instead
//! of a plan — that means the plan file was underspecified, so exec fails with
//! a distinct exit code (3) and prints the questions to stderr for the CI log.
//!
//! Flow (mirrors `cmd_draft`'s non-interactive seed→plan path, but then runs
//! the mission instead of parking it):
//!   parse file → `Ticket::mission_goal()` → build backend →
//!   `MissionEngine::create` → one `planning_turn` seeding the whole ticket →
//!   `request_plan()` → auto-approve (or exit 3) → `run()` to a terminal state.
//!
//! Events stream to stderr live (the same [`tail::tail_events`] feed `kranz
//! run` uses) so CI logs show progress; the only thing on stdout is the final
//! one-line machine-readable summary:
//!   `kranz exec <id> <STATUS> cost=$X.XX branch=<b>`
//!
//! stdin is never read and no TUI is ever opened.

use crate::commands::{augment_limit_hint, build_backend, load_config};
use crate::output;
use crate::tail::{self, EventRenderer};
use anyhow::{Context, Result};
use kranz_engine::backend::AgentBackend;
use kranz_engine::control;
use kranz_engine::git_ops::GitRepo;
use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
use kranz_engine::queue::{self, QueueEntry};
use kranz_engine::ticket::Ticket;
use kranz_engine::types::{ControlCommand, MissionConfig, MissionStatus};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

/// Exit code exec fails with when the plan file is underspecified: the
/// orchestrator wanted clarification it cannot get headlessly (`NotReady`).
pub const EXIT_UNDERSPECIFIED: i32 = 3;

/// Exit code when the mission COMPLETE'd but `--push` failed. Distinct from
/// mission failure (1) and underspecified (3) so CI can tell delivery apart
/// from the run itself. Stdout still reports `pushed=false`.
pub const EXIT_PUSH_FAILED: i32 = 4;

/// Restores the caller's branch whenever enqueue-only planning exits —
/// including error and underspecified paths that return before queueing.
#[derive(Debug, Clone, PartialEq, Eq)]
enum CheckoutPosition {
    Branch(String),
    Detached(String),
}

struct EnqueueCheckoutGuard {
    repo: PathBuf,
    original: Option<CheckoutPosition>,
    active: bool,
}

impl EnqueueCheckoutGuard {
    fn new(repo: &Path, active: bool) -> Self {
        let original = active.then(|| capture_checkout_position(repo)).flatten();
        Self {
            repo: repo.to_path_buf(),
            original,
            active,
        }
    }

    fn restore_now(&mut self) {
        if self.active {
            restore_enqueue_checkout(&self.repo, self.original.as_ref());
            self.active = false;
        }
    }
}

impl Drop for EnqueueCheckoutGuard {
    fn drop(&mut self) {
        self.restore_now();
    }
}

/// Non-file inputs to one headless exec invocation.
pub struct ExecOptions {
    pub max_cycles: Option<u32>,
    pub enqueue: bool,
    pub enqueue_source: Option<ExternalEnqueueSource>,
    pub push: Option<String>,
    pub dangerously_allow_all: bool,
    pub allow_unvalidated: bool,
}

pub struct ExternalEnqueueSource {
    pub producer: String,
    pub external_ref: String,
}

/// Map a terminal mission status to the process exit code exec returns.
///
/// `Complete` → 0, `Failed` → 1, `Blocked` → 2. Any other status is not a
/// terminal outcome of a headless run (the engine only returns Complete /
/// Failed / Blocked from `run()`), so it is treated as a failure (1). The
/// underspecified case ([`EXIT_UNDERSPECIFIED`]) is handled before the run
/// starts and never reaches this function.
pub fn exit_code_for(status: MissionStatus) -> i32 {
    match status {
        MissionStatus::Complete => 0,
        MissionStatus::Blocked => 2,
        MissionStatus::Failed => 1,
        _ => 1,
    }
}

/// The unattended scrutiny floor: `exec` runs with no human present, so a
/// mission whose config disables the scrutiny validator (`skipScrutiny`) has
/// no adversarial reader at all and can satisfy its own acceptance
/// tautologically (docs/gascity.md lesson 3 records exactly this incident).
/// Interactive `run`/`plan` are not gated — a human is present there. Passing
/// `--allow-unvalidated` (or setting `KRANZ_ALLOW_UNVALIDATED=1`) is an
/// explicit, auditable acknowledgment that overrides the floor.
pub fn scrutiny_gate(skip_scrutiny: bool, allow_unvalidated: bool) -> Result<(), String> {
    if skip_scrutiny && !allow_unvalidated {
        Err(
            "kranz exec: refusing to run an unattended mission with skipScrutiny set. \
             A headless run has no adversarial reader when the scrutiny validator is \
             disabled, so the mission can pass its own tautological acceptance (see \
             docs/gascity.md lesson 3). Pass --allow-unvalidated (or set \
             KRANZ_ALLOW_UNVALIDATED=1) to explicitly override this floor."
                .to_string(),
        )
    } else {
        Ok(())
    }
}

/// Parse a ticket-shaped plan file into a [`Ticket`]. The slug is derived from
/// the file stem (like [`Ticket::load`]), so the folded [`Ticket::mission_goal`]
/// carries the goal, scoping answers, acceptance hints, and context verbatim.
///
/// Pure over `(slug, markdown)` so the parse path is unit-tested without touching
/// the filesystem; [`read_mission_file`] is the thin I/O wrapper exec calls.
pub fn parse_mission_markdown(slug: &str, markdown: &str) -> Result<Ticket> {
    Ticket::parse(slug, markdown).with_context(|| format!("parsing mission plan file '{slug}'"))
}

/// Read + parse a plan file from disk. The slug is the file stem; a path with
/// no usable stem falls back to `"mission"`.
fn read_mission_file(path: &Path) -> Result<Ticket> {
    let slug = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("mission");
    let markdown = std::fs::read_to_string(path)
        .with_context(|| format!("reading mission plan file {}", path.display()))?;
    parse_mission_markdown(slug, &markdown)
}

/// `kranz exec -f <mission.md> [--repo <path>] [--yes] [--max-cycles N]
/// [--enqueue] [--allow-unvalidated]`.
///
/// `--yes` is accepted for symmetry with the interactive commands but is a
/// no-op: a headless run always auto-approves. `--max-cycles`, when given,
/// overrides `maxFixCyclesPerMilestone` for the run (recorded as a
/// config.changed event via the control inbox) so CI can bound spend.
///
/// Immediately after config loads and before any mission directory is
/// created, [`scrutiny_gate`] enforces the unattended scrutiny floor: see its
/// doc comment for the rationale.
pub async fn cmd_exec(repo: PathBuf, file: PathBuf, options: ExecOptions) -> Result<i32> {
    let ticket = read_mission_file(&file)?;
    let cfg = load_config(&repo, options.dangerously_allow_all)?;

    let allow_unvalidated = options.allow_unvalidated
        || std::env::var("KRANZ_ALLOW_UNVALIDATED").ok().as_deref() == Some("1");
    if let Err(msg) = scrutiny_gate(cfg.skip_scrutiny, allow_unvalidated) {
        eprintln!("{msg}");
        return Ok(1);
    }

    let backend = build_backend(&cfg)?;

    cmd_exec_with_backend(repo, cfg, backend, ticket, file, options).await
}

/// The body of [`cmd_exec`], parameterized on the backend so tests can drive
/// it with [`kranz_engine::backend_mock::MockBackend`] instead of discovering
/// a real `claude` binary.
async fn cmd_exec_with_backend(
    repo: PathBuf,
    cfg: MissionConfig,
    backend: Arc<dyn AgentBackend>,
    ticket: Ticket,
    file: PathBuf,
    options: ExecOptions,
) -> Result<i32> {
    // Declared before the engine so Rust drops the engine first on every
    // early return/unwind, then restores the checkout after its Git handles
    // are out of the way.
    let mut checkout_guard = EnqueueCheckoutGuard::new(&repo, options.enqueue);
    let goal = ticket.mission_goal();
    let mut engine = MissionEngine::create(backend, repo.clone(), &goal, cfg)?;
    let mission_id = engine.mission_id().to_string();
    eprintln!(
        "kranz exec: mission {mission_id} created from {}",
        file.display()
    );

    // Seed the orchestrator with the whole ticket, then demand the plan — the
    // same single-turn seed the non-interactive draft path uses.
    engine
        .planning_turn(&goal)
        .await
        .map_err(|e| augment_limit_hint(e.into()))
        .with_context(|| format!("seeding the orchestrator for mission {mission_id}"))?;
    if let Some(seed) = engine.take_seed_reply() {
        eprintln!("orchestrator: {}", output::one_line(&seed, 200));
    }

    let request = engine
        .request_plan()
        .await
        .map_err(|e| augment_limit_hint(e.into()))
        .with_context(|| format!("requesting the plan for mission {mission_id}"))?;

    let plan = match request {
        PlanRequest::Ready(plan) => plan,
        PlanRequest::NotReady(questions) => {
            // No human to answer: the plan file was underspecified. Signal CI
            // with a distinct exit code and surface the questions on stderr.
            eprintln!(
                "kranz exec: mission underspecified — the orchestrator needs clarification \
                 that a headless run cannot provide. Answer these in {} and re-run:",
                file.display()
            );
            for line in questions.lines() {
                let line = line.trim();
                if !line.is_empty() {
                    eprintln!("  - {line}");
                }
            }
            println!(
                "kranz exec {mission_id} UNDERSPECIFIED cost=${:.2} branch=-",
                engine.state().total_cost_usd
            );
            return Ok(EXIT_UNDERSPECIFIED);
        }
        PlanRequest::WrongPlan { reason } => {
            // The planner CAN plan but judges the plan likely wrong — the same
            // "cannot proceed headlessly" class as underspecified (exit 3),
            // with the escalation reason on stderr for the CI log.
            eprintln!(
                "kranz exec: the planner escalated — it can produce a plan but believes it \
                 is likely WRONG. Reframe {} and re-run:\n  {reason}",
                file.display()
            );
            println!(
                "kranz exec {mission_id} WRONG-PLAN cost=${:.2} branch=-",
                engine.state().total_cost_usd
            );
            return Ok(EXIT_UNDERSPECIFIED);
        }
    };

    // Auto-approve: commits plan.json/plan.md on the mission branch.
    engine
        .approve_plan(plan)
        .with_context(|| format!("approving the plan for mission {mission_id}"))?;
    let branch = engine.state().mission.mission_branch.clone();
    if options.enqueue {
        eprintln!("kranz exec: plan approved on {branch}; enqueueing without a worker");
    } else {
        eprintln!("kranz exec: plan approved on {branch}; running headlessly");
    }

    // A --max-cycles override is applied via the control inbox so it lands as a
    // config.changed event the run loop drains (never mutating config out of
    // band). Enqueued before the engine's run() drains the inbox.
    if let Some(n) = options.max_cycles {
        control::enqueue(
            engine.paths(),
            &ControlCommand::ConfigChange {
                patch: serde_json::json!({ "maxFixCyclesPerMilestone": n }),
            },
        )
        .with_context(|| format!("queuing the --max-cycles override for mission {mission_id}"))?;
    }

    if options.enqueue {
        if let Some(source) = &options.enqueue_source {
            queue::write_enqueue_source(
                &repo,
                &mission_id,
                &source.producer,
                &source.external_ref,
            )?;
        }
        let entry = match queue::enqueue(
            &repo,
            QueueEntry {
                mission_id: mission_id.clone(),
                ticket_slug: None,
                priority: ticket.priority,
                seq: 0,
            },
        ) {
            Ok(entry) => entry,
            Err(error) => {
                if options.enqueue_source.is_some() {
                    queue::remove_enqueue_source(&repo, &mission_id);
                }
                return Err(error.into());
            }
        };
        let cost = engine.state().total_cost_usd;
        drop(engine);
        checkout_guard.restore_now();
        println!(
            "kranz exec {mission_id} QUEUED cost=${cost:.2} branch={branch} seq={}",
            entry.seq
        );
        return Ok(0);
    }

    run_and_reconcile(engine, repo, mission_id, branch, options.push).await
}

/// Put an enqueue-only invocation back on the branch from which it started.
///
/// Without this, the non-worktree engine parks the primary checkout on the
/// mission branch even though the caller only asked to queue future work.
/// Keep stdout reserved for the one-line `exec` receipt; recovery warnings
/// belong on stderr.
fn capture_checkout_position(repo: &Path) -> Option<CheckoutPosition> {
    let git = GitRepo::open(repo).ok()?;
    match git.current_branch().ok()?.as_str() {
        "HEAD" => git.head_sha().ok().map(CheckoutPosition::Detached),
        branch => Some(CheckoutPosition::Branch(branch.to_string())),
    }
}

fn restore_enqueue_checkout(repo: &Path, original: Option<&CheckoutPosition>) {
    let Some(original) = original else { return };
    let Ok(git) = GitRepo::open(repo) else { return };
    let current = git.current_branch().unwrap_or_else(|_| "unknown".into());
    match original {
        CheckoutPosition::Branch(branch) if current == *branch => return,
        CheckoutPosition::Detached(sha)
            if current == "HEAD" && git.head_sha().ok().as_deref() == Some(sha.as_str()) =>
        {
            return
        }
        _ => {}
    }
    let target = match original {
        CheckoutPosition::Branch(branch) | CheckoutPosition::Detached(branch) => branch,
    };
    match git.is_clean_tracked() {
        Ok(true) => {
            if let Err(e) = git.checkout(target) {
                eprintln!("warning: could not restore checkout to {target}: {e}");
            }
        }
        Ok(false) => eprintln!(
            "warning: leaving checkout on {current}: tracked files have uncommitted changes"
        ),
        Err(e) => {
            eprintln!("warning: could not probe the working tree ({e}); checkout left on {current}")
        }
    }
}

/// The tail of [`cmd_exec_with_backend`]: run the (already planned and
/// approved) `engine` to a terminal state, reconcile the linked ticket, then
/// handle the `--push` handoff and print the machine-readable summary line.
///
/// Split out so tests can drive it against an `engine` whose mission id was
/// already used to link a ticket — proving the `reconcile_ticket_for_mission`
/// call actually fires from this code path, not merely that the helper works
/// in isolation.
async fn run_and_reconcile(
    mut engine: MissionEngine,
    repo: PathBuf,
    mission_id: String,
    branch: String,
    push: Option<String>,
) -> Result<i32> {
    // Live stderr feed for CI logs: tail events.jsonl from the pre-run head seq.
    let color = std::io::stderr().is_terminal();
    let renderer = EventRenderer::seeded(engine.state(), color);
    let stop = Arc::new(AtomicBool::new(false));
    let printer = tokio::spawn(tail::tail_events(
        engine.paths().events_file(),
        engine.state().last_seq,
        renderer,
        Arc::clone(&stop),
    ));

    let run_result = engine.run().await;
    // Read the final cost off state before dropping the engine, then drop it
    // (flushes buffered deltas + releases the lock) so the printer's catch-up
    // read sees every event.
    let cost = engine.state().total_cost_usd;
    drop(engine);
    stop.store(true, Ordering::Relaxed);
    let _ = printer.await;

    let status = run_result.map_err(|e| augment_limit_hint(e.into()))?;
    let code = exit_code_for(status);

    // Reconcile the linked ticket's .status sidecar to match the mission's
    // terminal/blocked status. Non-fatal: a reconcile failure must never
    // change the exit code or the push behaviour below.
    if let Err(e) = kranz_engine::work::reconcile_ticket_for_mission(&repo, &mission_id) {
        eprintln!("kranz exec: warning: failed to reconcile linked ticket: {e}");
    }

    // Cloud handoff: on a COMPLETE run, push the mission's kranz/* branch to the
    // requested remote so a human reviews it and opens the PR. GitRepo enforces
    // the kranz/* guard — this never pushes main or force-pushes. A push failure
    // keeps stdout `pushed=false` and returns [`EXIT_PUSH_FAILED`] (distinct
    // from the mission's own exit code) so CI can detect a delivery miss.
    //
    // `GitRepo::open` is hardened (audit H3): the tree being pushed is the one
    // the worker just wrote, so an unhardened handle would run a planted
    // `pre-push` hook outside every sandbox with the CLI's full ambient
    // environment, including whatever credential the remote is authenticated
    // with.
    //
    // The push is a NETWORK operation, so the hardening splits (audit F-10 /
    // F-11): the operator's own `~/.gitconfig` stays in force — nulling it
    // would leave an https push with no credential helper, no `insteadOf`
    // rewrite and no `http.proxy` — while `push_mission_branch` REFUSES
    // outright if this repository's own config, the scope the worker can
    // write, carries a credential helper, an ssh command, a URL rewrite or a
    // transport hook. Such a key in that scope is an attack signal, not a
    // setting to work around, and the refusal surfaces here as a push
    // failure naming every offending key.
    let mut pushed = false;
    let mut push_failed = false;
    if let (Some(remote), MissionStatus::Complete) = (&push, status) {
        match kranz_engine::git_ops::GitRepo::open(&repo)
            .and_then(|r| r.push_mission_branch(remote, &branch))
        {
            Ok(()) => {
                pushed = true;
                eprintln!("kranz exec: pushed {branch} to {remote}");
            }
            Err(e) => {
                push_failed = true;
                eprintln!("kranz exec: WARNING failed to push {branch} to {remote}: {e}");
            }
        }
    }

    // The only line on stdout: machine-readable, one line, always emitted.
    println!(
        "kranz exec {mission_id} {} cost=${cost:.2} branch={branch} pushed={pushed}",
        output::mission_status_label(status)
    );
    if push_failed {
        return Ok(EXIT_PUSH_FAILED);
    }
    Ok(code)
}

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

    #[test]
    fn exit_code_maps_terminal_statuses() {
        assert_eq!(exit_code_for(MissionStatus::Complete), 0);
        assert_eq!(exit_code_for(MissionStatus::Failed), 1);
        assert_eq!(exit_code_for(MissionStatus::Blocked), 2);
        // Non-terminal statuses (should not arise from run()) map to failure.
        assert_eq!(exit_code_for(MissionStatus::Running), 1);
        assert_eq!(exit_code_for(MissionStatus::Abandoned), 1);
    }

    /// Composition audit (ticket `config-fail-open-audit`):
    /// `--allow-unvalidated` lifts EXACTLY the unattended scrutiny floor —
    /// its blast radius is one refuse-to-run gate, never a validator's deny
    /// list or the validators themselves (`skipScrutiny` stays an operator
    /// config decision; the flag only acknowledges it for a headless run,
    /// and changes nothing when no floor was tripped).
    #[test]
    fn composition_audit_allow_unvalidated_lifts_only_the_unattended_scrutiny_floor() {
        // The floor holds without the flag, and the refusal names it.
        let err = scrutiny_gate(true, false).unwrap_err();
        assert!(err.contains("--allow-unvalidated"), "{err}");
        // The flag acknowledges the floor.
        assert!(scrutiny_gate(true, true).is_ok());
        // With validators enabled, the flag is inert — identical outcomes
        // with and without it.
        assert!(scrutiny_gate(false, false).is_ok());
        assert!(scrutiny_gate(false, true).is_ok());
    }

    #[test]
    fn push_failure_exit_code_is_distinct() {
        // Mission COMPLETE → 0; push failure must not reuse that (or 1/2/3).
        assert_eq!(exit_code_for(MissionStatus::Complete), 0);
        assert_eq!(EXIT_PUSH_FAILED, 4);
        assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Complete));
        assert_ne!(EXIT_PUSH_FAILED, exit_code_for(MissionStatus::Failed));
        assert_ne!(EXIT_PUSH_FAILED, EXIT_UNDERSPECIFIED);
    }

    /// Pure helper mirroring the post-run push decision in [`cmd_exec`]: when
    /// `--push` is set and the push Errs after COMPLETE, the process exit is
    /// [`EXIT_PUSH_FAILED`] while the summary still reports `pushed=false`.
    fn exit_after_push(mission_code: i32, push_requested: bool, push_ok: bool) -> (i32, bool) {
        let mut pushed = false;
        let mut push_failed = false;
        if push_requested {
            if push_ok {
                pushed = true;
            } else {
                push_failed = true;
            }
        }
        let code = if push_failed {
            EXIT_PUSH_FAILED
        } else {
            mission_code
        };
        (code, pushed)
    }

    #[test]
    fn push_failure_returns_exit_4_with_pushed_false() {
        let (code, pushed) = exit_after_push(0, true, false);
        assert_eq!(code, EXIT_PUSH_FAILED);
        assert!(!pushed);
    }

    #[test]
    fn push_success_keeps_mission_exit_and_pushed_true() {
        let (code, pushed) = exit_after_push(0, true, true);
        assert_eq!(code, 0);
        assert!(pushed);
    }

    #[test]
    fn no_push_flag_leaves_mission_exit_unchanged() {
        let (code, pushed) = exit_after_push(0, false, false);
        assert_eq!(code, 0);
        assert!(!pushed);
    }

    // -----------------------------------------------------------------------
    // reconcile-on-terminal: `kranz exec`'s post-run step heals a linked ticket
    // -----------------------------------------------------------------------

    fn reconcile_turn(reply: &str) -> Vec<kranz_engine::backend::AgentEvent> {
        vec![
            kranz_engine::backend_mock::mock_text(reply),
            kranz_engine::backend_mock::mock_result_text(reply),
        ]
    }

    fn reconcile_worker_pass() -> kranz_engine::backend_mock::MockScript {
        kranz_engine::backend_mock::MockScript::single_shot_json(&serde_json::json!({
            "result": "pass",
            "summary": "implemented and tested",
            "filesTouched": ["delivered.txt"],
            "testsAdded": [],
            "testEvidence": "all green",
            "commits": []
        }))
        .writes_file("delivered.txt", "delivered by the mock worker\n")
    }

    fn reconcile_plan_json() -> serde_json::Value {
        serde_json::json!({
            "goal": "ship the demo",
            "validationContract": [],
            "milestones": [{
                "title": "M1",
                "features": [{
                    "title": "F1",
                    "spec": "build the thing",
                    "validationCriteria": ["it works"]
                }]
            }]
        })
    }

    /// `run_and_reconcile`'s post-run reconcile call must heal the linked
    /// ticket's stale `.status` sidecar once the headless mission reaches
    /// Complete — proving the f-1-3 wiring in `exec.rs` (not just the
    /// engine-level helper unit tests). Links the ticket to the mission and
    /// seeds it at Failed (a stale mismatch) before calling the function
    /// under test, so the assertion only passes if the reconcile call
    /// actually ran, not merely if the ticket happened to already be Done.
    /// Fails if the `reconcile_ticket_for_mission` call is removed from
    /// `run_and_reconcile`.
    #[tokio::test]
    async fn reconcile_on_terminal_after_cli_exec_marks_ticket_done() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path().to_path_buf();
        let status = std::process::Command::new("git")
            .args(["init", "-b", "main"])
            .current_dir(&repo)
            .status()
            .unwrap();
        assert!(status.success());
        std::process::Command::new("git")
            .args(["config", "user.name", "test"])
            .current_dir(&repo)
            .status()
            .unwrap();
        std::process::Command::new("git")
            .args(["config", "user.email", "test@example.com"])
            .current_dir(&repo)
            .status()
            .unwrap();
        std::fs::write(repo.join("README.md"), "seed\n").unwrap();
        std::process::Command::new("git")
            .args(["add", "-A"])
            .current_dir(&repo)
            .status()
            .unwrap();
        std::process::Command::new("git")
            .args(["commit", "-m", "seed"])
            .current_dir(&repo)
            .status()
            .unwrap();
        let repo = std::fs::canonicalize(&repo).unwrap();

        let judgement = serde_json::json!({
            "decision": "complete",
            "guidance": "",
            "summary": "worker did the job"
        });
        // A single continuous orchestrator session: `cmd_exec_with_backend`
        // never drops/resumes the engine between planning and run, unlike
        // `kranz run`'s loop.
        let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
            kranz_engine::backend_mock::mock_init("orch-session"),
            kranz_engine::backend_mock::mock_result_text("seed-hi"),
        ])
        .responding(vec![
            reconcile_turn("let's scope the demo"),
            reconcile_turn(&reconcile_plan_json().to_string()),
            reconcile_turn("ack"),
            reconcile_turn(
                &serde_json::json!({"action": "commit-as-is", "note": "worker delivered files"})
                    .to_string(),
            ),
            reconcile_turn(&judgement.to_string()),
            reconcile_turn("NONE"),
        ]);
        let backend: Arc<dyn AgentBackend> =
            Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
                orch,
                // The run-phase auth probe (orchestrator.rs:2711) fires on the
                // first worker spawn and consumes a script of its own — a
                // single-shot, or the worker's script is eaten and the worker
                // errors on an empty queue.
                kranz_engine::backend_mock::MockScript::single_shot("ok"),
                reconcile_worker_pass(),
            ]));

        let cfg = MissionConfig {
            skip_scrutiny: true,
            skip_functional: true,
            ..Default::default()
        };
        let mut engine =
            MissionEngine::create(Arc::clone(&backend), repo.clone(), "ship the demo", cfg)
                .unwrap();
        let mission_id = engine.mission_id().to_string();
        engine.planning_turn("ship the demo").await.unwrap();
        let request = engine.request_plan().await.unwrap();
        let plan = match request {
            PlanRequest::Ready(plan) => plan,
            PlanRequest::NotReady(text) => panic!("expected a ready plan, got: {text}"),
            PlanRequest::WrongPlan { reason } => {
                panic!("expected a ready plan, got a wrong-plan escalation: {reason}")
            }
        };
        engine.approve_plan(plan).unwrap();
        let branch = engine.state().mission.mission_branch.clone();

        // Link a ticket to this mission and stamp it Failed — a stale
        // mismatch the drove-to-Complete run must heal.
        kranz_engine::ticket::Ticket::record_mission(&repo, "my-ticket", &mission_id).unwrap();
        kranz_engine::ticket::Ticket::write_state(
            &repo,
            "my-ticket",
            kranz_engine::ticket::TicketState::Failed,
            None,
        )
        .unwrap();

        let exit_code = run_and_reconcile(engine, repo.clone(), mission_id, branch, None)
            .await
            .unwrap();
        assert_eq!(exit_code, 0);

        assert_eq!(
            kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
            kranz_engine::ticket::TicketState::Done,
            "run_and_reconcile must reconcile the linked ticket to Done on Complete"
        );
    }

    #[tokio::test]
    async fn enqueue_only_exec_creates_approved_mission_without_running_worker() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path().to_path_buf();
        for args in [
            vec!["init", "-b", "main"],
            vec!["config", "user.name", "test"],
            vec!["config", "user.email", "test@example.com"],
        ] {
            assert!(std::process::Command::new("git")
                .args(args)
                .current_dir(&repo)
                .status()
                .unwrap()
                .success());
        }
        std::fs::write(repo.join("README.md"), "seed\n").unwrap();
        for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
            assert!(std::process::Command::new("git")
                .args(args)
                .current_dir(&repo)
                .status()
                .unwrap()
                .success());
        }
        let repo = std::fs::canonicalize(&repo).unwrap();

        let orch = kranz_engine::backend_mock::MockScript::streaming(vec![
            kranz_engine::backend_mock::mock_init("orch-session"),
            kranz_engine::backend_mock::mock_result_text("seed-hi"),
        ])
        .responding(vec![
            reconcile_turn("the brief is self-contained"),
            reconcile_turn(&reconcile_plan_json().to_string()),
            reconcile_turn("approved"),
        ]);
        let backend: Arc<dyn AgentBackend> =
            Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
                orch,
            ]));
        let ticket = parse_mission_markdown(
            "gas-city-bead",
            "---\npriority: 1\n---\n## Goal\nship the demo\n\n## Acceptance hints\nit works\n",
        )
        .unwrap();
        let cfg = MissionConfig {
            skip_scrutiny: true,
            skip_functional: true,
            ..Default::default()
        };

        let code = cmd_exec_with_backend(
            repo.clone(),
            cfg,
            backend,
            ticket,
            PathBuf::from("gas-city-bead.md"),
            ExecOptions {
                max_cycles: Some(1),
                enqueue: true,
                enqueue_source: Some(ExternalEnqueueSource {
                    producer: "gascity".to_string(),
                    external_ref: "rig-1".to_string(),
                }),
                push: None,
                dangerously_allow_all: false,
                allow_unvalidated: false,
            },
        )
        .await
        .unwrap();

        assert_eq!(code, 0);
        assert_eq!(
            GitRepo::open(&repo).unwrap().current_branch().unwrap(),
            "main",
            "enqueue-only exec must restore the caller's checkout"
        );
        let queued = queue::list(&repo);
        assert_eq!(queued.len(), 1);
        assert_eq!(queued[0].priority, 1);
        assert!(queued[0].ticket_slug.is_none());
        let source = queue::read_enqueue_source(&repo, &queued[0].mission_id).unwrap();
        assert_eq!(source.producer, "gascity");
        assert_eq!(source.external_ref, "rig-1");

        let state_path = repo
            .join(".kranz")
            .join("missions")
            .join(&queued[0].mission_id)
            .join("state.json");
        let state: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
        assert_eq!(
            state.pointer("/mission/status").and_then(|v| v.as_str()),
            Some("approved")
        );
        assert!(
            !repo.join("delivered.txt").exists(),
            "enqueue-only must not spawn a worker or run the approved mission"
        );
    }

    #[test]
    fn enqueue_checkout_guard_restores_detached_head() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path();
        for args in [
            vec!["init", "-b", "main"],
            vec!["config", "user.name", "test"],
            vec!["config", "user.email", "test@example.com"],
        ] {
            assert!(std::process::Command::new("git")
                .args(args)
                .current_dir(repo)
                .status()
                .unwrap()
                .success());
        }
        std::fs::write(repo.join("README.md"), "seed\n").unwrap();
        for args in [vec!["add", "README.md"], vec!["commit", "-m", "seed"]] {
            assert!(std::process::Command::new("git")
                .args(args)
                .current_dir(repo)
                .status()
                .unwrap()
                .success());
        }
        let git = GitRepo::open(repo).unwrap();
        let original_sha = git.head_sha().unwrap();
        git.checkout(&original_sha).unwrap();
        assert_eq!(git.current_branch().unwrap(), "HEAD");

        let mut guard = EnqueueCheckoutGuard::new(repo, true);
        git.create_branch("kranz/mission-test", None).unwrap();
        git.checkout("kranz/mission-test").unwrap();
        guard.restore_now();

        assert_eq!(git.current_branch().unwrap(), "HEAD");
        assert_eq!(git.head_sha().unwrap(), original_sha);
    }
}