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