Skip to main content

agentstatedeveloper_cli/commands/
think.rs

1//! `asd think <verb>` — capture agent thinking (Plan G t-003).
2//!
3//! Five verbs:
4//!   - speculate <qname> --conf F --summary S    → Hypothesis
5//!   - model NAME --symbols q1,q2 --summary S    → MentalModel
6//!   - failed <qname> --tried T --because B       → FailedAttempt
7//!   - question <qname> --q Q                    → OpenQuestion
8//!   - list [--kind X] [--symbol Q]              → read-side
9//!
10//! Entries use deterministic blake3-derived IDs so re-running the
11//! initial-read prompt overwrites instead of duplicating.
12
13use anyhow::{Result, anyhow, bail};
14use clap::{Args, Subcommand};
15use serde_json::json;
16
17use agentstatedeveloper_core::{
18    AsgIndexStore, AsgLedgerStore, Author, AuthorKind, ConclusionClass, Engine, IndexStore,
19    LedgerEntry, LedgerKind, LedgerStore,
20};
21
22use crate::config::Config;
23
24/// Initial-read prompt template, embedded at compile time so
25/// `asd think prompt` works from any CWD (AcmeProj or any other
26/// project that asd indexes — not just the AgentStateDeveloper
27/// source checkout). The embedded copy lives inside the crate so the
28/// published package builds; `docs/initial-read-prompt.md` is the copy
29/// people read, and `initial_read_prompt_matches_docs` keeps them equal.
30pub const INITIAL_READ_PROMPT: &str = include_str!("../../assets/initial-read-prompt.md");
31
32#[derive(Debug, Subcommand)]
33pub enum ThinkCmd {
34    /// Record a Hypothesis: speculation with a confidence (0.0–1.0).
35    Speculate(SpeculateArgs),
36    /// Record a MentalModel: multi-symbol structural understanding.
37    Model(ModelArgs),
38    /// Record a FailedAttempt: negative evidence.
39    Failed(FailedArgs),
40    /// Record an OpenQuestion: known unknown blocking confident action.
41    Question(QuestionArgs),
42    /// List captured thinking entries.
43    List(ListArgs),
44    /// Print the initial-read prompt template to stdout. Embedded at
45    /// compile time — works from any CWD. Pipe into your agent
46    /// (e.g. `asd think prompt | claude`) or redirect to a file
47    /// (`asd think prompt > .asd/onboarding-prompt.md`).
48    Prompt,
49    /// Print the initial-read prompt path + starter checklist.
50    /// With --check, scans existing thinking entries and reports gaps.
51    Bootstrap(BootstrapArgs),
52}
53
54#[derive(Debug, Args)]
55pub struct BootstrapArgs {
56    /// Scan existing thinking entries and report gaps instead of
57    /// printing the starter checklist verbatim.
58    #[arg(long)]
59    pub check: bool,
60    /// Emit JSON instead of human-readable text.
61    #[arg(long)]
62    pub json: bool,
63}
64
65#[derive(Debug, Args)]
66pub struct SpeculateArgs {
67    /// Fully-qualified symbol this hypothesis is about.
68    pub qname: String,
69    /// Confidence in [0.0, 1.0]. Below 0.3 is excluded from
70    /// prior_thinking auto-surface by default.
71    #[arg(long)]
72    pub conf: f64,
73    /// One-line claim.
74    #[arg(long)]
75    pub summary: String,
76    /// Optional evidence body (markdown).
77    #[arg(long)]
78    pub body: Option<String>,
79}
80
81#[derive(Debug, Args)]
82pub struct ModelArgs {
83    /// Short name for the model (becomes the entry summary prefix).
84    pub name: String,
85    /// Comma-separated qnames the model spans.
86    #[arg(long)]
87    pub symbols: String,
88    /// One-line description of how data/control flows.
89    #[arg(long)]
90    pub summary: String,
91}
92
93#[derive(Debug, Args)]
94pub struct FailedArgs {
95    /// Fully-qualified symbol the attempt targeted.
96    pub qname: String,
97    /// What was tried (one line).
98    #[arg(long)]
99    pub tried: String,
100    /// Why it didn't work (one line).
101    #[arg(long)]
102    pub because: String,
103}
104
105#[derive(Debug, Args)]
106pub struct QuestionArgs {
107    /// Fully-qualified symbol the question is about.
108    pub qname: String,
109    /// The question (one line).
110    #[arg(long = "q")]
111    pub question: String,
112}
113
114#[derive(Debug, Args)]
115pub struct ListArgs {
116    /// Filter to one thinking kind.
117    #[arg(long, value_enum)]
118    pub kind: Option<ThinkKind>,
119    /// Filter to one symbol.
120    #[arg(long)]
121    pub symbol: Option<String>,
122}
123
124#[derive(Debug, Clone, Copy, clap::ValueEnum)]
125pub enum ThinkKind {
126    Hypothesis,
127    Model,
128    Failed,
129    Question,
130}
131
132pub fn run(cfg: &Config, cmd: ThinkCmd) -> Result<()> {
133    match cmd {
134        ThinkCmd::Speculate(a) => run_speculate(cfg, a),
135        ThinkCmd::Model(a) => run_model(cfg, a),
136        ThinkCmd::Failed(a) => run_failed(cfg, a),
137        ThinkCmd::Question(a) => run_question(cfg, a),
138        ThinkCmd::List(a) => run_list(cfg, a),
139        ThinkCmd::Prompt => {
140            // No trailing newline added — include_str! preserves
141            // the file's final newline already; a second one would
142            // pollute pipe consumers.
143            print!("{}", INITIAL_READ_PROMPT);
144            Ok(())
145        }
146        ThinkCmd::Bootstrap(a) => run_bootstrap(cfg, a),
147    }
148}
149
150/// Counts of each thinking kind, plus optional you-vs-team
151/// breakdown. Plan K t-006: `bootstrap --check` reports both.
152#[derive(Debug, Default, Clone)]
153struct ThinkingCounts {
154    hypothesis: usize,
155    mental_model: usize,
156    failed_attempt: usize,
157    open_question: usize,
158    // "you" subset — entries whose author.id matches the local agent_id.
159    hypothesis_you: usize,
160    mental_model_you: usize,
161    failed_attempt_you: usize,
162    open_question_you: usize,
163}
164
165/// One inherited mental-model or hypothesis sample for the auto-
166/// surfaced "Inherited thinking from prior session(s)" block in
167/// default bootstrap output (Plan K t-006).
168#[derive(Debug, Clone)]
169struct InheritedSample {
170    qname: String,
171    summary: String,
172    confidence: Option<f64>,
173    author_id: String,
174}
175
176#[derive(Debug, Default, Clone)]
177struct InheritanceSummary {
178    counts: ThinkingCounts,
179    /// Up to 3 mental-model samples (newest first).
180    mental_models: Vec<InheritedSample>,
181    /// Up to 3 highest-confidence hypotheses.
182    top_hypotheses: Vec<InheritedSample>,
183}
184
185impl InheritanceSummary {
186    /// Detection trigger: ≥1 MentalModel OR ≥3 Hypotheses → there's
187    /// inherited thinking worth surfacing.
188    fn has_inheritance(&self) -> bool {
189        self.counts.mental_model >= 1 || self.counts.hypothesis >= 3
190    }
191}
192
193/// Plan K t-006: walk every indexed symbol's ledger, bucket thinking
194/// entries by kind and by author (you vs team), and collect a few
195/// samples for the inheritance preview.
196fn gather_thinking(cfg: &Config) -> Result<InheritanceSummary> {
197    let engine = Engine::open_sqlite(&cfg.db_path)?;
198
199    let mut counts = ThinkingCounts::default();
200    let mut mm_samples: Vec<InheritedSample> = Vec::new();
201    let mut hyp_samples: Vec<InheritedSample> = Vec::new();
202    let me = cfg.agent_id.as_str();
203
204    // One bulk walk (Plan T scale fix) instead of a per-qname symbol
205    // fetch + per-symbol ledger read.
206    for (qname, entries) in agentstatedeveloper_core::thinking::all_symbol_entries(&engine) {
207        for e in entries {
208            let is_me = e.author.id == me;
209            match e.kind {
210                LedgerKind::Hypothesis => {
211                    counts.hypothesis += 1;
212                    if is_me {
213                        counts.hypothesis_you += 1;
214                    }
215                    hyp_samples.push(InheritedSample {
216                        qname: qname.clone(),
217                        summary: e.summary.clone(),
218                        confidence: e.confidence,
219                        author_id: e.author.id.clone(),
220                    });
221                }
222                LedgerKind::MentalModel => {
223                    counts.mental_model += 1;
224                    if is_me {
225                        counts.mental_model_you += 1;
226                    }
227                    mm_samples.push(InheritedSample {
228                        qname: qname.clone(),
229                        summary: e.summary.clone(),
230                        confidence: None,
231                        author_id: e.author.id.clone(),
232                    });
233                }
234                LedgerKind::FailedAttempt => {
235                    counts.failed_attempt += 1;
236                    if is_me {
237                        counts.failed_attempt_you += 1;
238                    }
239                }
240                LedgerKind::OpenQuestion => {
241                    counts.open_question += 1;
242                    if is_me {
243                        counts.open_question_you += 1;
244                    }
245                }
246                _ => {}
247            }
248        }
249    }
250
251    // Top 3 hypotheses by confidence desc (None sorts last).
252    hyp_samples.sort_by(|a, b| {
253        b.confidence
254            .unwrap_or(0.0)
255            .partial_cmp(&a.confidence.unwrap_or(0.0))
256            .unwrap_or(std::cmp::Ordering::Equal)
257    });
258    hyp_samples.truncate(3);
259    mm_samples.truncate(3);
260
261    Ok(InheritanceSummary {
262        counts,
263        mental_models: mm_samples,
264        top_hypotheses: hyp_samples,
265    })
266}
267
268/// Backward-compatible bare-counts helper (kept for any external
269/// callers; internally we use `gather_thinking` for the richer
270/// inheritance summary).
271#[allow(dead_code)]
272fn count_thinking(cfg: &Config) -> Result<(usize, usize, usize, usize)> {
273    let s = gather_thinking(cfg)?;
274    Ok((
275        s.counts.hypothesis,
276        s.counts.mental_model,
277        s.counts.failed_attempt,
278        s.counts.open_question,
279    ))
280}
281
282fn run_bootstrap(cfg: &Config, args: BootstrapArgs) -> Result<()> {
283    let prompt_path = "docs/initial-read-prompt.md";
284    let summary = gather_thinking(cfg)?;
285
286    if args.check {
287        return run_bootstrap_check(prompt_path, &summary, args.json);
288    }
289
290    run_bootstrap_default(prompt_path, &summary, args.json)
291}
292
293fn run_bootstrap_check(prompt_path: &str, s: &InheritanceSummary, json_out: bool) -> Result<()> {
294    let c = &s.counts;
295    // Gaps are computed against the TEAM total (anything in the
296    // ledger counts as "we have it"). The "you" subset is reported
297    // alongside so a new dev can see what's inherited vs personal.
298    let mut gaps: Vec<&str> = Vec::new();
299    if c.mental_model == 0 {
300        gaps.push(
301            "no MentalModel yet — describe the top-level architecture with `asd think model`",
302        );
303    }
304    if c.hypothesis == 0 {
305        gaps.push("no Hypothesis yet — record at least one speculation with `asd think speculate`");
306    }
307    if c.open_question == 0 {
308        gaps.push("no OpenQuestion yet — record known unknowns with `asd think question`");
309    }
310    if c.failed_attempt == 0 {
311        gaps.push(
312            "no FailedAttempt yet — once a dead end appears, capture with `asd think failed`",
313        );
314    }
315
316    if json_out {
317        println!(
318            "{}",
319            serde_json::to_string_pretty(&json!({
320                "ok": true,
321                "prompt_path": prompt_path,
322                "counts_team": {
323                    "hypothesis": c.hypothesis,
324                    "mental_model": c.mental_model,
325                    "failed_attempt": c.failed_attempt,
326                    "open_question": c.open_question,
327                },
328                "counts_you": {
329                    "hypothesis": c.hypothesis_you,
330                    "mental_model": c.mental_model_you,
331                    "failed_attempt": c.failed_attempt_you,
332                    "open_question": c.open_question_you,
333                },
334                "gaps": gaps,
335            }))?
336        );
337        return Ok(());
338    }
339
340    println!("# asd think bootstrap --check");
341    println!();
342    println!("prompt: {prompt_path}");
343    println!();
344    // Plan K t-006: print you-vs-team counts side-by-side so a new
345    // dev can distinguish "what the team has captured" from
346    // "what I personally have contributed."
347    println!("counts (team / you):");
348    println!(
349        "  hypothesis     : {:>3} / {:>3}",
350        c.hypothesis, c.hypothesis_you
351    );
352    println!(
353        "  mental_model   : {:>3} / {:>3}",
354        c.mental_model, c.mental_model_you
355    );
356    println!(
357        "  failed_attempt : {:>3} / {:>3}",
358        c.failed_attempt, c.failed_attempt_you
359    );
360    println!(
361        "  open_question  : {:>3} / {:>3}",
362        c.open_question, c.open_question_you
363    );
364    println!();
365    if gaps.is_empty() {
366        println!("gaps: none — every thinking bucket has at least one entry.");
367    } else {
368        println!("gaps:");
369        for g in &gaps {
370            println!("  - {g}");
371        }
372    }
373    Ok(())
374}
375
376fn run_bootstrap_default(prompt_path: &str, s: &InheritanceSummary, json_out: bool) -> Result<()> {
377    if json_out {
378        // JSON output: include inheritance summary if present so MCP
379        // callers can detect it programmatically.
380        let mut payload = json!({
381            "ok": true,
382            "prompt_path": prompt_path,
383            "prompt_command": "asd think prompt",
384            "checklist": [
385                "Read the prompt: run `asd think prompt` (embedded — works from any CWD)",
386                "Run `asd reindex` so the project graph is current (defaults to CWD)",
387                "Capture at least one MentalModel for the top-level architecture",
388                "Capture Hypotheses for hot files with confidence in [0.0, 1.0]",
389                "Record OpenQuestions for known unknowns",
390                "Record FailedAttempts as dead ends emerge",
391                "Re-run `asd think bootstrap --check` to confirm coverage",
392            ],
393            "commands": {
394                "model": "asd think model <NAME> --symbols a.b,c.d --summary \"...\"",
395                "speculate": "asd think speculate <QNAME> --conf 0.6 --summary \"...\"",
396                "question": "asd think question <QNAME> --q \"...\"",
397                "failed": "asd think failed <QNAME> --tried \"...\" --because \"...\"",
398                "list": "asd think list [--kind hypothesis|model|failed|question]",
399            },
400        });
401        if s.has_inheritance() {
402            payload["inherited_thinking"] = inheritance_json(s);
403        }
404        println!("{}", serde_json::to_string_pretty(&payload)?);
405        return Ok(());
406    }
407
408    println!("# asd think bootstrap");
409    println!();
410    if s.has_inheritance() {
411        print_inheritance_block(s);
412    }
413    println!("Prompt template: `asd think prompt` (embedded — works from any CWD)");
414    println!("                 source: {prompt_path} (in the AgentStateDeveloper repo)");
415    println!();
416    println!("Starter checklist:");
417    println!("  1. Read the prompt: `asd think prompt` (pipe into your agent or read directly)");
418    println!("  2. Run `asd reindex` so the project graph is current (defaults to CWD)");
419    println!("  3. Capture at least one MentalModel for the top-level architecture");
420    println!("  4. Capture Hypotheses for hot files with confidence in [0.0, 1.0]");
421    println!("  5. Record OpenQuestions for known unknowns");
422    println!("  6. Record FailedAttempts as dead ends emerge");
423    println!("  7. Re-run `asd think bootstrap --check` to confirm coverage");
424    println!();
425    println!("Write-back commands:");
426    println!("  asd think model   <NAME>  --symbols a.b,c.d --summary \"...\"");
427    println!("  asd think speculate <QN>  --conf 0.6        --summary \"...\"");
428    println!("  asd think question  <QN>  --q \"...\"");
429    println!("  asd think failed    <QN>  --tried \"...\" --because \"...\"");
430    println!("  asd think list      [--kind hypothesis|model|failed|question]");
431    Ok(())
432}
433
434/// Plan K t-006: pretty-print the inherited-thinking block surfaced
435/// at the top of `asd think bootstrap` when a prior session captured
436/// enough thinking to be worth advertising. Goal: a new dev's agent
437/// sees "you're not starting from zero — here's what the team
438/// already concluded" before being pushed into the initial-read flow.
439fn print_inheritance_block(s: &InheritanceSummary) {
440    let c = &s.counts;
441    println!("## Inherited thinking from prior session(s)");
442    println!();
443    println!(
444        "  {} MentalModel{}, {} Hypothes{}, {} OpenQuestion{}, {} FailedAttempt{} already on file.",
445        c.mental_model,
446        if c.mental_model == 1 { "" } else { "s" },
447        c.hypothesis,
448        if c.hypothesis == 1 { "is" } else { "es" },
449        c.open_question,
450        if c.open_question == 1 { "" } else { "s" },
451        c.failed_attempt,
452        if c.failed_attempt == 1 { "" } else { "s" },
453    );
454    println!();
455    if !s.mental_models.is_empty() {
456        println!("  Mental models:");
457        for mm in &s.mental_models {
458            println!("    - {} ({}) — {}", mm.qname, mm.author_id, mm.summary);
459        }
460        println!();
461    }
462    if !s.top_hypotheses.is_empty() {
463        println!("  Top hypotheses (by confidence):");
464        for h in &s.top_hypotheses {
465            let conf = h
466                .confidence
467                .map(|c| format!("{:.2}", c))
468                .unwrap_or_else(|| "—".into());
469            println!(
470                "    - [{conf}] {} ({}) — {}",
471                h.qname, h.author_id, h.summary
472            );
473        }
474        println!();
475    }
476    println!("  Read `.asd/conclusions/thinking.jsonl` or run `asd think list` for the full set.");
477    println!("  The starter checklist below still applies for anything the team hasn't covered.");
478    println!();
479}
480
481fn inheritance_json(s: &InheritanceSummary) -> serde_json::Value {
482    let c = &s.counts;
483    json!({
484        "counts_team": {
485            "hypothesis": c.hypothesis,
486            "mental_model": c.mental_model,
487            "failed_attempt": c.failed_attempt,
488            "open_question": c.open_question,
489        },
490        "counts_you": {
491            "hypothesis": c.hypothesis_you,
492            "mental_model": c.mental_model_you,
493            "failed_attempt": c.failed_attempt_you,
494            "open_question": c.open_question_you,
495        },
496        "mental_models": s.mental_models.iter().map(|m| json!({
497            "qname": m.qname, "author": m.author_id, "summary": m.summary,
498        })).collect::<Vec<_>>(),
499        "top_hypotheses": s.top_hypotheses.iter().map(|h| json!({
500            "qname": h.qname, "author": h.author_id,
501            "confidence": h.confidence, "summary": h.summary,
502        })).collect::<Vec<_>>(),
503    })
504}
505
506fn open_with_symbol(cfg: &Config, qname: &str) -> Result<(Engine, String)> {
507    let engine = Engine::open_sqlite(&cfg.db_path)?;
508    let index = AsgIndexStore::from_engine(&engine);
509    let sym = index
510        .get_symbol_by_qname(&engine.ref_name, qname)?
511        .ok_or_else(|| anyhow!("symbol not found: {}", qname))?;
512    Ok((engine, sym.symbol_id))
513}
514
515fn agent_author(cfg: &Config) -> Author {
516    Author {
517        kind: AuthorKind::Agent,
518        id: cfg.agent_id.clone(),
519    }
520}
521
522/// Deterministic entry id so re-running the initial-read prompt
523/// overwrites instead of duplicating. Same (intent, qname, summary)
524/// → same id.
525fn det_id(intent: &str, qname: &str, content: &str) -> String {
526    let key = format!("think:{intent}:{qname}:{content}");
527    let h = blake3::hash(key.as_bytes()).to_hex();
528    let short: String = h.chars().take(24).collect();
529    format!("led_think_{short}")
530}
531
532/// Plan G t-007: read the active CTX task id from `CTX_ACTIVE_TASK`
533/// env var (JSON: `{"task_id": "..."}`) with a fallback to the
534/// `.asd/cache/active-task.json` file under the DB parent. Returns
535/// `None` when neither source is set — callers should skip the tag
536/// in that case.
537///
538/// Mirrors the helper in `commands::map`; kept local so think.rs
539/// stays self-contained.
540fn read_active_ctx_task_id_from(
541    env_raw: Option<&str>,
542    db_parent: Option<&std::path::Path>,
543) -> Option<String> {
544    let raw = match env_raw {
545        Some(s) if !s.is_empty() => s.to_string(),
546        _ => {
547            let p = db_parent?
548                .join(".asd")
549                .join("cache")
550                .join("active-task.json");
551            std::fs::read_to_string(p).ok()?
552        }
553    };
554    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
555    v.get("task_id")?.as_str().map(String::from)
556}
557
558fn active_ctx_task_tag(cfg: &Config) -> Option<String> {
559    let env_raw = std::env::var("CTX_ACTIVE_TASK").ok();
560    let db_parent = std::path::Path::new(&cfg.db_path).parent();
561    read_active_ctx_task_id_from(env_raw.as_deref(), db_parent).map(|id| format!("ctx:task:{id}"))
562}
563
564/// Append `source:asd-think` and (when set) `ctx:task:<id>` to an
565/// entry's tag list. Called by every `asd think *` writer so Plan G
566/// entries inherit the same provenance trail Plan E added for map/ledger.
567fn push_provenance_tags(cfg: &Config, tags: &mut Vec<String>) {
568    tags.push("source:asd-think".into());
569    if let Some(t) = active_ctx_task_tag(cfg) {
570        tags.push(t);
571    }
572}
573
574fn run_speculate(cfg: &Config, args: SpeculateArgs) -> Result<()> {
575    if !(0.0..=1.0).contains(&args.conf) {
576        bail!("--conf must be in [0.0, 1.0]; got {}", args.conf);
577    }
578    let (engine, symbol_id) = open_with_symbol(cfg, &args.qname)?;
579    let ledger = AsgLedgerStore::from_engine(&engine);
580    let mut entry = LedgerEntry::new(
581        &symbol_id,
582        LedgerKind::Hypothesis,
583        &args.summary,
584        agent_author(cfg),
585    );
586    entry.entry_id = det_id("hypothesis", &args.qname, &args.summary);
587    entry.confidence = Some(args.conf);
588    entry.body = args.body;
589    push_provenance_tags(cfg, &mut entry.tags);
590    ledger.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
591    println!(
592        "{}",
593        json!({
594            "ok": true, "kind": "hypothesis", "qname": args.qname,
595            "confidence": args.conf, "entry_id": entry.entry_id,
596        })
597    );
598    Ok(())
599}
600
601fn run_model(cfg: &Config, args: ModelArgs) -> Result<()> {
602    let symbols: Vec<String> = args
603        .symbols
604        .split(',')
605        .map(|s| s.trim().to_string())
606        .filter(|s| !s.is_empty())
607        .collect();
608    if symbols.is_empty() {
609        bail!("--symbols must list at least one qname (comma-separated)");
610    }
611    // Anchor the model on the FIRST symbol — that's the lookup target
612    // for prepare-change's prior_thinking surfacing.
613    let (engine, symbol_id) = open_with_symbol(cfg, &symbols[0])?;
614    let ledger = AsgLedgerStore::from_engine(&engine);
615    let body = json!({ "symbols": symbols, "name": args.name }).to_string();
616    let mut entry = LedgerEntry::new(
617        &symbol_id,
618        LedgerKind::MentalModel,
619        format!("{}: {}", args.name, args.summary),
620        agent_author(cfg),
621    );
622    entry.entry_id = det_id("model", &args.name, &args.summary);
623    entry.body = Some(body);
624    push_provenance_tags(cfg, &mut entry.tags);
625    ledger.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
626    println!(
627        "{}",
628        json!({
629            "ok": true, "kind": "mental_model", "name": args.name,
630            "symbols": symbols, "entry_id": entry.entry_id,
631        })
632    );
633    Ok(())
634}
635
636fn run_failed(cfg: &Config, args: FailedArgs) -> Result<()> {
637    let (engine, symbol_id) = open_with_symbol(cfg, &args.qname)?;
638    let ledger = AsgLedgerStore::from_engine(&engine);
639    let body = json!({ "tried": &args.tried, "because": &args.because }).to_string();
640    let mut entry = LedgerEntry::new(
641        &symbol_id,
642        LedgerKind::FailedAttempt,
643        format!("failed: {} — because {}", args.tried, args.because),
644        agent_author(cfg),
645    );
646    entry.entry_id = det_id("failed", &args.qname, &args.tried);
647    entry.body = Some(body);
648    push_provenance_tags(cfg, &mut entry.tags);
649    ledger.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
650    println!(
651        "{}",
652        json!({
653            "ok": true, "kind": "failed_attempt", "qname": args.qname,
654            "entry_id": entry.entry_id,
655        })
656    );
657    Ok(())
658}
659
660fn run_question(cfg: &Config, args: QuestionArgs) -> Result<()> {
661    let (engine, symbol_id) = open_with_symbol(cfg, &args.qname)?;
662    let ledger = AsgLedgerStore::from_engine(&engine);
663    let mut entry = LedgerEntry::new(
664        &symbol_id,
665        LedgerKind::OpenQuestion,
666        &args.question,
667        agent_author(cfg),
668    );
669    entry.entry_id = det_id("question", &args.qname, &args.question);
670    push_provenance_tags(cfg, &mut entry.tags);
671    ledger.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
672    println!(
673        "{}",
674        json!({
675            "ok": true, "kind": "open_question", "qname": args.qname,
676            "entry_id": entry.entry_id,
677        })
678    );
679    Ok(())
680}
681
682fn run_list(cfg: &Config, args: ListArgs) -> Result<()> {
683    let engine = Engine::open_sqlite(&cfg.db_path)?;
684    let index = AsgIndexStore::from_engine(&engine);
685    let ledger = AsgLedgerStore::from_engine(&engine);
686    let ref_name = engine.ref_name.clone();
687
688    let kind_filter = args.kind.map(|k| match k {
689        ThinkKind::Hypothesis => LedgerKind::Hypothesis,
690        ThinkKind::Model => LedgerKind::MentalModel,
691        ThinkKind::Failed => LedgerKind::FailedAttempt,
692        ThinkKind::Question => LedgerKind::OpenQuestion,
693    });
694
695    // Resolve which symbols to scan → (qname, live entries) pairs.
696    // Workspace-wide: one bulk walk (Plan T scale fix) instead of
697    // per-qname symbol + ledger reads.
698    let symbol_entries: Vec<(String, Vec<LedgerEntry>)> =
699        if let Some(qname) = args.symbol.as_deref() {
700            match index.get_symbol_by_qname(&ref_name, qname)? {
701                Some(sym) => vec![(
702                    sym.qname,
703                    ledger
704                        .list_entries(&ref_name, &sym.symbol_id)
705                        .unwrap_or_default(),
706                )],
707                None => return Err(anyhow!("symbol not found: {qname}")),
708            }
709        } else {
710            agentstatedeveloper_core::thinking::all_symbol_entries(&engine)
711        };
712
713    let mut entries = Vec::new();
714    for (qname, les) in &symbol_entries {
715        for entry in les.iter().cloned() {
716            if entry.kind.conclusion_class() != ConclusionClass::Thinking {
717                continue;
718            }
719            if let Some(filter) = kind_filter {
720                if entry.kind != filter {
721                    continue;
722                }
723            }
724            entries.push(json!({
725                "entry_id": entry.entry_id,
726                "kind": entry.kind.as_str(),
727                "qname": qname,
728                "summary": entry.summary,
729                "confidence": entry.confidence,
730                "body": entry.body,
731                "tags": entry.tags,
732                "created_at": entry.created_at,
733            }));
734        }
735    }
736
737    println!(
738        "{}",
739        serde_json::to_string_pretty(&json!({
740            "total": entries.len(),
741            "kind_filter": kind_filter.map(|k| k.as_str()),
742            "symbol_filter": args.symbol,
743            "entries": entries,
744        }))?
745    );
746    Ok(())
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    /// The embedded prompt must match `docs/initial-read-prompt.md`. Skipped
754    /// outside the source checkout (e.g. in the published package), where
755    /// `docs/` does not exist.
756    #[test]
757    fn initial_read_prompt_matches_docs() {
758        let docs = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
759            .join("../../docs/initial-read-prompt.md");
760        let Ok(text) = std::fs::read_to_string(&docs) else {
761            return;
762        };
763        assert_eq!(
764            INITIAL_READ_PROMPT, text,
765            "assets/initial-read-prompt.md has drifted from docs/initial-read-prompt.md; copy the docs file over it"
766        );
767    }
768
769    #[test]
770    fn ctx_task_id_extracted_from_env_json() {
771        let id = read_active_ctx_task_id_from(Some(r#"{"task_id":"plan-g-005"}"#), None);
772        assert_eq!(id.as_deref(), Some("plan-g-005"));
773    }
774
775    #[test]
776    fn ctx_task_id_returns_none_when_env_empty_and_no_file() {
777        let tmp = tempfile::tempdir().unwrap();
778        assert_eq!(read_active_ctx_task_id_from(None, Some(tmp.path())), None);
779        assert_eq!(
780            read_active_ctx_task_id_from(Some(""), Some(tmp.path())),
781            None
782        );
783    }
784
785    #[test]
786    fn ctx_task_id_falls_back_to_active_task_file() {
787        let tmp = tempfile::tempdir().unwrap();
788        let cache = tmp.path().join(".asd").join("cache");
789        std::fs::create_dir_all(&cache).unwrap();
790        std::fs::write(cache.join("active-task.json"), r#"{"task_id":"file-task"}"#).unwrap();
791        let id = read_active_ctx_task_id_from(None, Some(tmp.path()));
792        assert_eq!(id.as_deref(), Some("file-task"));
793    }
794
795    #[test]
796    fn ctx_task_id_env_wins_over_file() {
797        let tmp = tempfile::tempdir().unwrap();
798        let cache = tmp.path().join(".asd").join("cache");
799        std::fs::create_dir_all(&cache).unwrap();
800        std::fs::write(cache.join("active-task.json"), r#"{"task_id":"file-task"}"#).unwrap();
801        let id = read_active_ctx_task_id_from(Some(r#"{"task_id":"env-task"}"#), Some(tmp.path()));
802        assert_eq!(id.as_deref(), Some("env-task"));
803    }
804
805    // ---- Plan K t-006: inheritance detection logic ----------------------
806    //
807    // `gather_thinking` requires a live engine + Config, which is more
808    // setup than a unit test should own. We unit-test the pure logic
809    // (`has_inheritance` trigger) directly here; integration coverage
810    // of the full bootstrap flow lives in tests/think_bootstrap_inheritance.rs.
811
812    #[test]
813    fn has_inheritance_triggers_on_one_mental_model() {
814        let mut s = InheritanceSummary::default();
815        s.counts.mental_model = 1;
816        assert!(s.has_inheritance());
817    }
818
819    #[test]
820    fn has_inheritance_triggers_on_three_hypotheses() {
821        let mut s = InheritanceSummary::default();
822        s.counts.hypothesis = 3;
823        assert!(s.has_inheritance());
824    }
825
826    #[test]
827    fn has_inheritance_false_when_only_one_or_two_hypotheses() {
828        let mut s = InheritanceSummary::default();
829        s.counts.hypothesis = 2;
830        assert!(
831            !s.has_inheritance(),
832            "two hypotheses without a mental model should NOT trigger inheritance preview"
833        );
834    }
835
836    #[test]
837    fn has_inheritance_false_when_only_questions_or_failures() {
838        let mut s = InheritanceSummary::default();
839        s.counts.open_question = 10;
840        s.counts.failed_attempt = 10;
841        assert!(
842            !s.has_inheritance(),
843            "open questions / failed attempts alone are not the inheritance trigger"
844        );
845    }
846}