1use 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
24pub const INITIAL_READ_PROMPT: &str = include_str!("../../assets/initial-read-prompt.md");
31
32#[derive(Debug, Subcommand)]
33pub enum ThinkCmd {
34 Speculate(SpeculateArgs),
36 Model(ModelArgs),
38 Failed(FailedArgs),
40 Question(QuestionArgs),
42 List(ListArgs),
44 Prompt,
49 Bootstrap(BootstrapArgs),
52}
53
54#[derive(Debug, Args)]
55pub struct BootstrapArgs {
56 #[arg(long)]
59 pub check: bool,
60 #[arg(long)]
62 pub json: bool,
63}
64
65#[derive(Debug, Args)]
66pub struct SpeculateArgs {
67 pub qname: String,
69 #[arg(long)]
72 pub conf: f64,
73 #[arg(long)]
75 pub summary: String,
76 #[arg(long)]
78 pub body: Option<String>,
79}
80
81#[derive(Debug, Args)]
82pub struct ModelArgs {
83 pub name: String,
85 #[arg(long)]
87 pub symbols: String,
88 #[arg(long)]
90 pub summary: String,
91}
92
93#[derive(Debug, Args)]
94pub struct FailedArgs {
95 pub qname: String,
97 #[arg(long)]
99 pub tried: String,
100 #[arg(long)]
102 pub because: String,
103}
104
105#[derive(Debug, Args)]
106pub struct QuestionArgs {
107 pub qname: String,
109 #[arg(long = "q")]
111 pub question: String,
112}
113
114#[derive(Debug, Args)]
115pub struct ListArgs {
116 #[arg(long, value_enum)]
118 pub kind: Option<ThinkKind>,
119 #[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 print!("{}", INITIAL_READ_PROMPT);
144 Ok(())
145 }
146 ThinkCmd::Bootstrap(a) => run_bootstrap(cfg, a),
147 }
148}
149
150#[derive(Debug, Default, Clone)]
153struct ThinkingCounts {
154 hypothesis: usize,
155 mental_model: usize,
156 failed_attempt: usize,
157 open_question: usize,
158 hypothesis_you: usize,
160 mental_model_you: usize,
161 failed_attempt_you: usize,
162 open_question_you: usize,
163}
164
165#[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 mental_models: Vec<InheritedSample>,
181 top_hypotheses: Vec<InheritedSample>,
183}
184
185impl InheritanceSummary {
186 fn has_inheritance(&self) -> bool {
189 self.counts.mental_model >= 1 || self.counts.hypothesis >= 3
190 }
191}
192
193fn 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 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 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#[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 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 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 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
434fn 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
522fn 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
532fn 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
564fn 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 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 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 #[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 #[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}