Skip to main content

blotter/
lib.rs

1//! Implementation of the `blotter` binary.
2//!
3//! This library is not a supported API. Blotter's compatibility promise is the
4//! CLI, the JSON envelopes, the stored JSONL record format, the exit codes, and
5//! `blotter schema` (see `meta.contract`). Every item here is public to
6//! structure the binary and may change in any release without notice.
7//! Integrate through the CLI.
8
9pub mod cli;
10pub mod commands;
11pub mod error;
12pub mod output;
13pub(crate) mod redact;
14pub mod store;
15
16use crate::error::{AppError, AppResult};
17use jiff::{SignedDuration, Timestamp, Unit};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::fmt::Write as _;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
23#[serde(rename_all = "lowercase")]
24pub enum Impact {
25    Low,
26    Material,
27    Blocking,
28}
29
30impl Impact {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Low => "low",
34            Self::Material => "material",
35            Self::Blocking => "blocking",
36        }
37    }
38
39    pub fn rank(self) -> u8 {
40        match self {
41            Self::Low => 0,
42            Self::Material => 1,
43            Self::Blocking => 2,
44        }
45    }
46}
47
48/// How a cut's resolution classifies it (r48). Cuts always carry one; dogears
49/// never do.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
51#[serde(rename_all = "lowercase")]
52pub enum Disposition {
53    Fixed,
54    Promoted,
55    Accepted,
56    Invalid,
57}
58
59/// The two `retrospect` candidate patterns (r48/r51). Pattern detection and
60/// suggested intervention are separate axes: a pattern names what was
61/// observed, `suggested` names what kind of artifact might answer it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Pattern {
65    RecurrentFriction,
66    FailedIntervention,
67}
68
69/// The closed promotion artifact vocabulary (r48). An unrecognized
70/// `--artifact-type` is rejected by clap as `invalid_argument`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
72#[serde(rename_all = "lowercase")]
73pub enum ArtifactType {
74    Doc,
75    Skill,
76    Guard,
77    Test,
78    Tool,
79    Process,
80}
81
82impl ArtifactType {
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Doc => "doc",
86            Self::Skill => "skill",
87            Self::Guard => "guard",
88            Self::Test => "test",
89            Self::Tool => "tool",
90            Self::Process => "process",
91        }
92    }
93}
94
95/// What a promotion says these experiences became.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Artifact {
98    #[serde(rename = "type")]
99    pub kind: ArtifactType,
100    #[serde(rename = "ref")]
101    pub r#ref: String,
102}
103
104/// Structured provenance (r49). A typed three-member struct, never flattened
105/// and never an opaque `Value`: a `null` published member reads as absent, a
106/// non-string one fails the line, and an unknown member survives in the log's
107/// bytes without reaching any envelope.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Origin {
110    #[serde(rename = "type")]
111    pub kind: String,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub provider: Option<String>,
114    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
115    pub r#ref: Option<String>,
116}
117
118impl Origin {
119    /// The only origin any 1.0.0 command writes.
120    pub fn agent() -> Self {
121        Self {
122            kind: "agent".into(),
123            provider: None,
124            r#ref: None,
125        }
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Evidence {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub cmd: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub exit: Option<i32>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub stderr: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub note: Option<String>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "lowercase")]
143pub enum LogEvent {
144    Cut {
145        id: String,
146        ts: String,
147        agent: String,
148        text: String,
149        tags: Vec<String>,
150        impact: Impact,
151        cwd: String,
152        #[serde(default, skip_serializing_if = "Option::is_none")]
153        origin: Option<Origin>,
154        #[serde(skip_serializing_if = "Option::is_none")]
155        evidence: Option<Evidence>,
156    },
157    Dogear {
158        id: String,
159        ts: String,
160        agent: String,
161        text: String,
162        tags: Vec<String>,
163        #[serde(skip_serializing_if = "Option::is_none")]
164        evidence: Option<String>,
165        cwd: String,
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        origin: Option<Origin>,
168    },
169    Resolve {
170        id: String,
171        ts: String,
172        agent: String,
173        note: Option<String>,
174        #[serde(default, skip_serializing_if = "Option::is_none")]
175        task: Option<String>,
176        #[serde(default, skip_serializing_if = "Option::is_none")]
177        pr: Option<String>,
178        #[serde(default, skip_serializing_if = "Option::is_none")]
179        commit: Option<String>,
180        #[serde(default, skip_serializing_if = "Option::is_none")]
181        url: Option<String>,
182        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183        dropped: bool,
184        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
185        amend: bool,
186        #[serde(default, skip_serializing_if = "Option::is_none")]
187        disposition: Option<Disposition>,
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        disposition_ts: Option<String>,
190        #[serde(default, skip_serializing_if = "Option::is_none")]
191        promotion: Option<String>,
192    },
193    Promotion {
194        id: String,
195        ts: String,
196        agent: String,
197        sources: Vec<String>,
198        artifact: Artifact,
199        #[serde(default, skip_serializing_if = "Option::is_none")]
200        note: Option<String>,
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        origin: Option<Origin>,
203        cwd: String,
204    },
205    #[serde(other)]
206    Unknown,
207}
208
209impl LogEvent {
210    pub fn id(&self) -> Option<&str> {
211        match self {
212            Self::Cut { id, .. }
213            | Self::Dogear { id, .. }
214            | Self::Resolve { id, .. }
215            | Self::Promotion { id, .. } => Some(id),
216            Self::Unknown => None,
217        }
218    }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct Resolution {
223    pub ts: String,
224    pub agent: String,
225    pub note: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub task: Option<String>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub pr: Option<String>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub commit: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub url: Option<String>,
234    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235    pub dropped: bool,
236    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
237    pub amended: bool,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub disposition: Option<Disposition>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub disposition_ts: Option<String>,
242    /// The promotion this resolution links to (r48). Present only under
243    /// `disposition: promoted`, and only when the link is mutual.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub promotion: Option<String>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct ListItem {
250    pub kind: String,
251    pub id: String,
252    pub ts: String,
253    pub agent: String,
254    pub text: String,
255    pub tags: Vec<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub impact: Option<Impact>,
258    pub cwd: String,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub origin: Option<Origin>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub evidence: Option<serde_json::Value>,
263    pub status: ItemStatus,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub resolution: Option<Resolution>,
266}
267
268impl ListItem {
269    pub(crate) fn from_record(event: LogEvent, resolution: Option<Resolution>) -> Self {
270        let status = if resolution.is_some() {
271            ItemStatus::Resolved
272        } else {
273            ItemStatus::Open
274        };
275        match event {
276            LogEvent::Cut {
277                id,
278                ts,
279                agent,
280                text,
281                tags,
282                impact,
283                cwd,
284                origin,
285                evidence,
286            } => Self {
287                kind: "cut".into(),
288                id,
289                ts,
290                agent,
291                text,
292                tags,
293                impact: Some(impact),
294                cwd,
295                origin,
296                evidence: evidence
297                    .map(|evidence| serde_json::to_value(evidence).expect("evidence serializes")),
298                status,
299                resolution,
300            },
301            LogEvent::Dogear {
302                id,
303                ts,
304                agent,
305                text,
306                tags,
307                evidence,
308                cwd,
309                origin,
310            } => Self {
311                kind: "dogear".into(),
312                id,
313                ts,
314                agent,
315                text,
316                tags,
317                impact: None,
318                cwd,
319                origin,
320                evidence: evidence.map(serde_json::Value::String),
321                status,
322                resolution,
323            },
324            LogEvent::Resolve { .. } | LogEvent::Promotion { .. } | LogEvent::Unknown => {
325                unreachable!("folded list items are cut or dogear")
326            }
327        }
328    }
329}
330
331/// The promotion arm of `list`'s tagged union (r48). It carries no `status`,
332/// `resolution`, `text`, `tags`, `impact`, or `evidence`: a promotion has no
333/// lifecycle to filter and no friction text of its own.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct PromotionItem {
336    pub kind: String,
337    pub id: String,
338    pub ts: String,
339    pub agent: String,
340    pub sources: Vec<String>,
341    pub artifact: Artifact,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub note: Option<String>,
344    pub cwd: String,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub origin: Option<Origin>,
347}
348
349impl PromotionItem {
350    pub(crate) fn from_record(event: LogEvent) -> Self {
351        let LogEvent::Promotion {
352            id,
353            ts,
354            agent,
355            sources,
356            artifact,
357            note,
358            origin,
359            cwd,
360        } = event
361        else {
362            unreachable!("only promotion records become promotion items")
363        };
364        Self {
365            kind: "promotion".into(),
366            id,
367            ts,
368            agent,
369            sources,
370            artifact,
371            note,
372            cwd,
373            origin,
374        }
375    }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "lowercase")]
380pub enum ItemStatus {
381    Open,
382    Resolved,
383}
384
385/// One ID namespace under `bl2` (r48): every record kind is `bl_` plus lowercase
386/// hex, matched case-insensitively. `pc_` is gone, so this is a plain prefix
387/// predicate rather than a namespace enum.
388pub(crate) fn is_bl_id(id: &str) -> bool {
389    id.get(..3)
390        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bl_"))
391}
392
393pub fn effective_now() -> AppResult<Timestamp> {
394    let timestamp = match std::env::var("BLOTTER_NOW") {
395        Ok(value) if !value.is_empty() => value.parse::<Timestamp>().map_err(|_| {
396            AppError::config(
397                "BLOTTER_NOW must be a full RFC3339 timestamp",
398                "Set BLOTTER_NOW to a value like 2026-07-09T18:30:00Z or unset it.",
399            )
400        })?,
401        Ok(_) | Err(std::env::VarError::NotPresent) => Timestamp::now(),
402        Err(std::env::VarError::NotUnicode(_)) => {
403            return Err(AppError::config(
404                "BLOTTER_NOW is not valid UTF-8",
405                "Set BLOTTER_NOW to a full RFC3339 timestamp or unset it.",
406            ));
407        }
408    };
409    timestamp
410        .round(Unit::Millisecond)
411        .map_err(|error| AppError::internal(error.to_string()))
412}
413
414pub fn format_timestamp(timestamp: Timestamp) -> String {
415    format!("{timestamp:.3}")
416}
417
418pub fn parse_since(value: &str, now: Timestamp) -> AppResult<Timestamp> {
419    parse_cutoff("--since", value, now)
420}
421
422pub fn parse_before(value: &str, now: Timestamp) -> AppResult<Timestamp> {
423    parse_cutoff("--before", value, now)
424}
425
426fn parse_cutoff(flag_name: &str, value: &str, now: Timestamp) -> AppResult<Timestamp> {
427    let is_since = flag_name == "--since";
428    let relative_suggested_fix = || {
429        if is_since {
430            "Use a full RFC3339 timestamp, Nd, or Nh.".to_owned()
431        } else {
432            format!("Use {flag_name} with a full RFC3339 timestamp, Nd, or Nh.")
433        }
434    };
435    let smaller_duration_suggested_fix = || {
436        if is_since {
437            "Use a smaller Nd or Nh duration.".to_owned()
438        } else {
439            format!("Use a smaller relative value for {flag_name}.")
440        }
441    };
442    let absolute_suggested_fix = || {
443        if is_since {
444            "Use a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h.".to_owned()
445        } else {
446            format!(
447                "Use {flag_name} with a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h."
448            )
449        }
450    };
451    if let Some((number, unit)) = value.split_at_checked(value.len().saturating_sub(1))
452        && !number.is_empty()
453        && number.bytes().all(|byte| byte.is_ascii_digit())
454        && matches!(unit, "d" | "h")
455    {
456        let amount = number.parse::<i64>().map_err(|_| {
457            AppError::invalid_argument(
458                format!("invalid {flag_name} value '{value}'"),
459                relative_suggested_fix(),
460            )
461        })?;
462        let duration = if unit == "d" {
463            amount.checked_mul(24)
464        } else {
465            Some(amount)
466        }
467        .and_then(SignedDuration::try_from_hours)
468        .ok_or_else(|| {
469            AppError::invalid_argument(
470                format!("{flag_name} value '{value}' is too large"),
471                smaller_duration_suggested_fix(),
472            )
473        })?;
474        return now.checked_sub(duration).map_err(|_| {
475            AppError::invalid_argument(
476                format!("{flag_name} value '{value}' is outside the supported range"),
477                smaller_duration_suggested_fix(),
478            )
479        });
480    }
481
482    value.parse::<Timestamp>().map_err(|_| {
483        AppError::invalid_argument(
484            format!("invalid {flag_name} value '{value}'"),
485            absolute_suggested_fix(),
486        )
487    })
488}
489
490pub fn compute_id(ts: &str, agent: &str, text: &str, impact: Impact, tags: &[String]) -> String {
491    let mut tags = tags.to_vec();
492    tags.sort();
493    tags.dedup();
494    let count = tags.len().to_string();
495    let mut fields: Vec<&str> = vec![
496        "bl2",
497        "cut",
498        ts,
499        agent,
500        text,
501        impact.as_str(),
502        count.as_str(),
503    ];
504    fields.extend(tags.iter().map(String::as_str));
505    compute_id_fields_bytes(&fields, 10)
506}
507
508pub fn compute_dogear_id(ts: &str, agent: &str, text: &str, tags: &[String]) -> String {
509    let mut tags = tags.to_vec();
510    tags.sort();
511    tags.dedup();
512    let count = tags.len().to_string();
513    // bl2 dogear identity: the domain literal and the kind field provide domain
514    // separation, and every tag is its own length-prefixed field (TupleHash
515    // style) so tag-set boundaries cannot collide (`["a","b"]` != `["a,b"]`).
516    // 80-bit digest; every v2 identity is the same width (r51).
517    let mut fields: Vec<&str> = vec!["bl2", "dogear", ts, agent, text, count.as_str()];
518    fields.extend(tags.iter().map(String::as_str));
519    compute_id_fields_bytes(&fields, 10)
520}
521
522/// The `bl2` promotion identity (r48, r51): `note` stays outside the hash for
523/// r34's reason — it is authored commentary, and a promotion whose note is
524/// reworded is the same promotion. `sources` are normalized exactly as tags are:
525/// ascending raw UTF-8 byte order, exact-byte deduplication, case-sensitive.
526pub fn compute_promotion_id(
527    ts: &str,
528    agent: &str,
529    sources: &[String],
530    artifact_type: &str,
531    artifact_ref: &str,
532) -> String {
533    let sources = normalized(sources);
534    let count = sources.len().to_string();
535    let mut fields: Vec<&str> = vec!["bl2", "promotion", ts, agent, count.as_str()];
536    fields.extend(sources.iter().map(String::as_str));
537    fields.push(artifact_type);
538    fields.push(artifact_ref);
539    compute_id_fields_bytes(&fields, 10)
540}
541
542/// Ascending raw UTF-8 byte order with exact-byte deduplication (r48). The one
543/// normalization every hashed list — tags and promotion sources — runs through.
544pub fn normalized(values: &[String]) -> Vec<String> {
545    let mut values = values.to_vec();
546    values.sort();
547    values.dedup();
548    values
549}
550
551fn compute_id_fields_bytes(fields: &[&str], bytes: usize) -> String {
552    let mut hash = Sha256::new();
553    for field in fields {
554        hash.update((field.len() as u32).to_le_bytes());
555        hash.update(field.as_bytes());
556    }
557    let digest = hash.finalize();
558    let mut id = String::with_capacity(3 + bytes * 2);
559    id.push_str("bl_");
560    for byte in &digest[..bytes] {
561        write!(&mut id, "{byte:02x}").expect("writing to a String cannot fail");
562    }
563    id
564}
565
566pub fn resolve_agent(flag: Option<String>) -> AppResult<(String, &'static str)> {
567    if let Some(agent) = flag.filter(|value| !value.is_empty()) {
568        return Ok((agent, "flag"));
569    }
570    match std::env::var("BLOTTER_AGENT") {
571        Ok(agent) if !agent.is_empty() => return Ok((agent, "env")),
572        // An unset or empty BLOTTER_AGENT falls through to detection; a
573        // non-UTF-8 one names an agent this build cannot read, and silently
574        // filing under a detected name would contradict the operator.
575        Ok(_) | Err(std::env::VarError::NotPresent) => {}
576        Err(std::env::VarError::NotUnicode(_)) => {
577            return Err(AppError::config(
578                "BLOTTER_AGENT is not valid UTF-8",
579                "Set BLOTTER_AGENT to a UTF-8 agent name or unset it.",
580            ));
581        }
582    }
583    if std::env::var_os("CLAUDECODE").is_some() {
584        return Ok(("claude-code".into(), "detected"));
585    }
586    if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CODEX_")) {
587        return Ok(("codex".into(), "detected"));
588    }
589    if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CURSOR_")) {
590        return Ok(("cursor".into(), "detected"));
591    }
592    Ok(("unknown".into(), "default"))
593}
594
595pub(crate) fn resolve_agent_checked(
596    flag: Option<String>,
597    reject_resolved_whitespace: bool,
598) -> AppResult<(String, &'static str)> {
599    if flag.as_deref().is_some_and(|agent| agent.trim().is_empty()) {
600        return Err(AppError::invalid_input(
601            "agent name cannot be empty or whitespace-only",
602            "Pass a non-empty --agent NAME or omit the flag.",
603        ));
604    }
605    let (agent, source) = resolve_agent(flag)?;
606    if reject_resolved_whitespace && agent.trim().is_empty() {
607        return Err(AppError::invalid_input(
608            "agent name cannot be whitespace-only",
609            "Pass a non-empty --agent NAME or set BLOTTER_AGENT.",
610        ));
611    }
612    Ok((agent, source))
613}