Skip to main content

kranz_cli/
ready.rs

1//! Repository readiness scanner for `kranz ready`.
2//!
3//! This is deliberately read-only and deterministic: it detects the signals a
4//! repo exposes for autonomous missions, but does not run test suites or mutate
5//! anything. It may run agent CLIs with `--version`, but never starts a model
6//! turn or a repository test suite. The output is a serializable scorecard so
7//! the dashboard can reuse the same shape later.
8
9use kranz_engine::cost;
10use serde::Serialize;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ReadyReport {
18    pub score: u8,
19    pub level: ReadyLevel,
20    pub highest_leverage_fix: String,
21    pub dimensions: Vec<ReadyDimension>,
22    /// AMM-compatible projection (derived view over the native dimensions;
23    /// crates/cli/src/amm.rs owns the mapping table).
24    pub amm: crate::amm::AmmProjection,
25    /// The second readiness axis — present only for repos with mission
26    /// history (omitted from JSON otherwise, never a vacuous zero).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub contract_health: Option<kranz_engine::contract_health::ContractHealth>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum ReadyLevel {
34    Ready,
35    Warmup,
36    ColdStart,
37}
38
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ReadyDimension {
42    pub name: &'static str,
43    pub score: u8,
44    pub weight: u8,
45    pub status: ReadyStatus,
46    pub evidence: String,
47    pub remedy: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum ReadyStatus {
53    Pass,
54    Warn,
55    Fail,
56}
57
58#[derive(Debug, Clone)]
59struct ValidationCommand {
60    command: String,
61    program: String,
62    available: bool,
63}
64
65pub fn assess(repo: &Path) -> ReadyReport {
66    let validation_commands = detect_validation_commands(repo);
67    let dimensions = vec![
68        planner_instructions(repo),
69        readme_docs(repo),
70        test_runner(repo, &validation_commands),
71        ci_config(repo),
72        merge_gates(repo),
73        gitignore_hygiene(repo),
74        backend_lanes(repo),
75        contract_prerequisites(&validation_commands),
76        clean_git_state(repo),
77        calibration_corpus(repo),
78        context_not_credentials(repo),
79    ];
80    let total: u16 = dimensions.iter().map(|d| d.score as u16).sum();
81    let score = total.min(100) as u8;
82    let level = if score >= 80 {
83        ReadyLevel::Ready
84    } else if score >= 50 {
85        ReadyLevel::Warmup
86    } else {
87        ReadyLevel::ColdStart
88    };
89    let highest_leverage_fix = dimensions
90        .iter()
91        .filter(|d| !d.remedy.is_empty())
92        .max_by_key(|d| d.weight.saturating_sub(d.score))
93        .map(|d| d.remedy.clone())
94        .unwrap_or_else(|| {
95            "No obvious fix: this repo exposes the main signals kranz needs.".into()
96        });
97    // The two-axis view: AMM projection over the native dimensions, and the
98    // contract/consent axis from mission event logs (absent without history).
99    let contract_health = kranz_engine::contract_health::compute_contract_health(repo)
100        .ok()
101        .flatten();
102    let amm = crate::amm::project(&dimensions, contract_health.as_ref());
103    ReadyReport {
104        score,
105        level,
106        highest_leverage_fix,
107        dimensions,
108        amm,
109        contract_health,
110    }
111}
112
113pub fn render(report: &ReadyReport) -> String {
114    let mut out = String::new();
115    out.push_str(&format!(
116        "kranz ready: {}/100 ({})\n",
117        report.score,
118        level_label(report.level)
119    ));
120    out.push_str(&format!(
121        "highest-leverage fix: {}\n\n",
122        report.highest_leverage_fix
123    ));
124    out.push_str("scorecard:\n");
125    for dim in &report.dimensions {
126        out.push_str(&format!(
127            "  [{:>2}/{:<2}] {} — {}\n",
128            dim.score, dim.weight, dim.name, dim.evidence
129        ));
130        if !dim.remedy.is_empty() && dim.status != ReadyStatus::Pass {
131            out.push_str(&format!("          remedy: {}\n", dim.remedy));
132        }
133    }
134    out.push_str(&format!(
135        "\namm: {} (projection v{})",
136        crate::amm::level_label(report.amm.level),
137        report.amm.mapping_version
138    ));
139    if !report.amm.missing_signals.is_empty() {
140        out.push_str(&format!(
141            " — missing for next level: {}",
142            report.amm.missing_signals.join(", ")
143        ));
144    }
145    out.push('\n');
146    if let Some(health) = &report.contract_health {
147        let lint = health
148            .lint_pass_rate
149            .map(|r| {
150                format!(
151                    "{:.0}% ({} of {} linted)",
152                    r * 100.0,
153                    health.lint_clean,
154                    health.lint_linted
155                )
156            })
157            .unwrap_or_else(|| "n/a (no linted missions)".to_string());
158        out.push_str(&format!(
159            "contract health ({} missions): lint pass {lint}, waivers/mission {:.2}, blocked [{}]\n",
160            health.missions,
161            health.waivers_per_mission.unwrap_or(0.0),
162            [
163                ("grant", health.blocked.grant),
164                ("scan", health.blocked.secret_scan),
165                ("contract-bug", health.blocked.contract_bug),
166                ("cap", health.blocked.fix_cycle_cap),
167                ("untrusted", health.blocked.untrusted_validator),
168                ("other", health.blocked.other),
169            ]
170            .into_iter()
171            .filter(|(_, n)| *n > 0)
172            .map(|(k, n)| format!("{k}:{n}"))
173            .collect::<Vec<_>>()
174            .join(" "),
175        ));
176    }
177    out
178}
179
180// ---------------------------------------------------------------------------
181// Org view (`kranz ready --all`) — the M8 catalog scored per repo
182// ---------------------------------------------------------------------------
183
184/// One catalog repo's row in the org report. Unavailable repos degrade with
185/// a reason, never silently (and are excluded from the L3+ numerator).
186#[derive(Debug, Clone, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct OrgRepoReport {
189    pub id: String,
190    pub name: String,
191    pub root: PathBuf,
192    pub score: Option<u8>,
193    pub level: Option<crate::amm::AmmLevel>,
194    pub missing_for_next_level: Vec<String>,
195    pub unavailable: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize)]
199#[serde(rename_all = "camelCase")]
200pub struct OrgReport {
201    pub repos: Vec<OrgRepoReport>,
202    /// The org headline: repos at AMM L3 or better.
203    pub at_l3_plus: usize,
204    pub total: usize,
205    /// Set when the catalog itself is missing or malformed (the repos vec is
206    /// empty then — the headline must explain rather than show 0-of-0).
207    pub note: Option<String>,
208}
209
210/// Score every repo in the host catalog at `config_path`
211/// (`~/.kranz/config.json` for the CLI; injected for tests).
212pub fn assess_all(config_path: &Path) -> OrgReport {
213    let host = match kranz_server::load_host_config(config_path) {
214        Ok(host) => host,
215        Err(error) => {
216            return OrgReport {
217                repos: Vec::new(),
218                at_l3_plus: 0,
219                total: 0,
220                note: Some(format!(
221                    "cannot read host catalog {}: {error:#}",
222                    config_path.display()
223                )),
224            }
225        }
226    };
227    if host.repos.is_empty() {
228        return OrgReport {
229            repos: Vec::new(),
230            at_l3_plus: 0,
231            total: 0,
232            note: Some(format!(
233                "no host catalog at {} — register repos with `kranz init --register`",
234                config_path.display()
235            )),
236        };
237    }
238
239    let mut repos = Vec::new();
240    for repo in &host.repos {
241        let name = repo.display_name.clone().unwrap_or_else(|| repo.id.clone());
242        let unavailable = if !repo.root.is_dir() {
243            Some("root missing".to_string())
244        } else if !repo.root.join(".git").exists() {
245            Some("not a git repository".to_string())
246        } else {
247            None
248        };
249        if let Some(reason) = unavailable {
250            repos.push(OrgRepoReport {
251                id: repo.id.clone(),
252                name,
253                root: repo.root.clone(),
254                score: None,
255                level: None,
256                missing_for_next_level: Vec::new(),
257                unavailable: Some(reason),
258            });
259            continue;
260        }
261        let report = assess(&repo.root);
262        repos.push(OrgRepoReport {
263            id: repo.id.clone(),
264            name,
265            root: repo.root.clone(),
266            score: Some(report.score),
267            level: Some(report.amm.level),
268            missing_for_next_level: report.amm.missing_signals.clone(),
269            unavailable: None,
270        });
271    }
272    let at_l3_plus = repos
273        .iter()
274        .filter(|r| {
275            r.level
276                .map(|l| l >= crate::amm::AmmLevel::L3)
277                .unwrap_or(false)
278        })
279        .count();
280    OrgReport {
281        total: repos.len(),
282        repos,
283        at_l3_plus,
284        note: None,
285    }
286}
287
288pub fn render_org(report: &OrgReport) -> String {
289    let mut out = String::new();
290    if let Some(note) = &report.note {
291        out.push_str(&format!("kranz ready --all: {note}\n"));
292        return out;
293    }
294    out.push_str(&format!(
295        "kranz ready --all: {} of {} repos at L3+\n",
296        report.at_l3_plus, report.total
297    ));
298    for repo in &report.repos {
299        if let Some(reason) = &repo.unavailable {
300            out.push_str(&format!(
301                "  {:<20} —   unavailable: {reason} ({})\n",
302                repo.name,
303                repo.root.display()
304            ));
305        } else {
306            out.push_str(&format!(
307                "  {:<20} {:<3} {:>3}/100  ({})\n",
308                repo.name,
309                crate::amm::level_label(repo.level.expect("available repos have a level")),
310                repo.score.unwrap_or(0),
311                repo.root.display()
312            ));
313            if !repo.missing_for_next_level.is_empty() {
314                out.push_str(&format!(
315                    "  {:<20}     missing for next level: {}\n",
316                    "",
317                    repo.missing_for_next_level.join(", ")
318                ));
319            }
320        }
321    }
322    out
323}
324
325fn level_label(level: ReadyLevel) -> &'static str {
326    match level {
327        ReadyLevel::Ready => "ready",
328        ReadyLevel::Warmup => "warmup",
329        ReadyLevel::ColdStart => "cold-start",
330    }
331}
332
333fn planner_instructions(repo: &Path) -> ReadyDimension {
334    let agents = repo.join("AGENTS.md").exists();
335    let claude = repo.join("CLAUDE.md").exists();
336    match (agents, claude) {
337        (true, true) => dim(
338            "planner instructions",
339            20,
340            20,
341            ReadyStatus::Pass,
342            "AGENTS.md and CLAUDE.md present",
343            "",
344        ),
345        (true, false) => dim(
346            "planner instructions",
347            20,
348            20,
349            ReadyStatus::Pass,
350            "AGENTS.md present",
351            "",
352        ),
353        (false, true) => dim(
354            "planner instructions",
355            16,
356            20,
357            ReadyStatus::Warn,
358            "CLAUDE.md present; AGENTS.md absent",
359            "add AGENTS.md with repo-specific build/test/change rules",
360        ),
361        (false, false) => dim(
362            "planner instructions",
363            0,
364            20,
365            ReadyStatus::Fail,
366            "no AGENTS.md or CLAUDE.md",
367            "add AGENTS.md so planners inherit repo-specific constraints",
368        ),
369    }
370}
371
372fn readme_docs(repo: &Path) -> ReadyDimension {
373    if repo.join("README.md").exists() || repo.join("readme.md").exists() {
374        dim("repo docs", 10, 10, ReadyStatus::Pass, "README present", "")
375    } else {
376        dim(
377            "repo docs",
378            0,
379            10,
380            ReadyStatus::Fail,
381            "README missing",
382            "add a README with setup, architecture, and common commands",
383        )
384    }
385}
386
387fn test_runner(repo: &Path, commands: &[ValidationCommand]) -> ReadyDimension {
388    if commands.is_empty() {
389        return dim(
390            "test runner",
391            0,
392            20,
393            ReadyStatus::Fail,
394            "no common runnable test/build command detected",
395            "add a single documented runnable test command kranz can bind to",
396        );
397    }
398    let evidence = commands
399        .iter()
400        .map(|c| c.command.as_str())
401        .collect::<Vec<_>>()
402        .join(", ");
403    let has_test_signal = has_tests_dir(repo)
404        || repo.join("Cargo.toml").exists()
405        || package_json_has_script(repo, "test");
406    if has_test_signal {
407        dim("test runner", 20, 20, ReadyStatus::Pass, evidence, "")
408    } else {
409        dim(
410            "test runner",
411            10,
412            20,
413            ReadyStatus::Warn,
414            evidence,
415            "add committed tests or a clearly named test target",
416        )
417    }
418}
419
420fn ci_config(repo: &Path) -> ReadyDimension {
421    let has_ci = repo.join(".github").join("workflows").exists()
422        || repo.join(".gitlab-ci.yml").exists()
423        || repo.join(".circleci").join("config.yml").exists();
424    if has_ci {
425        dim(
426            "CI config",
427            5,
428            5,
429            ReadyStatus::Pass,
430            "CI config detected",
431            "",
432        )
433    } else {
434        dim(
435            "CI config",
436            0,
437            5,
438            ReadyStatus::Fail,
439            "no common CI config detected",
440            "add CI that runs the same validation commands kranz will gate on",
441        )
442    }
443}
444
445/// `kranz merge` fails closed without a tracked, parseable gate suite on the
446/// base branch, so readiness validates the same artifact the merge will read:
447/// the file committed to the CURRENT branch's tree (never the working tree —
448/// an uncommitted suite would not gate the first merge).
449fn merge_gates(repo: &Path) -> ReadyDimension {
450    use kranz_engine::merge_gate::MERGE_GATES_PATH;
451    let committed = kranz_engine::git_ops::GitRepo::open(repo)
452        .and_then(|git| git.show_file("HEAD", MERGE_GATES_PATH));
453    let bytes = match committed {
454        Ok(Some(bytes)) => bytes,
455        Ok(None) => {
456            return dim(
457                "merge gates",
458                0,
459                5,
460                ReadyStatus::Fail,
461                format!("no tracked {MERGE_GATES_PATH} on the current branch"),
462                format!(
463                    "commit a {MERGE_GATES_PATH} gate suite so the first merge does not fail closed"
464                ),
465            )
466        }
467        Err(error) => {
468            return dim(
469                "merge gates",
470                0,
471                5,
472                ReadyStatus::Fail,
473                format!("cannot read committed {MERGE_GATES_PATH}: {error}"),
474                format!("commit a parseable {MERGE_GATES_PATH} on a committed branch"),
475            )
476        }
477    };
478    match kranz_engine::merge_gate::parse_gate_suite(&bytes) {
479        Ok(suite) => dim(
480            "merge gates",
481            5,
482            5,
483            ReadyStatus::Pass,
484            format!(
485                "committed {MERGE_GATES_PATH} defines {} gate(s)",
486                suite.gates.len()
487            ),
488            "",
489        ),
490        Err(detail) => dim(
491            "merge gates",
492            0,
493            5,
494            ReadyStatus::Fail,
495            detail,
496            format!("fix {MERGE_GATES_PATH} so merges can run real gates"),
497        ),
498    }
499}
500
501fn gitignore_hygiene(repo: &Path) -> ReadyDimension {
502    // Ask Git about concrete sentinel paths instead of parsing the root
503    // .gitignore as text. Kranz writes its canonical rules to the nested
504    // .kranz/.gitignore, and Git's own matcher also handles negation and any
505    // equivalent rule shapes correctly.
506    let sentinels = [
507        ".kranz/missions/m-ready/events.jsonl",
508        ".kranz/missions/m-ready/events.jsonl.lock",
509        ".kranz/missions/m-ready/state.json",
510        ".kranz/missions/m-ready/runs/run.jsonl",
511        ".kranz/missions/m-ready/control/0001.json",
512        ".kranz/config.json",
513        ".kranz/serve.token",
514        ".kranz/serve.read.token",
515        ".kranz/tickets/ready.status",
516    ];
517    let present = sentinels
518        .iter()
519        .filter(|path| git_ignores(repo, path))
520        .count();
521    if present == sentinels.len() {
522        dim(
523            "kranz runtime gitignore",
524            10,
525            10,
526            ReadyStatus::Pass,
527            "runtime files ignored",
528            "",
529        )
530    } else if present > 0 {
531        dim(
532            "kranz runtime gitignore",
533            ((present * 10) / sentinels.len()) as u8,
534            10,
535            ReadyStatus::Warn,
536            format!("{present}/{} runtime paths ignored", sentinels.len()),
537            "add missing .kranz runtime patterns to .gitignore",
538        )
539    } else {
540        dim(
541            "kranz runtime gitignore",
542            0,
543            10,
544            ReadyStatus::Fail,
545            ".kranz runtime files not ignored",
546            "ignore .kranz runtime logs, snapshots, runs, control files, tokens, and status files",
547        )
548    }
549}
550
551fn git_ignores(repo: &Path, relative_path: &str) -> bool {
552    // Verdict first. `check-ignore --verbose` exits 0 whenever ANY rule
553    // decides the path — including a negation such as `!config.json` that
554    // makes it committable — so only `--quiet` (success == actually ignored)
555    // is trusted for the verdict; `--verbose` below is source attribution
556    // only.
557    let ignored = Command::new("git")
558        .arg("-C")
559        .arg(repo)
560        // Readiness is a repository property: ignore an operator's global
561        // excludes here and reject non-repo rule sources below, so
562        // `.git/info/exclude` and an uncommitted `.gitignore` cannot make a
563        // repo look ready.
564        .args(["-c", "core.excludesFile=", "check-ignore", "--quiet", "--"])
565        .arg(relative_path)
566        .output()
567        .map(|output| output.status.success())
568        .unwrap_or(false);
569    if !ignored {
570        return false;
571    }
572    let output = Command::new("git")
573        .arg("-C")
574        .arg(repo)
575        .args([
576            "-c",
577            "core.excludesFile=",
578            "check-ignore",
579            "--verbose",
580            "--",
581        ])
582        .arg(relative_path)
583        .output();
584    let Ok(output) = output else { return false };
585    if !output.status.success() {
586        return false;
587    }
588    let Ok(verbose) = std::str::from_utf8(&output.stdout) else {
589        return false;
590    };
591    let Some(metadata) = verbose.split_once('\t').map(|(metadata, _)| metadata) else {
592        return false;
593    };
594    // `--verbose` is `<source>:<line>:<pattern>\t<path>`. Locate the numeric
595    // line segment rather than splitting on the first colon (Windows sources
596    // begin with a drive designator such as `C:`).
597    let source_end = metadata.char_indices().find_map(|(index, character)| {
598        if character != ':' {
599            return None;
600        }
601        let rest = &metadata[index + 1..];
602        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
603        (digits > 0 && rest.as_bytes().get(digits) == Some(&b':')).then_some(index)
604    });
605    let Some(source_end) = source_end else {
606        return false;
607    };
608    let source = Path::new(&metadata[..source_end]);
609    let relative_source = if source.is_absolute() {
610        match source.strip_prefix(repo) {
611            Ok(relative) => relative,
612            Err(_) => return false,
613        }
614    } else {
615        source
616    };
617    if relative_source.file_name().and_then(|name| name.to_str()) != Some(".gitignore") {
618        return false;
619    }
620    // Engine-canonical exception (gitignore-self-ignore ticket, option b):
621    // `.kranz/.gitignore` is materialized by the engine on init and ignores
622    // ITSELF by design, so it can never satisfy the committed-bytes rule
623    // below — yet it is the canonical runtime hygiene for every repo kranz
624    // initializes. Its rules travel with the tool, not the repo. Accept it
625    // only when the on-disk file carries the full canonical rule set —
626    // operator additions are fine; anything else falls through to the
627    // committed-bytes standard below.
628    if relative_source == Path::new(".kranz/.gitignore") {
629        let canonical = std::fs::read_to_string(repo.join(relative_source))
630            .map(|text| {
631                kranz_engine::paths::KRANZ_GITIGNORE_RULES
632                    .iter()
633                    .all(|rule| text.lines().any(|line| line.trim() == *rule))
634            })
635            .unwrap_or(false);
636        if canonical {
637            return true;
638        }
639    }
640    // The decisive rule must come from the committed .gitignore bytes, not an
641    // uncommitted edit to a file that merely also exists in HEAD.
642    let source_spec = relative_source
643        .components()
644        .map(|component| component.as_os_str().to_string_lossy())
645        .collect::<Vec<_>>()
646        .join("/");
647    let Ok(git) = kranz_engine::git_ops::GitRepo::open(repo) else {
648        return false;
649    };
650    let Ok(Some(_)) = git.show_file("HEAD", &source_spec) else {
651        return false;
652    };
653    if !git.has_normal_index_entry(&source_spec).unwrap_or(false) {
654        return false;
655    }
656    // Let Git compare through its normal text conversion rules. This rejects
657    // staged and unstaged rule changes while accepting a clean CRLF worktree
658    // backed by an LF blob under core.autocrlf / .gitattributes.
659    Command::new("git")
660        .arg("-C")
661        .arg(repo)
662        .args(["diff", "--quiet", "HEAD", "--"])
663        .arg(&source_spec)
664        .status()
665        .map(|status| status.success())
666        .unwrap_or(false)
667}
668
669fn contract_prerequisites(commands: &[ValidationCommand]) -> ReadyDimension {
670    if commands.is_empty() {
671        return dim(
672            "contract prerequisites",
673            0,
674            5,
675            ReadyStatus::Fail,
676            "no validation command to preflight",
677            "add a runnable validation command before the first mission",
678        );
679    }
680    let missing: Vec<&ValidationCommand> = commands.iter().filter(|c| !c.available).collect();
681    if missing.is_empty() {
682        dim(
683            "contract prerequisites",
684            5,
685            5,
686            ReadyStatus::Pass,
687            "validation command programs are on PATH",
688            "",
689        )
690    } else {
691        let names = missing
692            .iter()
693            .map(|c| c.program.as_str())
694            .collect::<Vec<_>>()
695            .join(", ");
696        dim(
697            "contract prerequisites",
698            2,
699            5,
700            ReadyStatus::Warn,
701            format!("missing program(s): {names}"),
702            "install the missing validation-command programs or document the setup",
703        )
704    }
705}
706
707fn backend_lanes(repo: &Path) -> ReadyDimension {
708    let cfg = match kranz_engine::config::load(repo) {
709        Ok(cfg) => cfg,
710        Err(error) => {
711            return dim(
712                "agent backend lanes",
713                0,
714                5,
715                ReadyStatus::Fail,
716                format!("cannot resolve mission config: {error}"),
717                "fix mission config before probing agent backends",
718            )
719        }
720    };
721    backend_lanes_for_config(&cfg)
722}
723
724fn backend_lanes_for_config(cfg: &kranz_engine::types::MissionConfig) -> ReadyDimension {
725    let required = [
726        cfg.orchestrator.backend.as_deref().unwrap_or("claude"),
727        cfg.worker.backend.as_deref().unwrap_or("claude"),
728        cfg.validator_scrutiny
729            .backend
730            .as_deref()
731            .unwrap_or("claude"),
732        cfg.validator_functional
733            .backend
734            .as_deref()
735            .unwrap_or("claude"),
736    ];
737    // Probe only backends some role actually selects: a `--version` probe
738    // still execs a real binary, so an unrelated broken (or hung) CLI on
739    // PATH must not slow down or fail readiness for a repo that never
740    // dispatches to it. `None` marks a lane that was skipped, not probed.
741    let probes: Vec<(&str, Option<bool>)> = ["claude", "codex", "droid", "kimi", "cursor"]
742        .iter()
743        .map(|&backend| {
744            if !required.contains(&backend) {
745                return (backend, None);
746            }
747            let available = match backend {
748                "claude" => kranz_engine::backend_claude::discover_claude_binary(
749                    cfg.claude_binary.as_deref(),
750                )
751                .is_ok(),
752                "codex" => kranz_engine::backend_codex::discover_codex_binary(None).is_ok(),
753                "droid" => kranz_engine::backend_droid::discover_droid_binary(None).is_ok(),
754                "kimi" => kranz_engine::backend_kimi::discover_kimi_binary(None).is_ok(),
755                _ => kranz_engine::backend_cursor::discover_cursor_binary(None).is_ok(),
756            };
757            (backend, Some(available))
758        })
759        .collect();
760    let missing_required: Vec<&str> = required
761        .iter()
762        .copied()
763        .filter(|required| {
764            !probes
765                .iter()
766                .any(|(backend, available)| backend == required && *available == Some(true))
767        })
768        .collect();
769    let evidence = format!(
770        "{}; executable/version probes only (authentication is proven by the first live mission)",
771        probes
772            .iter()
773            .map(|(backend, available)| format!(
774                "{backend}={}",
775                match available {
776                    Some(true) => "ready",
777                    Some(false) => "unavailable",
778                    None => "skipped (no role selects it)",
779                }
780            ))
781            .collect::<Vec<_>>()
782            .join(", ")
783    );
784    if missing_required.is_empty() {
785        dim("agent backend lanes", 5, 5, ReadyStatus::Pass, evidence, "")
786    } else {
787        let mut missing = missing_required;
788        missing.sort_unstable();
789        missing.dedup();
790        dim(
791            "agent backend lanes",
792            0,
793            5,
794            ReadyStatus::Fail,
795            evidence,
796            format!(
797                "install or configure the selected backend CLI(s): {}",
798                missing.join(", ")
799            ),
800        )
801    }
802}
803
804fn clean_git_state(repo: &Path) -> ReadyDimension {
805    let output = Command::new("git")
806        .arg("-C")
807        .arg(repo)
808        .arg("status")
809        .arg("--porcelain")
810        .output();
811    match output {
812        Ok(out) if out.status.success() && out.stdout.is_empty() => dim(
813            "clean git state",
814            10,
815            10,
816            ReadyStatus::Pass,
817            "worktree clean",
818            "",
819        ),
820        Ok(out) if out.status.success() => dim(
821            "clean git state",
822            0,
823            10,
824            ReadyStatus::Fail,
825            "worktree has uncommitted changes",
826            "commit or shelve local changes before autonomous missions",
827        ),
828        _ => dim(
829            "clean git state",
830            0,
831            10,
832            ReadyStatus::Fail,
833            "git status unavailable",
834            "initialize a git repo and make sure git is available",
835        ),
836    }
837}
838
839/// Context-rich WITHOUT credential-rich (the ready-context-vs-credentials
840/// ticket): agents should get context from git artifacts — a knowledge
841/// vault, docs, and a secret-scan gate keeping the tree clean — not from
842/// live secrets. Four signals: knowledge vault present, merge-gate suite
843/// committed (the scan gate lives there), no tracked .env-shaped file, and
844/// worker-readable setup docs.
845fn context_not_credentials(repo: &Path) -> ReadyDimension {
846    let mut signals: Vec<(&str, bool, &str)> = Vec::new();
847
848    let vault = repo.join("docs/knowledge").is_dir();
849    signals.push((
850        "knowledge vault",
851        vault,
852        "create docs/knowledge/ (or an equivalent committed vault) so agent context lives in git",
853    ));
854
855    let gate = repo.join(".kranz/merge-gates.json").is_file();
856    signals.push((
857        "secret-scan gate",
858        gate,
859        "commit a .kranz/merge-gates.json suite so the secret-scan gate runs before every merge",
860    ));
861
862    // Tracked .env-shaped files: context-as-secret is the anti-signal. Read
863    // the index (never the working tree) so untracked local .env files don't
864    // count against the repo.
865    let tracked_env: Vec<String> = Command::new("git")
866        .arg("-C")
867        .arg(repo)
868        .args(["ls-files"])
869        .output()
870        .map(|out| {
871            String::from_utf8_lossy(&out.stdout)
872                .lines()
873                .filter(|line| {
874                    let name = line.rsplit('/').next().unwrap_or(line);
875                    name == ".env" || name.starts_with(".env.") || name.ends_with(".env")
876                })
877                .map(str::to_string)
878                .collect()
879        })
880        .unwrap_or_default();
881    let no_env = tracked_env.is_empty();
882    signals.push((
883        "no tracked .env",
884        no_env,
885        "untrack env files and move secrets to the credential store — tracked .env: see evidence",
886    ));
887
888    let docs = repo.join("README.md").is_file();
889    signals.push((
890        "worker-readable docs",
891        docs,
892        "document setup/build/test in the README so workers don't need tribal (or credential-gated) knowledge",
893    ));
894
895    let passed = signals.iter().filter(|(_, ok, _)| *ok).count();
896    let score = ((passed * 10) / signals.len()) as u8;
897    let status = match passed {
898        n if n == signals.len() => ReadyStatus::Pass,
899        0 | 1 => ReadyStatus::Fail,
900        _ => ReadyStatus::Warn,
901    };
902    let evidence = if passed == signals.len() {
903        "context lives in git, not credentials".to_string()
904    } else {
905        let mut parts: Vec<String> = signals
906            .iter()
907            .filter(|(_, ok, _)| !ok)
908            .map(|(name, _, _)| format!("{name} missing"))
909            .collect();
910        if !tracked_env.is_empty() {
911            parts.push(format!("tracked env files: {}", tracked_env.join(", ")));
912        }
913        parts.join("; ")
914    };
915    let remedy = signals
916        .iter()
917        .filter(|(_, ok, _)| !ok)
918        .map(|(_, _, hint)| *hint)
919        .collect::<Vec<_>>()
920        .join("; ");
921    dim(
922        "context over credentials",
923        score,
924        10,
925        status,
926        evidence,
927        remedy,
928    )
929}
930
931fn calibration_corpus(repo: &Path) -> ReadyDimension {
932    let missions = cost::calibrate(repo).missions_used;
933    match missions {
934        0 => dim(
935            "calibration corpus",
936            0,
937            10,
938            ReadyStatus::Warn,
939            "0 completed missions; estimates use built-in defaults",
940            "run a small first mission to seed cost calibration",
941        ),
942        1 | 2 => dim(
943            "calibration corpus",
944            7,
945            10,
946            ReadyStatus::Warn,
947            format!("{missions} completed mission(s)"),
948            "complete a few more representative missions to tighten estimates",
949        ),
950        _ => dim(
951            "calibration corpus",
952            10,
953            10,
954            ReadyStatus::Pass,
955            format!("{missions} completed mission(s)"),
956            "",
957        ),
958    }
959}
960
961fn detect_validation_commands(repo: &Path) -> Vec<ValidationCommand> {
962    let mut commands = Vec::new();
963    if repo.join("Cargo.toml").exists() {
964        commands.push(validation_command("cargo test --workspace"));
965    }
966    if package_json_has_script(repo, "test") {
967        commands.push(validation_command("npm test"));
968    }
969    if repo.join("pytest.ini").exists()
970        || repo.join("pyproject.toml").exists()
971        || repo.join("tests").exists()
972    {
973        commands.push(validation_command("pytest"));
974    }
975    commands
976}
977
978fn validation_command(command: &str) -> ValidationCommand {
979    let program = command
980        .split_whitespace()
981        .next()
982        .unwrap_or(command)
983        .to_string();
984    let available = program_available(&program);
985    ValidationCommand {
986        command: command.to_string(),
987        program,
988        available,
989    }
990}
991
992fn package_json_has_script(repo: &Path, script: &str) -> bool {
993    let Ok(text) = fs::read_to_string(repo.join("package.json")) else {
994        return false;
995    };
996    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
997        return false;
998    };
999    value
1000        .get("scripts")
1001        .and_then(|scripts| scripts.get(script))
1002        .and_then(|script| script.as_str())
1003        .is_some_and(|script| !script.trim().is_empty())
1004}
1005
1006fn has_tests_dir(repo: &Path) -> bool {
1007    ["tests", "test", "__tests__"]
1008        .iter()
1009        .any(|name| repo.join(name).exists())
1010}
1011
1012fn program_available(program: &str) -> bool {
1013    let path = Path::new(program);
1014    if path.components().count() > 1 {
1015        return path.exists();
1016    }
1017    let names = executable_names(program);
1018    std::env::var_os("PATH")
1019        .map(|paths| {
1020            std::env::split_paths(&paths).any(|dir| {
1021                names.iter().any(|name| {
1022                    let candidate: PathBuf = dir.join(name);
1023                    candidate.is_file()
1024                })
1025            })
1026        })
1027        .unwrap_or(false)
1028}
1029
1030/// The bare name on unix; the bare name plus PATHEXT variants on Windows,
1031/// where `cargo` itself is never a file — `cargo.exe`/`cargo.cmd` is.
1032/// Without this every PATH probe reports unavailable on Windows and
1033/// `kranz ready` calls every program missing.
1034fn executable_names(program: &str) -> Vec<String> {
1035    if !cfg!(windows) {
1036        return vec![program.to_string()];
1037    }
1038    let mut names = vec![program.to_string()];
1039    let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
1040    for ext in pathext.split(';').filter(|e| !e.is_empty()) {
1041        names.push(format!("{program}{ext}"));
1042        // Case-insensitive volumes make `cargo.EXE` find `cargo.exe`, but a
1043        // case-sensitive one needs the lowercase spelling too.
1044        names.push(format!("{program}{}", ext.to_ascii_lowercase()));
1045    }
1046    names
1047}
1048
1049fn dim(
1050    name: &'static str,
1051    score: u8,
1052    weight: u8,
1053    status: ReadyStatus,
1054    evidence: impl Into<String>,
1055    remedy: impl Into<String>,
1056) -> ReadyDimension {
1057    ReadyDimension {
1058        name,
1059        score,
1060        weight,
1061        status,
1062        evidence: evidence.into(),
1063        remedy: remedy.into(),
1064    }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use tempfile::TempDir;
1071
1072    fn write(path: &Path, text: &str) {
1073        if let Some(parent) = path.parent() {
1074            fs::create_dir_all(parent).unwrap();
1075        }
1076        fs::write(path, text).unwrap();
1077    }
1078
1079    fn git(dir: &Path, args: &[&str]) {
1080        let output = Command::new("git")
1081            .arg("-C")
1082            .arg(dir)
1083            .args(args)
1084            .output()
1085            .unwrap();
1086        assert!(
1087            output.status.success(),
1088            "git {args:?}: {}",
1089            String::from_utf8_lossy(&output.stderr)
1090        );
1091    }
1092
1093    fn commit_all(dir: &Path) {
1094        git(dir, &["config", "user.name", "ready-test"]);
1095        git(dir, &["config", "user.email", "ready@example.com"]);
1096        git(dir, &["add", "-A"]);
1097        git(dir, &["commit", "-m", "fixture"]);
1098    }
1099
1100    #[test]
1101    fn bare_repo_scores_low_and_names_test_runner_first() {
1102        let dir = TempDir::new().unwrap();
1103        let report = assess(dir.path());
1104
1105        assert!(report.score < 50, "{report:?}");
1106        assert!(
1107            report.highest_leverage_fix.contains("test command"),
1108            "{report:?}"
1109        );
1110        assert!(
1111            report
1112                .dimensions
1113                .iter()
1114                .any(|d| d.evidence.contains("0 completed missions")),
1115            "cold-start calibration warning present: {report:?}"
1116        );
1117    }
1118
1119    #[test]
1120    fn this_shape_scores_high_with_core_signals() {
1121        let dir = TempDir::new().unwrap();
1122        write(&dir.path().join("AGENTS.md"), "rules");
1123        write(&dir.path().join("README.md"), "readme");
1124        write(&dir.path().join("Cargo.toml"), "[workspace]\n");
1125        write(&dir.path().join(".github/workflows/ci.yml"), "name: ci\n");
1126        Command::new("git")
1127            .arg("-C")
1128            .arg(dir.path())
1129            .arg("init")
1130            .output()
1131            .unwrap();
1132        write(
1133            &dir.path().join(".kranz/.gitignore"),
1134            "missions/*/events.jsonl\n\
1135             missions/*/events.jsonl.lock\n\
1136             missions/*/state.json\n\
1137             missions/*/runs/\n\
1138             missions/*/control/\n\
1139             config.json\n\
1140             serve.token\n\
1141             serve.read.token\n\
1142             tickets/*.status\n",
1143        );
1144        write(
1145            &dir.path().join(".kranz/merge-gates.json"),
1146            r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1147        );
1148        commit_all(dir.path());
1149
1150        let report = assess(dir.path());
1151
1152        assert!(report.score >= 80, "{report:?}");
1153        assert_eq!(report.level, ReadyLevel::Ready);
1154        let hygiene = report
1155            .dimensions
1156            .iter()
1157            .find(|dimension| dimension.name == "kranz runtime gitignore")
1158            .unwrap();
1159        assert_eq!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1160        let gates = report
1161            .dimensions
1162            .iter()
1163            .find(|dimension| dimension.name == "merge gates")
1164            .unwrap();
1165        assert_eq!(gates.status, ReadyStatus::Pass, "{gates:?}");
1166    }
1167
1168    #[test]
1169    fn gitignore_hygiene_rejects_tracked_runtime_files() {
1170        let dir = TempDir::new().unwrap();
1171        git(dir.path(), &["init"]);
1172        write(
1173            &dir.path().join(".kranz/.gitignore"),
1174            "missions/\nconfig.json\nserve.token\nserve.read.token\ntickets/*.status\n",
1175        );
1176        write(
1177            &dir.path().join(".kranz/serve.token"),
1178            "must-not-be-tracked",
1179        );
1180        git(dir.path(), &["config", "user.name", "ready-test"]);
1181        git(dir.path(), &["config", "user.email", "ready@example.com"]);
1182        git(dir.path(), &["add", ".kranz/.gitignore"]);
1183        git(dir.path(), &["add", "-f", ".kranz/serve.token"]);
1184        git(dir.path(), &["commit", "-m", "tracked token fixture"]);
1185
1186        let hygiene = gitignore_hygiene(dir.path());
1187        assert_ne!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1188        assert!(hygiene.evidence.contains("8/9"), "{hygiene:?}");
1189    }
1190
1191    #[test]
1192    fn gitignore_hygiene_ignores_global_and_git_info_excludes() {
1193        let dir = TempDir::new().unwrap();
1194        git(dir.path(), &["init"]);
1195        let global = dir.path().join("global-excludes");
1196        write(&global, ".kranz/\n");
1197        git(
1198            dir.path(),
1199            &["config", "core.excludesFile", global.to_str().unwrap()],
1200        );
1201        write(&dir.path().join(".git/info/exclude"), ".kranz/\n");
1202
1203        let hygiene = gitignore_hygiene(dir.path());
1204        assert_eq!(hygiene.status, ReadyStatus::Fail, "{hygiene:?}");
1205    }
1206
1207    #[test]
1208    fn gitignore_hygiene_rejects_negated_ignore_rules() {
1209        let dir = TempDir::new().unwrap();
1210        git(dir.path(), &["init"]);
1211        // `*` ignores the runtime files, but the trailing negation makes
1212        // config.json committable without -f — the probe must not count it
1213        // as ignored (check-ignore --verbose exits 0 for negated matches).
1214        write(
1215            &dir.path().join(".kranz/.gitignore"),
1216            "*\n!.gitignore\n!config.json\n",
1217        );
1218        commit_all(dir.path());
1219
1220        assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1221        let hygiene = gitignore_hygiene(dir.path());
1222        assert_ne!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1223        assert!(hygiene.evidence.contains("8/9"), "{hygiene:?}");
1224    }
1225
1226    #[test]
1227    fn gitignore_hygiene_rejects_staged_but_uncommitted_gitignore() {
1228        let dir = TempDir::new().unwrap();
1229        git(dir.path(), &["init"]);
1230        write(&dir.path().join("README.md"), "readme");
1231        commit_all(dir.path());
1232        // Staged but never committed: the rules are not yet a repository
1233        // property, so the dimension must not pass.
1234        write(
1235            &dir.path().join(".kranz/.gitignore"),
1236            "missions/*/events.jsonl\n\
1237             missions/*/events.jsonl.lock\n\
1238             missions/*/state.json\n\
1239             missions/*/runs/\n\
1240             missions/*/control/\n\
1241             config.json\n\
1242             serve.token\n\
1243             tickets/*.status\n",
1244        );
1245        git(dir.path(), &["add", ".kranz/.gitignore"]);
1246
1247        assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1248        let hygiene = gitignore_hygiene(dir.path());
1249        assert_eq!(hygiene.status, ReadyStatus::Fail, "{hygiene:?}");
1250    }
1251
1252    #[test]
1253    fn gitignore_hygiene_rejects_uncommitted_rules_in_a_tracked_gitignore() {
1254        let dir = TempDir::new().unwrap();
1255        git(dir.path(), &["init"]);
1256        write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1257        commit_all(dir.path());
1258        write(
1259            &dir.path().join(".kranz/.gitignore"),
1260            "missions/\nconfig.json\n",
1261        );
1262
1263        let git_verdict = Command::new("git")
1264            .arg("-C")
1265            .arg(dir.path())
1266            .args(["check-ignore", "--quiet", "--", ".kranz/config.json"])
1267            .status()
1268            .unwrap();
1269        assert!(
1270            git_verdict.success(),
1271            "fixture's working-tree rule must ignore config.json"
1272        );
1273        assert!(
1274            !git_ignores(dir.path(), ".kranz/config.json"),
1275            "readiness must evaluate the committed .gitignore bytes"
1276        );
1277    }
1278
1279    #[test]
1280    fn gitignore_hygiene_accepts_engine_materialized_kranz_gitignore() {
1281        let dir = TempDir::new().unwrap();
1282        git(dir.path(), &["init"]);
1283        write(&dir.path().join("README.md"), "readme");
1284        commit_all(dir.path());
1285        // The engine-canonical file: the full canonical rule set, UNTRACKED
1286        // (it ignores itself by design) — exactly what kranz init
1287        // materializes. Its rules travel with the tool, so the probe credits
1288        // it without a commit.
1289        let mut text = "# kranz engine bookkeeping — never part of mission commits\n".to_string();
1290        for rule in kranz_engine::paths::KRANZ_GITIGNORE_RULES {
1291            text.push_str(rule);
1292            text.push('\n');
1293        }
1294        write(&dir.path().join(".kranz/.gitignore"), &text);
1295
1296        assert!(git_ignores(dir.path(), ".kranz/config.json"));
1297        let hygiene = gitignore_hygiene(dir.path());
1298        assert_eq!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1299    }
1300
1301    #[test]
1302    fn gitignore_hygiene_rejects_gutted_kranz_gitignore_when_uncommitted() {
1303        // A hand-rolled .kranz/.gitignore without the full canonical set is
1304        // NOT the engine artifact — it falls back to the committed-bytes
1305        // standard and loses (untracked).
1306        let dir = TempDir::new().unwrap();
1307        git(dir.path(), &["init"]);
1308        write(&dir.path().join("README.md"), "readme");
1309        commit_all(dir.path());
1310        write(
1311            &dir.path().join(".kranz/.gitignore"),
1312            "missions/\nconfig.json\nserve.token\ntickets/*.status\n",
1313        );
1314
1315        assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1316    }
1317
1318    #[test]
1319    fn gitignore_hygiene_rejects_rules_hidden_by_index_flags() {
1320        for flag in ["--assume-unchanged", "--skip-worktree"] {
1321            let dir = TempDir::new().unwrap();
1322            git(dir.path(), &["init"]);
1323            write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1324            commit_all(dir.path());
1325            git(dir.path(), &["update-index", flag, ".kranz/.gitignore"]);
1326            write(
1327                &dir.path().join(".kranz/.gitignore"),
1328                "missions/\nconfig.json\n",
1329            );
1330
1331            let git_verdict = Command::new("git")
1332                .arg("-C")
1333                .arg(dir.path())
1334                .args(["check-ignore", "--quiet", "--", ".kranz/config.json"])
1335                .status()
1336                .unwrap();
1337            assert!(git_verdict.success(), "fixture failed for {flag}");
1338            assert!(
1339                !git_ignores(dir.path(), ".kranz/config.json"),
1340                "readiness trusted a .gitignore hidden by {flag}"
1341            );
1342        }
1343    }
1344
1345    #[test]
1346    fn gitignore_hygiene_rejects_rules_hidden_by_fsmonitor_valid() {
1347        let dir = TempDir::new().unwrap();
1348        git(dir.path(), &["init"]);
1349        write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1350        commit_all(dir.path());
1351        git(dir.path(), &["config", "core.fsmonitor", "true"]);
1352        write(
1353            &dir.path().join(".kranz/.gitignore"),
1354            "missions/\nconfig.json\n",
1355        );
1356        git(
1357            dir.path(),
1358            &["update-index", "--fsmonitor-valid", ".kranz/.gitignore"],
1359        );
1360
1361        // Whether `update-index --fsmonitor-valid` sticks is git-version
1362        // dependent (ubuntu-latest's git leaves the entry `H`); when this
1363        // host's git can't establish the fixture, skip rather than fail —
1364        // the protection is still exercised wherever git supports the bit
1365        // (same pattern as the sandbox-exec skips in backend_claude_test).
1366        let index_tag = Command::new("git")
1367            .arg("-C")
1368            .arg(dir.path())
1369            .args(["ls-files", "-f", "--", ".kranz/.gitignore"])
1370            .output()
1371            .unwrap();
1372        if String::from_utf8_lossy(&index_tag.stdout) != "h .kranz/.gitignore\n" {
1373            eprintln!("this git does not honor --fsmonitor-valid; skipping");
1374            return;
1375        }
1376        let hidden_diff = Command::new("git")
1377            .arg("-C")
1378            .arg(dir.path())
1379            .args(["diff", "--quiet", "HEAD", "--", ".kranz/.gitignore"])
1380            .status()
1381            .unwrap();
1382        if !hidden_diff.success() {
1383            eprintln!("this git does not hide fsmonitor-valid worktree bytes; skipping");
1384            return;
1385        }
1386        assert!(
1387            !git_ignores(dir.path(), ".kranz/config.json"),
1388            "readiness trusted a .gitignore hidden by fsmonitor-valid"
1389        );
1390    }
1391
1392    #[test]
1393    fn merge_gates_dimension_requires_a_tracked_suite() {
1394        let dir = TempDir::new().unwrap();
1395        git(dir.path(), &["init"]);
1396        write(&dir.path().join("README.md"), "readme");
1397        commit_all(dir.path());
1398        // A working-tree-only suite must not count: merges read the file
1399        // from the committed tree, never the working tree.
1400        write(
1401            &dir.path().join(".kranz/merge-gates.json"),
1402            r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1403        );
1404
1405        let gates = merge_gates(dir.path());
1406        assert_eq!(gates.status, ReadyStatus::Fail, "{gates:?}");
1407        assert!(gates.evidence.contains("no tracked"), "{gates:?}");
1408        assert!(
1409            gates.remedy.contains(".kranz/merge-gates.json"),
1410            "{gates:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn merge_gates_dimension_rejects_unparseable_suites() {
1416        let dir = TempDir::new().unwrap();
1417        git(dir.path(), &["init"]);
1418        write(&dir.path().join(".kranz/merge-gates.json"), "not json");
1419        commit_all(dir.path());
1420
1421        let gates = merge_gates(dir.path());
1422        assert_eq!(gates.status, ReadyStatus::Fail, "{gates:?}");
1423        assert!(
1424            gates.evidence.contains("invalid .kranz/merge-gates.json"),
1425            "{gates:?}"
1426        );
1427        assert!(!gates.remedy.is_empty(), "{gates:?}");
1428    }
1429
1430    #[test]
1431    fn merge_gates_dimension_passes_on_a_committed_valid_suite() {
1432        let dir = TempDir::new().unwrap();
1433        git(dir.path(), &["init"]);
1434        write(
1435            &dir.path().join(".kranz/merge-gates.json"),
1436            r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1437        );
1438        commit_all(dir.path());
1439
1440        let gates = merge_gates(dir.path());
1441        assert_eq!(gates.status, ReadyStatus::Pass, "{gates:?}");
1442        assert!(gates.evidence.contains("1 gate"), "{gates:?}");
1443    }
1444
1445    #[test]
1446    fn backend_lanes_probe_only_backends_some_role_selects() {
1447        // The default config dispatches every role to claude, so the codex
1448        // and droid lanes must be skipped instead of exec'd.
1449        let cfg = kranz_engine::types::MissionConfig::default();
1450        let lanes = backend_lanes_for_config(&cfg);
1451        assert!(!lanes.evidence.contains("claude=skipped"), "{lanes:?}");
1452        assert!(lanes.evidence.contains("codex=skipped"), "{lanes:?}");
1453        assert!(lanes.evidence.contains("droid=skipped"), "{lanes:?}");
1454        assert!(lanes.evidence.contains("kimi=skipped"), "{lanes:?}");
1455    }
1456
1457    // -----------------------------------------------------------------------
1458    // Org view (kranz ready --all)
1459    // -----------------------------------------------------------------------
1460
1461    fn host_config(dir: &Path, repos: serde_json::Value) -> PathBuf {
1462        let path = dir.join("config.json");
1463        // serde_json, never string interpolation: Windows roots contain
1464        // backslashes, which become invalid JSON escapes when pasted raw.
1465        write(
1466            &path,
1467            &serde_json::json!({ "host": { "repos": repos } }).to_string(),
1468        );
1469        path
1470    }
1471
1472    fn repo_entry(id: &str, root: &Path) -> serde_json::Value {
1473        serde_json::json!({ "id": id, "root": root })
1474    }
1475
1476    fn git_repo(dir: &Path) {
1477        git(dir, &["init"]);
1478        write(&dir.join("README.md"), "readme");
1479        commit_all(dir);
1480    }
1481
1482    #[test]
1483    fn context_dimension_passes_when_context_lives_in_git() {
1484        let dir = TempDir::new().unwrap();
1485        git(dir.path(), &["init"]);
1486        write(&dir.path().join("README.md"), "readme");
1487        write(&dir.path().join("docs/knowledge/index.md"), "# vault");
1488        write(
1489            &dir.path().join(".kranz/merge-gates.json"),
1490            r#"{"gates":[{"command":"cargo test"}]}"#,
1491        );
1492        commit_all(dir.path());
1493
1494        let dimension = context_not_credentials(dir.path());
1495        assert_eq!(dimension.status, ReadyStatus::Pass, "{dimension:?}");
1496        assert_eq!(dimension.score, 10);
1497    }
1498
1499    #[test]
1500    fn context_dimension_flags_tracked_env_and_missing_vault() {
1501        let dir = TempDir::new().unwrap();
1502        git(dir.path(), &["init"]);
1503        write(&dir.path().join("README.md"), "readme");
1504        write(&dir.path().join(".env.production"), "SECRET=hunter2");
1505        write(
1506            &dir.path().join(".kranz/merge-gates.json"),
1507            r#"{"gates":[{"command":"cargo test"}]}"#,
1508        );
1509        commit_all(dir.path());
1510
1511        let dimension = context_not_credentials(dir.path());
1512        assert_eq!(dimension.status, ReadyStatus::Warn, "{dimension:?}");
1513        assert!(
1514            dimension.evidence.contains("knowledge vault missing"),
1515            "vault gap named: {dimension:?}"
1516        );
1517        assert!(
1518            dimension.evidence.contains(".env.production"),
1519            "the tracked env file is named in evidence: {dimension:?}"
1520        );
1521        assert!(dimension.remedy.contains("untrack"), "{dimension:?}");
1522    }
1523
1524    #[test]
1525    fn context_dimension_fails_when_nothing_is_in_place() {
1526        let dir = TempDir::new().unwrap();
1527        git(dir.path(), &["init"]);
1528        write(&dir.path().join("x.txt"), "x");
1529        commit_all(dir.path());
1530
1531        let dimension = context_not_credentials(dir.path());
1532        assert_eq!(dimension.status, ReadyStatus::Fail, "{dimension:?}");
1533        assert_eq!(dimension.score, 2, "only the env-absence signal scores");
1534    }
1535
1536    #[test]
1537    fn org_view_scores_catalog_and_counts_l3_plus() {
1538        let dir = TempDir::new().unwrap();
1539        let strong = dir.path().join("strong");
1540        let weak = dir.path().join("weak");
1541        fs::create_dir_all(&strong).unwrap();
1542        fs::create_dir_all(&weak).unwrap();
1543        // A fully-shaped repo (mirrors this_shape_scores_high_with_core_signals).
1544        write(&strong.join("AGENTS.md"), "rules");
1545        write(&strong.join("README.md"), "readme");
1546        write(&strong.join("Cargo.toml"), "[workspace]\n");
1547        write(&strong.join(".github/workflows/ci.yml"), "name: ci\n");
1548        git(&strong, &["init"]);
1549        write(
1550            &strong.join(".kranz/.gitignore"),
1551            "missions/*/events.jsonl\nmissions/*/events.jsonl.lock\nmissions/*/state.json\nmissions/*/runs/\nmissions/*/control/\nconfig.json\nserve.token\nserve.read.token\ntickets/*.status\n",
1552        );
1553        write(
1554            &strong.join(".kranz/merge-gates.json"),
1555            r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1556        );
1557        commit_all(&strong);
1558        git_repo(&weak);
1559
1560        let config = host_config(
1561            dir.path(),
1562            serde_json::json!([repo_entry("strong", &strong), repo_entry("weak", &weak)]),
1563        );
1564        let report = assess_all(&config);
1565
1566        assert_eq!(report.total, 2);
1567        assert!(report.note.is_none());
1568        let strong_row = report.repos.iter().find(|r| r.id == "strong").unwrap();
1569        let weak_row = report.repos.iter().find(|r| r.id == "weak").unwrap();
1570        assert!(
1571            strong_row.level.unwrap() >= crate::amm::AmmLevel::L3,
1572            "{strong_row:?}"
1573        );
1574        assert_eq!(
1575            weak_row.level,
1576            Some(crate::amm::AmmLevel::L1),
1577            "{weak_row:?}"
1578        );
1579        assert_eq!(report.at_l3_plus, 1, "{report:?}");
1580        // The headline renders the N-of-M line.
1581        let text = render_org(&report);
1582        assert!(text.contains("1 of 2 repos at L3+"), "{text}");
1583    }
1584
1585    #[test]
1586    fn org_view_degrades_unavailable_repos_with_a_reason() {
1587        let dir = TempDir::new().unwrap();
1588        let present = dir.path().join("present");
1589        fs::create_dir_all(&present).unwrap();
1590        git_repo(&present);
1591        let missing = dir.path().join("missing");
1592        let not_git = dir.path().join("not-git");
1593        fs::create_dir_all(&not_git).unwrap();
1594
1595        let mut notgit = repo_entry("notgit", &not_git);
1596        notgit["displayName"] = serde_json::Value::String("Not Git".to_string());
1597        let config = host_config(
1598            dir.path(),
1599            serde_json::json!([
1600                repo_entry("present", &present),
1601                repo_entry("missing", &missing),
1602                notgit,
1603            ]),
1604        );
1605        let report = assess_all(&config);
1606
1607        assert_eq!(report.total, 3);
1608        let missing_row = report.repos.iter().find(|r| r.id == "missing").unwrap();
1609        assert_eq!(missing_row.unavailable.as_deref(), Some("root missing"));
1610        assert!(missing_row.level.is_none());
1611        let notgit_row = report.repos.iter().find(|r| r.id == "notgit").unwrap();
1612        assert_eq!(
1613            notgit_row.unavailable.as_deref(),
1614            Some("not a git repository")
1615        );
1616        assert_eq!(notgit_row.name, "Not Git");
1617        // Unavailable repos never enter the L3+ numerator.
1618        assert_eq!(report.at_l3_plus, 0, "{report:?}");
1619        let text = render_org(&report);
1620        assert!(text.contains("unavailable: root missing"), "{text}");
1621    }
1622
1623    #[test]
1624    fn org_view_explains_empty_and_malformed_catalogs() {
1625        let dir = TempDir::new().unwrap();
1626        // Missing file → empty catalog with guidance, not a 0-of-0 shrug.
1627        let missing = assess_all(&dir.path().join("nope.json"));
1628        assert_eq!(missing.total, 0);
1629        assert!(missing.note.as_deref().unwrap().contains("no host catalog"));
1630        assert!(render_org(&missing).contains("kranz init --register"));
1631
1632        // Malformed → explicit error, never silent.
1633        let bad = dir.path().join("bad.json");
1634        write(&bad, "{not json");
1635        let malformed = assess_all(&bad);
1636        assert_eq!(malformed.total, 0);
1637        assert!(malformed
1638            .note
1639            .as_deref()
1640            .unwrap()
1641            .contains("cannot read host catalog"));
1642    }
1643}