Skip to main content

blotter/
cli.rs

1use crate::{ArtifactType, Disposition, Impact};
2use clap::{Args, Parser, Subcommand, ValueEnum};
3use std::path::PathBuf;
4
5#[derive(Debug, Parser)]
6#[command(
7    name = "blotter",
8    version,
9    about,
10    long_about = None,
11    arg_required_else_help = true,
12    subcommand_required = true,
13    rename_all = "kebab-case"
14)]
15pub struct Cli {
16    #[arg(
17        long,
18        global = true,
19        value_name = "PATH",
20        help = "Override log-file discovery for this invocation"
21    )]
22    pub file: Option<PathBuf>,
23
24    #[arg(
25        long,
26        global = true,
27        help = "Indent the JSON envelope for human reading"
28    )]
29    pub pretty: bool,
30
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35const ADD_AFTER_HELP: &str = "\
36Admission: file a cut only when at least one of these holds.
37  transferable   another agent or user would plausibly hit the same thing
38  consequential  cost real time, produced wrong work, forced retries, or stopped the task
39  recurring      the same underlying friction has happened before
40  misleading     the error pointed at the wrong cause, hid it, or blamed the wrong file
41  systemic       a missing affordance, a doc gap, a brittle interface, a reusable footgun
42Skip one-off execution slips unless they recur: typos, shell quoting, a bad first guess,
43a patch that missed on stale context, a linter correctly rejecting code you just wrote,
44a malformed fixture you authored. Impact records consequence, not admission.";
45
46#[derive(Debug, Subcommand)]
47pub enum Command {
48    #[command(alias = "log", after_help = ADD_AFTER_HELP)]
49    Add(AddArgs),
50    #[command(alias = "idea")]
51    Dogear(DogearArgs),
52    Promote(PromoteArgs),
53    List(ListArgs),
54    Export(ExportArgs),
55    Triage(TriageArgs),
56    Verify(VerifyArgs),
57    Retrospect(RetrospectArgs),
58    Digest(DigestArgs),
59    Sweep(SweepArgs),
60    Resolve(ResolveArgs),
61    Archive(ArchiveArgs),
62    Schema {
63        #[arg(
64            value_enum,
65            default_value_t = SchemaTarget::All,
66            help = "Contract section to emit"
67        )]
68        target: SchemaTarget,
69    },
70    Doctor(DoctorArgs),
71}
72
73#[derive(Debug, Args)]
74pub struct AddArgs {
75    #[arg(
76        value_name = "TEXT",
77        help = "Cut text; omit or use - to read from stdin"
78    )]
79    pub text: Option<String>,
80    #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
81    pub agent: Option<String>,
82    #[arg(long = "tag", help = "Tag the cut; repeatable")]
83    pub tags: Vec<String>,
84    #[arg(
85        long,
86        value_enum,
87        default_value_t = Impact::Low,
88        help = "Consequence, not admission. blocking: could not proceed; material: lost real time or produced wrong work; low: limited cost, still worth filing"
89    )]
90    pub impact: Impact,
91    #[arg(
92        long,
93        allow_hyphen_values = true,
94        value_name = "TEXT",
95        help = "Command that failed"
96    )]
97    pub cmd: Option<String>,
98    #[arg(long = "exit", value_name = "N", help = "Command exit status")]
99    pub exit_code: Option<i32>,
100    #[arg(
101        long,
102        value_name = "PATH",
103        help = "Read regular UTF-8 PATH (<=1 MiB); best-effort redaction; store sanitized value <=4096 bytes"
104    )]
105    pub stderr_file: Option<PathBuf>,
106    #[arg(
107        long,
108        allow_hyphen_values = true,
109        value_name = "TEXT",
110        help = "Additional evidence or filing note"
111    )]
112    pub evidence: Option<String>,
113    #[arg(long, help = "Validate without appending")]
114    pub dry_run: bool,
115}
116
117#[derive(Debug, Args)]
118pub struct DogearArgs {
119    #[arg(
120        value_name = "TEXT",
121        help = "Dogear text; omit or use - to read from stdin"
122    )]
123    pub text: Option<String>,
124    #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
125    pub agent: Option<String>,
126    #[arg(long = "tag", help = "Tag the dogear; repeatable")]
127    pub tags: Vec<String>,
128    #[arg(
129        long,
130        allow_hyphen_values = true,
131        value_name = "TEXT",
132        help = "Optional research note; leading hyphens accepted"
133    )]
134    pub evidence: Option<String>,
135    #[arg(long, help = "Validate without appending")]
136    pub dry_run: bool,
137}
138
139#[derive(Debug, Args)]
140pub struct PromoteArgs {
141    #[arg(
142        long = "source",
143        value_name = "ID",
144        required = true,
145        help = "Cut ID or unique prefix this artifact came from; repeatable"
146    )]
147    pub sources: Vec<String>,
148    #[arg(
149        long = "artifact-type",
150        value_enum,
151        required = true,
152        help = "What the experiences became"
153    )]
154    pub artifact_type: ArtifactType,
155    #[arg(
156        long = "artifact-ref",
157        value_name = "REF",
158        required = true,
159        allow_hyphen_values = true,
160        help = "Where the artifact lives; best-effort redaction"
161    )]
162    pub artifact_ref: String,
163    #[arg(
164        long,
165        allow_hyphen_values = true,
166        value_name = "TEXT",
167        help = "Optional commentary; best-effort redaction; outside the ID hash"
168    )]
169    pub note: Option<String>,
170    #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
171    pub agent: Option<String>,
172    #[arg(long, help = "Validate without appending")]
173    pub dry_run: bool,
174}
175
176#[derive(Debug, Args)]
177pub struct ListArgs {
178    #[arg(
179        long,
180        value_enum,
181        default_value_t = ListKind::Cut,
182        help = "Record kind to list"
183    )]
184    pub kind: ListKind,
185    // An explicit `--status` must be distinguishable from the default (r48):
186    // the default `open` never excludes a promotion, while an explicit
187    // `open`/`resolved` is a request for lifecycle records and does.
188    #[arg(
189        long,
190        value_enum,
191        help = "Filter by lifecycle status; default open, which does not exclude promotions"
192    )]
193    pub status: Option<StatusFilter>,
194    #[arg(long, help = "Filter by agent")]
195    pub agent: Option<String>,
196    #[arg(long, help = "Filter by tag")]
197    pub tag: Option<String>,
198    #[arg(long, value_enum, help = "Filter cuts by impact")]
199    pub impact: Option<Impact>,
200    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
201    pub since: Option<String>,
202    #[arg(long, default_value_t = 50, help = "Maximum records to return")]
203    pub limit: usize,
204    #[arg(
205        long,
206        value_enum,
207        default_value_t = OutputFormat::Json,
208        help = "Output format"
209    )]
210    pub format: OutputFormat,
211}
212
213#[derive(Debug, Args)]
214pub struct ExportArgs {
215    #[arg(long, value_enum, help = "Output bridge format; required: otlp-json")]
216    pub format: Option<ExportFormat>,
217    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
218    pub since: Option<String>,
219}
220
221#[derive(Debug, Args)]
222pub struct TriageArgs {
223    #[arg(
224        long,
225        default_value_t = 3,
226        value_name = "N",
227        help = "Minimum similar open cuts per cluster"
228    )]
229    pub min_count: usize,
230}
231
232#[derive(Debug, Args)]
233pub struct VerifyArgs {}
234
235#[derive(Debug, Args)]
236pub struct RetrospectArgs {}
237
238#[derive(Debug, Args)]
239pub struct DigestArgs {
240    #[arg(
241        long,
242        default_value = "7d",
243        help = "Report since an RFC3339 timestamp or Nd/Nh duration"
244    )]
245    pub since: String,
246    #[arg(
247        long,
248        value_enum,
249        default_value_t = OutputFormat::Json,
250        help = "Output format"
251    )]
252    pub format: OutputFormat,
253}
254
255#[derive(Debug, Args)]
256pub struct ArchiveArgs {
257    #[arg(
258        long,
259        required = true,
260        value_name = "VALUE",
261        help = "Archive closed groups before an RFC3339 timestamp or Nd/Nh duration"
262    )]
263    pub before: String,
264    #[arg(long, help = "Plan archive retention without writing")]
265    pub dry_run: bool,
266}
267
268#[derive(Debug, Args)]
269pub struct DoctorArgs {
270    #[arg(long, help = "Repair safe doctor findings")]
271    pub fix: bool,
272    #[arg(long, requires = "fix", help = "Plan doctor repairs without writing")]
273    pub dry_run: bool,
274    #[arg(
275        long,
276        conflicts_with = "fix",
277        help = "Scan physical lines for home-path leaks (decoded on a parsing line, raw otherwise)"
278    )]
279    pub leaks: bool,
280    #[arg(
281        long,
282        value_name = "LITERAL",
283        requires = "leaks",
284        help = "Flag a literal raw-line leak; repeatable; requires --leaks"
285    )]
286    pub deny: Vec<String>,
287}
288
289#[derive(Debug, Args)]
290pub struct SweepArgs {
291    #[arg(
292        value_name = "PATH",
293        help = "Repository directory or direct JSONL log file; repeatable"
294    )]
295    pub paths: Vec<PathBuf>,
296    #[arg(
297        long,
298        value_name = "FILE",
299        help = "User-owned file with one path per line; blank lines and # comments ignored"
300    )]
301    pub registry: Option<PathBuf>,
302    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
303    pub since: Option<String>,
304    #[arg(
305        long,
306        value_enum,
307        default_value_t = SweepKind::Cut,
308        help = "Record kind to include in items"
309    )]
310    pub kind: SweepKind,
311}
312
313#[derive(Debug, Args)]
314pub struct ResolveArgs {
315    #[arg(
316        value_name = "ID",
317        num_args = 1..,
318        required = true,
319        help = "One or more IDs or unique prefixes"
320    )]
321    pub ids: Vec<String>,
322    #[arg(
323        long,
324        allow_hyphen_values = true,
325        help = "Resolution note; leading hyphens accepted"
326    )]
327    pub note: Option<String>,
328    #[arg(long, help = "Resolving agent; overrides BLOTTER_AGENT")]
329    pub agent: Option<String>,
330    #[arg(long, value_name = "ID", help = "Graduation task ID")]
331    pub task: Option<String>,
332    #[arg(long, value_name = "URL", help = "Graduation pull request URL")]
333    pub pr: Option<String>,
334    #[arg(long, value_name = "SHA", help = "Graduation commit SHA")]
335    pub commit: Option<String>,
336    #[arg(
337        long,
338        value_name = "URL",
339        conflicts_with = "dropped",
340        help = "Published destination (dogear records only)"
341    )]
342    pub url: Option<String>,
343    #[arg(long, help = "Mark dropped (dogear records only)")]
344    pub dropped: bool,
345    #[arg(
346        long,
347        value_enum,
348        help = "How the cut was disposed of; required for cuts, rejected for dogears"
349    )]
350    pub disposition: Option<Disposition>,
351    #[arg(
352        long,
353        value_name = "ID",
354        help = "Link to an existing promotion; requires --disposition promoted"
355    )]
356    pub promotion: Option<String>,
357    #[arg(long, help = "Append a correction to an existing resolved record")]
358    pub amend: bool,
359    #[arg(long, help = "Validate without appending a resolution")]
360    pub dry_run: bool,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
364pub enum StatusFilter {
365    Open,
366    Resolved,
367    All,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
371pub enum ListKind {
372    Cut,
373    Dogear,
374    Promotion,
375    All,
376}
377
378/// `sweep` stays cut/dogear (r48): it is a cross-repo open-friction aggregate
379/// and a promotion has no open state to aggregate. The two enums are separate
380/// types so a `promotion` value cannot reach `sweep` at all.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
382pub enum SweepKind {
383    Cut,
384    Dogear,
385    All,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
389pub enum OutputFormat {
390    Json,
391    Md,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
395pub enum ExportFormat {
396    OtlpJson,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
400pub enum SchemaTarget {
401    All,
402    Record,
403    Error,
404    ExitCodes,
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use clap::CommandFactory;
411
412    fn assert_all_arguments_have_help(command: &clap::Command) {
413        for argument in command.get_arguments() {
414            assert!(
415                argument.get_help().is_some() || argument.get_long_help().is_some(),
416                "{} argument {:?} is missing help text",
417                command.get_name(),
418                argument.get_id()
419            );
420        }
421        for subcommand in command.get_subcommands() {
422            assert_all_arguments_have_help(subcommand);
423        }
424    }
425
426    #[test]
427    fn parser_covers_defaults_aliases_and_globals() {
428        let cli =
429            Cli::try_parse_from(["blotter", "--file", "x", "log", "ouch", "--pretty"]).unwrap();
430        assert!(cli.pretty);
431        assert_eq!(cli.file, Some(PathBuf::from("x")));
432        let Command::Add(args) = cli.command else {
433            panic!("expected add")
434        };
435        assert_eq!(args.text.as_deref(), Some("ouch"));
436        assert_eq!(args.impact, Impact::Low);
437
438        let cli = Cli::try_parse_from(["blotter", "list"]).unwrap();
439        let Command::List(args) = cli.command else {
440            panic!("expected list")
441        };
442        assert_eq!(args.kind, ListKind::Cut);
443        assert_eq!(args.status, None);
444        assert_eq!(args.limit, 50);
445        assert_eq!(args.format, OutputFormat::Json);
446
447        let cli = Cli::try_parse_from(["blotter", "export", "--format", "otlp-json"]).unwrap();
448        let Command::Export(args) = cli.command else {
449            panic!("expected export")
450        };
451        assert_eq!(args.format, Some(ExportFormat::OtlpJson));
452
453        let cli = Cli::try_parse_from(["blotter", "triage"]).unwrap();
454        let Command::Triage(args) = cli.command else {
455            panic!("expected triage")
456        };
457        assert_eq!(args.min_count, 3);
458
459        let cli = Cli::try_parse_from(["blotter", "verify"]).unwrap();
460        assert!(matches!(cli.command, Command::Verify(_)));
461
462        let cli = Cli::try_parse_from(["blotter", "retrospect"]).unwrap();
463        assert!(matches!(cli.command, Command::Retrospect(_)));
464
465        let cli = Cli::try_parse_from(["blotter", "digest"]).unwrap();
466        let Command::Digest(args) = cli.command else {
467            panic!("expected digest")
468        };
469        assert_eq!(args.since, "7d");
470        assert_eq!(args.format, OutputFormat::Json);
471
472        let cli = Cli::try_parse_from([
473            "blotter",
474            "sweep",
475            "repo",
476            "--registry",
477            "repos.txt",
478            "--since",
479            "1d",
480            "--kind",
481            "all",
482        ])
483        .unwrap();
484        let Command::Sweep(args) = cli.command else {
485            panic!("expected sweep")
486        };
487        assert_eq!(args.paths, [PathBuf::from("repo")]);
488        assert_eq!(args.registry, Some(PathBuf::from("repos.txt")));
489        assert_eq!(args.since.as_deref(), Some("1d"));
490        assert_eq!(args.kind, SweepKind::All);
491    }
492
493    #[test]
494    fn parser_rejects_bad_values_and_missing_required_id() {
495        assert!(Cli::try_parse_from(["blotter", "list", "--format", "jsonl"]).is_err());
496        assert!(Cli::try_parse_from(["blotter", "digest", "--format", "jsonl"]).is_err());
497        assert!(Cli::try_parse_from(["blotter", "sweep", "--kind", "other"]).is_err());
498        assert!(Cli::try_parse_from(["blotter", "sweep", "repo", "--kind", "promotion"]).is_err());
499        assert!(
500            Cli::try_parse_from([
501                "blotter",
502                "promote",
503                "--source",
504                "abcd",
505                "--artifact-type",
506                "poem",
507                "--artifact-ref",
508                "x"
509            ])
510            .is_err()
511        );
512        assert!(Cli::try_parse_from(["blotter", "promote", "--artifact-type", "doc"]).is_err());
513        assert!(Cli::try_parse_from(["blotter", "add", "x", "--impact", "critical"]).is_err());
514        assert!(Cli::try_parse_from(["blotter", "add", "x", "--severity", "minor"]).is_err());
515        assert!(Cli::try_parse_from(["blotter", "resolve"]).is_err());
516        assert!(Cli::try_parse_from(["blotter"]).is_err());
517        for args in [
518            vec!["blotter", "list", "--include-auto"],
519            vec![
520                "blotter",
521                "export",
522                "--format",
523                "otlp-json",
524                "--include-auto",
525            ],
526            vec!["blotter", "triage", "--include-auto"],
527            vec!["blotter", "verify", "--include-auto"],
528            vec!["blotter", "digest", "--include-auto"],
529            vec!["blotter", "sweep", "repo", "--include-auto"],
530            vec!["blotter", "hook", "exec", "claude-code"],
531            vec!["blotter", "hook", "install", "claude-code"],
532        ] {
533            assert!(Cli::try_parse_from(args).is_err());
534        }
535    }
536
537    #[test]
538    fn parser_accepts_every_command_and_stdin_marker() {
539        for args in [
540            vec!["blotter", "add", "-"],
541            vec!["blotter", "idea", "-"],
542            vec!["blotter", "list", "--status", "all"],
543            vec!["blotter", "list", "--kind", "dogear"],
544            vec!["blotter", "export", "--format", "otlp-json"],
545            vec!["blotter", "triage", "--min-count", "2"],
546            vec!["blotter", "verify"],
547            vec!["blotter", "digest"],
548            vec!["blotter", "sweep", "repo"],
549            vec!["blotter", "resolve", "abcd"],
550            vec!["blotter", "list", "--kind", "promotion"],
551            vec![
552                "blotter",
553                "promote",
554                "--source",
555                "abcd",
556                "--artifact-type",
557                "skill",
558                "--artifact-ref",
559                "skills/x.md",
560            ],
561            vec!["blotter", "archive", "--before", "1d"],
562            vec!["blotter", "schema", "record"],
563            vec!["blotter", "doctor"],
564        ] {
565            assert!(Cli::try_parse_from(args).is_ok());
566        }
567    }
568
569    #[test]
570    fn parser_accepts_leading_hyphen_text_values_without_swallowing_following_options() {
571        let cli = Cli::try_parse_from([
572            "blotter",
573            "add",
574            "text",
575            "--cmd",
576            "-tool arg",
577            "--evidence",
578            "--detail note",
579            "--agent",
580            "tester",
581        ])
582        .unwrap();
583        let Command::Add(args) = cli.command else {
584            panic!("expected add")
585        };
586        assert_eq!(args.cmd.as_deref(), Some("-tool arg"));
587        assert_eq!(args.evidence.as_deref(), Some("--detail note"));
588        assert_eq!(args.agent.as_deref(), Some("tester"));
589
590        let cli = Cli::try_parse_from([
591            "blotter",
592            "resolve",
593            "abcd1234",
594            "--note",
595            "--retry after timeout",
596            "--agent",
597            "fixer",
598        ])
599        .unwrap();
600        let Command::Resolve(args) = cli.command else {
601            panic!("expected resolve")
602        };
603        assert_eq!(args.note.as_deref(), Some("--retry after timeout"));
604        assert_eq!(args.agent.as_deref(), Some("fixer"));
605    }
606
607    #[test]
608    fn every_argument_has_help_text() {
609        let mut command = Cli::command();
610        command.build();
611        assert_all_arguments_have_help(&command);
612    }
613}