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