Skip to main content

agentstatedeveloper_cli/commands/
scratch.rs

1//! `asd scratch …` — ephemeral working notes with a promote-to-ledger path.
2//!
3//! Scratch entries are local-only (not synced via `asd sync`) and not
4//! subject to the policy gate. Only `promote` goes through policy + audit
5//! because it creates a real [`LedgerEntry`].
6//!
7//! ## Subcommands
8//!
9//! ```text
10//! asd scratch write <content> [--symbol <qname>] [--workflow <name>] [--tag <tag>]... [--ttl 24h|7d]
11//! asd scratch list  [--symbol <qname>] [--workflow <name>] [--session <id>] [--status draft|promoted|discarded] [--all] [--json]
12//! asd scratch read  <scratch_id> [--json]
13//! asd scratch update <scratch_id> <content>
14//! asd scratch discard <scratch_id>
15//! asd scratch promote <scratch_id> --kind <ledger-kind> [--summary <text>] [--symbol <qname>]
16//! asd scratch clean  --older-than <7d> [--status discarded,promoted] [--dry-run]
17//! ```
18
19use anyhow::{Result, bail};
20use clap::{Args, Subcommand};
21
22use agentstatedeveloper_core::{
23    AsgIndexStore, AsgLedgerStore, AsgScratchStore, Author, AuthorKind, CleanFilter, Engine,
24    IndexStore, LedgerEntry, LedgerKind, LedgerStore, ScratchEntry, ScratchFilter, ScratchStatus,
25    ScratchStore,
26};
27
28use crate::commands::ledger::open_engine_public;
29use crate::config::Config;
30
31// ---------------------------------------------------------------------------
32// Subcommand definitions
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Subcommand)]
36pub enum ScratchCmd {
37    /// Write a new draft scratch entry.
38    Write(WriteArgs),
39
40    /// List scratch entries (default: draft, non-expired).
41    List(ListArgs),
42
43    /// Read a single scratch entry by ID.
44    Read(ReadArgs),
45
46    /// Update the content of an existing draft entry.
47    Update(UpdateArgs),
48
49    /// Mark an entry as discarded (soft-delete; use `clean` to purge).
50    Discard(DiscardArgs),
51
52    /// Promote a draft entry to a durable ledger entry.
53    Promote(PromoteArgs),
54
55    /// Permanently delete entries matching the filter.
56    Clean(CleanArgs),
57}
58
59#[derive(Debug, Args)]
60pub struct WriteArgs {
61    /// Content of the scratch note (markdown OK).
62    pub content: String,
63
64    /// Scope to a symbol by qualified name (resolved to symbol_id).
65    #[arg(long)]
66    pub symbol: Option<String>,
67
68    /// Named investigation context (e.g. "tracing-sync-bug").
69    #[arg(long)]
70    pub workflow: Option<String>,
71
72    /// One or more tags. Can be repeated: `--tag perf --tag regression`.
73    #[arg(long = "tag", value_name = "TAG")]
74    pub tags: Vec<String>,
75
76    /// Time-to-live: `24h`, `7d`, `30d` etc. When not set, no expiry.
77    #[arg(long)]
78    pub ttl: Option<String>,
79
80    /// Emit JSON instead of human-readable output.
81    #[arg(long)]
82    pub json: bool,
83}
84
85#[derive(Debug, Args)]
86pub struct ListArgs {
87    /// Filter by symbol qualified name.
88    #[arg(long)]
89    pub symbol: Option<String>,
90
91    /// Filter by workflow name.
92    #[arg(long)]
93    pub workflow: Option<String>,
94
95    /// Filter by session/agent_id.
96    #[arg(long)]
97    pub session: Option<String>,
98
99    /// Filter by status: draft, promoted, discarded.
100    #[arg(long)]
101    pub status: Option<String>,
102
103    /// Include all statuses and expired entries (overrides --status).
104    #[arg(long)]
105    pub all: bool,
106
107    /// Emit JSON array instead of human-readable table.
108    #[arg(long)]
109    pub json: bool,
110}
111
112#[derive(Debug, Args)]
113pub struct ReadArgs {
114    /// Scratch entry ID (`scr_…`).
115    pub scratch_id: String,
116
117    /// Emit JSON instead of human-readable output.
118    #[arg(long)]
119    pub json: bool,
120}
121
122#[derive(Debug, Args)]
123pub struct UpdateArgs {
124    /// Scratch entry ID to update.
125    pub scratch_id: String,
126
127    /// Replacement content (replaces previous content entirely).
128    pub content: String,
129}
130
131#[derive(Debug, Args)]
132pub struct DiscardArgs {
133    /// Scratch entry ID to discard.
134    pub scratch_id: String,
135}
136
137#[derive(Debug, Args)]
138pub struct PromoteArgs {
139    /// Scratch entry ID to promote.
140    pub scratch_id: String,
141
142    /// Ledger entry kind: decision, assumption, constraint, rationale, hazard,
143    /// tradeoff, invariant, ownership, proof.
144    #[arg(long, short = 'k')]
145    pub kind: String,
146
147    /// One-line summary. Defaults to the first non-empty line of the scratch
148    /// content when not supplied.
149    #[arg(long, short = 's')]
150    pub summary: Option<String>,
151
152    /// Attach the ledger entry to this symbol (qualified name). When not set,
153    /// the scratch entry's existing symbol_id is reused. At least one of
154    /// --symbol or an existing symbol_id must be present.
155    #[arg(long)]
156    pub symbol: Option<String>,
157
158    /// Author id to record on the ledger entry (default: ASD_AGENT_ID or "asd-cli").
159    #[arg(long)]
160    pub author: Option<String>,
161}
162
163#[derive(Debug, Args)]
164pub struct CleanArgs {
165    /// Delete entries older than this duration (e.g. `7d`, `24h`, `30d`).
166    #[arg(long)]
167    pub older_than: String,
168
169    /// Comma-separated list of statuses to clean: discarded, promoted, draft.
170    /// Defaults to `discarded,promoted`.
171    #[arg(long, default_value = "discarded,promoted")]
172    pub status: String,
173
174    /// Report what would be deleted without removing anything.
175    #[arg(long)]
176    pub dry_run: bool,
177}
178
179// ---------------------------------------------------------------------------
180// Dispatch
181// ---------------------------------------------------------------------------
182
183pub fn run(cfg: &Config, cmd: ScratchCmd) -> Result<()> {
184    match cmd {
185        ScratchCmd::Write(args) => write_cmd(cfg, args),
186        ScratchCmd::List(args) => list_cmd(cfg, args),
187        ScratchCmd::Read(args) => read_cmd(cfg, args),
188        ScratchCmd::Update(args) => update_cmd(cfg, args),
189        ScratchCmd::Discard(args) => discard_cmd(cfg, args),
190        ScratchCmd::Promote(args) => promote_cmd(cfg, args),
191        ScratchCmd::Clean(args) => clean_cmd(cfg, args),
192    }
193}
194
195// ---------------------------------------------------------------------------
196// Helpers
197// ---------------------------------------------------------------------------
198
199fn parse_ttl(s: &str) -> Result<chrono::Duration> {
200    let s = s.trim();
201    if let Some(days) = s.strip_suffix('d') {
202        let n: i64 = days
203            .parse()
204            .map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
205        return Ok(chrono::Duration::days(n));
206    }
207    if let Some(hours) = s.strip_suffix('h') {
208        let n: i64 = hours
209            .parse()
210            .map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
211        return Ok(chrono::Duration::hours(n));
212    }
213    bail!("TTL must be in the form `24h` or `7d`")
214}
215
216fn parse_statuses(s: &str) -> Vec<ScratchStatus> {
217    s.split(',')
218        .filter_map(|t| match t.trim() {
219            "draft" => Some(ScratchStatus::Draft),
220            "promoted" => Some(ScratchStatus::Promoted),
221            "discarded" => Some(ScratchStatus::Discarded),
222            _ => None,
223        })
224        .collect()
225}
226
227fn parse_kind(s: &str) -> Result<LedgerKind> {
228    match s {
229        "decision" => Ok(LedgerKind::Decision),
230        "assumption" => Ok(LedgerKind::Assumption),
231        "constraint" => Ok(LedgerKind::Constraint),
232        "rationale" => Ok(LedgerKind::Rationale),
233        "hazard" => Ok(LedgerKind::Hazard),
234        "tradeoff" => Ok(LedgerKind::Tradeoff),
235        "invariant" => Ok(LedgerKind::Invariant),
236        "ownership" => Ok(LedgerKind::Ownership),
237        "proof" => Ok(LedgerKind::Proof),
238        other => bail!(
239            "unknown ledger kind: '{}'. Valid: decision, assumption, constraint, rationale, hazard, tradeoff, invariant, ownership, proof",
240            other
241        ),
242    }
243}
244
245/// Derive a first-line summary from scratch content as a fallback.
246fn first_line(content: &str) -> String {
247    content
248        .lines()
249        .find(|l| !l.trim().is_empty())
250        .unwrap_or(content)
251        .chars()
252        .take(140)
253        .collect()
254}
255
256// ---------------------------------------------------------------------------
257// Handlers
258// ---------------------------------------------------------------------------
259
260fn write_cmd(cfg: &Config, args: WriteArgs) -> Result<()> {
261    let engine = Engine::open_sqlite(&cfg.db_path)?;
262    let store = AsgScratchStore { repo: &engine.repo };
263
264    let mut entry = ScratchEntry::new(&args.content, &cfg.agent_id);
265    entry.tags = args.tags;
266
267    if let Some(ref wf) = args.workflow {
268        entry.workflow = Some(wf.clone());
269    }
270
271    if let Some(ref ttl) = args.ttl {
272        let dur = parse_ttl(ttl)?;
273        entry.expires_at = Some(chrono::Utc::now() + dur);
274    }
275
276    // Resolve --symbol to symbol_id.
277    if let Some(ref qname) = args.symbol {
278        let index = AsgIndexStore::from_engine(&engine);
279        let sym = index
280            .get_symbol_by_qname(&engine.ref_name, qname)?
281            .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
282        entry.symbol_id = Some(sym.symbol_id);
283    }
284
285    let stored = store.write_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
286
287    if args.json {
288        println!("{}", serde_json::to_string_pretty(&stored)?);
289    } else {
290        println!("scratch_id: {}", stored.scratch_id);
291        println!("status:     draft");
292        if let Some(ref wf) = stored.workflow {
293            println!("workflow:   {wf}");
294        }
295        if let Some(ref sym) = stored.symbol_id {
296            println!("symbol_id:  {sym}");
297        }
298    }
299    Ok(())
300}
301
302fn list_cmd(cfg: &Config, args: ListArgs) -> Result<()> {
303    let engine = Engine::open_sqlite(&cfg.db_path)?;
304    let store = AsgScratchStore { repo: &engine.repo };
305
306    let filter = if args.all {
307        ScratchFilter::all()
308    } else {
309        let status = args
310            .status
311            .as_deref()
312            .and_then(|s| match s {
313                "draft" => Some(ScratchStatus::Draft),
314                "promoted" => Some(ScratchStatus::Promoted),
315                "discarded" => Some(ScratchStatus::Discarded),
316                _ => None,
317            })
318            .or(Some(ScratchStatus::Draft)); // default: drafts only
319
320        ScratchFilter {
321            symbol_id: None, // we filter by qname below if provided
322            workflow: args.workflow.clone(),
323            session: args.session.clone(),
324            status,
325            exclude_expired: true,
326        }
327    };
328
329    // Resolve --symbol qname → symbol_id for filtering.
330    let sym_id_filter: Option<String> = if let Some(ref qname) = args.symbol {
331        let index = AsgIndexStore::from_engine(&engine);
332        let sym = index
333            .get_symbol_by_qname(&engine.ref_name, qname)?
334            .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
335        Some(sym.symbol_id)
336    } else {
337        None
338    };
339
340    let mut filter = filter;
341    filter.symbol_id = sym_id_filter;
342
343    let entries = store.list_entries(&engine.ref_name, &filter)?;
344
345    if args.json {
346        println!("{}", serde_json::to_string_pretty(&entries)?);
347        return Ok(());
348    }
349
350    if entries.is_empty() {
351        println!("No scratch entries found.");
352        return Ok(());
353    }
354
355    println!(
356        "{:<36}  {:<10}  {:<22}  {}",
357        "scratch_id", "status", "workflow", "content (first 60 chars)"
358    );
359    println!("{}", "-".repeat(120));
360    for e in &entries {
361        let snippet: String = e.content.chars().take(60).collect();
362        let snippet = snippet.replace('\n', " ");
363        println!(
364            "{:<36}  {:<10}  {:<22}  {}",
365            e.scratch_id,
366            format!("{:?}", e.status).to_lowercase(),
367            e.workflow.as_deref().unwrap_or("—"),
368            snippet,
369        );
370    }
371    println!("\n{} entry/entries.", entries.len());
372    Ok(())
373}
374
375fn read_cmd(cfg: &Config, args: ReadArgs) -> Result<()> {
376    let engine = Engine::open_sqlite(&cfg.db_path)?;
377    let store = AsgScratchStore { repo: &engine.repo };
378    let entry = store.read_entry(&engine.ref_name, &args.scratch_id)?;
379
380    if args.json {
381        println!("{}", serde_json::to_string_pretty(&entry)?);
382        return Ok(());
383    }
384
385    println!("scratch_id:  {}", entry.scratch_id);
386    println!("status:      {:?}", entry.status);
387    println!("session:     {}", entry.session);
388    if let Some(ref wf) = entry.workflow {
389        println!("workflow:    {wf}");
390    }
391    if let Some(ref sid) = entry.symbol_id {
392        println!("symbol_id:  {sid}");
393    }
394    if let Some(ref pt) = entry.promoted_to {
395        println!("promoted_to: {pt}");
396    }
397    if !entry.tags.is_empty() {
398        println!("tags:        {}", entry.tags.join(", "));
399    }
400    println!("created_at:  {}", entry.created_at);
401    println!("updated_at:  {}", entry.updated_at);
402    if let Some(exp) = entry.expires_at {
403        println!("expires_at:  {}", exp);
404    }
405    println!("\n{}", entry.content);
406    Ok(())
407}
408
409fn update_cmd(cfg: &Config, args: UpdateArgs) -> Result<()> {
410    let engine = Engine::open_sqlite(&cfg.db_path)?;
411    let store = AsgScratchStore { repo: &engine.repo };
412    let updated = store.update_entry(
413        &engine.ref_name,
414        &args.scratch_id,
415        &args.content,
416        &cfg.agent_id,
417    )?;
418    println!("Updated {}", updated.scratch_id);
419    Ok(())
420}
421
422fn discard_cmd(cfg: &Config, args: DiscardArgs) -> Result<()> {
423    let engine = Engine::open_sqlite(&cfg.db_path)?;
424    let store = AsgScratchStore { repo: &engine.repo };
425    store.discard_entry(&engine.ref_name, &args.scratch_id, &cfg.agent_id)?;
426    println!("Discarded {}", args.scratch_id);
427    Ok(())
428}
429
430fn promote_cmd(cfg: &Config, args: PromoteArgs) -> Result<()> {
431    // Policy + audit wired through open_engine_public.
432    let engine = open_engine_public(cfg)?;
433    let scratch_store = AsgScratchStore { repo: &engine.repo };
434    let ledger_store = AsgLedgerStore::from_engine(&engine);
435
436    // 1. Read the scratch entry.
437    let entry = scratch_store.read_entry(&engine.ref_name, &args.scratch_id)?;
438
439    // 2. Resolve symbol_id: --symbol flag wins, then fall back to entry.symbol_id.
440    let symbol_id = if let Some(ref qname) = args.symbol {
441        let index = AsgIndexStore::from_engine(&engine);
442        let sym = index
443            .get_symbol_by_qname(&engine.ref_name, qname)?
444            .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
445        sym.symbol_id
446    } else if let Some(ref sid) = entry.symbol_id {
447        sid.clone()
448    } else {
449        bail!(
450            "no symbol attached to scratch entry and --symbol was not provided. \
451             Use --symbol <qname> to attach the ledger entry to a symbol."
452        );
453    };
454
455    // 3. Build summary.
456    let summary = args.summary.unwrap_or_else(|| first_line(&entry.content));
457
458    // 4. Build LedgerEntry.
459    let kind = parse_kind(&args.kind)?;
460    let author_id = args.author.unwrap_or_else(|| cfg.agent_id.clone());
461    let author = Author {
462        kind: AuthorKind::Agent,
463        id: author_id.clone(),
464    };
465    let mut ledger_entry = LedgerEntry::new(&symbol_id, kind, &summary, author);
466    ledger_entry.body = Some(entry.content.clone());
467
468    // 5. Append via engine (policy + audit).
469    ledger_store.append_entry(&engine.ref_name, &ledger_entry, &author_id)?;
470
471    // 6. Mark scratch as promoted.
472    let promoted = scratch_store.mark_promoted(
473        &engine.ref_name,
474        &entry.scratch_id,
475        &ledger_entry.entry_id,
476        &author_id,
477    )?;
478
479    println!("scratch_id:  {}", promoted.scratch_id);
480    println!("promoted_to: {}", ledger_entry.entry_id);
481    println!("symbol_id:   {symbol_id}");
482    Ok(())
483}
484
485fn clean_cmd(cfg: &Config, args: CleanArgs) -> Result<()> {
486    let engine = Engine::open_sqlite(&cfg.db_path)?;
487    let store = AsgScratchStore { repo: &engine.repo };
488
489    let dur = parse_ttl(&args.older_than)?;
490    let statuses = parse_statuses(&args.status);
491
492    let filter = CleanFilter {
493        older_than: Some(dur),
494        statuses,
495    };
496
497    let count = store.clean_entries(&engine.ref_name, &filter, args.dry_run)?;
498
499    if args.dry_run {
500        println!(
501            "dry-run: {} entry/entries would be deleted. Run without --dry-run to apply.",
502            count
503        );
504    } else {
505        println!("Deleted {} entry/entries.", count);
506    }
507    Ok(())
508}