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