Skip to content

Commit 25e38ba

Browse files
ArghDA M0 complete: arghda doctor — honest backend availability probe (#37)
## What & why Closes the last M0 gap. `arghda doctor` probes every known backend by running `<command> --version` and reports which are actually runnable — **honestly** (runnable=false only when the binary genuinely cannot execute, never a guess), the same discipline as `check_file`'s graceful degradation. ## Changes - **`src/prover/mod.rs`** — `Probe { backend, kind, runnable, detail }` + `probe_tool()`; `Backend::command()` (the binary, which may differ from `name()` — `agda-cubical` runs `agda`) + a default `Backend::probe()`. - **`src/prover/{agda,idris2,smt}.rs`** — `command()` for each of the five backends. - **`src/main.rs`** — `doctor [--json]` subcommand over `KNOWN_BACKENDS`. - **`Justfile`** — `just doctor` repointed off the interim provision-script shim onto `cargo run -- doctor`. ## Verified (ran, not inferred) - `cargo test` → **108 passing, 0 failed** (+1 probe/command honesty unit). - `cargo clippy -D warnings` clean · `cargo fmt --check` clean · `check-spdx.sh` OK. - **Dogfooded** `arghda doctor` → **5/5 runnable**, real version lines: ``` [OK] agda assistant Agda version 2.6.3 [OK] agda-cubical assistant Agda version 2.6.3 [OK] idris2 assistant Idris 2, version 0.7.0 [OK] z3 solver Z3 version 4.8.12 - 64 bit [OK] cvc5 solver This is cvc5 version 1.2.0 … ``` `--json` emits the machine-readable form. ## Milestone status **Engine-spine core (M0–M5) complete.** Five backends across both interaction models, the Flying-Logic reasoning graph, and now a discovery command. Next: M6 (Lean4), M11 (studio JSON freeze + Groove manifest — `doctor` output feeds it). --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bbae97 commit 25e38ba

8 files changed

Lines changed: 152 additions & 14 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ milestones = [
4040
# "proven" on a real exit-0 (never embed a prover). See the plan doc.
4141
[route-to-flying-logic]
4242
milestones = [
43-
{ name = "M0: provision-provers.sh (pinned, honest --version verification) + `just provision/doctor` recipes", completion = 80 },
43+
{ name = "M0: provision-provers.sh (pinned, honest --version verification) + `just provision/doctor` recipes + `arghda doctor`", completion = 100 },
4444
{ name = "M1: Backend trait (Assistant|Solver kinds; Outcome/Verdict); agda.rs -> prover/agda.rs; graph/dag take &dyn Backend (pure refactor, 50 tests stay green)", completion = 100 },
4545
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 100 },
4646
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 100 },
@@ -60,6 +60,16 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# M0 COMPLETED 2026-07-01: the `arghda doctor` subcommand lands, closing the
64+
# last M0 gap. Probes every known backend by running `<command> --version`;
65+
# reports runnable/detail honestly (runnable=false only when the binary truly
66+
# can't execute, never a guess). `Backend::probe()` (default over a new
67+
# `command()` method) + `Probe` type; `command()` distinguishes binary from
68+
# name (agda-cubical runs `agda`). `arghda doctor [--json]` + `just doctor`
69+
# repointed off the interim provision-script shim. Dogfooded here: 5/5 runnable
70+
# (agda 2.6.3, idris2 0.7.0, z3 4.8.12, cvc5 1.2.0; agda-cubical shares agda).
71+
# 108 tests.
72+
#
6373
# M2 landed 2026-07-01: Cubical-Agda variant. Implemented as a SEPARATE backend
6474
# `AgdaCubical` (name "agda-cubical") rather than the plan's `Agda::cubical()`
6575
# constructor — a distinct type expresses the flag-profile ISLAND better (a
@@ -152,10 +162,10 @@ milestones = [
152162

153163
[critical-next-actions]
154164
actions = [
155-
"M1 + M2 + M3 + M4 + M5 DONE. Five backends across both interaction models: agda, agda-cubical, idris2 (Assistant); z3, cvc5 (Solver). Next:",
156-
"Finish M0: add the `arghda doctor` Rust subcommand — report which of the five backends are actually runnable (probe each binary), repoint `just doctor` at it, feed the Groove manifest. Closes the last M0 gap.",
157-
"Flying-Logic epic M6: Lean4 adapter (lake build + #print axioms audit → honest Unknown soundness when the audit is absent). Lean 4.13.0 provisioned.",
158-
"Flying-Logic epic M11: studio JSON freeze (reason/0.1) + Groove /.well-known/groove manifest (the one open v0.1 milestone).",
165+
"M0 + M1 + M2 + M3 + M4 + M5 DONE. Five backends across both interaction models (agda, agda-cubical, idris2 Assistant; z3, cvc5 Solver) + reasoning graph + doctor. Engine spine core complete. Next:",
166+
"Flying-Logic epic M6: Lean4 adapter (lake build + #print axioms audit → honest Unknown soundness when the audit is absent). Lean 4.13.0 provisioned. Sixth backend.",
167+
"Flying-Logic epic M11: studio JSON freeze (reason/0.1) + Groove /.well-known/groove manifest (the one open v0.1 milestone; `arghda doctor` output feeds it).",
168+
"Flying-Logic epic M7: Echidna dispatch seam (feature-gated client, same Outcome).",
159169
"M4 follow-on: .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
160170
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
161171
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning.",
@@ -167,9 +177,10 @@ last-result = "pass" # unknown | pass | warn | fail
167177
open-warnings = 0
168178
open-failures = 0
169179
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
170-
# post-M2: 79 lib + 28 integration = 107 tests pass. Dogfooded via real CLI on
171-
# all five backends: agda + agda-cubical + idris2 (Assistant), z3 + cvc5
172-
# (Solver). Dogfooded historically against echo-types + agda-unused.
180+
# post-M0-doctor: 80 lib + 28 integration = 108 tests pass. `arghda doctor`
181+
# dogfooded: 5/5 backends runnable. All five backends dogfooded via real CLI:
182+
# agda + agda-cubical + idris2 (Assistant), z3 + cvc5 (Solver). Dogfooded
183+
# historically against echo-types + agda-unused.
173184

174185
[ecosystem]
175186
part-of = ["arghda"]

Justfile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,9 @@ provision-heavy:
6666
bash scripts/provision-provers.sh --heavy
6767

6868
# Report which prover/solver backends are actually runnable, no installs.
69-
# (Interim: shells the provision script's --verify-only table until the
70-
# `arghda doctor` subcommand lands.)
69+
# The `arghda doctor` subcommand probes each binary (`<tool> --version`).
7170
doctor:
72-
bash scripts/provision-provers.sh --verify-only
71+
cargo run --quiet -- doctor
7372

7473
# RSR: the mandated machine-readable artefacts are present and well-formed.
7574
validate-rsr:

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub use event::{Event, EventKind};
3131
pub use graph::{build as build_graph, ImportGraph};
3232
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
3333
pub use prover::{
34-
default_backend, Agda, AgdaCubical, Backend, BackendKind, Idris2, Outcome, Smt, Verdict,
34+
default_backend, Agda, AgdaCubical, Backend, BackendKind, Idris2, Outcome, Probe, Smt, Verdict,
3535
};
3636
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
3737
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
use anyhow::{Context, Result};
55
use arghda_core::lint::LintContext;
66
use arghda_core::{
7-
build_dag, build_reason, event, run_lints, unused, watcher, Agda, AgdaCubical, Backend, Idris2,
8-
LintRule, RuleConfig, Smt, State, Workspace,
7+
build_dag, build_reason, event, run_lints, unused, watcher, Agda, AgdaCubical, Backend,
8+
BackendKind, Idris2, LintRule, RuleConfig, Smt, State, Workspace,
99
};
1010
use clap::{Parser, Subcommand};
1111
use std::path::{Path, PathBuf};
@@ -15,6 +15,9 @@ use walkdir::WalkDir;
1515
/// A lint pack (boxed trait objects).
1616
type RuleSet = Vec<Box<dyn LintRule>>;
1717

18+
/// Every backend `--backend` accepts and `doctor` probes.
19+
const KNOWN_BACKENDS: &[&str] = &["agda", "agda-cubical", "idris2", "z3", "cvc5"];
20+
1821
/// Resolve a `--backend` name to a backend instance. Agda is the default and
1922
/// v0.1 reference; Idris2 is the estate ABI language.
2023
fn backend_for(name: &str) -> Result<Box<dyn Backend>> {
@@ -156,6 +159,12 @@ enum Cmd {
156159
},
157160
/// Watch `inbox/` and `working/` in a workspace; print events.
158161
Watch { workspace: PathBuf },
162+
/// Probe which prover/solver backends are actually runnable here.
163+
Doctor {
164+
/// Emit the probe results as JSON.
165+
#[arg(long)]
166+
json: bool,
167+
},
159168
}
160169

161170
fn main() -> Result<()> {
@@ -237,6 +246,33 @@ fn main() -> Result<()> {
237246
invalidate,
238247
} => stale(&workspace, invalidate)?,
239248
Cmd::Watch { workspace } => watch(&workspace)?,
249+
Cmd::Doctor { json } => doctor(json)?,
250+
}
251+
Ok(())
252+
}
253+
254+
/// Probe every known backend and report which are actually runnable.
255+
fn doctor(json: bool) -> Result<()> {
256+
let probes = KNOWN_BACKENDS
257+
.iter()
258+
.map(|name| backend_for(name).map(|b| b.probe()))
259+
.collect::<Result<Vec<_>>>()?;
260+
261+
if json {
262+
let payload = serde_json::json!({ "version": "0.1", "backends": probes });
263+
println!("{}", serde_json::to_string_pretty(&payload)?);
264+
} else {
265+
println!("arghda doctor — backend availability");
266+
for p in &probes {
267+
let mark = if p.runnable { "OK" } else { "--" };
268+
let kind = match p.kind {
269+
BackendKind::Assistant => "assistant",
270+
BackendKind::Solver => "solver",
271+
};
272+
println!(" [{mark}] {:<13} {:<10} {}", p.backend, kind, p.detail);
273+
}
274+
let runnable = probes.iter().filter(|p| p.runnable).count();
275+
println!("\n{}/{} backends runnable", runnable, probes.len());
240276
}
241277
Ok(())
242278
}

src/prover/agda.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ impl Backend for Agda {
8181
fn lint_rules(&self, cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
8282
rules_with_config(cfg)
8383
}
84+
85+
fn command(&self) -> &'static str {
86+
"agda"
87+
}
8488
}
8589

8690
impl Backend for AgdaCubical {
@@ -125,6 +129,11 @@ impl Backend for AgdaCubical {
125129
fn lint_rules(&self, cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
126130
agda_cubical_rules(cfg)
127131
}
132+
133+
fn command(&self) -> &'static str {
134+
// Same binary as standard Agda; the mode is the `--cubical` flag.
135+
"agda"
136+
}
128137
}
129138

130139
/// Shared `agda` invocation for both flag profiles. `cubical` adds the
@@ -196,6 +205,20 @@ mod tests {
196205
assert_eq!(AgdaCubical.safe_mode(), Some("--cubical --safe"));
197206
}
198207

208+
#[test]
209+
fn command_and_probe_are_honest() {
210+
// Both Agda profiles run the same binary; the mode is a flag.
211+
assert_eq!(Agda.command(), "agda");
212+
assert_eq!(AgdaCubical.command(), "agda");
213+
// probe() reports availability honestly, whether or not agda is here.
214+
let p = Agda.probe();
215+
assert_eq!(p.backend, "agda");
216+
assert_eq!(p.kind, BackendKind::Assistant);
217+
assert!(!p.detail.is_empty());
218+
// agda-cubical probes the same binary, so agrees on runnability.
219+
assert_eq!(AgdaCubical.probe().runnable, p.runnable);
220+
}
221+
199222
#[test]
200223
fn check_file_is_honest_about_availability() {
201224
// We cannot guarantee agda is absent in every environment, so we

src/prover/idris2.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ impl Backend for Idris2 {
149149
fn lint_rules(&self, _cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
150150
Ok(vec![Box::new(Idris2EscapeHatch)])
151151
}
152+
153+
fn command(&self) -> &'static str {
154+
"idris2"
155+
}
152156
}
153157

154158
fn tail(s: &str, n: usize) -> String {

src/prover/mod.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::lint::{LintRule, RuleConfig};
2525
use anyhow::Result;
2626
use serde::Serialize;
2727
use std::path::{Path, PathBuf};
28+
use std::process::Command;
2829

2930
pub mod agda;
3031
pub mod idris2;
@@ -106,6 +107,55 @@ impl Outcome {
106107
}
107108
}
108109

110+
/// The result of probing whether a backend's tool is actually runnable —
111+
/// the honest input to `arghda doctor`.
112+
#[derive(Clone, Debug, Serialize)]
113+
pub struct Probe {
114+
/// The backend id (`agda`, `z3`, …).
115+
pub backend: String,
116+
/// Assistant or solver.
117+
pub kind: BackendKind,
118+
/// `true` iff the tool's binary could be executed.
119+
pub runnable: bool,
120+
/// The tool's version line, or why it is not runnable.
121+
pub detail: String,
122+
}
123+
124+
/// Probe a tool by running `<cmd> --version`. Reports `runnable` honestly:
125+
/// `false` only when the binary is absent / cannot be executed (never a
126+
/// guess). `detail` is the first output line, or the reason it failed.
127+
fn probe_tool(name: &str, kind: BackendKind, cmd: &str) -> Probe {
128+
match Command::new(cmd).arg("--version").output() {
129+
Ok(out) => {
130+
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
131+
combined.push_str(&String::from_utf8_lossy(&out.stderr));
132+
let first = combined.lines().next().unwrap_or("").trim().to_string();
133+
Probe {
134+
backend: name.to_string(),
135+
kind,
136+
runnable: true,
137+
detail: if first.is_empty() {
138+
format!("ran `{cmd} --version`")
139+
} else {
140+
first
141+
},
142+
}
143+
}
144+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Probe {
145+
backend: name.to_string(),
146+
kind,
147+
runnable: false,
148+
detail: format!("`{cmd}` not found on PATH"),
149+
},
150+
Err(e) => Probe {
151+
backend: name.to_string(),
152+
kind,
153+
runnable: false,
154+
detail: format!("`{cmd}` could not run: {e}"),
155+
},
156+
}
157+
}
158+
109159
/// An object-safe adapter over one external prover or solver.
110160
///
111161
/// Exactly the seams that used to be hardcoded to Agda live behind this
@@ -153,6 +203,17 @@ pub trait Backend: Send + Sync {
153203

154204
/// The per-language lint pack, parameterised by operator config.
155205
fn lint_rules(&self, cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>>;
206+
207+
/// The external binary this backend shells out to (`agda`, `z3`, …).
208+
/// May differ from [`Backend::name`] — e.g. `agda-cubical` runs `agda`.
209+
fn command(&self) -> &'static str;
210+
211+
/// Probe whether the tool is actually runnable (for `arghda doctor`).
212+
/// The default runs `<command> --version`; honest — `runnable` is false
213+
/// only when the binary genuinely cannot be executed.
214+
fn probe(&self) -> Probe {
215+
probe_tool(self.name(), self.kind(), self.command())
216+
}
156217
}
157218

158219
/// The default backend when none is selected: Agda, the v0.1 language.

src/prover/smt.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ impl Backend for Smt {
129129
// No source-level escape-hatch class for SMT-LIB2.
130130
Ok(Vec::new())
131131
}
132+
133+
fn command(&self) -> &'static str {
134+
self.cmd
135+
}
132136
}
133137

134138
/// Map solver output + exit status to a [`Verdict`]. Parses the actual

0 commit comments

Comments
 (0)