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    /// Petition to resolve a rejection, or waive it (human-only).
43    Resolve(ResolveArgs),
44    /// Inspect, render, approve, apply, or reject memory-skill candidates.
45    MemorySkill(MemorySkillArgs),
46    /// Run the post-commit reviewer loop.
47    Watch(WatchArgs),
48    /// Idempotently ensure a queue-tied watcher is running (spawn if none).
49    EnsureWatcher(EnsureWatcherArgs),
50    /// Show hook wiring, review queue, run, ledger, and checkpoint status.
51    Status(StatusArgs),
52    /// List FLAG-class findings (process / evidence / provenance debt) for the human.
53    Debt(DebtArgs),
54    /// Print or install the embedded truth-mirror skill document.
55    Skills(SkillsArgs),
56    /// Full teardown: remove all hooks, surfaces, and optional state dirs.
57    Uninstall(UninstallArgs),
58    /// Internal git hook dispatcher.
59    #[command(hide = true)]
60    HookDispatch(HookDispatchArgs),
61}
62
63#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
64#[serde(rename_all = "kebab-case")]
65pub enum Agent {
66    Claude,
67    Codex,
68    Pi,
69    Grok,
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
73#[serde(rename_all = "kebab-case")]
74pub enum ReviewerHarness {
75    Claude,
76    Codex,
77    Pi,
78    Gemini,
79    Opencode,
80    Custom,
81}
82
83#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
84#[serde(rename_all = "kebab-case")]
85pub enum ReviewScope {
86    Commit,
87    Staged,
88    Auto,
89    WorkingTree,
90    Branch,
91}
92
93#[derive(Debug, Args)]
94pub struct InstallHooksArgs {
95    #[arg(long)]
96    pub claude: bool,
97
98    #[arg(long)]
99    pub codex: bool,
100
101    #[arg(long)]
102    pub pi: bool,
103
104    #[arg(long)]
105    pub grok: bool,
106
107    #[arg(long)]
108    pub uninstall: bool,
109
110    #[arg(long)]
111    pub dry_run: bool,
112
113    /// Write a local-hook forwarder into a non-Husky core.hooksPath directory.
114    #[arg(long)]
115    pub inject_forwarder: bool,
116}
117
118/// Arguments for the standalone `uninstall` subcommand (full teardown).
119#[derive(Debug, Args)]
120pub struct UninstallArgs {
121    /// Print what would be done without making any changes.
122    #[arg(long)]
123    pub dry_run: bool,
124
125    /// Also delete the entire state dirs (`.truth/` and `.truth-mirror/`),
126    /// including the ledger and review queue. Without `--purge`, ledger data
127    /// is preserved.
128    #[arg(long)]
129    pub purge: bool,
130}
131
132#[derive(Debug, Args)]
133pub struct ReviewArgs {
134    #[command(subcommand)]
135    pub command: Option<ReviewCommand>,
136
137    #[arg(value_name = "SHA", conflicts_with = "staged")]
138    pub target: Option<String>,
139
140    #[arg(long)]
141    pub staged: bool,
142
143    #[arg(long, value_enum, value_name = "SCOPE", default_value_t = ReviewScope::Commit)]
144    pub scope: ReviewScope,
145
146    #[arg(long, value_name = "REF")]
147    pub base: Option<String>,
148
149    #[arg(long, value_enum, value_name = "AGENT")]
150    pub watched_agent: Option<Agent>,
151
152    #[arg(long, value_enum, value_name = "HARNESS")]
153    pub reviewer_harness: Option<ReviewerHarness>,
154
155    #[arg(long, value_name = "MODEL")]
156    pub watched_model: Option<String>,
157
158    #[arg(long, value_name = "MODEL")]
159    pub reviewer_model: Option<String>,
160
161    #[arg(long, value_enum, value_name = "EFFORT")]
162    pub reviewer_effort: Option<crate::config::Effort>,
163
164    #[arg(long)]
165    pub allow_same_model: bool,
166
167    #[arg(long)]
168    pub strict_two_pass: bool,
169
170    #[arg(long, value_enum, value_name = "HARNESS")]
171    pub arbiter_harness: Option<ReviewerHarness>,
172
173    #[arg(long, value_name = "MODEL")]
174    pub arbiter_model: Option<String>,
175
176    #[arg(long, value_enum, value_name = "EFFORT")]
177    pub arbiter_effort: Option<crate::config::Effort>,
178
179    /// Sic the adversarial reviewer in a loop until N lies or N fuckups.
180    #[arg(long)]
181    pub strict_goal: bool,
182
183    #[arg(long, value_name = "N")]
184    pub stop_after_lies: Option<u32>,
185
186    #[arg(long, value_name = "N")]
187    pub stop_after_fuckups: Option<u32>,
188
189    #[arg(long, value_name = "N")]
190    pub max_passes: Option<u32>,
191}
192
193#[derive(Debug, Subcommand)]
194pub enum ReviewCommand {
195    /// Show tracked review run status, or all known runs when no id is provided.
196    Status {
197        #[arg(value_name = "RUN_ID")]
198        run_id: Option<String>,
199    },
200    /// Show the latest completed/failed review run, or a specific run.
201    Result {
202        #[arg(value_name = "RUN_ID")]
203        run_id: Option<String>,
204    },
205    /// Cancel a review run and remove it from the review queue.
206    ///
207    /// Queued runs and running runs whose worker has died are cancelled directly.
208    /// Pass `--force` to kill a running run whose worker is still alive.
209    Cancel {
210        #[arg(value_name = "RUN_ID")]
211        run_id: String,
212
213        /// Kill the worker process of a still-running run before cancelling it.
214        #[arg(long)]
215        force: bool,
216    },
217}
218
219#[derive(Debug, Args)]
220#[command(group(
221    ArgGroup::new("gate_mode")
222        .required(true)
223        .args(["pre_push", "commit_msg", "pre_tool_use"])
224))]
225pub struct GateArgs {
226    #[arg(long, value_name = "RANGE", conflicts_with = "commit_msg")]
227    pub pre_push: Option<String>,
228
229    #[arg(long, value_name = "FILE", conflicts_with = "pre_push")]
230    pub commit_msg: Option<PathBuf>,
231
232    #[arg(long, value_name = "FILE", requires = "commit_msg")]
233    pub claim_file: Option<PathBuf>,
234
235    #[arg(long, value_name = "FILE", requires = "commit_msg")]
236    pub diff_file: Option<PathBuf>,
237
238    #[arg(long = "fake-marker", value_name = "TOKEN", requires = "commit_msg")]
239    pub fake_markers: Vec<String>,
240
241    /// Enforcement gate: block a mutating tool call when the ledger has unresolved
242    /// rejections beyond the configured threshold.
243    #[arg(long, conflicts_with_all = ["pre_push", "commit_msg"])]
244    pub pre_tool_use: bool,
245
246    /// The tool name being gated (for `--pre-tool-use`).
247    #[arg(long, value_name = "NAME", requires = "pre_tool_use")]
248    pub tool: Option<String>,
249}
250
251#[derive(Debug, Args)]
252pub struct ReinjectArgs {
253    #[arg(long, value_enum)]
254    pub agent: Agent,
255}
256
257#[derive(Debug, Args)]
258pub struct LedgerArgs {
259    #[command(subcommand)]
260    pub command: LedgerCommand,
261}
262
263#[derive(Debug, Subcommand)]
264pub enum LedgerCommand {
265    List,
266    Show {
267        #[arg(value_name = "SHA")]
268        sha: String,
269    },
270    /// Print one rejection's full audit trail (every recorded transition).
271    History {
272        #[arg(value_name = "SHA")]
273        sha: String,
274    },
275    Stats,
276}
277
278/// Arguments for the `truth-mirror resolve` subcommand: agent-side petition flow
279/// and human-only waive path.
280#[derive(Debug, Args)]
281pub struct ResolveArgs {
282    /// Original rejection SHA to resolve.
283    #[arg(value_name = "REJECTION_SHA")]
284    pub rejection_sha: String,
285
286    /// The fix commit SHA that should materially address each finding. Enqueues a
287    /// RESOLUTION re-review that runs through the existing watcher. The rejection
288    /// is only transitioned to `resolved` when the reviewer returns `PASS` (the
289    /// petition's accept verdict); otherwise the attempt stays spent and
290    /// reinjection stays open.
291    #[arg(long, value_name = "FIX_SHA", conflicts_with = "waive")]
292    pub fixed_by: Option<String>,
293
294    /// Human-only waive lane: writes an auditable `waived` ledger event. Refuses
295    /// to run when stdin is not an interactive TTY so agents structurally cannot
296    /// invoke this path.
297    #[arg(long, conflicts_with = "fixed_by", requires = "reason")]
298    pub waive: bool,
299
300    /// Required text justification for the `--waive` lane.
301    #[arg(long, value_name = "REASON", requires = "waive")]
302    pub reason: Option<String>,
303}
304
305#[derive(Debug, Args)]
306pub struct DebtArgs {}
307
308#[derive(Debug, Args)]
309pub struct MemorySkillArgs {
310    #[command(subcommand)]
311    pub command: MemorySkillCommand,
312}
313
314#[derive(Debug, Subcommand)]
315pub enum MemorySkillCommand {
316    /// List all memory-skill candidates.
317    List,
318    /// Show details for one memory-skill candidate.
319    Show {
320        #[arg(value_name = "CANDIDATE_ID")]
321        candidate_id: String,
322    },
323    /// Render one memory-skill candidate as a skill document.
324    Render {
325        #[arg(value_name = "CANDIDATE_ID")]
326        candidate_id: String,
327    },
328    /// Approve a candidate, optionally applying it immediately.
329    Approve {
330        #[arg(value_name = "CANDIDATE_ID")]
331        candidate_id: String,
332
333        #[arg(long)]
334        apply: bool,
335    },
336    /// Apply a previously approved candidate.
337    Apply {
338        #[arg(value_name = "CANDIDATE_ID")]
339        candidate_id: String,
340    },
341    /// Reject a candidate with a human-readable reason.
342    Reject {
343        #[arg(value_name = "CANDIDATE_ID")]
344        candidate_id: String,
345
346        #[arg(long, value_name = "REASON")]
347        reason: String,
348    },
349    /// Supersede a candidate with a better replacement candidate.
350    Supersede {
351        #[arg(value_name = "CANDIDATE_ID")]
352        candidate_id: String,
353
354        #[arg(long, value_name = "REPLACEMENT_ID")]
355        replacement: String,
356
357        #[arg(long, value_name = "REASON")]
358        reason: String,
359    },
360    /// Dismiss a suggested advisory so it stops being reinjected.
361    DismissAdvisory {
362        #[arg(value_name = "ADVISORY_ID")]
363        advisory_id: String,
364    },
365}
366
367#[derive(Debug, Args)]
368pub struct WatchArgs {
369    #[arg(long, value_enum, value_name = "AGENT")]
370    pub watched_agent: Option<Agent>,
371
372    #[arg(long, value_enum, value_name = "HARNESS")]
373    pub reviewer_harness: Option<ReviewerHarness>,
374
375    #[arg(long, value_name = "MODEL")]
376    pub watched_model: Option<String>,
377
378    #[arg(long, value_name = "MODEL")]
379    pub reviewer_model: Option<String>,
380
381    #[arg(long, value_enum, value_name = "EFFORT")]
382    pub reviewer_effort: Option<crate::config::Effort>,
383
384    #[arg(long)]
385    pub allow_same_model: bool,
386
387    /// Drain the review queue exactly once and exit (deterministic; used in CI).
388    #[arg(long, conflicts_with = "until_empty")]
389    pub once: bool,
390
391    /// Drain until the queue stays empty through a full grace window, then exit 0.
392    /// Ties the watcher's lifetime to the queue instead of a manual daemon.
393    #[arg(long)]
394    pub until_empty: bool,
395
396    /// Seconds to linger on an empty queue picking up late arrivals before exiting
397    /// under `--until-empty`. Ignored without `--until-empty`.
398    #[arg(long, value_name = "SECONDS", default_value_t = 60)]
399    pub grace: u64,
400
401    /// Poll interval in seconds when running as a daemon (ignored with --once).
402    #[arg(long, value_name = "SECONDS", default_value_t = 5)]
403    pub poll_secs: u64,
404}
405
406#[derive(Debug, Args)]
407pub struct EnsureWatcherArgs {}
408
409#[derive(Debug, Args)]
410pub struct StatusArgs {}
411
412#[derive(Debug, Args)]
413pub struct SkillsArgs {
414    #[command(subcommand)]
415    pub command: SkillsCommand,
416}
417
418#[derive(Debug, Subcommand)]
419pub enum SkillsCommand {
420    /// Print the embedded skill document to stdout.
421    Echo,
422    /// Write the skill document to <dir>/truth-mirror/SKILL.md.
423    Install {
424        /// Target skills directory (defaults to `.agents/skills`).
425        #[arg(long, value_name = "PATH")]
426        dir: Option<PathBuf>,
427
428        /// Overwrite an existing skill file.
429        #[arg(long)]
430        force: bool,
431    },
432}
433
434#[derive(Debug, Args)]
435pub struct HookDispatchArgs {
436    #[arg(value_enum)]
437    pub hook: HookName,
438
439    #[arg(value_name = "ARGS")]
440    pub args: Vec<String>,
441}
442
443#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
444#[value(rename_all = "kebab-case")]
445pub enum HookName {
446    CommitMsg,
447    PostCommit,
448    PrePush,
449}
450
451impl HookName {
452    pub fn as_str(self) -> &'static str {
453        match self {
454            Self::CommitMsg => "commit-msg",
455            Self::PostCommit => "post-commit",
456            Self::PrePush => "pre-push",
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use clap::CommandFactory;
464
465    use super::Cli;
466
467    #[test]
468    fn clap_contract_is_valid() {
469        Cli::command().debug_assert();
470    }
471}