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