1use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13use speccoder_core::stage::{Stage, StageState};
14use speccoder_core::{CommandData, CommandResult, CommandStatus};
15
16use crate::substrate::model_config::Prices;
17
18pub const FILE: &str = "live.json";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct PlannedTask {
24 pub id: String,
26 pub title: String,
28 pub state: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PlanEntry {
35 pub content: String,
37 pub status: String,
39}
40
41pub const RECENT: usize = 12;
43
44#[derive(Debug, Clone, PartialEq, Serialize)]
46#[serde(tag = "event", rename_all = "snake_case")]
47pub enum Event {
48 RunStarted {
51 feature: String,
53 tasks: Vec<PlannedTask>,
55 },
56 TaskStarted {
58 feature: String,
60 task: String,
62 title: String,
64 },
65 Stage {
67 task: String,
69 stage: Stage,
71 state: StageState,
73 detail: Option<String>,
75 elapsed_ms: Option<u64>,
77 tail: Vec<String>,
79 },
80 Resumed {
82 task: String,
84 stage: Option<Stage>,
86 last_call: Option<String>,
88 },
89 ApprovalRequested {
91 task: String,
93 call: String,
95 tool: String,
97 what: String,
99 class: String,
102 object: String,
105 },
106 ApprovalDecided {
108 task: String,
110 call: String,
112 decision: String,
114 scope: Option<String>,
116 by: String,
118 how: String,
120 class: String,
122 object: String,
124 },
125 ToolCall {
127 task: String,
129 turn: u32,
131 tool: String,
133 target: String,
135 decision: String,
137 ok: Option<bool>,
139 tail: Vec<String>,
141 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143 contained: bool,
144 },
145 Usage {
147 task: String,
149 input_tokens: u64,
151 output_tokens: u64,
153 cache_read_tokens: u64,
155 cache_write_tokens: u64,
157 usd: Option<f64>,
159 },
160 TaskFinished {
162 task: String,
164 outcome: String,
166 message: String,
168 run: Option<String>,
170 input_tokens: u64,
172 output_tokens: u64,
174 cache_read_tokens: u64,
176 cache_write_tokens: u64,
178 usd: Option<f64>,
180 regression: Option<speccoder_core::regression::Regression>,
182 },
183 CollectionChecked {
186 task: String,
188 files: Vec<CollectionFile>,
190 },
191 AgentStarted {
195 task: String,
197 agent: String,
199 mode: String,
201 decides_alone: bool,
203 known: bool,
206 },
207 AgentUnasked {
210 task: String,
212 calls: u32,
214 },
215 AgentPlan {
218 task: String,
220 entries: Vec<PlanEntry>,
222 },
223 Said {
227 task: String,
229 turn: u32,
231 text: String,
233 },
234 Reasoning {
238 task: String,
240 turn: u32,
242 text: String,
244 },
245 Notice {
247 text: String,
249 },
250 Bug {
252 task: String,
254 step: BugEvent,
256 },
257 Oracle {
260 step: OracleEvent,
262 },
263 PlanningStopped {
265 unit: Option<String>,
267 kind: String,
269 next_skill: Option<String>,
271 },
272 LadderOffered {
275 unit: String,
277 task: String,
279 signals: Vec<String>,
281 },
282 TaskTicked(Box<speccoder_core::hooks::TaskTicked>),
284 TaskStopped(Box<speccoder_core::hooks::TaskStopped>),
286 UnitPromoted {
289 unit: String,
291 from_unit: String,
293 requirement: String,
295 task: String,
297 requirement_sha256: String,
299 conversation_sha256: Vec<String>,
301 signals: Vec<String>,
303 by: String,
305 via: String,
307 },
308 Project {
312 step: ProjectEvent,
314 },
315 AskStep {
317 conversation: String,
319 turn: u32,
321 tool: String,
323 target: String,
325 read: Option<String>,
327 refused: bool,
329 },
330 AskText {
332 conversation: String,
334 turn: u32,
336 delta: String,
338 },
339 AskTurnEnded {
342 tag: String,
344 output: Box<serde_json::Value>,
346 },
347 AskDone {
349 report: Box<speccoder_core::ask::AskReport>,
351 },
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
356#[serde(tag = "event", rename_all = "snake_case")]
357pub enum ProjectEvent {
358 ScaffoldStarted {
360 step: String,
362 argv: Vec<String>,
364 access: String,
366 },
367 ScaffoldFinished {
369 step: String,
371 state: String,
373 exit: Option<i32>,
375 duration_ms: u64,
377 },
378 Created {
380 path_digest: String,
382 mode: String,
384 stack: String,
386 format: String,
388 commit: String,
390 via: String,
392 },
393 Undone {
395 path_digest: String,
397 how: String,
399 via: String,
401 },
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
406pub struct CollectionFile {
407 pub path: String,
409 pub state: speccoder_core::collection::FileState,
411 pub decided_by: Option<speccoder_core::collection::DecidedBy>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
417#[serde(tag = "kind", rename_all = "snake_case")]
418pub enum OracleEvent {
419 Stage {
421 requirement: String,
423 stage: String,
425 n: u32,
427 of: u32,
429 },
430 Frozen {
432 unit: String,
434 requirement: String,
436 manifest_sha256: String,
438 approval_sha256: String,
440 visible_count: usize,
442 held_out_count: usize,
444 by: String,
446 via: String,
448 },
449 Lapsed {
451 unit: String,
453 requirement: String,
455 frozen_wording_sha256: String,
457 current_wording_sha256: String,
459 },
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
464pub struct StatementClass {
465 pub statement: String,
467 pub class: String,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
473pub struct ShownReproduction {
474 pub statement: String,
476 pub file: String,
478 pub test: String,
480 pub digest: String,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
486pub struct ShownSpecialCase {
487 pub statement: String,
489 pub rejected: bool,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
495#[serde(tag = "kind", rename_all = "snake_case")]
496pub enum BugEvent {
497 Recorded {
499 home: String,
501 unit: String,
503 requirement: String,
505 triad_digest: String,
507 via: String,
509 },
510 ReproduceTry {
512 n: u32,
514 of: u32,
516 per_statement: Vec<StatementClass>,
518 },
519 Reproduced {
521 reproductions: Vec<ShownReproduction>,
523 },
524 ConfirmWaiting {
526 digest: String,
528 next: String,
530 },
531 Confirmed {
533 digest: String,
535 by: String,
537 via: String,
539 basis: String,
541 },
542 ConfirmRejected {
544 digest: String,
546 reason: String,
548 },
549 UnchangedSelected {
551 state: String,
553 count: usize,
555 },
556 Discrimination {
558 revert: String,
560 special_case: Vec<ShownSpecialCase>,
562 },
563 Outcome {
565 outcome: String,
567 verdict: Option<String>,
569 code: Option<String>,
571 next: Vec<String>,
573 },
574}
575
576impl BugEvent {
577 #[must_use]
580 pub const fn name(&self) -> &'static str {
581 match self {
582 Self::Recorded { .. } => "bug.recorded",
583 Self::ReproduceTry { .. } => "bug.reproduce_try",
584 Self::Reproduced { .. } => "bug.reproduced",
585 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
586 Self::Confirmed { .. } => "bug.confirmed",
587 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
588 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
589 Self::Discrimination { .. } => "bug.discrimination",
590 Self::Outcome { .. } => "bug.outcome",
591 }
592 }
593
594 #[must_use]
596 pub fn line(&self) -> String {
597 match self {
598 Self::Recorded {
599 unit, requirement, ..
600 } => format!("bug recorded as {requirement} in {unit}"),
601 Self::ReproduceTry {
602 n,
603 of,
604 per_statement,
605 } => format!(
606 "reproduce try {n}/{of}: {}",
607 per_statement
608 .iter()
609 .map(|s| format!("{} {}", s.statement, s.class))
610 .collect::<Vec<_>>()
611 .join(", ")
612 ),
613 Self::Reproduced { reproductions } => format!(
614 "reproduced: {} test(s) fail on an assertion",
615 reproductions
616 .iter()
617 .filter(|r| r.statement.starts_with('E'))
618 .count()
619 ),
620 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
621 Self::Confirmed { by, basis, .. } => {
622 format!("reproductions confirmed by {by} ({basis})")
623 }
624 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
625 Self::UnchangedSelected { state, count } => {
626 format!("pass-to-pass: {count} test(s) ({state})")
627 }
628 Self::Discrimination {
629 revert,
630 special_case,
631 } => format!(
632 "revert {revert}; {} special case(s), {} rejected",
633 special_case.len(),
634 special_case.iter().filter(|s| s.rejected).count()
635 ),
636 Self::Outcome {
637 outcome, verdict, ..
638 } => match verdict {
639 Some(v) => format!("bug {outcome}: {v}"),
640 None => format!("bug {outcome}"),
641 },
642 }
643 }
644}
645
646pub trait Progress: Send + Sync + std::fmt::Debug {
648 fn event(&self, event: &Event);
650}
651
652#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
654pub struct LiveState {
655 pub pid: u32,
657 pub feature: String,
659 pub task: String,
661 pub title: String,
663 #[serde(default)]
665 pub stage: Option<Stage>,
666 #[serde(default)]
668 pub stage_detail: Option<String>,
669 pub last_call: Option<String>,
671 #[serde(default)]
673 pub said: Option<String>,
674 #[serde(default)]
676 pub thinking: Option<String>,
677 #[serde(default)]
679 pub recent: Vec<String>,
680 #[serde(default)]
682 pub contained: u32,
683 pub input_tokens: u64,
685 pub output_tokens: u64,
687 pub usd: Option<f64>,
689 pub finished: Option<String>,
691 pub started_at: u64,
693 pub updated_at: u64,
695 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub agent: Option<LiveAgent>,
698}
699
700#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
703pub struct LiveAgent {
704 pub name: String,
706 pub mode: String,
708 pub decides_alone: bool,
711 #[serde(default)]
713 pub known: bool,
714 #[serde(default)]
716 pub unasked: u32,
717 #[serde(default)]
719 pub plan: Vec<PlanEntry>,
720 pub asked: u32,
722 pub allowed: u32,
724 pub waited: u32,
726 pub refused: u32,
728}
729
730fn now() -> u64 {
731 std::time::SystemTime::now()
732 .duration_since(std::time::UNIX_EPOCH)
733 .map_or(0, |d| d.as_secs())
734}
735
736#[must_use]
738pub fn usd(
739 prices: Option<Prices>,
740 input: u64,
741 output: u64,
742 cache_read: u64,
743 cache_write: u64,
744) -> Option<f64> {
745 prices.map(|p| p.usd(input, output, cache_read, cache_write))
746}
747
748#[derive(Debug)]
750pub struct Live {
751 file: PathBuf,
752 prices: Option<Prices>,
753 sink: Option<Arc<dyn Progress>>,
754 state: Mutex<LiveState>,
755}
756
757impl Live {
758 #[must_use]
760 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
761 let _ = std::fs::create_dir_all(dir);
762 Self {
763 file: dir.join(FILE),
764 prices,
765 sink,
766 state: Mutex::new(LiveState {
767 pid: std::process::id(),
768 ..LiveState::default()
769 }),
770 }
771 }
772
773 fn write(&self, state: &LiveState) {
774 if let Ok(text) = serde_json::to_string_pretty(state) {
775 let tmp = self.file.with_extension("json.tmp");
776 if std::fs::write(&tmp, text).is_ok() {
777 let _ = std::fs::rename(&tmp, &self.file);
778 }
779 }
780 }
781}
782
783impl Progress for Live {
784 fn event(&self, event: &Event) {
785 let mut event = event.clone();
786 match &mut event {
788 Event::Usage {
789 input_tokens,
790 output_tokens,
791 cache_read_tokens,
792 cache_write_tokens,
793 usd: price,
794 ..
795 } => {
796 *price = usd(
797 self.prices,
798 *input_tokens,
799 *output_tokens,
800 *cache_read_tokens,
801 *cache_write_tokens,
802 )
803 }
804 Event::TaskFinished {
805 input_tokens,
806 output_tokens,
807 cache_read_tokens,
808 cache_write_tokens,
809 usd: price,
810 ..
811 } => {
812 *price = usd(
813 self.prices,
814 *input_tokens,
815 *output_tokens,
816 *cache_read_tokens,
817 *cache_write_tokens,
818 )
819 }
820 _ => {}
821 }
822 if let Ok(mut s) = self.state.lock() {
823 match &event {
824 Event::TaskStarted {
825 feature,
826 task,
827 title,
828 } => {
829 *s = LiveState {
830 pid: s.pid,
831 feature: feature.clone(),
832 task: task.clone(),
833 title: title.clone(),
834 started_at: now(),
835 ..LiveState::default()
836 };
837 }
838 Event::Stage {
839 stage,
840 state: StageState::Started,
841 detail,
842 ..
843 } => {
844 s.stage = Some(*stage);
845 s.stage_detail.clone_from(detail);
846 }
847 Event::Said { text, .. } => {
848 s.said = text
849 .lines()
850 .find(|l| !l.trim().is_empty())
851 .map(|l| l.chars().take(160).collect());
852 }
853 Event::Reasoning { text, .. } => {
855 s.thinking = text
856 .lines()
857 .rev()
858 .find(|l| !l.trim().is_empty())
859 .map(|l| l.chars().take(160).collect());
860 }
861 Event::AgentStarted {
862 agent,
863 mode,
864 decides_alone,
865 known,
866 ..
867 } => {
868 s.agent = Some(LiveAgent {
869 name: agent.clone(),
870 mode: mode.clone(),
871 decides_alone: *decides_alone,
872 known: *known,
873 ..LiveAgent::default()
874 });
875 }
876 Event::AgentUnasked { calls, .. } => {
877 if let Some(a) = s.agent.as_mut() {
878 a.unasked = *calls;
879 a.decides_alone = true;
880 }
881 }
882 Event::AgentPlan { entries, .. } => {
883 if let Some(a) = s.agent.as_mut() {
884 a.plan.clone_from(entries);
885 }
886 }
887 Event::ApprovalRequested { .. } => {
888 if let Some(a) = s.agent.as_mut() {
889 a.waited += 1;
890 }
891 }
892 Event::Stage { .. } | Event::Resumed { .. } | Event::ApprovalDecided { .. } => {}
893 Event::ToolCall {
894 turn,
895 tool,
896 target,
897 decision,
898 contained,
899 ..
900 } => {
901 let decision = if *contained {
902 s.contained += 1;
903 "contained"
904 } else {
905 decision.as_str()
906 };
907 if let Some(a) = s.agent.as_mut()
908 && decision != "reported"
909 {
910 a.asked += 1;
911 match decision {
912 "allow" => a.allowed += 1,
913 "deny" => a.refused += 1,
914 _ => {}
915 }
916 }
917 let call = format!("{tool} {target} ({decision})");
918 s.recent.push(format!("turn {turn} {call}"));
919 if s.recent.len() > RECENT {
920 s.recent.remove(0);
921 }
922 s.last_call = Some(call);
923 }
924 Event::Usage {
925 input_tokens,
926 output_tokens,
927 usd,
928 ..
929 } => {
930 s.input_tokens = *input_tokens;
931 s.output_tokens = *output_tokens;
932 s.usd = *usd;
933 }
934 Event::TaskFinished {
935 outcome, message, ..
936 } => s.finished = Some(format!("{outcome}: {message}")),
937 Event::Notice { .. }
938 | Event::RunStarted { .. }
939 | Event::Bug { .. }
940 | Event::Oracle { .. }
941 | Event::CollectionChecked { .. }
942 | Event::PlanningStopped { .. }
943 | Event::LadderOffered { .. }
944 | Event::UnitPromoted { .. }
945 | Event::TaskTicked(_)
946 | Event::TaskStopped(_)
947 | Event::Project { .. }
948 | Event::AskStep { .. }
949 | Event::AskText { .. }
950 | Event::AskTurnEnded { .. }
951 | Event::AskDone { .. } => {}
952 }
953 s.updated_at = now();
954 self.write(&s);
955 }
956 if let Some(sink) = &self.sink {
957 sink.event(&event);
958 }
959 }
960}
961
962#[must_use]
964pub fn target(tool: &str, args: &serde_json::Value) -> String {
965 let text = match tool {
966 "run_command" => args["argv"]
967 .as_array()
968 .map(|a| {
969 a.iter()
970 .filter_map(|v| v.as_str())
971 .collect::<Vec<_>>()
972 .join(" ")
973 })
974 .unwrap_or_default(),
975 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
976 "report_blocked" => format!(
977 "{}: {}",
978 args["kind"].as_str().unwrap_or("?"),
979 args["evidence"].as_str().unwrap_or_default()
980 ),
981 name if name.starts_with(crate::mcp::PREFIX) => args
983 .as_object()
984 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
985 .unwrap_or_default(),
986 _ => args["path"].as_str().unwrap_or_default().to_owned(),
987 };
988 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
989 if one.chars().count() > 60 {
990 format!("{}…", one.chars().take(59).collect::<String>())
991 } else {
992 one
993 }
994}
995
996#[must_use]
1000pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
1001 let root = match crate::inspect(path, None).data() {
1002 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1003 _ => return None,
1004 };
1005 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
1006 .ok()?;
1007 let live: LiveState = serde_json::from_str(&text).ok()?;
1008 let running = live.finished.is_none() && alive(live.pid);
1009 Some((live, running))
1010}
1011
1012pub fn alive(pid: u32) -> bool {
1014 i32::try_from(pid)
1015 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
1016}
1017
1018pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1021 status_at(path, state, agentplane::core::Timestamp::now_utc())
1022}
1023
1024#[doc(hidden)]
1027pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1028 let inspected = crate::inspect(path, None);
1030 let root = match inspected.data() {
1031 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1032 _ => return CommandResult::carried("report", &inspected),
1033 };
1034 let plane_dir = crate::worktree::project_state_dir(state, &root);
1035 let res = status_inner(&plane_dir);
1036 let lines = waiting_lines(&plane_dir, now);
1037 if lines.is_empty() {
1038 return res;
1039 }
1040 let mut next = res.next().to_vec();
1041 next.insert(
1042 0,
1043 speccoder_core::next::NextStep {
1044 command: "speccoder report --approvals".to_owned(),
1045 why: "see the waiting calls, then approve or reject them".to_owned(),
1046 },
1047 );
1048 let message = format!("{}\n{}", res.message(), lines.join("\n"));
1049 let status = res.status();
1050 res.reworded(status, &message).with_next(next)
1051}
1052
1053fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1057 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
1061 let rt = match crate::tokio_runtime() {
1062 Ok(rt) => rt,
1063 Err(e) => return unknown(e),
1064 };
1065 let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
1066 Ok(Some(p)) => p,
1067 Ok(None) => return Vec::new(),
1068 Err(e) => return unknown(e.0),
1069 };
1070 let open = match rt.block_on(plane.open_tasks()) {
1071 Ok(open) => open,
1072 Err(e) => return unknown(e.0),
1073 };
1074 open.into_iter()
1075 .filter(|t| crate::approvals::still_waits(t.due_at, now))
1076 .filter_map(|t| {
1077 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
1078 let since = (now - t.created_at).whole_minutes().max(0);
1079 let until = t
1080 .due_at
1081 .map(|d| {
1082 format!(
1083 ", denied as expired in {} h",
1084 (d - now).whole_hours().max(0)
1085 )
1086 })
1087 .unwrap_or_default();
1088 Some(format!(
1089 "waiting for a person: {} — {} (for {since} min{until})",
1090 request.task,
1091 request.covers()
1092 ))
1093 })
1094 .collect()
1095}
1096
1097fn status_inner(plane_dir: &Path) -> CommandResult {
1098 let plane_dir = plane_dir.to_path_buf();
1099 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
1101 let mode = s.mode_line();
1102 if !alive(s.pid) {
1103 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
1104 command: command.to_owned(),
1105 why: why.to_owned(),
1106 };
1107 return CommandResult::new(
1108 "status",
1109 CommandStatus::Ok,
1110 &format!(
1111 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
1112 s.feature, s.pid, s.branch
1113 ),
1114 )
1115 .with_next(vec![
1116 step("speccoder run --in-place", "resume where it stopped"),
1117 step(
1118 "speccoder run --in-place --return",
1119 "save what it left and switch back without running",
1120 ),
1121 ]);
1122 }
1123 let res = status_live(&plane_dir);
1124 let message = format!("{}\n{mode}", res.message());
1125 let status = res.status();
1126 return res.reworded(status, &message);
1127 }
1128 status_live(&plane_dir)
1129}
1130
1131fn status_live(plane_dir: &Path) -> CommandResult {
1133 let file = plane_dir.join(FILE);
1134 let live: LiveState = match std::fs::read_to_string(&file)
1135 .ok()
1136 .and_then(|t| serde_json::from_str(&t).ok())
1137 {
1138 Some(l) => l,
1139 None => {
1140 return CommandResult::new(
1141 "status",
1142 CommandStatus::Ok,
1143 "no run has been recorded for this project yet",
1144 );
1145 }
1146 };
1147 let ago = now().saturating_sub(live.updated_at);
1148 let tokens = format!(
1149 "{} in · {} out{}",
1150 live.input_tokens,
1151 live.output_tokens,
1152 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
1153 );
1154 use speccoder_core::next::{Outcome, next};
1155 let outcome = match (&live.finished, alive(live.pid)) {
1156 (None, true) => Some(Outcome::Running),
1157 (None, false) => Some(Outcome::Interrupted {
1158 task: live.task.clone(),
1159 }),
1160 (Some(_), _) => None,
1161 };
1162 let message = match (&live.finished, alive(live.pid)) {
1163 (None, true) => format!(
1164 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
1165 live.pid,
1166 live.feature,
1167 live.task,
1168 stage_word(&live),
1169 now().saturating_sub(live.started_at),
1170 live.last_call.as_deref().unwrap_or("-"),
1171 live.thinking
1172 .as_deref()
1173 .map(|s| format!("\nthe model thinks (a claim): {s}"))
1174 .unwrap_or_default(),
1175 live.said
1176 .as_deref()
1177 .map(|s| format!("\nthe model says (a claim): “{s}”"))
1178 .unwrap_or_default(),
1179 ),
1180 (None, false) => format!(
1181 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
1182 live.feature,
1183 live.task,
1184 stage_word(&live),
1185 live.pid
1186 ),
1187 (Some(outcome), _) => format!(
1188 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
1189 live.feature, live.task
1190 ),
1191 };
1192 CommandResult::new("status", CommandStatus::Ok, &message)
1193 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
1194}
1195
1196#[must_use]
1198pub fn stage_word(live: &LiveState) -> String {
1199 match (live.stage, &live.stage_detail) {
1200 (Some(st), Some(d)) => format!("{} {d}", st.name()),
1201 (Some(st), None) => st.name().to_owned(),
1202 (None, _) => "starting".to_owned(),
1203 }
1204}
1205
1206#[must_use]
1209pub fn tail(text: &str) -> Vec<String> {
1210 let lines: Vec<String> = text
1211 .lines()
1212 .map(|l| {
1213 l.chars()
1214 .filter(|c| !c.is_control())
1215 .take(200)
1216 .collect::<String>()
1217 })
1218 .filter(|l| !l.trim().is_empty())
1219 .collect();
1220 lines[lines.len().saturating_sub(5)..].to_vec()
1221}
1222
1223#[must_use]
1225pub fn status(path: &Path) -> CommandResult {
1226 match crate::worktree::state_dir() {
1227 Some(state) => status_with(path, &state),
1228 None => CommandResult::new(
1229 "status",
1230 CommandStatus::ConfigError,
1231 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1232 )
1233 .with_code("NO-STATE-DIR"),
1234 }
1235}