Skip to main content

truth_mirror/
cli.rs

1use std::path::PathBuf;
2
3use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Parser)]
7#[command(
8    name = "truth-mirror",
9    version,
10    about = "Truthfulness gate and reviewer harness for coding agents.",
11    propagate_version = true
12)]
13pub struct Cli {
14    #[arg(
15        long,
16        global = true,
17        env = "TRUTH_MIRROR_STATE_DIR",
18        default_value = ".truth",
19        value_name = "DIR"
20    )]
21    pub state_dir: PathBuf,
22
23    #[arg(long, global = true, env = "TRUTH_MIRROR_CONFIG", value_name = "FILE")]
24    pub config: Option<PathBuf>,
25
26    #[command(subcommand)]
27    pub command: Commands,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum Commands {
32    /// Install, uninstall, or preview agent hook shims.
33    InstallHooks(InstallHooksArgs),
34    /// Review a commit or the staged diff with a separate reviewer model.
35    Review(ReviewArgs),
36    /// Run deterministic repository gates.
37    Gate(GateArgs),
38    /// Reinject unresolved findings into an agent prompt surface.
39    Reinject(ReinjectArgs),
40    /// Inspect or update the dual ledger.
41    Ledger(LedgerArgs),
42    /// Inspect, render, approve, apply, or reject memory-skill candidates.
43    MemorySkill(MemorySkillArgs),
44    /// Run the post-commit reviewer loop.
45    Watch(WatchArgs),
46    /// Show hook wiring, review queue, run, ledger, and checkpoint status.
47    Status(StatusArgs),
48    /// Print or install the embedded truth-mirror skill document.
49    Skills(SkillsArgs),
50    /// Full teardown: remove all hooks, surfaces, and optional state dirs.
51    Uninstall(UninstallArgs),
52    /// Internal git hook dispatcher.
53    #[command(hide = true)]
54    HookDispatch(HookDispatchArgs),
55}
56
57#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
58#[serde(rename_all = "kebab-case")]
59pub enum Agent {
60    Claude,
61    Codex,
62    Pi,
63}
64
65#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
66#[serde(rename_all = "kebab-case")]
67pub enum ReviewerHarness {
68    Claude,
69    Codex,
70    Pi,
71    Gemini,
72    Opencode,
73    Custom,
74}
75
76#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
77#[serde(rename_all = "kebab-case")]
78pub enum ReviewScope {
79    Commit,
80    Staged,
81    Auto,
82    WorkingTree,
83    Branch,
84}
85
86#[derive(Debug, Args)]
87pub struct InstallHooksArgs {
88    #[arg(long)]
89    pub claude: bool,
90
91    #[arg(long)]
92    pub codex: bool,
93
94    #[arg(long)]
95    pub pi: bool,
96
97    #[arg(long)]
98    pub uninstall: bool,
99
100    #[arg(long)]
101    pub dry_run: bool,
102
103    /// Write a local-hook forwarder into a non-Husky core.hooksPath directory.
104    #[arg(long)]
105    pub inject_forwarder: bool,
106}
107
108/// Arguments for the standalone `uninstall` subcommand (full teardown).
109#[derive(Debug, Args)]
110pub struct UninstallArgs {
111    /// Print what would be done without making any changes.
112    #[arg(long)]
113    pub dry_run: bool,
114
115    /// Also delete the entire state dirs (`.truth/` and `.truth-mirror/`),
116    /// including the ledger and review queue. Without `--purge`, ledger data
117    /// is preserved.
118    #[arg(long)]
119    pub purge: bool,
120}
121
122#[derive(Debug, Args)]
123pub struct ReviewArgs {
124    #[command(subcommand)]
125    pub command: Option<ReviewCommand>,
126
127    #[arg(value_name = "SHA", conflicts_with = "staged")]
128    pub target: Option<String>,
129
130    #[arg(long)]
131    pub staged: bool,
132
133    #[arg(long, value_enum, value_name = "SCOPE", default_value_t = ReviewScope::Commit)]
134    pub scope: ReviewScope,
135
136    #[arg(long, value_name = "REF")]
137    pub base: Option<String>,
138
139    #[arg(long, value_enum, value_name = "AGENT")]
140    pub watched_agent: Option<Agent>,
141
142    #[arg(long, value_enum, value_name = "HARNESS")]
143    pub reviewer_harness: Option<ReviewerHarness>,
144
145    #[arg(long, value_name = "MODEL")]
146    pub watched_model: Option<String>,
147
148    #[arg(long, value_name = "MODEL")]
149    pub reviewer_model: Option<String>,
150
151    #[arg(long, value_enum, value_name = "EFFORT")]
152    pub reviewer_effort: Option<crate::config::Effort>,
153
154    #[arg(long)]
155    pub allow_same_model: bool,
156
157    #[arg(long)]
158    pub strict_two_pass: bool,
159
160    #[arg(long, value_enum, value_name = "HARNESS")]
161    pub arbiter_harness: Option<ReviewerHarness>,
162
163    #[arg(long, value_name = "MODEL")]
164    pub arbiter_model: Option<String>,
165
166    #[arg(long, value_enum, value_name = "EFFORT")]
167    pub arbiter_effort: Option<crate::config::Effort>,
168
169    /// Sic the adversarial reviewer in a loop until N lies or N fuckups.
170    #[arg(long)]
171    pub strict_goal: bool,
172
173    #[arg(long, value_name = "N")]
174    pub stop_after_lies: Option<u32>,
175
176    #[arg(long, value_name = "N")]
177    pub stop_after_fuckups: Option<u32>,
178
179    #[arg(long, value_name = "N")]
180    pub max_passes: Option<u32>,
181}
182
183#[derive(Debug, Subcommand)]
184pub enum ReviewCommand {
185    /// Show tracked review run status, or all known runs when no id is provided.
186    Status {
187        #[arg(value_name = "RUN_ID")]
188        run_id: Option<String>,
189    },
190    /// Show the latest completed/failed review run, or a specific run.
191    Result {
192        #[arg(value_name = "RUN_ID")]
193        run_id: Option<String>,
194    },
195    /// Cancel a review run and remove it from the review queue.
196    ///
197    /// Queued runs and running runs whose worker has died are cancelled directly.
198    /// Pass `--force` to kill a running run whose worker is still alive.
199    Cancel {
200        #[arg(value_name = "RUN_ID")]
201        run_id: String,
202
203        /// Kill the worker process of a still-running run before cancelling it.
204        #[arg(long)]
205        force: bool,
206    },
207}
208
209#[derive(Debug, Args)]
210#[command(group(
211    ArgGroup::new("gate_mode")
212        .required(true)
213        .args(["pre_push", "commit_msg", "pre_tool_use"])
214))]
215pub struct GateArgs {
216    #[arg(long, value_name = "RANGE", conflicts_with = "commit_msg")]
217    pub pre_push: Option<String>,
218
219    #[arg(long, value_name = "FILE", conflicts_with = "pre_push")]
220    pub commit_msg: Option<PathBuf>,
221
222    #[arg(long, value_name = "FILE", requires = "commit_msg")]
223    pub claim_file: Option<PathBuf>,
224
225    #[arg(long, value_name = "FILE", requires = "commit_msg")]
226    pub diff_file: Option<PathBuf>,
227
228    #[arg(long = "fake-marker", value_name = "TOKEN", requires = "commit_msg")]
229    pub fake_markers: Vec<String>,
230
231    /// Enforcement gate: block a mutating tool call when the ledger has unresolved
232    /// rejections beyond the configured threshold.
233    #[arg(long, conflicts_with_all = ["pre_push", "commit_msg"])]
234    pub pre_tool_use: bool,
235
236    /// The tool name being gated (for `--pre-tool-use`).
237    #[arg(long, value_name = "NAME", requires = "pre_tool_use")]
238    pub tool: Option<String>,
239}
240
241#[derive(Debug, Args)]
242pub struct ReinjectArgs {
243    #[arg(long, value_enum)]
244    pub agent: Agent,
245}
246
247#[derive(Debug, Args)]
248pub struct LedgerArgs {
249    #[command(subcommand)]
250    pub command: LedgerCommand,
251}
252
253#[derive(Debug, Subcommand)]
254pub enum LedgerCommand {
255    List,
256    Show {
257        #[arg(value_name = "SHA")]
258        sha: String,
259    },
260    Resolve {
261        #[arg(value_name = "SHA")]
262        sha: String,
263    },
264    Waive {
265        #[arg(value_name = "SHA")]
266        sha: String,
267
268        #[arg(long, value_name = "REASON")]
269        reason: String,
270    },
271    Stats,
272}
273
274#[derive(Debug, Args)]
275pub struct MemorySkillArgs {
276    #[command(subcommand)]
277    pub command: MemorySkillCommand,
278}
279
280#[derive(Debug, Subcommand)]
281pub enum MemorySkillCommand {
282    /// List all memory-skill candidates.
283    List,
284    /// Show details for one memory-skill candidate.
285    Show {
286        #[arg(value_name = "CANDIDATE_ID")]
287        candidate_id: String,
288    },
289    /// Render one memory-skill candidate as a skill document.
290    Render {
291        #[arg(value_name = "CANDIDATE_ID")]
292        candidate_id: String,
293    },
294    /// Approve a candidate, optionally applying it immediately.
295    Approve {
296        #[arg(value_name = "CANDIDATE_ID")]
297        candidate_id: String,
298
299        #[arg(long)]
300        apply: bool,
301    },
302    /// Apply a previously approved candidate.
303    Apply {
304        #[arg(value_name = "CANDIDATE_ID")]
305        candidate_id: String,
306    },
307    /// Reject a candidate with a human-readable reason.
308    Reject {
309        #[arg(value_name = "CANDIDATE_ID")]
310        candidate_id: String,
311
312        #[arg(long, value_name = "REASON")]
313        reason: String,
314    },
315    /// Supersede a candidate with a better replacement candidate.
316    Supersede {
317        #[arg(value_name = "CANDIDATE_ID")]
318        candidate_id: String,
319
320        #[arg(long, value_name = "REPLACEMENT_ID")]
321        replacement: String,
322
323        #[arg(long, value_name = "REASON")]
324        reason: String,
325    },
326    /// Dismiss a suggested advisory so it stops being reinjected.
327    DismissAdvisory {
328        #[arg(value_name = "ADVISORY_ID")]
329        advisory_id: String,
330    },
331}
332
333#[derive(Debug, Args)]
334pub struct WatchArgs {
335    #[arg(long, value_enum, value_name = "AGENT")]
336    pub watched_agent: Option<Agent>,
337
338    #[arg(long, value_enum, value_name = "HARNESS")]
339    pub reviewer_harness: Option<ReviewerHarness>,
340
341    #[arg(long, value_name = "MODEL")]
342    pub watched_model: Option<String>,
343
344    #[arg(long, value_name = "MODEL")]
345    pub reviewer_model: Option<String>,
346
347    #[arg(long, value_enum, value_name = "EFFORT")]
348    pub reviewer_effort: Option<crate::config::Effort>,
349
350    #[arg(long)]
351    pub allow_same_model: bool,
352
353    /// Drain the review queue exactly once and exit (deterministic; used in CI).
354    #[arg(long)]
355    pub once: bool,
356
357    /// Poll interval in seconds when running as a daemon (ignored with --once).
358    #[arg(long, value_name = "SECONDS", default_value_t = 5)]
359    pub poll_secs: u64,
360}
361
362#[derive(Debug, Args)]
363pub struct StatusArgs {}
364
365#[derive(Debug, Args)]
366pub struct SkillsArgs {
367    #[command(subcommand)]
368    pub command: SkillsCommand,
369}
370
371#[derive(Debug, Subcommand)]
372pub enum SkillsCommand {
373    /// Print the embedded skill document to stdout.
374    Echo,
375    /// Write the skill document to <dir>/truth-mirror/SKILL.md.
376    Install {
377        /// Target skills directory (defaults to `.agents/skills`).
378        #[arg(long, value_name = "PATH")]
379        dir: Option<PathBuf>,
380
381        /// Overwrite an existing skill file.
382        #[arg(long)]
383        force: bool,
384    },
385}
386
387#[derive(Debug, Args)]
388pub struct HookDispatchArgs {
389    #[arg(value_enum)]
390    pub hook: HookName,
391
392    #[arg(value_name = "ARGS")]
393    pub args: Vec<String>,
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
397#[value(rename_all = "kebab-case")]
398pub enum HookName {
399    CommitMsg,
400    PostCommit,
401    PrePush,
402}
403
404impl HookName {
405    pub fn as_str(self) -> &'static str {
406        match self {
407            Self::CommitMsg => "commit-msg",
408            Self::PostCommit => "post-commit",
409            Self::PrePush => "pre-push",
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use clap::CommandFactory;
417
418    use super::Cli;
419
420    #[test]
421    fn clap_contract_is_valid() {
422        Cli::command().debug_assert();
423    }
424}