Skip to main content

kranz_cli/
commands.rs

1//! One function per `kranz` subcommand, plus the shared helpers (config
2//! loading, backend construction, mission selection).
3//!
4//! The `ClaudeBackend` is constructed LAZILY — only `plan` and `run` spawn
5//! agent sessions, so `status`/`msg`/`pause`/`resume`/`missions`/`serve`
6//! work on machines without a `claude` binary installed.
7
8use crate::backlog;
9use crate::cli::{Cli, Command, GrantCommand, QuestionCommand, RevisionCommand, TicketCommand};
10use crate::output::{self, ansi};
11use crate::planning_tui::PlanningOutcome;
12use crate::tail::{self, EventRenderer};
13use anyhow::{anyhow, bail, Context, Result};
14use kranz_engine::backend::AgentBackend;
15use kranz_engine::backend_claude::ClaudeBackend;
16use kranz_engine::config;
17use kranz_engine::control;
18use kranz_engine::corpus_export;
19use kranz_engine::cost;
20use kranz_engine::event_log::{EventLog, LockForce};
21use kranz_engine::mission_catalog;
22use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
23use kranz_engine::paths::MissionPaths;
24use kranz_engine::reducer;
25use kranz_engine::trace_export;
26use kranz_engine::types::{ControlCommand, MissionConfig, MissionState, MissionStatus};
27use std::io::{IsTerminal, Write};
28use std::path::{Path, PathBuf};
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::Arc;
31use std::time::{Duration, SystemTime};
32
33/// Parse-level entry point: resolve the repo, print the danger banner when
34/// requested, dispatch the subcommand, and return the process exit code.
35pub async fn run_cli(cli: Cli) -> Result<i32> {
36    let lock_force = cli.lock_force();
37    let repo = match cli.repo {
38        Some(repo) => repo,
39        None => std::env::current_dir().context("cannot determine the current directory")?,
40    };
41    if cli.dangerously_allow_all {
42        print_danger_banner();
43    }
44
45    match cli.command {
46        Command::Licenses => {
47            let mut out = std::io::stdout().lock();
48            out.write_all(include_bytes!("../LICENSE"))?;
49            out.write_all(b"\n\n")?;
50            out.write_all(include_bytes!("../assets/THIRD_PARTY_NOTICES.txt"))?;
51            out.write_all(b"\n\nEmbedded dashboard dependency notices\n")?;
52            out.write_all(include_bytes!(
53                "../assets/dashboard/dist/THIRD_PARTY_NOTICES.txt"
54            ))?;
55            Ok(0)
56        }
57        Command::Init {
58            gates,
59            register,
60            id,
61            display_name,
62        } => {
63            let options = crate::init::InitOptions {
64                gates,
65                registration: register.then_some(crate::init::Registration { id, display_name }),
66                global_config: kranz_engine::paths::global_config(),
67            };
68            let report = crate::init::initialize(&repo, &options)?;
69            print!("{}", crate::init::render(&report));
70            Ok(0)
71        }
72        Command::Plan { goal } => {
73            let cfg = load_config(&repo, cli.dangerously_allow_all)?;
74            cmd_plan(repo, goal, cfg, cli.mission.as_deref(), lock_force)
75                .await
76                .map_err(augment_limit_hint)
77        }
78        Command::Run => {
79            let mission = select_mission(&repo, cli.mission.as_deref())?;
80            cmd_run(repo, mission, lock_force, cli.dangerously_allow_all)
81                .await
82                .map_err(augment_limit_hint)
83        }
84        Command::Status { json } => {
85            let mission = select_mission(&repo, cli.mission.as_deref())?;
86            let state = load_state(&repo, &mission)?;
87            if json {
88                println!("{}", serde_json::to_string_pretty(&state)?);
89            } else {
90                print!("{}", output::render_status(&state));
91            }
92            Ok(0)
93        }
94        Command::SandboxProbe { json } => {
95            let report = kranz_engine::sandbox_windows::probe();
96            if json {
97                println!("{}", serde_json::to_string_pretty(&report)?);
98            } else {
99                print!("{}", report.render_text());
100            }
101            Ok(0)
102        }
103        Command::SandboxPrepare { targets } => {
104            for target in targets {
105                eprintln!(
106                    "Preparing AppContainer host metadata: target={}",
107                    target.display()
108                );
109                let changed = kranz_engine::sandbox_windows::prepare_appcontainer_host(&target)
110                    .map_err(anyhow::Error::msg)?;
111                let result = if changed {
112                    "applied"
113                } else {
114                    "already prepared"
115                };
116                println!(
117                    "AppContainer host preparation {result}: target={} mask=0x00120088 inheritance=none",
118                    target.display()
119                );
120            }
121            // Derived from USERPROFILE, never operator-supplied. Windows tools
122            // lstat every ancestor during module resolution, so a contained
123            // Node gate fails EPERM on the profile parent without this.
124            eprintln!("Preparing AppContainer profile-parent metadata (derived from USERPROFILE)");
125            let (profile_parent, profile_changed) =
126                kranz_engine::sandbox_windows::prepare_appcontainer_profile_parent()
127                    .map_err(anyhow::Error::msg)?;
128            let profile_result = if profile_changed {
129                "applied"
130            } else {
131                "already prepared"
132            };
133            println!(
134                "AppContainer profile-parent preparation {profile_result}: target={} mask=0x00120088 inheritance=none",
135                profile_parent.display()
136            );
137            eprintln!("Preparing AppContainer null-device metadata: target=\\Device\\Null");
138            kranz_engine::sandbox_windows::prepare_appcontainer_null_device()
139                .map_err(anyhow::Error::msg)?;
140            println!(
141                "AppContainer null-device preparation applied: target=\\Device\\Null inheritance=none"
142            );
143            Ok(0)
144        }
145        Command::Outcomes {
146            json,
147            all,
148            window_days,
149        } => {
150            if all {
151                // KRZ-329: the same fold, grouped by repo across the M8 host
152                // catalog (mirrors `kranz ready --all`).
153                let config = kranz_engine::paths::global_config()
154                    .ok_or_else(|| anyhow::anyhow!("cannot locate the home directory"))?;
155                let report =
156                    crate::merged_costs::assess_all(&config, window_days, chrono::Utc::now());
157                if json {
158                    println!("{}", serde_json::to_string_pretty(&report)?);
159                } else {
160                    print!("{}", crate::merged_costs::render_org(&report));
161                }
162            } else {
163                let outcomes = kranz_engine::outcomes::compute_outcomes(&repo)?;
164                if json {
165                    println!("{}", output::render_outcomes_json(&outcomes)?);
166                } else {
167                    print!("{}", output::render_outcomes(&outcomes));
168                }
169            }
170            Ok(0)
171        }
172        Command::EscalationMetrics { json } => {
173            let metrics = kranz_engine::escalation_metrics::compute_escalation_metrics(&repo)?;
174            if json {
175                println!("{}", output::render_escalation_metrics_json(&metrics)?);
176            } else {
177                print!("{}", output::render_escalation_metrics(&metrics));
178            }
179            Ok(0)
180        }
181        Command::Provenance { mission_id, json } => {
182            let mission = select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
183            let chain = kranz_engine::provenance::compute_provenance(&repo, &mission)?;
184            if json {
185                println!("{}", output::render_provenance_json(&chain)?);
186            } else {
187                print!("{}", output::render_provenance(&chain));
188            }
189            Ok(0)
190        }
191        Command::GateScores { gate, json } => {
192            let series = kranz_engine::gate_scores::compute_gate_score_series(&repo, &gate)?;
193            if json {
194                println!("{}", output::render_gate_score_series_json(&series)?);
195            } else {
196                print!("{}", output::render_gate_score_series(&series));
197            }
198            Ok(0)
199        }
200        Command::EvidenceBundle { mission_id, out } => {
201            let mission = select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
202            let out = out.unwrap_or_else(|| PathBuf::from(format!("evidence-bundle-{mission}")));
203            let outcome =
204                kranz_engine::evidence_bundle::export_evidence_bundle(&repo, &mission, &out)?;
205            println!(
206                "evidence bundle for mission {mission} written to {} ({} files; {} resolved artefacts, {} unresolved)",
207                outcome.out_dir.display(),
208                outcome.files_written,
209                outcome.resolved_artefacts,
210                outcome.unresolved_artefacts,
211            );
212            Ok(0)
213        }
214        Command::ExportTraces {
215            mission_id,
216            all,
217            out,
218        } => {
219            let jsonl = if all {
220                cmd_export_traces_all(&repo)
221            } else {
222                let mission =
223                    select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
224                cmd_export_traces(&repo, &mission)?
225            };
226            match out {
227                // No-follow + refuse a symlinked destination: a bare
228                // `fs::write` truncates through a link an agent can plant at
229                // a plausible `--out` path.
230                Some(path) => {
231                    kranz_engine::trace_export::write_export_output(&path, jsonl.as_bytes())
232                        .with_context(|| {
233                            format!("writing export-traces output to {}", path.display())
234                        })?
235                }
236                None => print!("{jsonl}"),
237            }
238            Ok(0)
239        }
240        Command::ExportCorpus {
241            mission_id,
242            all,
243            out,
244        } => {
245            let jsonl = if all {
246                cmd_export_corpus_all(&repo)
247            } else {
248                let mission =
249                    select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
250                cmd_export_corpus(&repo, &mission)?
251            };
252            match out {
253                // Same no-follow write as export-traces above.
254                Some(path) => {
255                    kranz_engine::trace_export::write_export_output(&path, jsonl.as_bytes())
256                        .with_context(|| {
257                            format!("writing export-corpus output to {}", path.display())
258                        })?
259                }
260                None => print!("{jsonl}"),
261            }
262            Ok(0)
263        }
264        Command::Pause => {
265            let mission = select_control_mission(&repo, cli.mission.as_deref())?;
266            cmd_pause(&repo, &mission)?;
267            println!("pause queued for mission {mission} (takes effect between worker runs)");
268            if let Some(hint) = control_queue_hint(&repo, &mission) {
269                println!("{hint}");
270            }
271            Ok(0)
272        }
273        Command::Resume => {
274            let mission = select_control_mission(&repo, cli.mission.as_deref())?;
275            cmd_resume(&repo, &mission)?;
276            println!("resume queued for mission {mission} (takes effect between worker runs)");
277            if let Some(hint) = control_queue_hint(&repo, &mission) {
278                println!("{hint}");
279            }
280            Ok(0)
281        }
282        Command::Msg { text, interrupt } => {
283            let mission = select_control_mission(&repo, cli.mission.as_deref())?;
284            cmd_msg(&repo, &mission, &text, interrupt)?;
285            if interrupt {
286                println!(
287                    "message queued for mission {mission} with --interrupt: the current worker \
288                     run will be aborted (recorded as partial) before the message is injected"
289                );
290            } else {
291                println!(
292                    "message queued for mission {mission}; it is processed between worker runs"
293                );
294            }
295            if let Some(hint) = control_queue_hint(&repo, &mission) {
296                println!("{hint}");
297            }
298            Ok(0)
299        }
300        Command::Revise { id, instructions } => {
301            let instructions = instructions.join(" ");
302            cmd_request_revision(&repo, &id, &instructions)?;
303            println!("revision request queued for mission {id}");
304            if let Some(hint) = control_queue_hint(&repo, &id) {
305                println!("{hint}");
306            }
307            Ok(0)
308        }
309        Command::Revision { command } => {
310            match command {
311                RevisionCommand::Approve { id, revision } => {
312                    cmd_approve_revision(&repo, &id, revision)?;
313                    println!("revision {revision} approval queued for mission {id}");
314                    if let Some(hint) = control_queue_hint(&repo, &id) {
315                        println!("{hint}");
316                    }
317                }
318                RevisionCommand::Reject { id, revision } => {
319                    cmd_reject_revision(&repo, &id, revision)?;
320                    println!("revision {revision} rejection queued for mission {id}");
321                    if let Some(hint) = control_queue_hint(&repo, &id) {
322                        println!("{hint}");
323                    }
324                }
325            }
326            Ok(0)
327        }
328        Command::Grant { command } => {
329            match command {
330                GrantCommand::Approve { id, command } => {
331                    cmd_approve_grant(&repo, &id, &command)?;
332                    println!("grant approval for `{command}` queued for mission {id}");
333                    if let Some(hint) = control_queue_hint(&repo, &id) {
334                        println!("{hint}");
335                    }
336                }
337                GrantCommand::Deny {
338                    id,
339                    command,
340                    reason,
341                } => {
342                    cmd_deny_grant(&repo, &id, &command, &reason)?;
343                    println!("grant denial for `{command}` queued for mission {id}");
344                    if let Some(hint) = control_queue_hint(&repo, &id) {
345                        println!("{hint}");
346                    }
347                }
348            }
349            Ok(0)
350        }
351        Command::Question { command } => {
352            match command {
353                QuestionCommand::List { id } => {
354                    print!("{}", cmd_list_questions(&repo, &id)?);
355                }
356                QuestionCommand::Answer {
357                    id,
358                    question_id,
359                    answer,
360                    option,
361                } => {
362                    cmd_answer_question(&repo, &id, &question_id, &answer, option)?;
363                    println!("answer for question {question_id} queued for mission {id}");
364                    if let Some(hint) = control_queue_hint(&repo, &id) {
365                        println!("{hint}");
366                    }
367                }
368            }
369            Ok(0)
370        }
371        Command::Missions => {
372            print!("{}", cmd_missions(&repo)?);
373            Ok(0)
374        }
375        Command::Abandon { id, reason } => {
376            // A positional id wins over the global --mission; otherwise fall
377            // back to the usual auto-selection.
378            let mission = select_mission(&repo, id.as_deref().or(cli.mission.as_deref()))?;
379            let reason = reason.as_deref().unwrap_or("abandoned by operator");
380            cmd_abandon(&repo, &mission, reason, lock_force)?;
381            println!("mission {mission} ABANDONED ({reason})");
382            Ok(0)
383        }
384        Command::Clean { yes, all } => cmd_clean(&repo, yes, all),
385        Command::Ticket { command } => dispatch_ticket(&repo, command, cli.mission.as_deref()),
386        Command::Draft {
387            slug,
388            yes,
389            from_mission,
390        } => backlog::cmd_draft(
391            repo,
392            &slug,
393            yes,
394            from_mission.as_deref(),
395            cli.dangerously_allow_all,
396        )
397        .await
398        .map_err(augment_limit_hint),
399        Command::Decompose { goal, yes } => {
400            backlog::cmd_decompose(repo, &goal, yes, cli.dangerously_allow_all)
401                .await
402                .map_err(augment_limit_hint)
403        }
404        Command::Exec {
405            file,
406            yes: _,
407            max_cycles,
408            enqueue,
409            enqueue_source,
410            enqueue_external_ref,
411            push,
412            allow_unvalidated,
413        } => crate::exec::cmd_exec(
414            repo,
415            file,
416            crate::exec::ExecOptions {
417                max_cycles,
418                enqueue,
419                enqueue_source: enqueue_source.zip(enqueue_external_ref).map(
420                    |(producer, external_ref)| crate::exec::ExternalEnqueueSource {
421                        producer,
422                        external_ref,
423                    },
424                ),
425                push,
426                dangerously_allow_all: cli.dangerously_allow_all,
427                allow_unvalidated,
428            },
429        )
430        .await
431        .map_err(augment_limit_hint),
432        Command::Queue { remove } => match remove {
433            Some(mission_id) => {
434                print!("{}", backlog::cmd_queue_remove(&repo, &mission_id)?);
435                Ok(0)
436            }
437            None => {
438                print!("{}", backlog::cmd_queue(&repo));
439                Ok(0)
440            }
441        },
442        Command::KnowledgeRefresh { json } => cmd_knowledge_refresh(&repo, json),
443        Command::Scan { staged, range } => cmd_scan(&repo, staged, range.as_deref()),
444        Command::DomainLint { seed_config, json } => {
445            cmd_domain_lint(&repo, seed_config.as_deref(), json)
446        }
447        Command::HookGuard { config } => {
448            let mut stdin = std::io::stdin();
449            Ok(crate::hook_guard::run_hook_guard(&config, &mut stdin))
450        }
451        Command::HookStatus { config } => {
452            let mut stdin = std::io::stdin();
453            Ok(crate::hook_status::run_hook_status(
454                &config,
455                &mut stdin,
456                &crate::hook_status::post_signal,
457            )
458            .await)
459        }
460        Command::Ready { json, all } => {
461            if all {
462                let config = kranz_engine::paths::global_config()
463                    .ok_or_else(|| anyhow::anyhow!("cannot locate the home directory"))?;
464                let report = crate::ready::assess_all(&config);
465                if json {
466                    println!("{}", serde_json::to_string_pretty(&report)?);
467                } else {
468                    print!("{}", crate::ready::render_org(&report));
469                }
470            } else {
471                let report = crate::ready::assess(&repo);
472                if json {
473                    println!("{}", serde_json::to_string_pretty(&report)?);
474                } else {
475                    print!("{}", crate::ready::render(&report));
476                }
477            }
478            Ok(0)
479        }
480        Command::Work { once, expect } => backlog::cmd_work(repo, once, expect)
481            .await
482            .map_err(augment_limit_hint),
483        Command::Serve {
484            port,
485            host,
486            insecure_lan,
487            read_auth,
488            open,
489            dashboard,
490            token,
491            read_token,
492            slack,
493        } => {
494            cmd_serve(
495                repo,
496                host,
497                port,
498                insecure_lan,
499                read_auth,
500                open,
501                dashboard,
502                token,
503                read_token,
504                slack,
505            )
506            .await
507        }
508        Command::Release { url, token } => {
509            let mission = select_mission(&repo, cli.mission.as_deref())?;
510            cmd_release(&repo, &mission, &url, token).await
511        }
512        Command::Config { command } => {
513            crate::config_cmd::cmd_config(&repo, command, cli.mission.as_deref())
514        }
515        Command::Pack { command } => match command {
516            crate::cli::PackCommand::Lint { dir } => cmd_pack_lint(&repo, &dir),
517        },
518        Command::Standards { command } => match command {
519            crate::cli::StandardsCommand::Metrics { json } => {
520                let report = kranz_engine::standards_metrics::compute(&repo)?;
521                if json {
522                    println!("{}", serde_json::to_string_pretty(&report)?);
523                } else {
524                    print!("{}", crate::output::render_standards_metrics(&report));
525                }
526                Ok(0)
527            }
528            crate::cli::StandardsCommand::Lint { dir, against } => {
529                cmd_standards_lint(&repo, &dir, against.as_deref())
530            }
531            crate::cli::StandardsCommand::Waive {
532                rule,
533                revision,
534                finding,
535                reason,
536                expires,
537            } => {
538                let mission = select_mission(&repo, cli.mission.as_deref())?;
539                cmd_standards_waive(
540                    &repo,
541                    &mission,
542                    &rule,
543                    revision,
544                    finding.as_deref(),
545                    &reason,
546                    &expires,
547                    lock_force,
548                )
549            }
550            crate::cli::StandardsCommand::Attest { rule, reason } => {
551                let mission = select_mission(&repo, cli.mission.as_deref())?;
552                cmd_standards_attest(&repo, &mission, &rule, &reason, lock_force)
553            }
554        },
555        Command::Otel {
556            endpoint,
557            from_start,
558        } => crate::otel::run_otel(repo, cli.mission.clone(), endpoint, from_start).await,
559    }
560}
561
562/// Dispatch the backend-free `kranz ticket …` subcommands. The global
563/// `--mission` flag supplies the mission id to `ticket approve` when the ticket
564/// goal can't be matched automatically.
565fn dispatch_ticket(repo: &Path, command: TicketCommand, mission: Option<&str>) -> Result<i32> {
566    match command {
567        TicketCommand::List => {
568            print!("{}", backlog::cmd_ticket_list(repo));
569            Ok(0)
570        }
571        TicketCommand::Ready { include_deferred } => {
572            print!("{}", backlog::cmd_ticket_ready(repo, include_deferred));
573            Ok(0)
574        }
575        TicketCommand::Show { slug } => {
576            print!("{}", backlog::cmd_ticket_show(repo, &slug)?);
577            Ok(0)
578        }
579        TicketCommand::New { slug, title, goal } => {
580            let path = backlog::cmd_ticket_new(repo, &slug, &title, goal.as_deref())?;
581            println!("created ticket '{slug}' at {}", path.display());
582            Ok(0)
583        }
584        TicketCommand::ImportOpenspec { path, slug } => {
585            let written = crate::openspec::import_change(repo, &path, slug.as_deref())?;
586            println!("imported {} as {}", path.display(), written.display());
587            println!(
588                "acceptance criteria are still prose: replace the placeholder in \
589                 '## Acceptance hints' with commands that can fail"
590            );
591            Ok(0)
592        }
593        TicketCommand::Note { slug, text } => {
594            print!(
595                "{}",
596                crate::ticket_notes::cmd_ticket_note(repo, &slug, &text.join(" "))?
597            );
598            Ok(0)
599        }
600        TicketCommand::Notes { slug } => {
601            print!("{}", crate::ticket_notes::cmd_ticket_notes(repo, &slug)?);
602            Ok(0)
603        }
604        TicketCommand::Queue {
605            slug,
606            mission: explicit,
607            force,
608        } => {
609            // A `ticket queue --mission` wins over the global `--mission`.
610            backlog::cmd_ticket_queue(repo, &slug, explicit.as_deref().or(mission), force)
611        }
612        TicketCommand::Approve {
613            slug,
614            mission: explicit,
615            force,
616        } => {
617            // Deprecated alias for `ticket queue` (D-A); `--mission` still
618            // wins over the global `--mission`.
619            backlog::cmd_ticket_approve(repo, &slug, explicit.as_deref().or(mission), force)
620        }
621        TicketCommand::MigrateState { yes } => backlog::cmd_ticket_migrate_state(repo, yes),
622    }
623}
624
625// ---------------------------------------------------------------------------
626// Shared helpers
627// ---------------------------------------------------------------------------
628
629/// Load + validate the layered config for `repo`; `--dangerously-allow-all`
630/// is applied on top of the merged file layers.
631pub fn load_config(repo: &Path, dangerously_allow_all: bool) -> Result<MissionConfig> {
632    let mut cfg = config::load(repo)?;
633    if dangerously_allow_all {
634        cfg.dangerously_allow_all = true;
635    }
636    config::validate(&cfg)?;
637    Ok(cfg)
638}
639
640/// Construct the real backend. Called lazily — only by `plan`, `run`, and the
641/// backlog `draft` handler.
642pub(crate) fn build_backend(cfg: &MissionConfig) -> Result<Arc<dyn AgentBackend>> {
643    let backend = ClaudeBackend::discover(cfg.claude_binary.as_deref())?;
644    Ok(Arc::new(backend))
645}
646
647/// Resolve the mission id: `--mission` wins; otherwise the repo's only
648/// mission; with several, the one whose `events.jsonl` was modified last.
649pub fn select_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
650    if let Some(id) = explicit {
651        require_mission(repo, id)?;
652        return Ok(id.to_string());
653    }
654    let ids = MissionPaths::list_missions(repo);
655    match ids.len() {
656        0 => bail!(
657            "no missions found under {}; create one with `kranz plan \"<goal>\"`",
658            repo.join(".kranz").join("missions").display()
659        ),
660        1 => Ok(ids.into_iter().next().expect("len checked")),
661        _ => {
662            let mut best: Option<(SystemTime, String)> = None;
663            for id in ids {
664                let events = MissionPaths::new(repo, &id).events_file();
665                let mtime = std::fs::metadata(&events)
666                    .and_then(|m| m.modified())
667                    .unwrap_or(SystemTime::UNIX_EPOCH);
668                let newer = match &best {
669                    Some((t, _)) => mtime >= *t,
670                    None => true,
671                };
672                if newer {
673                    best = Some((mtime, id));
674                }
675            }
676            Ok(best.expect("non-empty list").1)
677        }
678    }
679}
680
681/// Resolve the mission a control-inbox WRITE (`kranz pause|resume|msg`)
682/// targets. A terminal mission's inbox is never drained (the engine refuses
683/// to run terminal missions), so enqueuing there would print success for a
684/// silent no-op — the same lie `kranz config role` already refuses via the
685/// shared engine resolver:
686///
687/// - explicit `--mission` id: routed through
688///   [`control::resolve_active_mission`] — it must exist and be non-terminal
689///   (the resolver's error names the actual status);
690/// - no id: keeps [`select_mission`]'s newest-by-mtime defaulting UX, but
691///   refuses a terminal pick instead of "succeeding" into a dead inbox.
692pub fn select_control_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
693    if explicit.is_some() {
694        return Ok(control::resolve_active_mission(repo, explicit)?);
695    }
696    let mission = select_mission(repo, None)?;
697    let status = load_state(repo, &mission)?.mission.status;
698    if mission_catalog::is_terminal_status(status) {
699        bail!(
700            "mission {mission} is {status:?}; control commands apply only to active \
701             missions (a terminal mission's inbox is never drained — see \
702             `kranz missions`)"
703        );
704    }
705    Ok(mission)
706}
707
708/// The honest post-enqueue note for `pause`/`resume`/`msg`: when no live
709/// engine holds the mission lock, the command just sits in the inbox — say
710/// so instead of implying it takes effect now. `None` while the mission is
711/// actually running (a live lock holder will drain the inbox shortly).
712pub fn control_queue_hint(repo: &Path, mission_id: &str) -> Option<String> {
713    let paths = MissionPaths::new(repo, mission_id);
714    (!mission_catalog::mission_lock_is_live(&paths)).then(|| {
715        format!(
716            "note: mission {mission_id} is not currently running — the command is \
717             queued and applies when the mission next runs"
718        )
719    })
720}
721
722/// Pick the mission to resume planning: the explicit `--mission` (validated
723/// to be in planning), or the newest-by-mtime mission whose folded status is
724/// still `Planning`.
725pub fn select_planning_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
726    if let Some(id) = explicit {
727        require_mission(repo, id)?;
728        return Ok(id.to_string());
729    }
730    let mut best: Option<(SystemTime, String)> = None;
731    for id in MissionPaths::list_missions(repo) {
732        let Ok(state) = load_state(repo, &id) else {
733            continue; // corrupt/foreign logs never block resume of a healthy one
734        };
735        if state.mission.status != MissionStatus::Planning {
736            continue;
737        }
738        let events = MissionPaths::new(repo, &id).events_file();
739        let mtime = std::fs::metadata(&events)
740            .and_then(|m| m.modified())
741            .unwrap_or(SystemTime::UNIX_EPOCH);
742        if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
743            best = Some((mtime, id));
744        }
745    }
746    best.map(|(_, id)| id).ok_or_else(|| {
747        anyhow!(
748            "no mission is currently in planning under {} — start one with \
749             `kranz plan \"<goal>\"`",
750            repo.join(".kranz").join("missions").display()
751        )
752    })
753}
754
755/// When an error is really Claude's subscription usage window (session/rate
756/// limit), say so and tell the user how to pick the work back up — the raw
757/// backend error reads like a Kranz failure otherwise.
758pub fn augment_limit_hint(e: anyhow::Error) -> anyhow::Error {
759    let msg = format!("{e:#}").to_ascii_lowercase();
760    if [
761        "session limit",
762        "usage limit",
763        "rate limit",
764        "hit your limit",
765    ]
766    .iter()
767    .any(|s| msg.contains(s))
768    {
769        e.context(
770            "this is your Claude subscription's usage window, not a Kranz failure. \
771             The mission and its conversation are saved: when the limit resets, \
772             `kranz plan` (no goal) resumes planning and `kranz run` resumes execution",
773        )
774    } else {
775        e
776    }
777}
778
779/// A mission exists iff its `events.jsonl` does.
780fn require_mission(repo: &Path, mission_id: &str) -> Result<MissionPaths> {
781    if !MissionPaths::is_safe_id(mission_id) {
782        bail!(
783            "invalid mission id '{mission_id}': ids cannot contain path separators, '..', or drive designators"
784        );
785    }
786    let paths = MissionPaths::new(repo, mission_id);
787    if !paths.events_file().is_file() {
788        bail!(
789            "mission '{mission_id}' not found under {} (see `kranz missions`)",
790            paths.missions_dir().display()
791        );
792    }
793    Ok(paths)
794}
795
796/// Read + fold a mission's event log (no lock — read-only observers are
797/// always allowed, §4.3).
798pub fn load_state(repo: &Path, mission_id: &str) -> Result<MissionState> {
799    let paths = require_mission(repo, mission_id)?;
800    let events = EventLog::read_events(&paths.events_file())
801        .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
802    let state = reducer::fold(&events)
803        .with_context(|| format!("folding the event log of mission '{mission_id}'"))?;
804    Ok(state)
805}
806
807/// Read + fold a mission's event log, then derive the validation-PASSED
808/// instruction-pair dataset and render it as JSONL. Pure function of the
809/// on-disk event log (no persisted dataset file), so consecutive
810/// invocations over an unchanged log are byte-identical.
811pub fn cmd_export_traces(repo: &Path, mission_id: &str) -> Result<String> {
812    let paths = require_mission(repo, mission_id)?;
813    let events = EventLog::read_events(&paths.events_file())
814        .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
815    let state = reducer::fold(&events)
816        .with_context(|| format!("folding the event log of mission '{mission_id}'"))?;
817    let pairs = trace_export::export_validated_traces(&state, &events);
818    Ok(trace_export::to_jsonl(&pairs))
819}
820
821/// `--all`: aggregate validation-PASSED traces across every mission under
822/// .kranz/missions. A mission whose event log is missing or unreadable (e.g.
823/// still Planning, or a corrupt log) is skipped rather than failing the whole
824/// export — one bad mission must not block the rest of the dataset.
825pub fn cmd_export_traces_all(repo: &Path) -> String {
826    let mut pairs = Vec::new();
827    for mission_id in MissionPaths::list_missions(repo) {
828        let paths = MissionPaths::new(repo, &mission_id);
829        let Ok(events) = EventLog::read_events(&paths.events_file()) else {
830            continue;
831        };
832        let Ok(state) = reducer::fold(&events) else {
833            continue;
834        };
835        pairs.extend(trace_export::export_validated_traces(&state, &events));
836    }
837    trace_export::to_jsonl(&pairs)
838}
839
840/// Read a mission's event log, derive the provenance-tagged training corpus
841/// (validated worker traces + divergence pairs + escalation judgments), and
842/// render it as JSONL. Pure function of the on-disk event log (no persisted
843/// dataset file), so consecutive invocations over an unchanged log are
844/// byte-identical. No-follow like every read that probes the mission dir
845/// (the corpus anchors the provenance replay's artefact resolution there).
846pub fn cmd_export_corpus(repo: &Path, mission_id: &str) -> Result<String> {
847    let paths = require_mission(repo, mission_id)?;
848    paths.require_no_follow()?;
849    let events = EventLog::read_events(&paths.events_file())
850        .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
851    let records = corpus_export::export_corpus(&paths.mission_dir(), mission_id, &events)
852        .with_context(|| format!("deriving the training corpus of mission '{mission_id}'"))?;
853    Ok(corpus_export::to_jsonl(&records))
854}
855
856/// `--all`: aggregate the corpus across every mission under .kranz/missions
857/// (ids sorted, so the aggregate's mission order is deterministic). A
858/// mission whose event log is missing, unreadable, or corrupt — or whose
859/// path fails the no-follow guard — is skipped rather than failing the
860/// whole export, mirroring `cmd_export_traces_all`.
861pub fn cmd_export_corpus_all(repo: &Path) -> String {
862    let mut records = Vec::new();
863    for mission_id in MissionPaths::list_missions(repo) {
864        let paths = MissionPaths::new(repo, &mission_id);
865        if paths.require_no_follow().is_err() {
866            continue;
867        }
868        let Ok(events) = EventLog::read_events(&paths.events_file()) else {
869            continue;
870        };
871        let Ok(mission_records) =
872            corpus_export::export_corpus(&paths.mission_dir(), &mission_id, &events)
873        else {
874            continue;
875        };
876        records.extend(mission_records);
877    }
878    corpus_export::to_jsonl(&records)
879}
880
881/// Loud multi-line warning on stderr for `--dangerously-allow-all`.
882pub fn print_danger_banner() {
883    eprintln!(
884        "\n\
885         ============================================================\n\
886         !!  --dangerously-allow-all IS SET                        !!\n\
887         !!                                                        !!\n\
888         !!  Permission gating is BYPASSED for every agent         !!\n\
889         !!  session (bypassPermissions). Workers can run ANY      !!\n\
890         !!  command: file writes, network access, git push,       !!\n\
891         !!  package publishes, sudo.                              !!\n\
892         !!                                                        !!\n\
893         !!  Only use this on a sandboxed, disposable checkout.    !!\n\
894         ============================================================\n"
895    );
896}
897
898/// Stdin as a channel of lines, so prompts can DISCARD type-ahead: a line
899/// typed while an orchestrator turn was running must not silently answer the
900/// next prompt (an early "/quit" once ate the plan-approval "y").
901///
902/// The blocking reader thread and its channel are a process-global
903/// singleton shared by every instance. A per-instance thread would sit
904/// blocked in `read_line` (holding the stdin lock) long after its receiver
905/// is gone and steal the first line meant for a later prompt — exactly what
906/// would happen when the plan-approval "start execution now" handoff reaches
907/// the run loop's blocked-guidance prompt with the planning prompt's reader
908/// still alive.
909struct StdinLines {
910    rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<String>>>,
911}
912
913impl StdinLines {
914    fn spawn() -> Self {
915        static CHANNEL: std::sync::OnceLock<
916            Arc<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<String>>>,
917        > = std::sync::OnceLock::new();
918        let rx = CHANNEL
919            .get_or_init(|| {
920                let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
921                std::thread::spawn(move || {
922                    let mut buf = String::new();
923                    loop {
924                        buf.clear();
925                        match std::io::stdin().read_line(&mut buf) {
926                            Ok(0) | Err(_) => break, // EOF: channel closes on tx drop
927                            Ok(_) => {
928                                let line = buf.trim_end_matches(['\r', '\n']).to_string();
929                                if tx.send(line).is_err() {
930                                    break;
931                                }
932                            }
933                        }
934                    }
935                });
936                Arc::new(tokio::sync::Mutex::new(rx))
937            })
938            .clone();
939        StdinLines { rx }
940    }
941
942    /// Next line; `None` on EOF.
943    async fn next(&mut self) -> Option<String> {
944        self.rx.lock().await.recv().await
945    }
946
947    /// Drop everything already typed (returns how many lines were discarded).
948    fn drain(&mut self) -> usize {
949        // Prompts are strictly sequential, so the lock is always free; if it
950        // ever were held, draining nothing is the safe answer.
951        let Ok(mut rx) = self.rx.try_lock() else {
952            return 0;
953        };
954        let mut n = 0;
955        while rx.try_recv().is_ok() {
956            n += 1;
957        }
958        n
959    }
960
961    /// Drain + warn: call right before showing a prompt. Interactive
962    /// terminals only — piped stdin (scripted planning) delivers all lines
963    /// up-front by design and must never be discarded.
964    fn drain_noisily(&mut self, tty: bool) {
965        if !std::io::stdin().is_terminal() {
966            return;
967        }
968        let n = self.drain();
969        if n > 0 {
970            let (dim, reset) = if tty {
971                (ansi::DIM, ansi::RESET)
972            } else {
973                ("", "")
974            };
975            eprintln!(
976                "{dim}(ignored {n} line(s) typed while the orchestrator was working — \
977                 the prompt below wants fresh input){reset}"
978            );
979        }
980    }
981}
982
983// ---------------------------------------------------------------------------
984// plan
985// ---------------------------------------------------------------------------
986
987/// `kranz plan [<goal>]`: create a mission (goal given) or resume the most
988/// recent in-planning mission (no goal), then run the interactive planning
989/// conversation until a plan is approved or the user quits.
990async fn cmd_plan(
991    repo: PathBuf,
992    goal: Option<String>,
993    cfg: MissionConfig,
994    explicit_mission: Option<&str>,
995    force_lock: LockForce,
996) -> Result<i32> {
997    let backend = build_backend(&cfg)?;
998    let (mut engine, intro) = match goal {
999        Some(goal) => {
1000            let engine = MissionEngine::create(backend, repo.clone(), &goal, cfg)?;
1001            let intro = format!("mission {} created (planning)", engine.mission_id());
1002            (engine, intro)
1003        }
1004        None => {
1005            let mission = select_planning_mission(&repo, explicit_mission)?;
1006            let engine = MissionEngine::resume(backend, repo.clone(), &mission, force_lock)?;
1007            if engine.state().mission.status != MissionStatus::Planning {
1008                return Err(anyhow!(
1009                    "mission {mission} is {:?}, not in planning — use 'kranz run' \
1010                     to execute it, or 'kranz plan \"<goal>\"' to start a new mission",
1011                    engine.state().mission.status
1012                ));
1013            }
1014            let intro = format!(
1015                "resuming planning for mission {mission} — the conversation continues \
1016                 where it left off"
1017            );
1018            (engine, intro)
1019        }
1020    };
1021
1022    // A real terminal on both ends gets the full-screen planning TUI, which
1023    // owns the whole interaction (conversation, live activity, /plan +
1024    // approval, exit hints) — the event-tail printer below must never run
1025    // concurrently with it. Piped/scripted stdio keeps the line-mode REPL
1026    // unchanged: lines arrive up-front by design and are never discarded.
1027    if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1028        let mission_id = engine.mission_id().to_string();
1029        // The TUI tears down completely before returning (terminal restored,
1030        // engine dropped, mission lock released), so the run path below
1031        // starts on a clean main screen and can re-acquire the lock.
1032        return match crate::planning_tui::run(engine, intro).await? {
1033            PlanningOutcome::ApprovedRun => start_run_after_plan(repo, mission_id).await,
1034            PlanningOutcome::ApprovedExit | PlanningOutcome::NotApproved => Ok(0),
1035        };
1036    }
1037    println!("{intro}");
1038    let tty = std::io::stdout().is_terminal();
1039
1040    // Live activity feed: without it, a long orchestrator turn (opus reading
1041    // the repo, extended thinking) is indistinguishable from a hang.
1042    let color = std::io::stderr().is_terminal();
1043    let stop = Arc::new(AtomicBool::new(false));
1044    let printer = tokio::spawn(tail::tail_events(
1045        engine.paths().events_file(),
1046        engine.state().last_seq,
1047        EventRenderer::planning(engine.state(), color),
1048        Arc::clone(&stop),
1049    ));
1050    let mut approved = false;
1051    let mut run_now = false;
1052    let mut stdin_lines = StdinLines::spawn();
1053
1054    println!("talk to the orchestrator to shape the plan:");
1055    println!("  /plan   request the plan + cost estimate and review it for approval");
1056    println!("  /quit   exit planning (Ctrl-D works too)");
1057
1058    loop {
1059        stdin_lines.drain_noisily(tty);
1060        if tty {
1061            print!("you> ");
1062            let _ = std::io::stdout().flush();
1063        }
1064        let Some(line) = stdin_lines.next().await else {
1065            break; // EOF = /quit
1066        };
1067        let line = line.trim().to_string();
1068        if line.is_empty() {
1069            continue;
1070        }
1071        match line.as_str() {
1072            "/quit" => break,
1073            "/plan" => {
1074                let request = engine.request_plan().await;
1075                // A seed turn may have run inside this request (fresh session
1076                // or resume-ack); its reply came first — show it first.
1077                if let Some(seed) = engine.take_seed_reply() {
1078                    print_orchestrator_reply(&seed, tty);
1079                }
1080                let plan = match request {
1081                    Ok(PlanRequest::Ready(plan)) => plan,
1082                    Ok(PlanRequest::NotReady(text)) => {
1083                        // A conversational state, not an error: the
1084                        // orchestrator wants answers before emitting.
1085                        print_orchestrator_reply(&text, tty);
1086                        println!(
1087                            "the orchestrator isn't ready to emit the plan yet — answer it \
1088                             above, then /plan again."
1089                        );
1090                        continue;
1091                    }
1092                    Ok(PlanRequest::WrongPlan { reason }) => {
1093                        // Planner-initiated escalation, not an error: it can
1094                        // plan but believes the plan is likely wrong.
1095                        print_orchestrator_reply(&reason, tty);
1096                        println!(
1097                            "the orchestrator believes a plan here is likely WRONG — reframe \
1098                             the goal or fix the premise above, then /plan again."
1099                        );
1100                        continue;
1101                    }
1102                    Err(e) => {
1103                        eprintln!(
1104                            "kranz: plan request failed: {:#}",
1105                            augment_limit_hint(e.into())
1106                        );
1107                        continue;
1108                    }
1109                };
1110                println!("{}", output::render_plan(&plan));
1111                // Estimate with params calibrated from this repo's completed
1112                // missions (built-in defaults when there are none yet).
1113                let calibration = cost::calibrate(&repo);
1114                let estimate = cost::estimate(&plan, &engine.state().config, &calibration.params);
1115                let estimate = cost::apply_shape(estimate, &plan, &calibration);
1116                println!(
1117                    "{}",
1118                    output::render_cost_estimate(&estimate, calibration.missions_used)
1119                );
1120
1121                stdin_lines.drain_noisily(tty);
1122                print!("approve? [y/N] ");
1123                let _ = std::io::stdout().flush();
1124                let answer = stdin_lines.next().await.unwrap_or_default();
1125                if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
1126                    match engine.approve_plan(plan) {
1127                        Ok(()) => {
1128                            let branch = engine.state().mission.mission_branch.clone();
1129                            approved = true;
1130                            // Interactive stdin gets the run-now offer;
1131                            // piped/scripted stdin keeps the historical
1132                            // output and never starts execution — scripts
1133                            // depend on `kranz plan` exiting after approval.
1134                            if std::io::stdin().is_terminal() {
1135                                println!("plan approved and committed on {branch}.");
1136                                stdin_lines.drain_noisily(tty);
1137                                print!("start execution now? [Y/n] ");
1138                                let _ = std::io::stdout().flush();
1139                                let reply = stdin_lines.next().await;
1140                                if run_now_answer(reply.as_deref()) {
1141                                    run_now = true;
1142                                } else {
1143                                    println!("run 'kranz run' to execute.");
1144                                }
1145                            } else {
1146                                println!(
1147                                    "plan approved and committed on {branch}. \
1148                                     run 'kranz run' to execute."
1149                                );
1150                            }
1151                            break;
1152                        }
1153                        Err(e) => eprintln!("kranz: plan approval failed: {e}"),
1154                    }
1155                } else {
1156                    println!("not approved — back to the conversation.");
1157                }
1158            }
1159            _ if line.starts_with('/') => {
1160                println!("unknown command {line}; use /plan or /quit");
1161            }
1162            _ => {
1163                let result = engine.planning_turn(&line).await;
1164                // Surface a captured seed reply (session start / re-seed)
1165                // before this turn's own output — it happened first.
1166                if let Some(seed) = engine.take_seed_reply() {
1167                    print_orchestrator_reply(&seed, tty);
1168                }
1169                match result {
1170                    Ok(reply) => print_orchestrator_reply(&reply, tty),
1171                    Err(e) => eprintln!(
1172                        "kranz: orchestrator turn failed: {:#}",
1173                        augment_limit_hint(e.into())
1174                    ),
1175                }
1176            }
1177        }
1178    }
1179    if !approved {
1180        println!(
1181            "leaving planning; mission {} was not approved. Resume anytime with `kranz plan`.",
1182            engine.mission_id()
1183        );
1184    }
1185    let mission_id = engine.mission_id().to_string();
1186    // Engine drop flushes buffered deltas and releases the lock; the
1187    // printer's final catch-up read then sees every event.
1188    drop(engine);
1189    stop.store(true, Ordering::Relaxed);
1190    let _ = printer.await;
1191    if run_now {
1192        // The planning engine (and its mission lock) is gone; the run path
1193        // re-acquires the lock itself.
1194        return start_run_after_plan(repo, mission_id).await;
1195    }
1196    Ok(0)
1197}
1198
1199/// Parse the answer to the line-mode "start execution now? [Y/n]" prompt.
1200/// Empty input takes the default (yes); `n`/`no` (any case) decline; EOF
1201/// (`None`, e.g. Ctrl-D) declines too — execution spend must never start
1202/// without a live keyboard behind the consent.
1203pub fn run_now_answer(answer: Option<&str>) -> bool {
1204    match answer {
1205        None => false,
1206        Some(text) => !matches!(text.trim().to_ascii_lowercase().as_str(), "n" | "no"),
1207    }
1208}
1209
1210/// Shared plan→run handoff: announce the transition, then drive the mission
1211/// loop exactly like `kranz run`. The planning engine must already be
1212/// dropped — [`run_mission_loop`] re-acquires the mission lock.
1213async fn start_run_after_plan(repo: PathBuf, mission_id: String) -> Result<i32> {
1214    println!(
1215        "starting mission {mission_id} — live event feed follows \
1216         (Ctrl-C safe; resume with 'kranz run')"
1217    );
1218    run_mission_loop(repo, mission_id, LockForce::No, true).await
1219}
1220
1221/// Print an orchestrator reply, each line under a dim `orchestrator>` prefix.
1222fn print_orchestrator_reply(text: &str, tty: bool) {
1223    let prefix = if tty {
1224        format!("{}orchestrator>{} ", ansi::DIM, ansi::RESET)
1225    } else {
1226        "orchestrator> ".to_string()
1227    };
1228    for line in text.lines() {
1229        println!("{prefix}{line}");
1230    }
1231}
1232
1233// ---------------------------------------------------------------------------
1234// run
1235// ---------------------------------------------------------------------------
1236
1237/// `kranz run`: apply the `--dangerously-allow-all` opt-in (recorded as a
1238/// config.changed event via the control inbox), then drive the mission loop.
1239async fn cmd_run(
1240    repo: PathBuf,
1241    mission: String,
1242    force_lock: LockForce,
1243    dangerously_allow_all: bool,
1244) -> Result<i32> {
1245    // The mission's own config lives in the event log; the flag opts in via
1246    // the control inbox so the change is recorded as a config.changed event.
1247    if dangerously_allow_all {
1248        let paths = require_mission(&repo, &mission)?;
1249        control::enqueue(
1250            &paths,
1251            &ControlCommand::ConfigChange {
1252                patch: serde_json::json!({ "dangerouslyAllowAll": true }),
1253            },
1254        )?;
1255    }
1256    run_mission_loop(repo, mission, force_lock, true).await
1257}
1258
1259/// Resume the mission, tail its events live, drive the loop to a terminal
1260/// state, and map it to an exit code (0 complete / 2 blocked / 1 failed).
1261/// Shared by `kranz run` and the plan-approval "start execution now" path.
1262pub(crate) async fn run_mission_loop(
1263    repo: PathBuf,
1264    mission: String,
1265    force_lock: LockForce,
1266    // Interactive callers (`kranz run`) prompt for guidance on a blocked
1267    // milestone; the batch dispatcher (`kranz work`) passes false so a blocked
1268    // mission returns exit 2 immediately instead of hanging on stdin forever.
1269    interactive: bool,
1270) -> Result<i32> {
1271    let cfg = load_config(&repo, false)?;
1272    let backend = build_backend(&cfg)?;
1273    run_mission_loop_with_backend(repo, mission, force_lock, interactive, backend).await
1274}
1275
1276/// The body of [`run_mission_loop`], parameterized on the backend so tests
1277/// can drive it with [`kranz_engine::backend_mock::MockBackend`] instead of
1278/// discovering a real `claude` binary.
1279async fn run_mission_loop_with_backend(
1280    repo: PathBuf,
1281    mission: String,
1282    force_lock: LockForce,
1283    interactive: bool,
1284    backend: Arc<dyn AgentBackend>,
1285) -> Result<i32> {
1286    let paths = require_mission(&repo, &mission)?;
1287
1288    loop {
1289        let mut engine =
1290            MissionEngine::resume(Arc::clone(&backend), repo.clone(), &mission, force_lock)?;
1291
1292        // Live printer: tail events.jsonl from the pre-run head seq.
1293        let color = std::io::stderr().is_terminal();
1294        let renderer = EventRenderer::seeded(engine.state(), color);
1295        let stop = Arc::new(AtomicBool::new(false));
1296        let printer = tokio::spawn(tail::tail_events(
1297            engine.paths().events_file(),
1298            engine.state().last_seq,
1299            renderer,
1300            Arc::clone(&stop),
1301        ));
1302
1303        let run_result = engine.run().await;
1304        // Drop the engine first: it flushes buffered stream deltas and releases
1305        // the lock, so the printer's final catch-up read sees every event.
1306        drop(engine);
1307        stop.store(true, Ordering::Relaxed);
1308        let _ = printer.await;
1309
1310        let status = run_result?;
1311        // Reconcile the linked ticket's .status sidecar to match the mission's
1312        // terminal/blocked status. Non-fatal: a reconcile failure must never
1313        // change the exit code below.
1314        if let Err(e) = kranz_engine::work::reconcile_ticket_for_mission(&repo, &mission) {
1315            eprintln!("kranz run: warning: failed to reconcile linked ticket: {e}");
1316        }
1317
1318        match status {
1319            MissionStatus::Complete => {
1320                println!("mission {mission} COMPLETE");
1321                return Ok(0);
1322            }
1323            MissionStatus::Blocked => {
1324                eprintln!(
1325                    "\n\
1326                     ==================== MILESTONE BLOCKED ====================\n\
1327                     A milestone is blocked (fix-cycle cap reached or blocked by\n\
1328                     the orchestrator). Inspect it with `kranz status`.\n\
1329                     ==========================================================="
1330                );
1331                // Interactive recovery: ask for guidance right here instead of
1332                // demanding the kranz msg / kranz run two-step. The batch
1333                // dispatcher (interactive=false) skips this so it never hangs.
1334                if interactive && std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1335                {
1336                    print!(
1337                        "guidance for the orchestrator (what to do about the block; \
1338                         empty line or Ctrl-D exits)\nguidance> "
1339                    );
1340                    let _ = std::io::stdout().flush();
1341                    let mut lines = StdinLines::spawn();
1342                    if let Some(text) = lines.next().await {
1343                        let text = text.trim().to_string();
1344                        if !text.is_empty() {
1345                            control::enqueue(
1346                                &paths,
1347                                &ControlCommand::Msg {
1348                                    text,
1349                                    interrupt: false,
1350                                },
1351                            )?;
1352                            println!("guidance queued — resuming the mission…");
1353                            continue;
1354                        }
1355                    }
1356                }
1357                eprintln!(
1358                    "unblock later by sending guidance via `kranz msg \"<text>\"` \
1359                     and re-running `kranz run`."
1360                );
1361                println!("mission {mission} BLOCKED");
1362                return Ok(2);
1363            }
1364            MissionStatus::Failed => {
1365                println!("mission {mission} FAILED");
1366                return Ok(1);
1367            }
1368            other => {
1369                println!(
1370                    "mission {mission} ended as {}",
1371                    output::mission_status_label(other)
1372                );
1373                return Ok(1);
1374            }
1375        }
1376    }
1377}
1378
1379// ---------------------------------------------------------------------------
1380// pause / resume / msg / revision (control inbox writers; no lock, no backend)
1381// ---------------------------------------------------------------------------
1382
1383/// Enqueue a Pause control command. Returns the queued file path.
1384pub fn cmd_pause(repo: &Path, mission_id: &str) -> Result<PathBuf> {
1385    let paths = require_mission(repo, mission_id)?;
1386    Ok(control::enqueue(&paths, &ControlCommand::Pause)?)
1387}
1388
1389/// Enqueue a Resume control command. Returns the queued file path.
1390pub fn cmd_resume(repo: &Path, mission_id: &str) -> Result<PathBuf> {
1391    let paths = require_mission(repo, mission_id)?;
1392    Ok(control::enqueue(&paths, &ControlCommand::Resume)?)
1393}
1394
1395/// Enqueue a Msg control command. Returns the queued file path.
1396pub fn cmd_msg(repo: &Path, mission_id: &str, text: &str, interrupt: bool) -> Result<PathBuf> {
1397    let paths = require_mission(repo, mission_id)?;
1398    let cmd = ControlCommand::Msg {
1399        text: text.to_string(),
1400        interrupt,
1401    };
1402    Ok(control::enqueue(&paths, &cmd)?)
1403}
1404
1405/// Enqueue a RequestRevision control command. Returns the queued file path.
1406pub fn cmd_request_revision(repo: &Path, mission_id: &str, instructions: &str) -> Result<PathBuf> {
1407    let instructions = instructions.trim();
1408    if instructions.is_empty() {
1409        bail!("revision instructions must not be empty");
1410    }
1411    let paths = require_revisable_mission(repo, mission_id)?;
1412    Ok(control::enqueue(
1413        &paths,
1414        &ControlCommand::RequestRevision {
1415            instructions: instructions.to_string(),
1416        },
1417    )?)
1418}
1419
1420/// Enqueue an ApproveRevision control command. Returns the queued file path.
1421pub fn cmd_approve_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<PathBuf> {
1422    let paths = require_pending_revision(repo, mission_id, revision)?;
1423    Ok(control::enqueue(
1424        &paths,
1425        &ControlCommand::ApproveRevision { revision },
1426    )?)
1427}
1428
1429/// Enqueue a RejectRevision control command. Returns the queued file path.
1430pub fn cmd_reject_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<PathBuf> {
1431    let paths = require_pending_revision(repo, mission_id, revision)?;
1432    Ok(control::enqueue(
1433        &paths,
1434        &ControlCommand::RejectRevision { revision },
1435    )?)
1436}
1437
1438/// Enqueue an ApproveGrant control command. Returns the queued file path.
1439pub fn cmd_approve_grant(repo: &Path, mission_id: &str, command: &str) -> Result<PathBuf> {
1440    let paths = require_pending_grant(repo, mission_id, command)?;
1441    Ok(control::enqueue(
1442        &paths,
1443        &ControlCommand::ApproveGrant {
1444            command: command.to_string(),
1445        },
1446    )?)
1447}
1448
1449/// Enqueue a DenyGrant control command. Returns the queued file path.
1450pub fn cmd_deny_grant(
1451    repo: &Path,
1452    mission_id: &str,
1453    command: &str,
1454    reason: &str,
1455) -> Result<PathBuf> {
1456    let paths = require_pending_grant(repo, mission_id, command)?;
1457    Ok(control::enqueue(
1458        &paths,
1459        &ControlCommand::DenyGrant {
1460            command: command.to_string(),
1461            reason: reason.to_string(),
1462        },
1463    )?)
1464}
1465
1466/// Render the mission's open structured questions (ticket
1467/// `structured-human-question-events`) — the pending-decision projection the
1468/// dashboard and Slack also render — one block per question: id, ask, and
1469/// the indexed options (or a free-text note).
1470pub fn cmd_list_questions(repo: &Path, mission_id: &str) -> Result<String> {
1471    let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1472    let state = load_state(repo, &mission_id)?;
1473    if state.pending_questions.is_empty() {
1474        return Ok(format!("mission {mission_id} has no open questions\n"));
1475    }
1476    let mut out = String::new();
1477    for q in &state.pending_questions {
1478        out.push_str(&format!(
1479            "{} ({}): {}\n",
1480            q.question_id,
1481            q.feature_id.as_deref().unwrap_or("mission"),
1482            q.text
1483        ));
1484        if q.options.is_empty() {
1485            out.push_str("  free-text answer expected\n");
1486        } else {
1487            for (index, option) in q.options.iter().enumerate() {
1488                out.push_str(&format!("  [{index}] {option}\n"));
1489            }
1490        }
1491    }
1492    Ok(out)
1493}
1494
1495/// Enqueue an AnswerQuestion control command (ticket
1496/// `structured-human-question-events`). Returns the queued file path.
1497pub fn cmd_answer_question(
1498    repo: &Path,
1499    mission_id: &str,
1500    question_id: &str,
1501    answer: &str,
1502    option: Option<u32>,
1503) -> Result<PathBuf> {
1504    let paths = require_pending_question(repo, mission_id, question_id, option, answer)?;
1505    Ok(control::enqueue(
1506        &paths,
1507        &ControlCommand::AnswerQuestion {
1508            question_id: question_id.to_string(),
1509            answer: answer.to_string(),
1510            option,
1511        },
1512    )?)
1513}
1514
1515/// Resolve the mission and confirm question `question_id` is open (and an
1516/// option-index answer is in range and matches the offered option), so the
1517/// enqueued answer can't silently land on a different (or absent) question
1518/// than the operator saw — the same stale-decision discipline as
1519/// [`require_pending_grant`]. The engine re-validates at drain time.
1520fn require_pending_question(
1521    repo: &Path,
1522    mission_id: &str,
1523    question_id: &str,
1524    option: Option<u32>,
1525    answer: &str,
1526) -> Result<MissionPaths> {
1527    let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1528    let state = load_state(repo, &mission_id)?;
1529    let Some(pending) = state
1530        .pending_questions
1531        .iter()
1532        .find(|q| q.question_id == question_id)
1533    else {
1534        bail!("mission {mission_id} has no open question '{question_id}'");
1535    };
1536    if let Some(index) = option {
1537        match pending.options.get(index as usize) {
1538            Some(expected) if expected == answer => {}
1539            Some(expected) => bail!(
1540                "answer `{answer}` does not match option {index} (`{expected}`) of question '{question_id}'"
1541            ),
1542            None => bail!(
1543                "question '{question_id}' has no option {index} (it offered {})",
1544                pending.options.len()
1545            ),
1546        }
1547    }
1548    Ok(MissionPaths::new(repo, &mission_id))
1549}
1550
1551fn require_revisable_mission(repo: &Path, mission_id: &str) -> Result<MissionPaths> {
1552    let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1553    let state = load_state(repo, &mission_id)?;
1554    if state.mission.status == MissionStatus::Planning {
1555        bail!("mission {mission_id} has no approved plan to revise yet");
1556    }
1557    Ok(MissionPaths::new(repo, &mission_id))
1558}
1559
1560fn require_pending_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<MissionPaths> {
1561    let paths = require_revisable_mission(repo, mission_id)?;
1562    let state = load_state(repo, mission_id)?;
1563    match state.pending_revision {
1564        Some(pending) if pending.revision == revision => Ok(paths),
1565        Some(pending) => bail!(
1566            "mission {mission_id} is awaiting revision {}, not {revision}",
1567            pending.revision
1568        ),
1569        None => bail!("mission {mission_id} has no pending revision"),
1570    }
1571}
1572
1573/// Resolve the mission and confirm a grant request for exactly `command` is
1574/// parked, so the enqueued approve/deny can't silently target a different (or
1575/// absent) request than the operator saw.
1576fn require_pending_grant(repo: &Path, mission_id: &str, command: &str) -> Result<MissionPaths> {
1577    let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1578    let state = load_state(repo, &mission_id)?;
1579    match state.pending_grant_request {
1580        Some(pending) if pending.command == command => Ok(MissionPaths::new(repo, &mission_id)),
1581        Some(pending) => bail!(
1582            "mission {mission_id} is awaiting a grant for `{}`, not `{command}`",
1583            pending.command
1584        ),
1585        None => bail!("mission {mission_id} has no pending grant request"),
1586    }
1587}
1588
1589/// `kranz knowledge-refresh`: report-only vault drift (slice 3).
1590/// Exit 0 when no check-needed verdicts; exit 1 when any note is stale-by-drift,
1591/// unverifiable, malformed, outside the repo, or blocked by a failed probe.
1592pub fn cmd_knowledge_refresh(repo: &Path, json: bool) -> Result<i32> {
1593    let report = kranz_engine::knowledge::refresh_knowledge(repo);
1594    if json {
1595        println!("{}", serde_json::to_string_pretty(&report)?);
1596    } else if report.findings.is_empty() {
1597        println!("knowledge refresh: no notes");
1598    } else {
1599        for finding in &report.findings {
1600            let labels: Vec<String> = finding
1601                .verdicts
1602                .iter()
1603                .map(|v| match v {
1604                    kranz_engine::knowledge::RefreshVerdict::Ok => "ok".into(),
1605                    kranz_engine::knowledge::RefreshVerdict::AlreadyStale => "already-stale".into(),
1606                    kranz_engine::knowledge::RefreshVerdict::Unverified => "unverified".into(),
1607                    kranz_engine::knowledge::RefreshVerdict::InvalidMetadata { field, value } => {
1608                        format!(
1609                            "invalid-metadata:{field}:{}",
1610                            value.as_deref().unwrap_or("missing")
1611                        )
1612                    }
1613                    kranz_engine::knowledge::RefreshVerdict::InvalidCitation { citation } => {
1614                        format!("invalid-citation:{citation}")
1615                    }
1616                    kranz_engine::knowledge::RefreshVerdict::PathMissing { path } => {
1617                        format!("path-missing:{path}")
1618                    }
1619                    kranz_engine::knowledge::RefreshVerdict::PathDrifted { path } => {
1620                        format!("path-drifted:{path}")
1621                    }
1622                    kranz_engine::knowledge::RefreshVerdict::CommandSkipped { command } => {
1623                        format!("command-skipped:{command}")
1624                    }
1625                    kranz_engine::knowledge::RefreshVerdict::ProbeFailed { target, error } => {
1626                        format!("probe-failed:{target}:{error}")
1627                    }
1628                })
1629                .collect();
1630            println!(
1631                "{} ({}) [{}] {}",
1632                finding.rel_path,
1633                finding.title,
1634                finding.freshness,
1635                labels.join(", ")
1636            );
1637        }
1638        if report.check_needed() {
1639            println!("knowledge refresh: check-needed");
1640        } else {
1641            println!("knowledge refresh: ok");
1642        }
1643    }
1644    Ok(if report.check_needed() { 1 } else { 0 })
1645}
1646
1647pub fn cmd_scan(repo: &Path, staged: bool, range: Option<&str>) -> Result<i32> {
1648    if staged && range.is_some() {
1649        bail!("choose either --staged or --range, not both");
1650    }
1651    let git = kranz_engine::git_ops::GitRepo::open(repo)?;
1652    let diff = if staged {
1653        git.diff_staged()?
1654    } else if let Some(range) = range {
1655        git.diff_range(range)?
1656    } else {
1657        git.diff_range("HEAD")?
1658    };
1659    let allowed = std::fs::read_to_string(repo.join(kranz_engine::scrub::SECRET_ALLOWLIST_PATH))
1660        .ok()
1661        .map(|text| kranz_engine::scrub::read_allowlist_text(&text))
1662        .unwrap_or_default();
1663    let findings = kranz_engine::scrub::filter_allowed(
1664        kranz_engine::scrub::scan_unified_diff(&diff),
1665        &allowed,
1666    );
1667    if findings.is_empty() {
1668        println!("secret scan passed");
1669        Ok(0)
1670    } else {
1671        println!(
1672            "secret scan failed; add a fingerprint to {} only for a reviewed false positive:\n{}",
1673            kranz_engine::scrub::SECRET_ALLOWLIST_PATH,
1674            kranz_engine::scrub::format_findings(&findings)
1675        );
1676        Ok(2)
1677    }
1678}
1679
1680/// `kranz domain-lint` (KRZ-314 clean-room boundary — see
1681/// `kranz_engine::domain_lint` module docs and docs/domain-lint.md).
1682///
1683/// Default mode lints the scoped tree against the committed hashed denylist:
1684/// exit 0 clean, exit 1 with each unwaived hit printed as
1685/// `<fingerprint> <path>:<line>` — never the matched text, which is the
1686/// vocabulary the boundary protects. `--seed-config` is the other half of
1687/// the workflow: regenerate the hash config from the operator-local
1688/// plaintext terms file (kept outside the repo), preserving the salt so
1689/// existing waiver fingerprints survive.
1690pub fn cmd_domain_lint(repo: &Path, seed_config: Option<&Path>, json: bool) -> Result<i32> {
1691    use kranz_engine::domain_lint as dl;
1692    let config_path = repo.join(dl::DENYLIST_PATH);
1693
1694    if let Some(terms_file) = seed_config {
1695        let terms = std::fs::read_to_string(terms_file)
1696            .with_context(|| format!("read terms file {}", terms_file.display()))?;
1697        let existing = std::fs::read_to_string(&config_path).ok();
1698        let config = dl::seed_config(existing.as_deref(), &terms)?;
1699        // A repo may not have a .kranz/ directory yet (lint-only use).
1700        if let Some(parent) = config_path.parent() {
1701            std::fs::create_dir_all(parent)
1702                .with_context(|| format!("create {}", parent.display()))?;
1703        }
1704        std::fs::write(&config_path, &config)
1705            .with_context(|| format!("write {}", config_path.display()))?;
1706        let denylist = dl::load_denylist(&config)?;
1707        // The report is the count and the salt's fate — never a term.
1708        println!(
1709            "seeded {} ({} hashed terms, {})",
1710            dl::DENYLIST_PATH,
1711            denylist.term_count(),
1712            if existing.is_some() {
1713                "salt preserved"
1714            } else {
1715                "fresh salt"
1716            }
1717        );
1718        warn_if_terms_file_unprotected(repo, terms_file);
1719        return Ok(0);
1720    }
1721
1722    let config_text = std::fs::read_to_string(&config_path).with_context(|| {
1723        format!(
1724            "read {} — seed it with `kranz domain-lint --seed-config <terms-file>` (docs/domain-lint.md)",
1725            dl::DENYLIST_PATH
1726        )
1727    })?;
1728    let denylist = dl::load_denylist(&config_text)?;
1729    let allowed = std::fs::read_to_string(repo.join(dl::ALLOWLIST_PATH))
1730        .ok()
1731        .map(|text| kranz_engine::scrub::read_allowlist_text(&text))
1732        .unwrap_or_default();
1733    let report = dl::lint_tree(repo, &denylist, &allowed)?;
1734
1735    if json {
1736        println!(
1737            "{}",
1738            serde_json::to_string_pretty(&serde_json::json!({
1739                "passed": report.is_clean(),
1740                "filesScanned": report.files_scanned,
1741                "filesSkipped": report.files_skipped,
1742                "findings": report.findings,
1743            }))?
1744        );
1745    }
1746    if report.is_clean() {
1747        if !json {
1748            println!(
1749                "domain lint passed ({} files scanned)",
1750                report.files_scanned
1751            );
1752        }
1753        Ok(0)
1754    } else {
1755        if !json {
1756            println!(
1757                "domain lint failed: {} unwaived hit(s); add a fingerprint to {} only for a reviewed false positive:",
1758                report.findings.len(),
1759                dl::ALLOWLIST_PATH
1760            );
1761            for finding in &report.findings {
1762                println!("{} {}:{}", finding.fingerprint, finding.path, finding.line);
1763            }
1764        }
1765        Ok(1)
1766    }
1767}
1768
1769/// Loudly warn when the plaintext terms file lives inside the repo and is
1770/// not gitignored — that file IS the protected vocabulary, so tracking it
1771/// would be the leak the boundary exists to prevent (the lint itself would
1772/// flag it on the next run; better to say so at seed time).
1773fn warn_if_terms_file_unprotected(repo: &Path, terms_file: &Path) {
1774    let (Ok(repo), Ok(terms_file)) = (repo.canonicalize(), terms_file.canonicalize()) else {
1775        return;
1776    };
1777    let Ok(relative) = terms_file.strip_prefix(&repo) else {
1778        return; // outside the repo: exactly where the plaintext belongs
1779    };
1780    let ignored = std::process::Command::new("git")
1781        .args(["check-ignore", "-q", "--"])
1782        .arg(relative)
1783        .current_dir(&repo)
1784        .status()
1785        .map(|status| status.success())
1786        // A failed probe must not nag; the lint is the backstop either way.
1787        .unwrap_or(true);
1788    if !ignored {
1789        eprintln!(
1790            "warning: {} is inside the repo and NOT gitignored — move it outside the repo or use {} (gitignored)",
1791            relative.display(),
1792            kranz_engine::domain_lint::TERMS_LOCAL_PATH
1793        );
1794    }
1795}
1796
1797/// `kranz pack lint <dir>` (ticket pack-contract-gates-prompts): fully-local
1798/// pack contract validation — no City infrastructure, no repo needed. A
1799/// valid pack prints what it registered; a directory without a pack.toml is
1800/// not a pack and says so plainly (exit 0); an invalid pack fails closed
1801/// with exit 1 naming the offending field.
1802///
1803/// Flight Rules trust (KRZ-341 D-A/D-J): when the directory is a tracked,
1804/// in-repo pack the standards corpus may activate enforced rules; anything
1805/// else lints as external/untracked — advisory-only, enforced content fails
1806/// the load naming the remedy.
1807fn cmd_pack_lint(repo: &Path, dir: &Path) -> Result<i32> {
1808    let trust = kranz_engine::pack::standards::trust_for_dir(repo, dir);
1809    match kranz_engine::pack::Pack::load_with_trust(dir, trust) {
1810        Ok(Some(pack)) => {
1811            print!("{}", kranz_engine::pack::render_lint(&pack));
1812            Ok(0)
1813        }
1814        Ok(None) => {
1815            println!(
1816                "no pack at {} (no {}) — nothing to lint",
1817                dir.display(),
1818                kranz_engine::pack::PACK_MANIFEST
1819            );
1820            Ok(0)
1821        }
1822        Err(err) => {
1823            eprintln!("invalid pack at {}: {err}", dir.display());
1824            Ok(1)
1825        }
1826    }
1827}
1828
1829/// `kranz standards lint <pack> [--against <ref>]` (KRZ-341): report the
1830/// normalized Flight Rules manifest of a schema-4 pack — RFCs, rules with
1831/// effective lifecycle status and checker bindings, the content digest, and
1832/// the trust posture. With `--against`, the base corpus is read from
1833/// tracked blobs at that git ref (never the worktree) and lifecycle
1834/// transition violations (D-B/D-C) are refused with exit 1.
1835fn cmd_standards_lint(repo: &Path, dir: &Path, against: Option<&str>) -> Result<i32> {
1836    let trust = kranz_engine::pack::standards::trust_for_dir(repo, dir);
1837    let pack = match kranz_engine::pack::Pack::load_with_trust(dir, trust) {
1838        Ok(Some(pack)) => pack,
1839        Ok(None) => {
1840            println!(
1841                "no pack at {} (no {}) — nothing to lint",
1842                dir.display(),
1843                kranz_engine::pack::PACK_MANIFEST
1844            );
1845            return Ok(0);
1846        }
1847        Err(err) => {
1848            eprintln!("invalid pack at {}: {err}", dir.display());
1849            return Ok(1);
1850        }
1851    };
1852    let Some(manifest) = &pack.standards else {
1853        println!(
1854            "pack `{}` (schema {}) at {} declares no [standards] root — nothing to lint",
1855            pack.name,
1856            pack.schema,
1857            dir.display()
1858        );
1859        return Ok(0);
1860    };
1861    print!(
1862        "{}",
1863        kranz_engine::pack::standards::render_manifest(manifest, trust)
1864    );
1865    if let Some(refname) = against {
1866        // The base comparison reads tracked blobs from THIS repo, so the
1867        // pack must live inside it (tracked or not — an untracked pack
1868        // simply has no base history and fails the trust gate at load).
1869        let Some(pack_rel) = kranz_engine::pack::standards::repo_relative_dir(repo, dir) else {
1870            eprintln!(
1871                "--against reads the base pack from tracked git blobs in {}; {} is outside \
1872                 the repo — external packs have no base history to compare against (D-A)",
1873                repo.display(),
1874                dir.display()
1875            );
1876            return Ok(1);
1877        };
1878        let git = kranz_engine::git_ops::GitRepo::open(repo)?;
1879        let base = match kranz_engine::pack::standards::load_at_ref(&git, refname, &pack_rel) {
1880            Ok(base) => base,
1881            Err(err) => {
1882                eprintln!("cannot load the base standards at `{refname}`: {err}");
1883                return Ok(1);
1884            }
1885        };
1886        let errors = kranz_engine::pack::standards::check_transitions(base.as_ref(), manifest);
1887        print!(
1888            "{}",
1889            kranz_engine::pack::standards::render_transition_report(
1890                refname,
1891                base.as_ref(),
1892                &errors
1893            )
1894        );
1895        if !errors.is_empty() {
1896            return Ok(1);
1897        }
1898    }
1899    Ok(0)
1900}
1901
1902/// `kranz standards waive --rule <id> --reason <text> --expires <rfc3339>`
1903/// (KRZ-344, design D-I): the one authorized exception path for a
1904/// standards failure. The engine validates every refusal shape and appends
1905/// `standards.waiver.approved`; this wrapper displays the evidence the
1906/// waiver binds. Refusals print plainly and exit 1 — they are operator
1907/// feedback, not crashes. The approver is never a flag: the local
1908/// authority model cannot name a person, so the record honestly carries
1909/// `local-operator` plus the `cli` surface (D-I — a model can request a
1910/// waiver but never approve one, and there is no identity to invent).
1911#[allow(clippy::too_many_arguments)]
1912fn cmd_standards_waive(
1913    repo: &Path,
1914    mission_id: &str,
1915    rule: &str,
1916    revision: Option<u64>,
1917    finding: Option<&str>,
1918    reason: &str,
1919    expires: &str,
1920    force_lock: LockForce,
1921) -> Result<i32> {
1922    let expires_at = match chrono::DateTime::parse_from_rfc3339(expires) {
1923        Ok(parsed) => parsed.with_timezone(&chrono::Utc),
1924        Err(err) => {
1925            eprintln!("waiver refused: --expires must be an RFC 3339 instant: {err}");
1926            return Ok(1);
1927        }
1928    };
1929    let request = kranz_engine::standards_waiver::WaiverRequest {
1930        rule_id: rule.to_string(),
1931        revision,
1932        finding_subject: finding.map(str::to_string),
1933        reason: reason.to_string(),
1934        expires_at,
1935    };
1936    let outcome = match kranz_engine::standards_waiver::approve_standards_waiver(
1937        repo, mission_id, &request, "cli", force_lock,
1938    ) {
1939        Ok(outcome) => outcome,
1940        Err(kranz_engine::error::EngineError::LockHeld(e)) => {
1941            eprintln!(
1942                "waiver refused: an engine still holds mission '{mission_id}'s lock — stop \
1943                 the running mission first (a waiver against a live mission would race the \
1944                 runner's own appends).\n  (underlying: {e})"
1945            );
1946            return Ok(1);
1947        }
1948        Err(e) => {
1949            eprintln!("waiver refused: {e}");
1950            return Ok(1);
1951        }
1952    };
1953    // Display the evidence the waiver binds — the finding, the rule, the
1954    // affected paths, and the diff digest — exactly as recorded.
1955    let pinned = &outcome.rule;
1956    println!(
1957        "recorded standards.waiver.approved (seq {})",
1958        outcome.event.seq
1959    );
1960    println!("mission: {mission_id}");
1961    println!(
1962        "rule: {} r{} — {}, {}; checker {}; waivable: {}",
1963        pinned.id,
1964        pinned.revision,
1965        pinned.level,
1966        pinned.effective_status,
1967        pinned.checker.as_deref().unwrap_or("-"),
1968        pinned.waivable
1969    );
1970    println!("  statement: {}", pinned.statement);
1971    println!(
1972        "finding: {} (run {})\n  evidence: {}",
1973        outcome.finding_subject, outcome.run_id, outcome.finding_evidence
1974    );
1975    println!("  fingerprint: sha256:{}", outcome.finding_fingerprint);
1976    if pinned.when_paths.is_empty() {
1977        println!(
1978            "affected paths: the whole mission diff ({} path(s)) — the rule is unscoped",
1979            outcome.affected_paths.len()
1980        );
1981    } else if outcome.affected_paths.is_empty() {
1982        println!(
1983            "affected paths: (none — the rule's when-paths match no changed path; the \
1984             waiver binds the empty scoped diff)"
1985        );
1986    } else {
1987        println!("affected paths: {}", outcome.affected_paths.join(", "));
1988    }
1989    println!(
1990        "diff digest: sha256:{} (covers the affected-path diff at the mission branch tip)",
1991        outcome.diff_digest
1992    );
1993    println!(
1994        "approver: {} via cli\nreason: {reason}\nexpires: {}",
1995        kranz_engine::standards_waiver::LOCAL_OPERATOR,
1996        expires_at.to_rfc3339()
1997    );
1998    Ok(0)
1999}
2000
2001/// Record the positive human checker verdict for one `manual-attestation`
2002/// rule. This is intentionally separate from a waiver: the operator is
2003/// attesting that the current diff satisfies the rule, not excepting a
2004/// failure. The engine owns all binding and refusal checks.
2005fn cmd_standards_attest(
2006    repo: &Path,
2007    mission_id: &str,
2008    rule: &str,
2009    reason: &str,
2010    force_lock: LockForce,
2011) -> Result<i32> {
2012    let record = match kranz_engine::standards_attestation::approve_attestation(
2013        repo, mission_id, rule, reason, "cli", force_lock,
2014    ) {
2015        Ok(record) => record,
2016        Err(kranz_engine::error::EngineError::LockHeld(e)) => {
2017            eprintln!(
2018                "attestation refused: an engine still holds mission '{mission_id}'s lock — stop \
2019                 the running mission first.\n  (underlying: {e})"
2020            );
2021            return Ok(1);
2022        }
2023        Err(e) => {
2024            eprintln!("attestation refused: {e}");
2025            return Ok(1);
2026        }
2027    };
2028    println!(
2029        "recorded standards.attestation.approved (seq {})",
2030        record.seq
2031    );
2032    println!("mission: {mission_id}");
2033    println!("rule: {} r{}", record.rule_id, record.rule_revision);
2034    if record.paths.is_empty() {
2035        println!("affected paths: (none)");
2036    } else {
2037        println!("affected paths: {}", record.paths.join(", "));
2038    }
2039    println!("diff digest: sha256:{}", record.diff_digest);
2040    println!(
2041        "approver: {} via {}\nreason: {}",
2042        record.approver, record.surface, record.reason
2043    );
2044    Ok(0)
2045}
2046
2047// ---------------------------------------------------------------------------
2048// missions
2049// ---------------------------------------------------------------------------
2050
2051/// One line per mission: `<id>  <STATUS>  <goal>`. Corrupt/unreadable logs
2052/// are reported inline instead of failing the whole listing.
2053pub fn cmd_missions(repo: &Path) -> Result<String> {
2054    let index_contents =
2055        std::fs::read_to_string(MissionPaths::new(repo, "_").missions_dir().join("index.md"))
2056            .unwrap_or_default();
2057    let mut ids = MissionPaths::list_missions(repo);
2058    for id in mission_catalog::mission_index_ids(&index_contents) {
2059        if !ids.contains(&id) {
2060            ids.push(id);
2061        }
2062    }
2063    ids.sort();
2064    if ids.is_empty() {
2065        return Ok("no missions\n".to_string());
2066    }
2067    // Reverse map mission→ticket from the ticket sidecars (the durable link
2068    // kranz draft records), so drafted missions are recognizable at a glance.
2069    let ticket_of: std::collections::HashMap<String, String> =
2070        kranz_engine::ticket::Ticket::list(repo)
2071            .into_iter()
2072            .filter_map(|t| {
2073                kranz_engine::ticket::Ticket::mission_for(repo, &t.slug).map(|m| (m, t.slug))
2074            })
2075            .collect();
2076    let mut out = String::new();
2077    for id in ids {
2078        let ticket = ticket_of
2079            .get(&id)
2080            .map(|s| format!("  [ticket: {s}]"))
2081            .unwrap_or_default();
2082        let paths = MissionPaths::new(repo, &id);
2083        if !paths.events_file().is_file() {
2084            out.push_str(&format!(
2085                "{id}  {:<10}  deleted mission (no data recorded)\n",
2086                "DELETED"
2087            ));
2088            continue;
2089        }
2090        // A symlinked mission dir is refused (P1 mission-path-no-follow),
2091        // never read into another repository's tree.
2092        if let Err(error) = paths.require_no_follow() {
2093            out.push_str(&format!("{id}  {:<10}  (unreadable: {error})\n", "FAILED"));
2094            continue;
2095        }
2096        match load_state(repo, &id) {
2097            Ok(state) => out.push_str(&format!(
2098                "{id}  {:<10}  {}{ticket}\n",
2099                output::mission_status_label(state.mission.status),
2100                state.mission.goal
2101            )),
2102            Err(e) => out.push_str(&format!("{id}  {:<10}  (unreadable: {e:#})\n", "FAILED")),
2103        }
2104    }
2105    Ok(out)
2106}
2107
2108// ---------------------------------------------------------------------------
2109// abandon / clean (mission hygiene, roadmap M2)
2110// ---------------------------------------------------------------------------
2111
2112/// Retire a mission via the engine's abandon path. Maps the engine's
2113/// `LockHeld` error to an actionable hint (stop the running mission, or pick
2114/// the right lock-steal tier) since that is the common operator mistake.
2115pub fn cmd_abandon(
2116    repo: &Path,
2117    mission_id: &str,
2118    reason: &str,
2119    force_lock: LockForce,
2120) -> Result<()> {
2121    require_mission(repo, mission_id)?;
2122    mission_catalog::abandon_mission(repo, mission_id, reason, force_lock).map_err(|e| {
2123        if matches!(e, kranz_engine::error::EngineError::LockHeld(_)) {
2124            anyhow!(
2125                "cannot abandon mission '{mission_id}' — an engine still holds its lock. \
2126                 Stop the running `kranz run` first. If the holder is a crashed leftover, \
2127                 pass --force-lock (steals unless the holder is provably alive); a provably \
2128                 LIVE holder that you have verified to be a zombie or foreign process \
2129                 additionally requires --dangerously-steal-live-lock.\n  (underlying: {e})"
2130            )
2131        } else {
2132            anyhow::Error::new(e).context(format!("abandoning mission '{mission_id}'"))
2133        }
2134    })
2135}
2136
2137/// One mission the cleaner would remove, resolved to a printable row.
2138#[derive(Debug, Clone, PartialEq, Eq)]
2139pub struct CleanEntry {
2140    pub id: String,
2141    pub status_label: String,
2142    pub goal: String,
2143}
2144
2145/// Decide which missions under `repo` are cleanable given the `--all` opt-in.
2146///
2147/// Never selects a mission whose lock is held by a live engine, nor one whose
2148/// log is unreadable (a corrupt log is left for the operator to inspect, not
2149/// silently deleted). The pure status→class decision lives in
2150/// [`mission_catalog::cleanable_class`]; this function layers the filesystem facts
2151/// (plan.json presence, lock liveness) on top.
2152pub fn select_cleanable(repo: &Path, all: bool) -> Vec<CleanEntry> {
2153    let mut out = Vec::new();
2154    for id in MissionPaths::list_missions(repo) {
2155        let paths = MissionPaths::new(repo, &id);
2156        // A live engine owns this directory: never touch it.
2157        if mission_catalog::mission_lock_is_live(&paths) {
2158            continue;
2159        }
2160        let Ok(state) = load_state(repo, &id) else {
2161            continue; // unreadable/corrupt log: leave it for inspection
2162        };
2163        let has_plan = paths.plan_file().is_file();
2164        if mission_catalog::cleanable_class(state.mission.status, has_plan).is_cleaned(all) {
2165            out.push(CleanEntry {
2166                id,
2167                status_label: output::mission_status_label(state.mission.status).to_string(),
2168                goal: state.mission.goal,
2169            });
2170        }
2171    }
2172    out
2173}
2174
2175/// Render the "would remove" listing: one `<STATUS>  <id>  <goal>` row per
2176/// entry, or a single "nothing to clean" line when empty.
2177pub fn render_clean_listing(entries: &[CleanEntry]) -> String {
2178    if entries.is_empty() {
2179        return "nothing to clean\n".to_string();
2180    }
2181    let mut out = String::new();
2182    for e in entries {
2183        out.push_str(&format!("{:<10}  {}  {}\n", e.status_label, e.id, e.goal));
2184    }
2185    out
2186}
2187
2188/// `kranz clean [--yes] [--all]`: list cleanable mission directories, confirm
2189/// (unless `--yes`), then `remove_dir_all` each. Only mission directories are
2190/// removed — branches and tags are never touched, and each removed mission's
2191/// own `missions/index.md` line is pruned while every other line is left
2192/// intact.
2193fn cmd_clean(repo: &Path, yes: bool, all: bool) -> Result<i32> {
2194    let entries = select_cleanable(repo, all);
2195    if entries.is_empty() {
2196        print!("{}", render_clean_listing(&entries));
2197        return Ok(0);
2198    }
2199
2200    print!("{}", render_clean_listing(&entries));
2201    println!(
2202        "\n{} mission director{} above would be removed.{}",
2203        entries.len(),
2204        if entries.len() == 1 { "y" } else { "ies" },
2205        if all {
2206            ""
2207        } else {
2208            " (Complete missions are kept; pass --all to include them.)"
2209        }
2210    );
2211
2212    if !yes && !confirm_clean()? {
2213        println!("clean aborted; nothing removed.");
2214        return Ok(0);
2215    }
2216
2217    let removed = remove_missions(repo, &entries, true);
2218    println!(
2219        "cleaned {} mission director{}",
2220        removed.len(),
2221        if removed.len() == 1 { "y" } else { "ies" }
2222    );
2223    Ok(0)
2224}
2225
2226/// `remove_dir_all` each entry's `.kranz/missions/<id>` directory, then prune
2227/// that mission's own line from `missions/index.md` (never a git branch/tag,
2228/// never any other mission's index line). Returns the ids actually removed;
2229/// `verbose` echoes each removal. Removal failures are reported on stderr and
2230/// skipped, never aborting the batch.
2231pub fn remove_missions(repo: &Path, entries: &[CleanEntry], verbose: bool) -> Vec<String> {
2232    let mut removed = Vec::new();
2233    for e in entries {
2234        let paths = MissionPaths::new(repo, &e.id);
2235        // Re-check liveness immediately before deleting: a Planning-husk can go
2236        // live during the confirmation prompt (kranz plan writes the lock file
2237        // before its session runs), and deleting a mission dir out from under a
2238        // running engine would corrupt it. This closes the unbounded
2239        // human-prompt window (a sub-ms race remains but is bounded).
2240        if mission_catalog::mission_lock_is_live(&paths) {
2241            eprintln!("kranz: skipping {} — became live since listing", e.id);
2242            continue;
2243        }
2244        let dir = paths.mission_dir();
2245        match std::fs::remove_dir_all(&dir) {
2246            Ok(()) => {
2247                if verbose {
2248                    println!("removed {}", dir.display());
2249                }
2250                mission_catalog::prune_mission_index_file(repo, &e.id);
2251                removed.push(e.id.clone());
2252            }
2253            Err(err) => eprintln!("kranz: could not remove {}: {err}", dir.display()),
2254        }
2255    }
2256    removed
2257}
2258
2259/// Read a `[y/N]` answer from stdin. Anything other than y/yes (any case) —
2260/// including EOF — declines, so a piped/closed stdin never deletes by default.
2261fn confirm_clean() -> Result<bool> {
2262    use std::io::BufRead;
2263    print!("proceed? [y/N] ");
2264    std::io::stdout().flush().ok();
2265    let mut line = String::new();
2266    let n = std::io::stdin().lock().read_line(&mut line)?;
2267    if n == 0 {
2268        return Ok(false); // EOF
2269    }
2270    Ok(matches!(
2271        line.trim().to_ascii_lowercase().as_str(),
2272        "y" | "yes"
2273    ))
2274}
2275
2276// ---------------------------------------------------------------------------
2277// serve
2278// ---------------------------------------------------------------------------
2279
2280/// `kranz serve`: run the REST/WS server, serving the dashboard build from
2281/// the first location that exists (see [`resolve_dashboard_dist`]).
2282///
2283/// Refuse a non-loopback bind unless the operator passed `--insecure-lan`.
2284/// Extracted so the gate is unit-testable without starting the server.
2285pub(crate) fn refuse_non_loopback_without_insecure_lan(
2286    bind: std::net::IpAddr,
2287    insecure_lan: bool,
2288) -> Result<()> {
2289    if !bind.is_loopback() && !insecure_lan {
2290        anyhow::bail!(
2291            "refusing to bind {bind}: non-loopback binds expose the API on the \
2292             network (reads require the read-only or mutation token; POSTs \
2293             require the mutation token). \
2294             Re-run with `--insecure-lan` if you intentionally trust this \
2295             network (LAN/tailnet), or keep the default `--host 127.0.0.1`."
2296        );
2297    }
2298    Ok(())
2299}
2300
2301/// Maps `(bind_is_loopback, read_auth_flag)` to the effective
2302/// `require_read_token` boolean threaded into the server. Off-loopback binds
2303/// always require the read token (unchanged); `--read-auth` additionally
2304/// forces it on loopback binds — the deployment-ready read-auth mode.
2305/// Extracted so the gate is unit-testable without starting the server.
2306pub(crate) fn effective_require_read_token(bind_is_loopback: bool, read_auth: bool) -> bool {
2307    !bind_is_loopback || read_auth
2308}
2309
2310/// Every `POST /api/...` requires the mutation token (protocol "Authority:
2311/// mutation token"): generated per serve (or pinned via `--token` for
2312/// scripting) and printed for the operator to paste into the dashboard's own
2313/// TokenPrompt. `--open` launches the BARE url and hands the browser no
2314/// token at all (follow-up review M-10). See the `if open` block below for
2315/// why neither token belongs in the opener's argv. The read-only token
2316/// (`--read-token` / `$KRANZ_READ_TOKEN`) is generated alongside and stored
2317/// in its own file — it authenticates gated GETs and the WS upgrade but
2318/// never a mutation, so it is the one safe to hand to dashboards and agents.
2319#[allow(clippy::too_many_arguments)]
2320async fn cmd_serve(
2321    repo: PathBuf,
2322    host: String,
2323    port: u16,
2324    insecure_lan: bool,
2325    read_auth: bool,
2326    open: bool,
2327    dashboard: Option<PathBuf>,
2328    token: Option<String>,
2329    read_token: Option<String>,
2330    slack: bool,
2331) -> Result<i32> {
2332    let bind: std::net::IpAddr = host
2333        .parse()
2334        .map_err(|e| anyhow!("--host '{host}' is not an IP address: {e}"))?;
2335    refuse_non_loopback_without_insecure_lan(bind, insecure_lan)?;
2336    let token = token
2337        .or_else(|| std::env::var("KRANZ_TOKEN").ok())
2338        .unwrap_or_else(kranz_server::generate_token);
2339    let read_token = read_token
2340        .or_else(|| std::env::var("KRANZ_READ_TOKEN").ok())
2341        .unwrap_or_else(kranz_server::generate_token);
2342    // Validate before binding, starting workers, printing credentials, or
2343    // writing token files. The router's compatibility normalization must
2344    // never make the CLI publish a credential different from the active one.
2345    kranz_server::MutationAuthority::new(token.clone())
2346        .context("--token / KRANZ_TOKEN is invalid")?;
2347    anyhow::ensure!(
2348        !read_token.is_empty() && read_token.bytes().all(|byte| byte.is_ascii_graphic()),
2349        "--read-token / KRANZ_READ_TOKEN must be non-empty visible ASCII without whitespace"
2350    );
2351    anyhow::ensure!(
2352        read_token != token,
2353        "--read-token / KRANZ_READ_TOKEN must differ from the mutation token"
2354    );
2355    if !bind.is_loopback() {
2356        eprintln!(
2357            "WARNING: binding {bind} with --insecure-lan — the API is reachable \
2358             beyond this machine. Reads require the read-only or mutation token; \
2359             POSTs require the mutation token. Use only on a network you trust."
2360        );
2361    }
2362    if read_auth && effective_require_read_token(bind.is_loopback(), read_auth) {
2363        eprintln!(
2364            "--read-auth: GETs and the WS upgrade now require the read-only or \
2365             mutation token, including on loopback; POSTs still require mutation \
2366             authority."
2367        );
2368    }
2369    // Bind BEFORE printing anything: `--port 0` picks an ephemeral port, and
2370    // the printed / `--open`ed URL must carry the REAL one.
2371    let listener = kranz_server::bind_listener(bind, port)
2372        .await
2373        .map_err(|e| anyhow!("failed to bind {bind}:{port}: {e}"))?;
2374    let local_addr = listener
2375        .local_addr()
2376        .map_err(|e| anyhow!("failed to read bound address: {e}"))?;
2377    // The global operator catalog composes one existing MissionHost per root.
2378    // With no host.repos block this resolves to the historical current-repo
2379    // host and retains every unscoped route.
2380    let multi_host = Arc::new(kranz_server::MultiRepoHost::from_global_config(
2381        kranz_engine::paths::global_config().as_deref(),
2382        repo.clone(),
2383    )?);
2384    // One watcher schedules ready repository queues in round-robin order;
2385    // each run holds a process-wide maxConcurrentRepos permit until it ends.
2386    multi_host.ensure_auto_work_started();
2387
2388    // Opt-in Slack bridge, spawned alongside the server and stopped when the
2389    // process exits. serve_slack is a no-op (logs) when Slack is unconfigured,
2390    // so `--slack` is safe to pass unconditionally.
2391    if slack {
2392        if multi_host.uses_operator_catalog() {
2393            let catalog = multi_repo_slack_catalog(&multi_host)?;
2394            tokio::spawn(async move {
2395                let never = std::future::pending::<()>();
2396                if let Err(error) = kranz_slack::serve_slack_catalog(catalog, never).await {
2397                    tracing::error!(%error, "slack catalog bridge exited with an error");
2398                }
2399            });
2400        } else {
2401            let context = single_repo_slack_context(&multi_host)?;
2402            let repo_slack = context.root().to_path_buf();
2403            let hosted = context.host().ok_or_else(|| {
2404                anyhow!(
2405                    "cannot start Slack bridge for unavailable repository '{}': {}",
2406                    context.id(),
2407                    context.unavailable_reason().unwrap_or("unavailable")
2408                )
2409            })?;
2410            let bridge_host: kranz_slack::SharedHost =
2411                Arc::new(crate::host_bridge::HostedPlanning(hosted.clone()));
2412            tokio::spawn(async move {
2413                // No graceful-shutdown wiring for the CLI's long-lived server:
2414                // this future never resolves, so the bridge runs until the
2415                // process is killed (same lifetime as the server below).
2416                let never = std::future::pending::<()>();
2417                if let Err(e) =
2418                    kranz_slack::serve_slack(&repo_slack, Some(bridge_host), never).await
2419                {
2420                    tracing::error!(error = %e, "slack bridge exited with an error");
2421                }
2422            });
2423        }
2424    }
2425
2426    let dashboard_assets = resolve_dashboard_assets(&repo, dashboard);
2427    // Display the ADDRESS ACTUALLY BOUND: `--host ::1` must not print an
2428    // unconnectable 127.0.0.1 URL, and v6 literals need brackets.
2429    let display_host = match local_addr.ip() {
2430        std::net::IpAddr::V6(v6) => format!("[{v6}]"),
2431        std::net::IpAddr::V4(v4) => v4.to_string(),
2432    };
2433    let url = format!("http://{display_host}:{}/", local_addr.port());
2434    println!("kranz server on {url}");
2435    println!("mutation token: {token}");
2436    println!("read token: {read_token} (GETs/WS only — safe for dashboards and agents)");
2437    if open {
2438        println!(
2439            "opening the bare URL; no token rides in the opener's argv. Paste the \
2440             mutation token above when the dashboard asks for one"
2441        );
2442    }
2443    match &dashboard_assets {
2444        Some(DashboardAssets::Embedded) => println!(
2445            "serving embedded dashboard ({})",
2446            crate::embedded_dashboard::EMBEDDED_DASHBOARD_SOURCE
2447        ),
2448        Some(DashboardAssets::Dir(dir)) => println!("serving dashboard from {}", dir.display()),
2449        None => println!(
2450            "no dashboard build found (--dashboard, $KRANZ_DASHBOARD_DIST, \
2451             <repo>/apps/dashboard/dist, installed asset dirs, or the kranz checkout) \
2452             and no embedded dashboard is available; serving API only"
2453        ),
2454    }
2455
2456    let static_assets = dashboard_assets.map(|assets| match assets {
2457        DashboardAssets::Dir(dir) => kranz_server::DashboardStatic::Dir(dir),
2458        DashboardAssets::Embedded => {
2459            kranz_server::DashboardStatic::Embedded(crate::embedded_dashboard::EMBEDDED_DASHBOARD)
2460        }
2461    });
2462
2463    if open {
2464        // Give the server a moment to bind before pointing a browser at it.
2465        //
2466        // BEHAVIOUR (follow-up review M-10): the opened URL carries NO token
2467        // at all: not the mutation token, and not the read token either.
2468        // Two reasons, and the second is the one that changed:
2469        //
2470        // Threat: `open`/`xdg-open` is a separate process whose argv any
2471        // local process can read (`ps`, `/proc/<pid>/cmdline`). A fragment
2472        // keeps a token out of request lines and server logs, but a fragment
2473        // is still argv, so putting the read token there downgraded the
2474        // disclosure rather than eliminating it.
2475        //
2476        // Correctness: the dashboard has ONE token slot
2477        // (apps/dashboard/src/lib/token.ts). Seeding it with the read token
2478        // wedges the UI the moment the operator mutates anything: the
2479        // server 401s, `awaitToken()` CLEARS the slot, and when
2480        // `require_read_token` is on (a non-loopback bind, or read auth
2481        // configured) every subsequent GET 401s too and the WS reconnect-
2482        // loops. The fragment is already stripped by `history.replaceState`,
2483        // so a reload does not recover it.
2484        //
2485        // So: open bare, and let the dashboard's own TokenPrompt ask. The
2486        // pasted mutation token authenticates reads as well as writes, which
2487        // is the single-slot shape the dashboard actually has.
2488        let url = url.clone();
2489        tokio::spawn(async move {
2490            tokio::time::sleep(Duration::from_millis(600)).await;
2491            open_browser(&url);
2492        });
2493    }
2494
2495    let shutdown = async {
2496        if let Err(e) = tokio::signal::ctrl_c().await {
2497            tracing::error!(error = %e, "failed to install ctrl-c handler");
2498        }
2499    };
2500    let result = serve_multi_with_token_cleanup(
2501        &repo,
2502        multi_host,
2503        listener,
2504        static_assets,
2505        token,
2506        read_token,
2507        read_auth,
2508        shutdown,
2509    )
2510    .await;
2511    match result {
2512        Ok(()) => Ok(0),
2513        Err(e) => Err(anyhow!("server failed: {e}")),
2514    }
2515}
2516
2517fn single_repo_slack_context(
2518    multi_host: &kranz_server::MultiRepoHost,
2519) -> Result<Arc<kranz_server::RepoContext>> {
2520    multi_host
2521        .compatibility_context()
2522        .ok_or_else(|| anyhow!("--slack requires one healthy configured repository"))
2523}
2524
2525fn multi_repo_slack_catalog(
2526    multi_host: &kranz_server::MultiRepoHost,
2527) -> Result<kranz_slack::SlackCatalog> {
2528    let global_config = kranz_engine::paths::global_config()
2529        .ok_or_else(|| anyhow!("cannot locate the operator config directory for Slack affinity"))?;
2530    let affinity_path = global_config
2531        .parent()
2532        .expect("global config has a parent")
2533        .join("slack")
2534        .join("thread-affinity.json");
2535    let repos = multi_host
2536        .contexts()
2537        .map(|context| {
2538            let host = context.host().map(|host| {
2539                Arc::new(crate::host_bridge::HostedPlanning(host.clone()))
2540                    as kranz_slack::SharedHost
2541            });
2542            kranz_slack::SlackRepo {
2543                id: context.id().to_string(),
2544                root: context.root().to_path_buf(),
2545                display_name: context
2546                    .config()
2547                    .display_name
2548                    .clone()
2549                    .unwrap_or_else(|| context.id().to_string()),
2550                routes: context
2551                    .config()
2552                    .slack
2553                    .channels
2554                    .iter()
2555                    .map(|route| kranz_slack::SlackRoute {
2556                        team_id: route.team.clone(),
2557                        channel_id: route.channel.clone(),
2558                    })
2559                    .collect(),
2560                allow_users: context.config().slack.allow_users.clone(),
2561                available: context.is_healthy(),
2562                host,
2563                unavailable_reason: context.unavailable_reason().map(str::to_string),
2564                default: multi_host.default_repo() == Some(context.id()),
2565            }
2566        })
2567        .collect();
2568    kranz_slack::SlackCatalog::new(repos, affinity_path)
2569}
2570
2571#[allow(clippy::too_many_arguments)]
2572async fn serve_multi_with_token_cleanup(
2573    repo: &Path,
2574    multi_host: Arc<kranz_server::MultiRepoHost>,
2575    listener: tokio::net::TcpListener,
2576    static_assets: Option<kranz_server::DashboardStatic>,
2577    token: String,
2578    read_token: String,
2579    read_auth: bool,
2580    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
2581) -> anyhow::Result<()> {
2582    let (token_file, read_token_file) = if multi_host.uses_operator_catalog() {
2583        let address = listener
2584            .local_addr()
2585            .context("cannot resolve the bound address for operator token storage")?;
2586        let token_file = write_operator_serve_token(address, &token)
2587            .context("cannot securely store the operator serve token")?;
2588        let read_token_file = write_operator_serve_read_token(address, &read_token)
2589            .context("cannot securely store the operator serve read token")?;
2590        (token_file, read_token_file)
2591    } else {
2592        let token_file =
2593            write_serve_token(repo, &token).context("cannot securely store the serve token")?;
2594        let read_token_file = write_serve_read_token(repo, &read_token)
2595            .context("cannot securely store the serve read token")?;
2596        (token_file, read_token_file)
2597    };
2598    let result = kranz_server::serve_multi_on_listener(
2599        multi_host,
2600        listener,
2601        static_assets,
2602        kranz_server::MutationAuthority::new(token)?,
2603        Some(read_token),
2604        read_auth,
2605        shutdown,
2606    )
2607    .await;
2608    remove_token_file(&token_file);
2609    remove_token_file(&read_token_file);
2610    result
2611}
2612
2613/// Owns the write→serve→remove sequence for `.kranz/serve.token` so the
2614/// removal-on-shutdown behaviour is exercised by tests instead of just
2615/// asserted by a helper the tests bypass. Removes the token file on both the
2616/// `Ok` and `Err` serve paths — a server that fails to bind must not leave a
2617/// stale mutation token behind.
2618#[cfg(test)]
2619async fn serve_with_token_cleanup(
2620    repo: &Path,
2621    host: Arc<kranz_server::MissionHost>,
2622    listener: tokio::net::TcpListener,
2623    static_assets: Option<kranz_server::DashboardStatic>,
2624    token: String,
2625    read_token: String,
2626    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
2627) -> anyhow::Result<()> {
2628    // Filesystem read access to .kranz/serve.token confers mutation
2629    // authority — the same trust boundary as the .kranz/ directory itself,
2630    // so this file must never be written world- or group-readable.
2631    let token_file = write_serve_token(repo, &token)?;
2632    let read_token_file = write_serve_read_token(repo, &read_token)?;
2633
2634    let result = kranz_server::serve_on_listener(
2635        host,
2636        listener,
2637        static_assets,
2638        kranz_server::MutationAuthority::new(token)?,
2639        shutdown,
2640    )
2641    .await;
2642
2643    remove_token_file(&token_file);
2644    remove_token_file(&read_token_file);
2645
2646    result
2647}
2648
2649/// Write the per-serve mutation token to `<repo>/.kranz/serve.token` so
2650/// local CLI commands can read it automatically instead of requiring
2651/// `--token`/`$KRANZ_TOKEN`. Filesystem read access to this file confers
2652/// mutation authority over the served repo — the same trust boundary as the
2653/// `.kranz/` directory itself, so it is written owner-only (0600 on Unix).
2654fn write_serve_token(repo: &Path, token: &str) -> std::io::Result<PathBuf> {
2655    write_token_file(&repo.join(".kranz").join("serve.token"), token)
2656}
2657
2658/// Write the per-serve READ-ONLY token to `<repo>/.kranz/serve.read.token`.
2659/// Same storage discipline as the mutation token: it authenticates gated
2660/// GETs (mission state, transcripts), so it stays owner-only — but unlike
2661/// `serve.token` it never carries mutation authority, which is what makes it
2662/// safe to hand to dashboards and agents.
2663fn write_serve_read_token(repo: &Path, read_token: &str) -> std::io::Result<PathBuf> {
2664    write_token_file(&repo.join(".kranz").join("serve.read.token"), read_token)
2665}
2666
2667/// Multi-root token location: `~/.kranz/serve/<endpoint>.token`. The complete
2668/// bound socket address distinguishes servers sharing a port on different
2669/// interfaces and lets `kranz release --url ...` refuse host-ambiguous
2670/// automatic credential discovery.
2671fn write_operator_serve_token(
2672    address: std::net::SocketAddr,
2673    token: &str,
2674) -> std::io::Result<PathBuf> {
2675    let global = kranz_engine::paths::global_config().ok_or_else(|| {
2676        std::io::Error::new(
2677            std::io::ErrorKind::NotFound,
2678            "cannot resolve operator config directory",
2679        )
2680    })?;
2681    let path = operator_serve_token_path(&global, address);
2682    write_token_file(&path, token)
2683}
2684
2685/// The read-only sibling of the operator token: `~/.kranz/serve/<endpoint>.read.token`.
2686fn write_operator_serve_read_token(
2687    address: std::net::SocketAddr,
2688    read_token: &str,
2689) -> std::io::Result<PathBuf> {
2690    let global = kranz_engine::paths::global_config().ok_or_else(|| {
2691        std::io::Error::new(
2692            std::io::ErrorKind::NotFound,
2693            "cannot resolve operator config directory",
2694        )
2695    })?;
2696    let path = operator_serve_read_token_path(&global, address);
2697    write_token_file(&path, read_token)
2698}
2699
2700/// `<endpoint>.token` → `<endpoint>.read.token` beside the operator token.
2701fn operator_serve_read_token_path(global_config: &Path, address: std::net::SocketAddr) -> PathBuf {
2702    operator_serve_token_path(global_config, address).with_extension("read.token")
2703}
2704
2705fn operator_serve_token_path(global_config: &Path, address: std::net::SocketAddr) -> PathBuf {
2706    let endpoint = match address.ip() {
2707        std::net::IpAddr::V4(ip) => format!("v4-{:08x}-{}", u32::from(ip), address.port()),
2708        std::net::IpAddr::V6(ip) => format!("v6-{:032x}-{}", u128::from(ip), address.port()),
2709    };
2710    global_config
2711        .parent()
2712        .unwrap_or_else(|| Path::new("."))
2713        .join("serve")
2714        .join(format!("{endpoint}.token"))
2715}
2716
2717/// Pre-endpoint migration path: `~/.kranz/serve/<port>.token`. Kept as a
2718/// last-resort discovery fallback so a live serve that still has only the
2719/// legacy file remains usable until the next `kranz serve` rewrite.
2720fn legacy_operator_serve_token_path(global_config: &Path, port: u16) -> PathBuf {
2721    global_config
2722        .parent()
2723        .unwrap_or_else(|| Path::new("."))
2724        .join("serve")
2725        .join(format!("{port}.token"))
2726}
2727
2728fn write_token_file(path: &Path, token: &str) -> std::io::Result<PathBuf> {
2729    let dir = path.parent().ok_or_else(|| {
2730        std::io::Error::new(std::io::ErrorKind::InvalidInput, "token path has no parent")
2731    })?;
2732    std::fs::create_dir_all(dir)?;
2733    // Write a sibling temp file then rename over the destination so a crash
2734    // never leaves an empty/truncated credential at the stable path, and an
2735    // existing inode (or symlink) is replaced rather than rewritten in place.
2736    // A random suffix avoids PID-reuse create_new collisions after a crash.
2737    let tmp = dir.join(format!(
2738        ".{}.tmp-{}",
2739        path.file_name()
2740            .and_then(|name| name.to_str())
2741            .unwrap_or("serve.token"),
2742        uuid::Uuid::new_v4().as_simple()
2743    ));
2744    let write_tmp = || -> std::io::Result<()> {
2745        let mut options = std::fs::OpenOptions::new();
2746        options.create_new(true).write(true);
2747        #[cfg(unix)]
2748        {
2749            use std::os::unix::fs::OpenOptionsExt;
2750            options.mode(0o600);
2751        }
2752        let mut file = options.open(&tmp)?;
2753        file.write_all(token.as_bytes())?;
2754        file.flush()?;
2755        Ok(())
2756    };
2757    if let Err(error) = write_tmp() {
2758        let _ = std::fs::remove_file(&tmp);
2759        return Err(error);
2760    }
2761    #[cfg(unix)]
2762    {
2763        use std::os::unix::fs::PermissionsExt;
2764        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2765    }
2766    if let Err(error) = std::fs::rename(&tmp, path) {
2767        let _ = std::fs::remove_file(&tmp);
2768        return Err(error);
2769    }
2770    Ok(path.to_path_buf())
2771}
2772
2773fn remove_token_file(path: &Path) {
2774    if let Err(err) = std::fs::remove_file(path) {
2775        if err.kind() != std::io::ErrorKind::NotFound {
2776            eprintln!("kranz: could not remove {}: {err}", path.display());
2777        }
2778    }
2779}
2780
2781#[derive(Debug, Clone, PartialEq, Eq)]
2782enum DashboardAssets {
2783    Dir(PathBuf),
2784    Embedded,
2785}
2786
2787#[derive(Debug, Clone, Default)]
2788struct DashboardResolutionInputs {
2789    env_dist: Option<PathBuf>,
2790    home: Option<PathBuf>,
2791    exe: Option<PathBuf>,
2792    manifest_dir: Option<PathBuf>,
2793    embedded_available: bool,
2794}
2795
2796impl DashboardResolutionInputs {
2797    fn runtime() -> Self {
2798        Self {
2799            env_dist: std::env::var_os("KRANZ_DASHBOARD_DIST").map(PathBuf::from),
2800            home: std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
2801                .map(PathBuf::from),
2802            exe: std::env::current_exe().ok(),
2803            manifest_dir: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))),
2804            embedded_available: !crate::embedded_dashboard::EMBEDDED_DASHBOARD.is_empty(),
2805        }
2806    }
2807}
2808
2809fn resolve_dashboard_assets(repo: &Path, explicit: Option<PathBuf>) -> Option<DashboardAssets> {
2810    resolve_dashboard_assets_from(repo, explicit, &DashboardResolutionInputs::runtime())
2811}
2812
2813/// Search order:
2814/// 1. explicit `--dashboard DIR`
2815/// 2. `$KRANZ_DASHBOARD_DIST`
2816/// 3. `<repo>/apps/dashboard/dist` (mission repo IS the kranz checkout)
2817/// 4. installed asset dirs (`~/.kranz/dashboard/dist`, `<prefix>/share/kranz/...`)
2818/// 5. `apps/dashboard/dist` in the kranz source checkout used to build the binary
2819/// 6. packaged embedded dashboard assets (future crates.io/source installs)
2820fn resolve_dashboard_assets_from(
2821    repo: &Path,
2822    explicit: Option<PathBuf>,
2823    inputs: &DashboardResolutionInputs,
2824) -> Option<DashboardAssets> {
2825    if let Some(d) = explicit {
2826        // Explicitly requested: honor it even without index.html so the user
2827        // sees their own path in the log line (the server 404s clearly).
2828        return Some(DashboardAssets::Dir(d));
2829    }
2830
2831    if let Some(d) = first_dashboard_dir(dashboard_dir_candidates(repo, inputs)) {
2832        return Some(DashboardAssets::Dir(d));
2833    }
2834
2835    inputs
2836        .embedded_available
2837        .then_some(DashboardAssets::Embedded)
2838}
2839
2840/// Locate a built dashboard (`index.html` + assets) on disk. This excludes the
2841/// embedded dashboard fallback used by `kranz serve`.
2842pub fn resolve_dashboard_dist(repo: &Path, explicit: Option<PathBuf>) -> Option<PathBuf> {
2843    match resolve_dashboard_assets(repo, explicit) {
2844        Some(DashboardAssets::Dir(dir)) => Some(dir),
2845        Some(DashboardAssets::Embedded) | None => None,
2846    }
2847}
2848
2849fn dashboard_dir_candidates(repo: &Path, inputs: &DashboardResolutionInputs) -> Vec<PathBuf> {
2850    let mut candidates = Vec::new();
2851
2852    candidates.extend(inputs.env_dist.clone());
2853    candidates.push(repo.join("apps").join("dashboard").join("dist"));
2854
2855    if let Some(home) = &inputs.home {
2856        candidates.push(home.join(".kranz").join("dashboard").join("dist"));
2857        candidates.push(home.join(".kranz").join("dashboard"));
2858    }
2859
2860    if let Some(exe) = &inputs.exe {
2861        candidates.extend(installed_dashboard_dirs(exe));
2862        candidates.extend(source_checkout_dist_from_exe(exe));
2863    }
2864
2865    if let Some(manifest_dir) = &inputs.manifest_dir {
2866        candidates.extend(source_checkout_dist_from_manifest(manifest_dir));
2867        candidates.push(manifest_dir.join("assets").join("dashboard").join("dist"));
2868    }
2869
2870    candidates
2871}
2872
2873fn first_dashboard_dir(candidates: Vec<PathBuf>) -> Option<PathBuf> {
2874    candidates
2875        .into_iter()
2876        .find(|d| d.join("index.html").is_file())
2877}
2878
2879fn installed_dashboard_dirs(exe: &Path) -> Vec<PathBuf> {
2880    let Some(bin_dir) = exe.parent() else {
2881        return Vec::new();
2882    };
2883    let mut dirs = vec![
2884        bin_dir.join("dashboard").join("dist"),
2885        bin_dir.join("dashboard"),
2886    ];
2887    if let Some(prefix) = bin_dir.parent() {
2888        dirs.push(
2889            prefix
2890                .join("share")
2891                .join("kranz")
2892                .join("dashboard")
2893                .join("dist"),
2894        );
2895        dirs.push(prefix.join("share").join("kranz").join("dashboard"));
2896    }
2897    dirs
2898}
2899
2900fn source_checkout_dist_from_exe(exe: &Path) -> Option<PathBuf> {
2901    let profile_dir = exe.parent()?;
2902    let target_dir = profile_dir.parent()?;
2903    if target_dir.file_name()? != "target" {
2904        return None;
2905    }
2906    Some(
2907        target_dir
2908            .parent()?
2909            .join("apps")
2910            .join("dashboard")
2911            .join("dist"),
2912    )
2913}
2914
2915fn source_checkout_dist_from_manifest(manifest_dir: &Path) -> Option<PathBuf> {
2916    Some(
2917        manifest_dir
2918            .parent()?
2919            .parent()?
2920            .join("apps")
2921            .join("dashboard")
2922            .join("dist"),
2923    )
2924}
2925
2926/// Best-effort browser launch via the platform opener.
2927fn open_browser(url: &str) {
2928    #[cfg(target_os = "macos")]
2929    let mut command = {
2930        let mut c = std::process::Command::new("open");
2931        c.arg(url);
2932        c
2933    };
2934    #[cfg(target_os = "windows")]
2935    let mut command = {
2936        let mut c = std::process::Command::new("cmd");
2937        // `start` treats its first quoted argument as a window title.
2938        c.args(["/C", "start", "", url]);
2939        c
2940    };
2941    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2942    let mut command = {
2943        let mut c = std::process::Command::new("xdg-open");
2944        c.arg(url);
2945        c
2946    };
2947
2948    match command.spawn() {
2949        Ok(mut child) => {
2950            // Reap the opener off-thread; it exits immediately.
2951            std::thread::spawn(move || {
2952                let _ = child.wait();
2953            });
2954        }
2955        Err(e) => eprintln!("kranz: could not open the browser: {e}"),
2956    }
2957}
2958
2959// ---------------------------------------------------------------------------
2960// release
2961// ---------------------------------------------------------------------------
2962
2963/// `kranz release [--url <u>] [--token <t>]`: POST to a running `kranz
2964/// serve`'s release endpoint to free the mission's single-writer lock. The
2965/// CLI runs in a different process and cannot reach serve's in-memory
2966/// registry directly, so this always goes over HTTP — never the event log.
2967/// Resolve the mutation token for `kranz release`, in precedence order:
2968/// `--token` > `$KRANZ_TOKEN` > operator process token for `--url` > the
2969/// single-repo compatibility file (loopback URLs only).
2970fn resolve_release_token(
2971    repo: &Path,
2972    url: &str,
2973    flag: Option<String>,
2974) -> Result<String, ReleaseTokenError> {
2975    let parsed = reqwest::Url::parse(url).ok();
2976    let operator_lookup = parsed
2977        .as_ref()
2978        .and_then(|url| {
2979            kranz_engine::paths::global_config()
2980                .as_deref()
2981                .map(|global| operator_token_for_url(global, url))
2982        })
2983        .unwrap_or(OperatorTokenLookup::Absent);
2984    let allow_repo_fallback = parsed.as_ref().is_some_and(automatic_repo_token_allowed);
2985    resolve_release_token_from_sources(repo, operator_lookup, allow_repo_fallback, flag)
2986}
2987
2988/// Outcome of scanning endpoint-scoped and legacy operator token files for a
2989/// release URL. `Ambiguous` must not fall through to the single-repo
2990/// compatibility file — that would bypass the "refuse to guess" policy with
2991/// a different credential.
2992#[derive(Debug, Clone, PartialEq, Eq)]
2993enum OperatorTokenLookup {
2994    Absent,
2995    Found(String),
2996    Legacy(String),
2997    Ambiguous,
2998}
2999
3000#[derive(Debug, Clone, PartialEq, Eq)]
3001enum ReleaseTokenError {
3002    Absent,
3003    Ambiguous,
3004}
3005
3006fn resolve_release_token_from_sources(
3007    repo: &Path,
3008    operator_lookup: OperatorTokenLookup,
3009    allow_repo_fallback: bool,
3010    flag: Option<String>,
3011) -> Result<String, ReleaseTokenError> {
3012    if let Some(flag) = flag {
3013        return Ok(flag);
3014    }
3015    if let Ok(env) = std::env::var("KRANZ_TOKEN") {
3016        return Ok(env);
3017    }
3018    match operator_lookup {
3019        OperatorTokenLookup::Found(authority) => Ok(authority),
3020        // The port-only file predates endpoint scoping and may have survived
3021        // an ungraceful shutdown. A live single-repo serve writes the more
3022        // specific repository token, so prefer that before using the legacy
3023        // compatibility credential.
3024        OperatorTokenLookup::Legacy(authority) => {
3025            let repo_authority = allow_repo_fallback
3026                .then(|| read_token_file(&repo.join(".kranz").join("serve.token")))
3027                .flatten();
3028            Ok(repo_authority.unwrap_or(authority))
3029        }
3030        OperatorTokenLookup::Ambiguous => Err(ReleaseTokenError::Ambiguous),
3031        OperatorTokenLookup::Absent => allow_repo_fallback
3032            .then(|| read_token_file(&repo.join(".kranz").join("serve.token")))
3033            .flatten()
3034            .ok_or(ReleaseTokenError::Absent),
3035    }
3036}
3037
3038fn scan_operator_token_addresses(
3039    global_config: &Path,
3040    addresses: &[std::net::SocketAddr],
3041) -> OperatorTokenLookup {
3042    let mut authority = None;
3043    for address in addresses {
3044        if let Some(found) = read_token_file(&operator_serve_token_path(global_config, *address)) {
3045            if authority.is_some() {
3046                // Several local servers match this URL (for example both v4
3047                // and v6 localhost). Refuse to guess which authority to send.
3048                return OperatorTokenLookup::Ambiguous;
3049            }
3050            authority = Some(found);
3051        }
3052    }
3053    match authority {
3054        Some(authority) => OperatorTokenLookup::Found(authority),
3055        None => OperatorTokenLookup::Absent,
3056    }
3057}
3058
3059fn operator_token_for_url(global_config: &Path, url: &reqwest::Url) -> OperatorTokenLookup {
3060    let Some(port) = url.port_or_known_default() else {
3061        return OperatorTokenLookup::Absent;
3062    };
3063    let Some(host) = normalized_url_host(url) else {
3064        return OperatorTokenLookup::Absent;
3065    };
3066    // Exact named endpoints first; only if none match, fall back to
3067    // unspecified-bind aliases. That keeps a live 127.0.0.1 token usable
3068    // even when a stale 0.0.0.0 file from an earlier bind remains on disk.
3069    let endpoint_lookup = if host.eq_ignore_ascii_case("localhost") {
3070        let primary = [
3071            std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)),
3072            std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port)),
3073        ];
3074        match scan_operator_token_addresses(global_config, &primary) {
3075            OperatorTokenLookup::Absent => scan_operator_token_addresses(
3076                global_config,
3077                &[
3078                    std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, port)),
3079                    std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, port)),
3080                ],
3081            ),
3082            other => other,
3083        }
3084    } else {
3085        let Ok(ip) = host.parse::<std::net::IpAddr>() else {
3086            return OperatorTokenLookup::Absent;
3087        };
3088        // Automatic discovery is loopback-only — same trust boundary as
3089        // automatic_repo_token_allowed. Non-loopback URLs require --token.
3090        if !ip.is_loopback() {
3091            return OperatorTokenLookup::Absent;
3092        }
3093        let primary = [std::net::SocketAddr::new(ip, port)];
3094        match scan_operator_token_addresses(global_config, &primary) {
3095            OperatorTokenLookup::Absent => {
3096                let unspecified = match ip {
3097                    std::net::IpAddr::V4(_) => {
3098                        std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
3099                    }
3100                    std::net::IpAddr::V6(_) => {
3101                        std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
3102                    }
3103                };
3104                scan_operator_token_addresses(
3105                    global_config,
3106                    &[std::net::SocketAddr::new(unspecified, port)],
3107                )
3108            }
3109            other => other,
3110        }
3111    };
3112    match endpoint_lookup {
3113        OperatorTokenLookup::Absent => {
3114            // Deprecated port-only filename from before endpoint scoping.
3115            match read_token_file(&legacy_operator_serve_token_path(global_config, port)) {
3116                Some(token) => OperatorTokenLookup::Legacy(token),
3117                None => OperatorTokenLookup::Absent,
3118            }
3119        }
3120        other => other,
3121    }
3122}
3123
3124fn automatic_repo_token_allowed(url: &reqwest::Url) -> bool {
3125    normalized_url_host(url).is_some_and(|host| {
3126        host.eq_ignore_ascii_case("localhost")
3127            || host
3128                .parse::<std::net::IpAddr>()
3129                .is_ok_and(|ip| ip.is_loopback())
3130    })
3131}
3132
3133fn normalized_url_host(url: &reqwest::Url) -> Option<&str> {
3134    let host = url.host_str()?;
3135    Some(
3136        host.strip_prefix('[')
3137            .and_then(|host| host.strip_suffix(']'))
3138            .unwrap_or(host),
3139    )
3140}
3141
3142fn read_token_file(path: &Path) -> Option<String> {
3143    let contents = std::fs::read_to_string(path).ok()?;
3144    let authority = contents.trim_end().to_string();
3145    if authority.is_empty() {
3146        None
3147    } else {
3148        Some(authority)
3149    }
3150}
3151
3152fn release_http_client() -> Result<reqwest::Client> {
3153    reqwest::Client::builder()
3154        .redirect(reqwest::redirect::Policy::none())
3155        .connect_timeout(Duration::from_secs(5))
3156        .timeout(Duration::from_secs(30))
3157        .build()
3158        .context("building release HTTP client")
3159}
3160
3161fn release_repo_id_is_valid(id: &str) -> bool {
3162    let mut chars = id.chars();
3163    let valid_first = chars.next().is_some_and(|ch| ch.is_ascii_alphanumeric());
3164    let valid_rest = chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'));
3165    valid_first && valid_rest
3166}
3167
3168async fn cmd_release(
3169    repo: &Path,
3170    mission_id: &str,
3171    url: &str,
3172    token: Option<String>,
3173) -> Result<i32> {
3174    let token = match resolve_release_token(repo, url, token) {
3175        Ok(token) => token,
3176        Err(ReleaseTokenError::Ambiguous) => {
3177            bail!(
3178                "multiple ~/.kranz/serve/<endpoint>.token files match {url}; \
3179                 pass --token for the intended serve instead of guessing"
3180            );
3181        }
3182        Err(ReleaseTokenError::Absent) => {
3183            bail!(
3184                "no mutation token available — pass --token, set $KRANZ_TOKEN, or use a local URL matching \
3185                 a live ~/.kranz/serve/<endpoint>.token / single-repo .kranz/serve.token \
3186                 (the token `kranz serve` prints on startup)"
3187            );
3188        }
3189    };
3190
3191    let client = release_http_client()?;
3192    let repo_id = resolve_release_repo_id(repo, url, &client, &token).await?;
3193    if let Some(id) = repo_id.as_deref() {
3194        if !release_repo_id_is_valid(id) {
3195            bail!("live serve returned an invalid repository id '{id}'; refusing release");
3196        }
3197    }
3198    let endpoint = release_endpoint(url, mission_id, repo_id.as_deref());
3199
3200    let response = client
3201        .post(&endpoint)
3202        .header("x-kranz-token", token)
3203        .json(&serde_json::json!({}))
3204        .send()
3205        .await
3206        .map_err(|e| {
3207            if e.is_connect() {
3208                anyhow!("no kranz serve reachable at {url} — is it running?")
3209            } else {
3210                anyhow::Error::new(e).context(format!("releasing mission '{mission_id}'"))
3211            }
3212        })?;
3213
3214    match response.status() {
3215        reqwest::StatusCode::OK => {
3216            let body: serde_json::Value = response.json().await.unwrap_or_default();
3217            let released = body
3218                .get("released")
3219                .and_then(serde_json::Value::as_bool)
3220                .unwrap_or(false);
3221            if released {
3222                println!("mission {mission_id} released — the lock is now free");
3223            } else {
3224                println!("mission {mission_id} was already free (no lock held)");
3225            }
3226            Ok(0)
3227        }
3228        reqwest::StatusCode::CONFLICT => {
3229            eprintln!("kranz: mission {mission_id} has a turn in flight — try again shortly");
3230            Ok(1)
3231        }
3232        reqwest::StatusCode::NOT_FOUND => {
3233            eprintln!("kranz: unknown mission '{mission_id}' at {url}");
3234            Ok(1)
3235        }
3236        reqwest::StatusCode::UNAUTHORIZED => {
3237            eprintln!(
3238                "kranz: the token was missing or invalid — check --token / $KRANZ_TOKEN \
3239                 against the token `kranz serve` printed on startup"
3240            );
3241            Ok(1)
3242        }
3243        other => {
3244            let body = response.text().await.unwrap_or_default();
3245            eprintln!("kranz: release failed ({other}): {body}");
3246            Ok(1)
3247        }
3248    }
3249}
3250
3251/// Ask the target serve which repository the selected root maps to. The
3252/// process-global config may have changed since that serve started, or the
3253/// URL may name a different process entirely, so it is never authoritative
3254/// for a mutation target.
3255async fn resolve_release_repo_id(
3256    repo: &Path,
3257    url: &str,
3258    client: &reqwest::Client,
3259    token: &str,
3260) -> Result<Option<String>> {
3261    live_release_repo_id(repo, url, client, token).await
3262}
3263
3264fn release_repo_id_from_summaries(
3265    repo: &Path,
3266    repos: &[kranz_server::RepoSummary],
3267) -> Result<Option<String>> {
3268    if repos.is_empty() {
3269        bail!("the live serve returned an empty repository catalog; refusing an unscoped release");
3270    }
3271    let root = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
3272    let root_str = root.to_string_lossy();
3273    let matches: Vec<&kranz_server::RepoSummary> = repos
3274        .iter()
3275        .filter(|entry| {
3276            let entry_root = PathBuf::from(&entry.root);
3277            let entry_canon =
3278                std::fs::canonicalize(&entry_root).unwrap_or_else(|_| entry_root.clone());
3279            entry_canon == root || entry.root == root_str
3280        })
3281        .collect();
3282    match matches.as_slice() {
3283        [one] => Ok(Some(one.id.clone())),
3284        [] => Err(anyhow!(
3285            "selected repository '{}' is not present in the live serve catalog; refusing an unscoped release",
3286            repo.display()
3287        )),
3288        _ => Err(anyhow!(
3289            "selected repository '{}' matches multiple live serve catalog entries; refusing release",
3290            repo.display()
3291        )),
3292    }
3293}
3294
3295async fn live_release_repo_id(
3296    repo: &Path,
3297    url: &str,
3298    client: &reqwest::Client,
3299    token: &str,
3300) -> Result<Option<String>> {
3301    let base = url.trim_end_matches('/');
3302    // On loopback binds GETs are tokenless (docs/protocol.md). Attaching the
3303    // mutation token would leak it to any process listening on a wrong
3304    // loopback port. Off-loopback serves require the read token — send it then.
3305    let mut request = client.get(format!("{base}/api/repos"));
3306    let loopback_catalog = reqwest::Url::parse(url)
3307        .ok()
3308        .is_some_and(|parsed| automatic_repo_token_allowed(&parsed));
3309    if !loopback_catalog {
3310        request = request.header("x-kranz-token", token);
3311    }
3312    let send_error = |e: reqwest::Error| {
3313        if e.is_connect() {
3314            anyhow!("no kranz serve reachable at {url} — is it running?")
3315        } else {
3316            anyhow::Error::new(e).context("listing live serve repositories")
3317        }
3318    };
3319    let mut response = request.send().await.map_err(send_error)?;
3320    // A loopback URL does not imply a loopback bind: `serve --host 0.0.0.0`
3321    // gates reads even when reached via 127.0.0.1. Only once the tokenless
3322    // read is refused is the token proven necessary — resend it then, to the
3323    // very server that just demanded it.
3324    if loopback_catalog && response.status() == reqwest::StatusCode::UNAUTHORIZED {
3325        response = client
3326            .get(format!("{base}/api/repos"))
3327            .header("x-kranz-token", token)
3328            .send()
3329            .await
3330            .map_err(send_error)?;
3331    }
3332    if !response.status().is_success() {
3333        bail!(
3334            "cannot list repositories at {url}: HTTP {}",
3335            response.status()
3336        );
3337    }
3338    let repos: Vec<kranz_server::RepoSummary> = response
3339        .json()
3340        .await
3341        .context("parsing GET /api/repos response")?;
3342    release_repo_id_from_summaries(repo, &repos)
3343}
3344
3345fn release_endpoint(url: &str, mission_id: &str, repo_id: Option<&str>) -> String {
3346    let base = url.trim_end_matches('/');
3347    match repo_id {
3348        Some(repo_id) => {
3349            format!("{base}/api/repos/{repo_id}/missions/{mission_id}/release")
3350        }
3351        None => format!("{base}/api/missions/{mission_id}/release"),
3352    }
3353}
3354
3355#[cfg(test)]
3356mod tests {
3357    use super::*;
3358    use std::fs;
3359
3360    /// `cargo test` runs unit tests concurrently on multiple threads by
3361    /// default, but `$KRANZ_TOKEN` is process-global state. Every test below
3362    /// that reads or writes it must hold this lock for its whole body so the
3363    /// mutations don't interleave across threads.
3364    static KRANZ_TOKEN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3365
3366    /// With no --token and no $KRANZ_TOKEN, `cmd_release` fails fast with an
3367    /// actionable error instead of attempting the HTTP call.
3368    #[tokio::test]
3369    // The guard is a plain std Mutex held only to serialize this test's
3370    // $KRANZ_TOKEN mutation against sibling tests in this module; the await
3371    // below never touches the lock itself.
3372    #[allow(clippy::await_holding_lock)]
3373    async fn release_without_a_token_errors_clearly() {
3374        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3375        // SAFETY: serialized by KRANZ_TOKEN_ENV_LOCK above.
3376        unsafe {
3377            std::env::remove_var("KRANZ_TOKEN");
3378        }
3379        let tmp = tempfile::tempdir().unwrap();
3380        let repo = tmp.path().to_path_buf();
3381        // A loopback URL can discover an operator token from the developer's
3382        // running server. A non-loopback documentation address has no ambient
3383        // token source, so this exercises the missing-token error without I/O.
3384        let err = cmd_release(&repo, "m-1", "http://192.0.2.1:4560", None)
3385            .await
3386            .unwrap_err();
3387        let msg = err.to_string();
3388        assert!(msg.contains("--token"), "{msg}");
3389        assert!(msg.contains("KRANZ_TOKEN"), "{msg}");
3390    }
3391
3392    /// With no --token and no $KRANZ_TOKEN, resolution falls back to
3393    /// `<repo>/.kranz/serve.token`.
3394    #[test]
3395    fn release_reads_token_file_when_flag_and_env_absent() {
3396        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3397        // SAFETY: serialized by KRANZ_TOKEN_ENV_LOCK above.
3398        unsafe {
3399            std::env::remove_var("KRANZ_TOKEN");
3400        }
3401        let tmp = tempfile::tempdir().unwrap();
3402        let repo = tmp.path().to_path_buf();
3403        write_serve_token(&repo, "file-token").unwrap();
3404
3405        assert_eq!(
3406            resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None),
3407            Ok("file-token".to_string())
3408        );
3409    }
3410
3411    #[test]
3412    fn release_reads_token_file_but_flag_overrides() {
3413        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3414        // SAFETY: serialized by KRANZ_TOKEN_ENV_LOCK above.
3415        unsafe {
3416            std::env::remove_var("KRANZ_TOKEN");
3417        }
3418        let tmp = tempfile::tempdir().unwrap();
3419        let repo = tmp.path().to_path_buf();
3420        write_serve_token(&repo, "file-token").unwrap();
3421
3422        assert_eq!(
3423            resolve_release_token_from_sources(
3424                &repo,
3425                OperatorTokenLookup::Absent,
3426                true,
3427                Some("flag-token".to_string()),
3428            ),
3429            Ok("flag-token".to_string())
3430        );
3431    }
3432
3433    #[test]
3434    fn release_reads_token_file_but_env_overrides() {
3435        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3436        // SAFETY: serialized by KRANZ_TOKEN_ENV_LOCK above.
3437        unsafe {
3438            std::env::set_var("KRANZ_TOKEN", "env-token");
3439        }
3440        let tmp = tempfile::tempdir().unwrap();
3441        let repo = tmp.path().to_path_buf();
3442        write_serve_token(&repo, "file-token").unwrap();
3443
3444        let result =
3445            resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None);
3446        unsafe {
3447            std::env::remove_var("KRANZ_TOKEN");
3448        }
3449        assert_eq!(result, Ok("env-token".to_string()));
3450    }
3451
3452    /// When flag, env, and file are all absent, resolution yields Absent so
3453    /// `cmd_release` can raise its actionable error.
3454    #[test]
3455    fn release_reads_token_file_none_present_yields_none() {
3456        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3457        // SAFETY: serialized by KRANZ_TOKEN_ENV_LOCK above.
3458        unsafe {
3459            std::env::remove_var("KRANZ_TOKEN");
3460        }
3461        let tmp = tempfile::tempdir().unwrap();
3462        let repo = tmp.path().to_path_buf();
3463
3464        assert_eq!(
3465            resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None),
3466            Err(ReleaseTokenError::Absent)
3467        );
3468    }
3469
3470    #[test]
3471    fn release_prefers_operator_process_token_over_repo_compatibility_token() {
3472        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3473        unsafe {
3474            std::env::remove_var("KRANZ_TOKEN");
3475        }
3476        let tmp = tempfile::tempdir().unwrap();
3477        let repo = tmp.path().join("repo");
3478        write_serve_token(&repo, "repo-token").unwrap();
3479        let global = tmp.path().join("operator").join("config.json");
3480        let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3481        let operator = operator_serve_token_path(&global, address);
3482        write_token_file(&operator, "operator-token").unwrap();
3483        let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3484
3485        assert_eq!(
3486            resolve_release_token_from_sources(
3487                &repo,
3488                operator_token_for_url(&global, &url),
3489                true,
3490                None,
3491            ),
3492            Ok("operator-token".to_string())
3493        );
3494    }
3495
3496    #[test]
3497    fn ambiguous_operator_token_does_not_fall_through_to_repo_file() {
3498        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3499        unsafe {
3500            std::env::remove_var("KRANZ_TOKEN");
3501        }
3502        let tmp = tempfile::tempdir().unwrap();
3503        let repo = tmp.path().join("repo");
3504        write_serve_token(&repo, "repo-token").unwrap();
3505
3506        assert_eq!(
3507            resolve_release_token_from_sources(&repo, OperatorTokenLookup::Ambiguous, true, None,),
3508            Err(ReleaseTokenError::Ambiguous)
3509        );
3510    }
3511
3512    #[test]
3513    fn operator_token_discovery_is_ambiguous_for_dual_localhost_endpoint_files() {
3514        let tmp = tempfile::tempdir().unwrap();
3515        let global = tmp.path().join(".kranz").join("config.json");
3516        let v4 = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3517        let v6 = std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 4560));
3518        write_token_file(&operator_serve_token_path(&global, v4), "v4-token").unwrap();
3519        write_token_file(&operator_serve_token_path(&global, v6), "v6-token").unwrap();
3520        let url = reqwest::Url::parse("http://localhost:4560").unwrap();
3521
3522        assert_eq!(
3523            operator_token_for_url(&global, &url),
3524            OperatorTokenLookup::Ambiguous
3525        );
3526    }
3527
3528    #[test]
3529    fn operator_token_path_is_scoped_by_full_bound_endpoint() {
3530        let global = Path::new("/operator/.kranz/config.json");
3531        let a =
3532            operator_serve_token_path(global, std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
3533        let b =
3534            operator_serve_token_path(global, std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
3535        assert_ne!(a, b);
3536        assert_eq!(
3537            a,
3538            Path::new("/operator/.kranz/serve/v4-7f000001-4560.token")
3539        );
3540    }
3541
3542    #[test]
3543    fn operator_token_discovery_handles_ipv6_url_brackets() {
3544        let tmp = tempfile::tempdir().unwrap();
3545        let global = tmp.path().join(".kranz").join("config.json");
3546        let address = std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 4560));
3547        write_token_file(&operator_serve_token_path(&global, address), "ipv6-token").unwrap();
3548        let url = reqwest::Url::parse("http://[::1]:4560").unwrap();
3549
3550        assert_eq!(
3551            operator_token_for_url(&global, &url),
3552            OperatorTokenLookup::Found("ipv6-token".to_string())
3553        );
3554    }
3555
3556    #[test]
3557    fn automatic_token_discovery_refuses_remote_domain_urls() {
3558        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3559        unsafe {
3560            std::env::remove_var("KRANZ_TOKEN");
3561        }
3562        let tmp = tempfile::tempdir().unwrap();
3563        let repo = tmp.path().join("repo");
3564        write_serve_token(&repo, "repo-token").unwrap();
3565
3566        assert_eq!(
3567            resolve_release_token(&repo, "https://example.com:4560", None),
3568            Err(ReleaseTokenError::Absent)
3569        );
3570    }
3571
3572    #[test]
3573    fn operator_token_discovery_refuses_non_loopback_ip_literals() {
3574        let tmp = tempfile::tempdir().unwrap();
3575        let global = tmp.path().join(".kranz").join("config.json");
3576        let address = std::net::SocketAddr::from(([203, 0, 113, 10], 4560));
3577        write_token_file(&operator_serve_token_path(&global, address), "remote-token").unwrap();
3578        let url = reqwest::Url::parse("http://203.0.113.10:4560").unwrap();
3579
3580        assert_eq!(
3581            operator_token_for_url(&global, &url),
3582            OperatorTokenLookup::Absent
3583        );
3584    }
3585
3586    #[test]
3587    fn operator_token_discovery_prefers_exact_loopback_over_stale_unspecified() {
3588        let tmp = tempfile::tempdir().unwrap();
3589        let global = tmp.path().join(".kranz").join("config.json");
3590        let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3591        let unspecified = std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 4560));
3592        write_token_file(&operator_serve_token_path(&global, loopback), "live-token").unwrap();
3593        write_token_file(
3594            &operator_serve_token_path(&global, unspecified),
3595            "stale-token",
3596        )
3597        .unwrap();
3598        let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3599
3600        assert_eq!(
3601            operator_token_for_url(&global, &url),
3602            OperatorTokenLookup::Found("live-token".to_string())
3603        );
3604    }
3605
3606    #[test]
3607    fn operator_token_discovery_falls_back_to_legacy_port_token() {
3608        let tmp = tempfile::tempdir().unwrap();
3609        let global = tmp.path().join(".kranz").join("config.json");
3610        write_token_file(
3611            &legacy_operator_serve_token_path(&global, 4560),
3612            "legacy-token",
3613        )
3614        .unwrap();
3615        let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3616
3617        assert_eq!(
3618            operator_token_for_url(&global, &url),
3619            OperatorTokenLookup::Legacy("legacy-token".to_string())
3620        );
3621    }
3622
3623    #[test]
3624    fn stale_legacy_operator_token_does_not_mask_live_repo_token() {
3625        let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3626        unsafe {
3627            std::env::remove_var("KRANZ_TOKEN");
3628        }
3629        let tmp = tempfile::tempdir().unwrap();
3630        let repo = tmp.path().join("repo");
3631        write_serve_token(&repo, "live-repo-token").unwrap();
3632        let global = tmp.path().join(".kranz").join("config.json");
3633        write_token_file(
3634            &legacy_operator_serve_token_path(&global, 4560),
3635            "stale-legacy-token",
3636        )
3637        .unwrap();
3638        let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3639        let operator = operator_token_for_url(&global, &url);
3640
3641        assert_eq!(
3642            resolve_release_token_from_sources(&repo, operator, true, None),
3643            Ok("live-repo-token".to_string())
3644        );
3645    }
3646
3647    #[test]
3648    fn operator_token_discovery_prefers_endpoint_file_over_legacy_port_token() {
3649        let tmp = tempfile::tempdir().unwrap();
3650        let global = tmp.path().join(".kranz").join("config.json");
3651        let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3652        write_token_file(
3653            &operator_serve_token_path(&global, loopback),
3654            "endpoint-token",
3655        )
3656        .unwrap();
3657        write_token_file(
3658            &legacy_operator_serve_token_path(&global, 4560),
3659            "legacy-token",
3660        )
3661        .unwrap();
3662        let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3663
3664        assert_eq!(
3665            operator_token_for_url(&global, &url),
3666            OperatorTokenLookup::Found("endpoint-token".to_string())
3667        );
3668    }
3669
3670    #[test]
3671    fn release_repo_id_from_summaries_refuses_duplicate_root() {
3672        let tmp = tempfile::tempdir().unwrap();
3673        let repo = tmp.path().join("repo");
3674        std::fs::create_dir_all(&repo).unwrap();
3675        let root = repo.to_string_lossy().into_owned();
3676        let summaries = vec![
3677            kranz_server::RepoSummary {
3678                id: "alpha".to_string(),
3679                root: root.clone(),
3680                display_name: "alpha".to_string(),
3681                group: None,
3682                pinned: false,
3683                is_default: true,
3684                status: "healthy".to_string(),
3685                error: None,
3686                activity: kranz_server::RepoActivity::default(),
3687            },
3688            kranz_server::RepoSummary {
3689                id: "beta".to_string(),
3690                root,
3691                display_name: "beta".to_string(),
3692                group: None,
3693                pinned: false,
3694                is_default: false,
3695                status: "healthy".to_string(),
3696                error: None,
3697                activity: kranz_server::RepoActivity::default(),
3698            },
3699        ];
3700        let error = release_repo_id_from_summaries(&repo, &summaries).unwrap_err();
3701        assert!(error.to_string().contains("matches multiple"));
3702    }
3703
3704    #[test]
3705    fn slack_operator_catalog_is_not_rejected_by_legacy_context_helper() {
3706        fn init_git(root: &Path) {
3707            std::fs::create_dir_all(root).unwrap();
3708            let status = std::process::Command::new("git")
3709                .args(["init", "-q"])
3710                .arg(root)
3711                .status()
3712                .unwrap();
3713            assert!(status.success());
3714        }
3715
3716        let tmp = tempfile::tempdir().unwrap();
3717        let a = tmp.path().join("a");
3718        init_git(&a);
3719        let multi = kranz_server::MultiRepoHost::from_config(kranz_server::HostConfig {
3720            default_repo: Some("a".to_string()),
3721            max_concurrent_repos: 1,
3722            repos: vec![kranz_server::RepoConfig {
3723                id: "a".to_string(),
3724                root: a,
3725                display_name: None,
3726                group: None,
3727                pinned: false,
3728                slack: kranz_server::RepoSlackConfig::default(),
3729            }],
3730        })
3731        .unwrap();
3732        let context = single_repo_slack_context(&multi).unwrap();
3733        assert_eq!(context.id(), "a");
3734    }
3735
3736    #[test]
3737    fn release_endpoint_is_repo_scoped_when_catalog_id_is_known() {
3738        assert_eq!(
3739            release_endpoint("http://127.0.0.1:4560/", "same-id", Some("repo-b")),
3740            "http://127.0.0.1:4560/api/repos/repo-b/missions/same-id/release"
3741        );
3742    }
3743
3744    #[test]
3745    fn release_repo_id_from_summaries_matches_live_catalog_root() {
3746        let tmp = tempfile::tempdir().unwrap();
3747        let repo = tmp.path().join("repo");
3748        std::fs::create_dir_all(&repo).unwrap();
3749        let summaries = vec![kranz_server::RepoSummary {
3750            id: "alpha".to_string(),
3751            root: repo.to_string_lossy().into_owned(),
3752            display_name: "alpha".to_string(),
3753            group: None,
3754            pinned: false,
3755            is_default: true,
3756            status: "healthy".to_string(),
3757            error: None,
3758            activity: kranz_server::RepoActivity::default(),
3759        }];
3760        assert_eq!(
3761            release_repo_id_from_summaries(&repo, &summaries)
3762                .unwrap()
3763                .as_deref(),
3764            Some("alpha")
3765        );
3766    }
3767
3768    #[test]
3769    fn release_repo_id_from_summaries_refuses_unmatched_root() {
3770        let tmp = tempfile::tempdir().unwrap();
3771        let repo = tmp.path().join("local");
3772        let other = tmp.path().join("other");
3773        std::fs::create_dir_all(&repo).unwrap();
3774        std::fs::create_dir_all(&other).unwrap();
3775        let summaries = vec![kranz_server::RepoSummary {
3776            id: "other".to_string(),
3777            root: other.to_string_lossy().into_owned(),
3778            display_name: "other".to_string(),
3779            group: None,
3780            pinned: false,
3781            is_default: false,
3782            status: "healthy".to_string(),
3783            error: None,
3784            activity: kranz_server::RepoActivity::default(),
3785        }];
3786        let error = release_repo_id_from_summaries(&repo, &summaries).unwrap_err();
3787        assert!(error
3788            .to_string()
3789            .contains("not present in the live serve catalog"));
3790    }
3791
3792    #[test]
3793    fn release_repo_id_from_summaries_refuses_empty_catalog() {
3794        let tmp = tempfile::tempdir().unwrap();
3795        let error = release_repo_id_from_summaries(tmp.path(), &[]).unwrap_err();
3796        assert!(error.to_string().contains("empty repository catalog"));
3797    }
3798
3799    #[tokio::test]
3800    async fn live_release_repo_lookup_authenticates_protected_catalog() {
3801        let tmp = tempfile::tempdir().unwrap();
3802        let repo = tmp.path().join("repo");
3803        std::fs::create_dir_all(&repo).unwrap();
3804        let status = std::process::Command::new("git")
3805            .args(["init", "-q"])
3806            .arg(&repo)
3807            .status()
3808            .unwrap();
3809        assert!(status.success());
3810
3811        let catalog = Arc::new(kranz_server::MultiRepoHost::single(repo.clone()).unwrap());
3812        let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
3813            .await
3814            .unwrap();
3815        let address = listener.local_addr().unwrap();
3816        // Loopback bind => read routes stay tokenless (matches production serve).
3817        let app = kranz_server::router_with_multi_repo_host_and_addr(
3818            catalog,
3819            None,
3820            kranz_server::MutationAuthority::new("catalog-token").unwrap(),
3821            Some(address),
3822            true,
3823            false,
3824        );
3825        let server = tokio::spawn(async move {
3826            axum::serve(listener, app).await.unwrap();
3827        });
3828        let client = release_http_client().unwrap();
3829        let url = format!("http://{address}");
3830
3831        let repo_id = live_release_repo_id(&repo, &url, &client, "catalog-token")
3832            .await
3833            .unwrap();
3834
3835        server.abort();
3836        let _ = server.await;
3837        assert_eq!(repo_id.as_deref(), Some("repo"));
3838    }
3839
3840    #[tokio::test]
3841    async fn live_release_repo_lookup_retries_with_token_when_read_gated() {
3842        let tmp = tempfile::tempdir().unwrap();
3843        let repo = tmp.path().join("repo");
3844        std::fs::create_dir_all(&repo).unwrap();
3845        let status = std::process::Command::new("git")
3846            .args(["init", "-q"])
3847            .arg(&repo)
3848            .status()
3849            .unwrap();
3850        assert!(status.success());
3851
3852        let catalog = Arc::new(kranz_server::MultiRepoHost::single(repo.clone()).unwrap());
3853        let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
3854            .await
3855            .unwrap();
3856        let address = listener.local_addr().unwrap();
3857        // Non-loopback bind => reads are token-gated, but the operator on the
3858        // serve host still reaches it through a loopback URL. The tokenless
3859        // first read 401s; the lookup must retry with the token instead of
3860        // failing (`serve --host 0.0.0.0` + default `kranz release` URL).
3861        let app = kranz_server::router_with_multi_repo_host_and_addr(
3862            catalog,
3863            None,
3864            kranz_server::MutationAuthority::new("catalog-token").unwrap(),
3865            Some(address),
3866            false,
3867            true,
3868        );
3869        let server = tokio::spawn(async move {
3870            axum::serve(listener, app).await.unwrap();
3871        });
3872        let client = release_http_client().unwrap();
3873        let url = format!("http://{address}");
3874
3875        let repo_id = live_release_repo_id(&repo, &url, &client, "catalog-token")
3876            .await
3877            .unwrap();
3878
3879        server.abort();
3880        let _ = server.await;
3881        assert_eq!(repo_id.as_deref(), Some("repo"));
3882    }
3883
3884    #[test]
3885    fn empty_token_files_are_ignored() {
3886        let tmp = tempfile::tempdir().unwrap();
3887        let path = tmp.path().join("serve.token");
3888        std::fs::write(&path, "").unwrap();
3889        assert_eq!(read_token_file(&path), None);
3890    }
3891
3892    #[cfg(unix)]
3893    #[test]
3894    fn serve_token_file_is_written_with_owner_only_permissions() {
3895        use std::os::unix::fs::PermissionsExt;
3896        let tmp = tempfile::tempdir().unwrap();
3897        let repo = tmp.path().to_path_buf();
3898        let path = write_serve_token(&repo, "secret").unwrap();
3899        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3900        assert_eq!(mode & 0o777, 0o600);
3901    }
3902
3903    #[cfg(unix)]
3904    #[test]
3905    fn serve_read_token_file_is_written_with_owner_only_permissions() {
3906        use std::os::unix::fs::PermissionsExt;
3907        let tmp = tempfile::tempdir().unwrap();
3908        let repo = tmp.path().to_path_buf();
3909        let path = write_serve_read_token(&repo, "read-secret").unwrap();
3910        assert!(path.ends_with(".kranz/serve.read.token"));
3911        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3912        assert_eq!(mode & 0o777, 0o600);
3913    }
3914
3915    #[test]
3916    fn operator_read_token_path_sits_beside_the_operator_token() {
3917        let global = Path::new("/home/op/.kranz/config.json");
3918        let address = std::net::SocketAddr::from(([127, 0, 0, 1], 4560));
3919        let mutation = operator_serve_token_path(global, address);
3920        let read = operator_serve_read_token_path(global, address);
3921        assert_eq!(read, mutation.with_extension("read.token"));
3922        assert!(read.to_string_lossy().ends_with(".read.token"));
3923    }
3924
3925    #[cfg(unix)]
3926    #[test]
3927    fn existing_token_permissions_are_hardened_before_replacement() {
3928        use std::os::unix::fs::PermissionsExt;
3929        let tmp = tempfile::tempdir().unwrap();
3930        let path = tmp.path().join("serve.token");
3931        std::fs::write(&path, "old-token").unwrap();
3932        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3933
3934        write_token_file(&path, "new-token").unwrap();
3935
3936        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new-token");
3937        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3938        assert_eq!(mode & 0o777, 0o600);
3939    }
3940
3941    #[tokio::test]
3942    async fn serve_token_file_is_removed_after_graceful_shutdown() {
3943        let tmp = tempfile::tempdir().unwrap();
3944        let repo = tmp.path().to_path_buf();
3945        let path = repo.join(".kranz").join("serve.token");
3946        let read_path = repo.join(".kranz").join("serve.read.token");
3947
3948        let host = std::sync::Arc::new(kranz_server::MissionHost::new(repo.clone()));
3949        let listener =
3950            kranz_server::bind_listener(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 0)
3951                .await
3952                .unwrap();
3953        serve_with_token_cleanup(
3954            &repo,
3955            host,
3956            listener,
3957            None,
3958            "tok".to_string(),
3959            "read-tok".to_string(),
3960            std::future::ready(()),
3961        )
3962        .await
3963        .unwrap();
3964
3965        assert!(!path.exists());
3966        assert!(!read_path.exists());
3967    }
3968
3969    fn dashboard_at(path: PathBuf) -> PathBuf {
3970        fs::create_dir_all(&path).unwrap();
3971        fs::write(path.join("index.html"), "<!doctype html>").unwrap();
3972        path
3973    }
3974
3975    fn inputs() -> DashboardResolutionInputs {
3976        DashboardResolutionInputs {
3977            embedded_available: false,
3978            ..Default::default()
3979        }
3980    }
3981
3982    #[test]
3983    fn dashboard_resolution_honors_explicit_path_verbatim() {
3984        let tmp = tempfile::tempdir().unwrap();
3985        let repo = tmp.path().join("repo");
3986        let explicit = tmp.path().join("missing-dashboard");
3987
3988        assert_eq!(
3989            resolve_dashboard_assets_from(&repo, Some(explicit.clone()), &inputs()),
3990            Some(DashboardAssets::Dir(explicit))
3991        );
3992    }
3993
3994    #[test]
3995    fn dashboard_resolution_env_precedes_repo_and_invalid_env_is_skipped() {
3996        let tmp = tempfile::tempdir().unwrap();
3997        let repo = tmp.path().join("repo");
3998        let repo_dist = dashboard_at(repo.join("apps").join("dashboard").join("dist"));
3999        let env_dist = dashboard_at(tmp.path().join("env-dist"));
4000
4001        let mut with_env = inputs();
4002        with_env.env_dist = Some(env_dist.clone());
4003        assert_eq!(
4004            resolve_dashboard_assets_from(&repo, None, &with_env),
4005            Some(DashboardAssets::Dir(env_dist))
4006        );
4007
4008        let mut with_invalid_env = inputs();
4009        with_invalid_env.env_dist = Some(tmp.path().join("missing-env-dist"));
4010        assert_eq!(
4011            resolve_dashboard_assets_from(&repo, None, &with_invalid_env),
4012            Some(DashboardAssets::Dir(repo_dist))
4013        );
4014    }
4015
4016    #[test]
4017    fn dashboard_resolution_finds_installed_asset_dirs() {
4018        let tmp = tempfile::tempdir().unwrap();
4019        let repo = tmp.path().join("repo");
4020        let exe = tmp.path().join("prefix").join("bin").join("kranz");
4021        let installed = dashboard_at(
4022            tmp.path()
4023                .join("prefix")
4024                .join("share")
4025                .join("kranz")
4026                .join("dashboard")
4027                .join("dist"),
4028        );
4029
4030        let mut inputs = inputs();
4031        inputs.exe = Some(exe);
4032        assert_eq!(
4033            resolve_dashboard_assets_from(&repo, None, &inputs),
4034            Some(DashboardAssets::Dir(installed))
4035        );
4036    }
4037
4038    #[test]
4039    fn dashboard_resolution_finds_checkout_used_to_build_installed_binary() {
4040        let tmp = tempfile::tempdir().unwrap();
4041        let repo = tmp.path().join("mission-repo");
4042        let checkout = tmp.path().join("kranz");
4043        let manifest_dir = checkout.join("crates").join("cli");
4044        let checkout_dist = dashboard_at(checkout.join("apps").join("dashboard").join("dist"));
4045
4046        let mut inputs = inputs();
4047        inputs.manifest_dir = Some(manifest_dir);
4048        inputs.exe = Some(tmp.path().join("cargo-home").join("bin").join("kranz"));
4049        assert_eq!(
4050            resolve_dashboard_assets_from(&repo, None, &inputs),
4051            Some(DashboardAssets::Dir(checkout_dist))
4052        );
4053    }
4054
4055    #[test]
4056    fn dashboard_resolution_falls_back_to_embedded_assets() {
4057        let tmp = tempfile::tempdir().unwrap();
4058        let repo = tmp.path().join("repo");
4059        let mut inputs = inputs();
4060        inputs.embedded_available = true;
4061
4062        assert_eq!(
4063            resolve_dashboard_assets_from(&repo, None, &inputs),
4064            Some(DashboardAssets::Embedded)
4065        );
4066    }
4067
4068    #[test]
4069    fn embedded_dashboard_bundle_contains_index() {
4070        assert!(
4071            crate::embedded_dashboard::EMBEDDED_DASHBOARD
4072                .iter()
4073                .any(|file| file.path == "index.html"),
4074            "embedded dashboard source: {}",
4075            crate::embedded_dashboard::EMBEDDED_DASHBOARD_SOURCE
4076        );
4077    }
4078
4079    #[test]
4080    fn serve_refuses_non_loopback_without_insecure_lan() {
4081        let bind: std::net::IpAddr = "0.0.0.0".parse().unwrap();
4082        let err = refuse_non_loopback_without_insecure_lan(bind, false).unwrap_err();
4083        let msg = err.to_string();
4084        assert!(
4085            msg.contains("refusing to bind") && msg.contains("--insecure-lan"),
4086            "unexpected error: {msg}"
4087        );
4088    }
4089
4090    #[test]
4091    fn serve_allows_non_loopback_with_insecure_lan() {
4092        let bind: std::net::IpAddr = "0.0.0.0".parse().unwrap();
4093        refuse_non_loopback_without_insecure_lan(bind, true).unwrap();
4094    }
4095
4096    #[test]
4097    fn serve_allows_loopback_without_insecure_lan() {
4098        let bind: std::net::IpAddr = "127.0.0.1".parse().unwrap();
4099        refuse_non_loopback_without_insecure_lan(bind, false).unwrap();
4100    }
4101
4102    #[test]
4103    fn read_auth_on_loopback_requires_read_token() {
4104        assert!(effective_require_read_token(true, true));
4105    }
4106
4107    #[test]
4108    fn read_auth_off_loopback_bind_does_not_require_read_token() {
4109        assert!(!effective_require_read_token(true, false));
4110    }
4111
4112    #[test]
4113    fn read_auth_non_loopback_always_requires_read_token() {
4114        assert!(effective_require_read_token(false, true));
4115        assert!(effective_require_read_token(false, false));
4116    }
4117
4118    // -----------------------------------------------------------------------
4119    // reconcile-on-terminal: `kranz run`'s loop heals a linked ticket
4120    // -----------------------------------------------------------------------
4121
4122    fn reconcile_turn(reply: &str) -> Vec<kranz_engine::backend::AgentEvent> {
4123        vec![
4124            kranz_engine::backend_mock::mock_text(reply),
4125            kranz_engine::backend_mock::mock_result_text(reply),
4126        ]
4127    }
4128
4129    fn reconcile_worker_pass() -> kranz_engine::backend_mock::MockScript {
4130        kranz_engine::backend_mock::MockScript::single_shot_json(&serde_json::json!({
4131            "result": "pass",
4132            "summary": "implemented and tested",
4133            "filesTouched": ["delivered.txt"],
4134            "testsAdded": [],
4135            "testEvidence": "all green",
4136            "commits": []
4137        }))
4138        .writes_file("delivered.txt", "delivered by the mock worker\n")
4139    }
4140
4141    fn reconcile_plan_json() -> serde_json::Value {
4142        serde_json::json!({
4143            "goal": "ship the demo",
4144            "validationContract": [],
4145            "milestones": [{
4146                "title": "M1",
4147                "features": [{
4148                    "title": "F1",
4149                    "spec": "build the thing",
4150                    "validationCriteria": ["it works"]
4151                }]
4152            }]
4153        })
4154    }
4155
4156    /// `run_mission_loop`'s post-run reconcile call must heal the linked
4157    /// ticket's stale `.status` sidecar once the mission reaches Complete —
4158    /// proving the f-1-3 wiring in `commands.rs` (not just the engine-level
4159    /// helper unit tests). Seeds the ticket at Running/Failed (a stale
4160    /// mismatch) so the assertion only passes if the reconcile call actually
4161    /// ran, not merely if the ticket happened to already be Done. Fails if
4162    /// the `reconcile_ticket_for_mission` call is removed from
4163    /// `run_mission_loop_with_backend`.
4164    #[tokio::test]
4165    async fn reconcile_on_terminal_after_cli_run_marks_ticket_done() {
4166        let tmp = tempfile::tempdir().unwrap();
4167        let repo = tmp.path().to_path_buf();
4168        let status = std::process::Command::new("git")
4169            .args(["init", "-b", "main"])
4170            .current_dir(&repo)
4171            .status()
4172            .unwrap();
4173        assert!(status.success());
4174        std::process::Command::new("git")
4175            .args(["config", "user.name", "test"])
4176            .current_dir(&repo)
4177            .status()
4178            .unwrap();
4179        std::process::Command::new("git")
4180            .args(["config", "user.email", "test@example.com"])
4181            .current_dir(&repo)
4182            .status()
4183            .unwrap();
4184        std::fs::write(repo.join("README.md"), "seed\n").unwrap();
4185        std::process::Command::new("git")
4186            .args(["add", "-A"])
4187            .current_dir(&repo)
4188            .status()
4189            .unwrap();
4190        std::process::Command::new("git")
4191            .args(["commit", "-m", "seed"])
4192            .current_dir(&repo)
4193            .status()
4194            .unwrap();
4195        let repo = std::fs::canonicalize(&repo).unwrap();
4196
4197        let judgement = serde_json::json!({
4198            "decision": "complete",
4199            "guidance": "",
4200            "summary": "worker did the job"
4201        });
4202        // Two orchestrator sessions: `drop(engine)` + `resume()` between the
4203        // plan-approval phase and the run phase means the run phase gets a
4204        // fresh orchestrator session, not a continuation of the first.
4205        let orch_setup = kranz_engine::backend_mock::MockScript::streaming(vec![
4206            kranz_engine::backend_mock::mock_init("orch-session"),
4207            kranz_engine::backend_mock::mock_result_text("seed-hi"),
4208        ])
4209        .responding(vec![
4210            reconcile_turn("let's scope the demo"),
4211            reconcile_turn(&reconcile_plan_json().to_string()),
4212        ]);
4213        let orch_run = kranz_engine::backend_mock::MockScript::streaming(vec![
4214            kranz_engine::backend_mock::mock_init("orch-session-2"),
4215            kranz_engine::backend_mock::mock_result_text("ack"),
4216        ])
4217        .responding(vec![
4218            reconcile_turn("ack"),
4219            reconcile_turn(
4220                &serde_json::json!({"action": "commit-as-is", "note": "worker delivered files"})
4221                    .to_string(),
4222            ),
4223            reconcile_turn(&judgement.to_string()),
4224            reconcile_turn("NONE"),
4225            // Padding: extra decision turns the run loop may make (report,
4226            // milestone-complete, second judgement). Unused responses are
4227            // harmless; under-provisioning parks the streaming mock forever.
4228            reconcile_turn("NONE"),
4229            reconcile_turn("NONE"),
4230            reconcile_turn("NONE"),
4231        ]);
4232        let backend: Arc<dyn AgentBackend> =
4233            Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
4234                orch_setup,
4235                // The run-phase auth probe (orchestrator.rs:2711) fires BEFORE
4236                // the run's first orchestrator turn in this resumed-approved
4237                // flow, so it consumes the second script. It must be a
4238                // single-shot — a streaming script parks the probe forever.
4239                kranz_engine::backend_mock::MockScript::single_shot("ok"),
4240                // Consumption order in this flow: planning-orch, probe, worker,
4241                // run-orchestrator (its session starts at the judgement turn).
4242                reconcile_worker_pass(),
4243                orch_run,
4244            ]));
4245
4246        let cfg = MissionConfig {
4247            skip_scrutiny: true,
4248            skip_functional: true,
4249            ..Default::default()
4250        };
4251        let mut engine =
4252            MissionEngine::create(Arc::clone(&backend), repo.clone(), "ship the demo", cfg)
4253                .unwrap();
4254        let mission_id = engine.mission_id().to_string();
4255        engine.planning_turn("ship the demo").await.unwrap();
4256        let request = engine.request_plan().await.unwrap();
4257        let plan = match request {
4258            PlanRequest::Ready(plan) => plan,
4259            PlanRequest::NotReady(text) => panic!("expected a ready plan, got: {text}"),
4260            PlanRequest::WrongPlan { reason } => {
4261                panic!("expected a ready plan, got a wrong-plan escalation: {reason}")
4262            }
4263        };
4264        engine.approve_plan(plan).unwrap();
4265        drop(engine);
4266
4267        // Link a ticket to this mission and stamp it Running/Failed — a
4268        // stale mismatch the drove-to-Complete run must heal.
4269        kranz_engine::ticket::Ticket::record_mission(&repo, "my-ticket", &mission_id).unwrap();
4270        kranz_engine::ticket::Ticket::write_state(
4271            &repo,
4272            "my-ticket",
4273            kranz_engine::ticket::TicketState::Failed,
4274            None,
4275        )
4276        .unwrap();
4277        assert_eq!(
4278            kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
4279            kranz_engine::ticket::TicketState::Failed
4280        );
4281
4282        let exit_code =
4283            run_mission_loop_with_backend(repo.clone(), mission_id, LockForce::No, false, backend)
4284                .await
4285                .unwrap();
4286        assert_eq!(exit_code, 0);
4287
4288        assert_eq!(
4289            kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
4290            kranz_engine::ticket::TicketState::Done,
4291            "run_mission_loop must reconcile the linked ticket to Done on Complete"
4292        );
4293    }
4294}