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 UnitPromoted {
285 unit: String,
287 from_unit: String,
289 requirement: String,
291 task: String,
293 requirement_sha256: String,
295 conversation_sha256: Vec<String>,
297 signals: Vec<String>,
299 by: String,
301 via: String,
303 },
304 Project {
308 step: ProjectEvent,
310 },
311 AskStep {
313 conversation: String,
315 turn: u32,
317 tool: String,
319 target: String,
321 read: Option<String>,
323 refused: bool,
325 },
326 AskText {
328 conversation: String,
330 turn: u32,
332 delta: String,
334 },
335 AskTurnEnded {
338 tag: String,
340 output: Box<serde_json::Value>,
342 },
343 AskDone {
345 report: Box<speccoder_core::ask::AskReport>,
347 },
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
352#[serde(tag = "event", rename_all = "snake_case")]
353pub enum ProjectEvent {
354 ScaffoldStarted {
356 step: String,
358 argv: Vec<String>,
360 access: String,
362 },
363 ScaffoldFinished {
365 step: String,
367 state: String,
369 exit: Option<i32>,
371 duration_ms: u64,
373 },
374 Created {
376 path_digest: String,
378 mode: String,
380 stack: String,
382 format: String,
384 commit: String,
386 via: String,
388 },
389 Undone {
391 path_digest: String,
393 how: String,
395 via: String,
397 },
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
402pub struct CollectionFile {
403 pub path: String,
405 pub state: speccoder_core::collection::FileState,
407 pub decided_by: Option<speccoder_core::collection::DecidedBy>,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
413#[serde(tag = "kind", rename_all = "snake_case")]
414pub enum OracleEvent {
415 Stage {
417 requirement: String,
419 stage: String,
421 n: u32,
423 of: u32,
425 },
426 Frozen {
428 unit: String,
430 requirement: String,
432 manifest_sha256: String,
434 approval_sha256: String,
436 visible_count: usize,
438 held_out_count: usize,
440 by: String,
442 via: String,
444 },
445 Lapsed {
447 unit: String,
449 requirement: String,
451 frozen_wording_sha256: String,
453 current_wording_sha256: String,
455 },
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460pub struct StatementClass {
461 pub statement: String,
463 pub class: String,
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
469pub struct ShownReproduction {
470 pub statement: String,
472 pub file: String,
474 pub test: String,
476 pub digest: String,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
482pub struct ShownSpecialCase {
483 pub statement: String,
485 pub rejected: bool,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum BugEvent {
493 Recorded {
495 home: String,
497 unit: String,
499 requirement: String,
501 triad_digest: String,
503 via: String,
505 },
506 ReproduceTry {
508 n: u32,
510 of: u32,
512 per_statement: Vec<StatementClass>,
514 },
515 Reproduced {
517 reproductions: Vec<ShownReproduction>,
519 },
520 ConfirmWaiting {
522 digest: String,
524 next: String,
526 },
527 Confirmed {
529 digest: String,
531 by: String,
533 via: String,
535 basis: String,
537 },
538 ConfirmRejected {
540 digest: String,
542 reason: String,
544 },
545 UnchangedSelected {
547 state: String,
549 count: usize,
551 },
552 Discrimination {
554 revert: String,
556 special_case: Vec<ShownSpecialCase>,
558 },
559 Outcome {
561 outcome: String,
563 verdict: Option<String>,
565 code: Option<String>,
567 next: Vec<String>,
569 },
570}
571
572impl BugEvent {
573 #[must_use]
576 pub const fn name(&self) -> &'static str {
577 match self {
578 Self::Recorded { .. } => "bug.recorded",
579 Self::ReproduceTry { .. } => "bug.reproduce_try",
580 Self::Reproduced { .. } => "bug.reproduced",
581 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
582 Self::Confirmed { .. } => "bug.confirmed",
583 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
584 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
585 Self::Discrimination { .. } => "bug.discrimination",
586 Self::Outcome { .. } => "bug.outcome",
587 }
588 }
589
590 #[must_use]
592 pub fn line(&self) -> String {
593 match self {
594 Self::Recorded {
595 unit, requirement, ..
596 } => format!("bug recorded as {requirement} in {unit}"),
597 Self::ReproduceTry {
598 n,
599 of,
600 per_statement,
601 } => format!(
602 "reproduce try {n}/{of}: {}",
603 per_statement
604 .iter()
605 .map(|s| format!("{} {}", s.statement, s.class))
606 .collect::<Vec<_>>()
607 .join(", ")
608 ),
609 Self::Reproduced { reproductions } => format!(
610 "reproduced: {} test(s) fail on an assertion",
611 reproductions
612 .iter()
613 .filter(|r| r.statement.starts_with('E'))
614 .count()
615 ),
616 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
617 Self::Confirmed { by, basis, .. } => {
618 format!("reproductions confirmed by {by} ({basis})")
619 }
620 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
621 Self::UnchangedSelected { state, count } => {
622 format!("pass-to-pass: {count} test(s) ({state})")
623 }
624 Self::Discrimination {
625 revert,
626 special_case,
627 } => format!(
628 "revert {revert}; {} special case(s), {} rejected",
629 special_case.len(),
630 special_case.iter().filter(|s| s.rejected).count()
631 ),
632 Self::Outcome {
633 outcome, verdict, ..
634 } => match verdict {
635 Some(v) => format!("bug {outcome}: {v}"),
636 None => format!("bug {outcome}"),
637 },
638 }
639 }
640}
641
642pub trait Progress: Send + Sync + std::fmt::Debug {
644 fn event(&self, event: &Event);
646}
647
648#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
650pub struct LiveState {
651 pub pid: u32,
653 pub feature: String,
655 pub task: String,
657 pub title: String,
659 #[serde(default)]
661 pub stage: Option<Stage>,
662 #[serde(default)]
664 pub stage_detail: Option<String>,
665 pub last_call: Option<String>,
667 #[serde(default)]
669 pub said: Option<String>,
670 #[serde(default)]
672 pub thinking: Option<String>,
673 #[serde(default)]
675 pub recent: Vec<String>,
676 #[serde(default)]
678 pub contained: u32,
679 pub input_tokens: u64,
681 pub output_tokens: u64,
683 pub usd: Option<f64>,
685 pub finished: Option<String>,
687 pub started_at: u64,
689 pub updated_at: u64,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub agent: Option<LiveAgent>,
694}
695
696#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
699pub struct LiveAgent {
700 pub name: String,
702 pub mode: String,
704 pub decides_alone: bool,
707 #[serde(default)]
709 pub known: bool,
710 #[serde(default)]
712 pub unasked: u32,
713 #[serde(default)]
715 pub plan: Vec<PlanEntry>,
716 pub asked: u32,
718 pub allowed: u32,
720 pub waited: u32,
722 pub refused: u32,
724}
725
726fn now() -> u64 {
727 std::time::SystemTime::now()
728 .duration_since(std::time::UNIX_EPOCH)
729 .map_or(0, |d| d.as_secs())
730}
731
732#[must_use]
734pub fn usd(
735 prices: Option<Prices>,
736 input: u64,
737 output: u64,
738 cache_read: u64,
739 cache_write: u64,
740) -> Option<f64> {
741 prices.map(|p| p.usd(input, output, cache_read, cache_write))
742}
743
744#[derive(Debug)]
746pub struct Live {
747 file: PathBuf,
748 prices: Option<Prices>,
749 sink: Option<Arc<dyn Progress>>,
750 state: Mutex<LiveState>,
751}
752
753impl Live {
754 #[must_use]
756 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
757 let _ = std::fs::create_dir_all(dir);
758 Self {
759 file: dir.join(FILE),
760 prices,
761 sink,
762 state: Mutex::new(LiveState {
763 pid: std::process::id(),
764 ..LiveState::default()
765 }),
766 }
767 }
768
769 fn write(&self, state: &LiveState) {
770 if let Ok(text) = serde_json::to_string_pretty(state) {
771 let tmp = self.file.with_extension("json.tmp");
772 if std::fs::write(&tmp, text).is_ok() {
773 let _ = std::fs::rename(&tmp, &self.file);
774 }
775 }
776 }
777}
778
779impl Progress for Live {
780 fn event(&self, event: &Event) {
781 let mut event = event.clone();
782 match &mut event {
784 Event::Usage {
785 input_tokens,
786 output_tokens,
787 cache_read_tokens,
788 cache_write_tokens,
789 usd: price,
790 ..
791 } => {
792 *price = usd(
793 self.prices,
794 *input_tokens,
795 *output_tokens,
796 *cache_read_tokens,
797 *cache_write_tokens,
798 )
799 }
800 Event::TaskFinished {
801 input_tokens,
802 output_tokens,
803 cache_read_tokens,
804 cache_write_tokens,
805 usd: price,
806 ..
807 } => {
808 *price = usd(
809 self.prices,
810 *input_tokens,
811 *output_tokens,
812 *cache_read_tokens,
813 *cache_write_tokens,
814 )
815 }
816 _ => {}
817 }
818 if let Ok(mut s) = self.state.lock() {
819 match &event {
820 Event::TaskStarted {
821 feature,
822 task,
823 title,
824 } => {
825 *s = LiveState {
826 pid: s.pid,
827 feature: feature.clone(),
828 task: task.clone(),
829 title: title.clone(),
830 started_at: now(),
831 ..LiveState::default()
832 };
833 }
834 Event::Stage {
835 stage,
836 state: StageState::Started,
837 detail,
838 ..
839 } => {
840 s.stage = Some(*stage);
841 s.stage_detail.clone_from(detail);
842 }
843 Event::Said { text, .. } => {
844 s.said = text
845 .lines()
846 .find(|l| !l.trim().is_empty())
847 .map(|l| l.chars().take(160).collect());
848 }
849 Event::Reasoning { text, .. } => {
851 s.thinking = text
852 .lines()
853 .rev()
854 .find(|l| !l.trim().is_empty())
855 .map(|l| l.chars().take(160).collect());
856 }
857 Event::AgentStarted {
858 agent,
859 mode,
860 decides_alone,
861 known,
862 ..
863 } => {
864 s.agent = Some(LiveAgent {
865 name: agent.clone(),
866 mode: mode.clone(),
867 decides_alone: *decides_alone,
868 known: *known,
869 ..LiveAgent::default()
870 });
871 }
872 Event::AgentUnasked { calls, .. } => {
873 if let Some(a) = s.agent.as_mut() {
874 a.unasked = *calls;
875 a.decides_alone = true;
876 }
877 }
878 Event::AgentPlan { entries, .. } => {
879 if let Some(a) = s.agent.as_mut() {
880 a.plan.clone_from(entries);
881 }
882 }
883 Event::ApprovalRequested { .. } => {
884 if let Some(a) = s.agent.as_mut() {
885 a.waited += 1;
886 }
887 }
888 Event::Stage { .. } | Event::Resumed { .. } | Event::ApprovalDecided { .. } => {}
889 Event::ToolCall {
890 turn,
891 tool,
892 target,
893 decision,
894 contained,
895 ..
896 } => {
897 let decision = if *contained {
898 s.contained += 1;
899 "contained"
900 } else {
901 decision.as_str()
902 };
903 if let Some(a) = s.agent.as_mut()
904 && decision != "reported"
905 {
906 a.asked += 1;
907 match decision {
908 "allow" => a.allowed += 1,
909 "deny" => a.refused += 1,
910 _ => {}
911 }
912 }
913 let call = format!("{tool} {target} ({decision})");
914 s.recent.push(format!("turn {turn} {call}"));
915 if s.recent.len() > RECENT {
916 s.recent.remove(0);
917 }
918 s.last_call = Some(call);
919 }
920 Event::Usage {
921 input_tokens,
922 output_tokens,
923 usd,
924 ..
925 } => {
926 s.input_tokens = *input_tokens;
927 s.output_tokens = *output_tokens;
928 s.usd = *usd;
929 }
930 Event::TaskFinished {
931 outcome, message, ..
932 } => s.finished = Some(format!("{outcome}: {message}")),
933 Event::Notice { .. }
934 | Event::RunStarted { .. }
935 | Event::Bug { .. }
936 | Event::Oracle { .. }
937 | Event::CollectionChecked { .. }
938 | Event::PlanningStopped { .. }
939 | Event::LadderOffered { .. }
940 | Event::UnitPromoted { .. }
941 | Event::Project { .. }
942 | Event::AskStep { .. }
943 | Event::AskText { .. }
944 | Event::AskTurnEnded { .. }
945 | Event::AskDone { .. } => {}
946 }
947 s.updated_at = now();
948 self.write(&s);
949 }
950 if let Some(sink) = &self.sink {
951 sink.event(&event);
952 }
953 }
954}
955
956#[must_use]
958pub fn target(tool: &str, args: &serde_json::Value) -> String {
959 let text = match tool {
960 "run_command" => args["argv"]
961 .as_array()
962 .map(|a| {
963 a.iter()
964 .filter_map(|v| v.as_str())
965 .collect::<Vec<_>>()
966 .join(" ")
967 })
968 .unwrap_or_default(),
969 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
970 "report_blocked" => format!(
971 "{}: {}",
972 args["kind"].as_str().unwrap_or("?"),
973 args["evidence"].as_str().unwrap_or_default()
974 ),
975 name if name.starts_with(crate::mcp::PREFIX) => args
977 .as_object()
978 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
979 .unwrap_or_default(),
980 _ => args["path"].as_str().unwrap_or_default().to_owned(),
981 };
982 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
983 if one.chars().count() > 60 {
984 format!("{}…", one.chars().take(59).collect::<String>())
985 } else {
986 one
987 }
988}
989
990#[must_use]
994pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
995 let root = match crate::inspect(path, None).data() {
996 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
997 _ => return None,
998 };
999 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
1000 .ok()?;
1001 let live: LiveState = serde_json::from_str(&text).ok()?;
1002 let running = live.finished.is_none() && alive(live.pid);
1003 Some((live, running))
1004}
1005
1006pub fn alive(pid: u32) -> bool {
1008 i32::try_from(pid)
1009 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
1010}
1011
1012pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1015 status_at(path, state, agentplane::core::Timestamp::now_utc())
1016}
1017
1018#[doc(hidden)]
1021pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1022 let inspected = crate::inspect(path, None);
1024 let root = match inspected.data() {
1025 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1026 _ => return CommandResult::carried("report", &inspected),
1027 };
1028 let plane_dir = crate::worktree::project_state_dir(state, &root);
1029 let res = status_inner(&plane_dir);
1030 let lines = waiting_lines(&plane_dir, now);
1031 if lines.is_empty() {
1032 return res;
1033 }
1034 let mut next = res.next().to_vec();
1035 next.insert(
1036 0,
1037 speccoder_core::next::NextStep {
1038 command: "speccoder report --approvals".to_owned(),
1039 why: "see the waiting calls, then approve or reject them".to_owned(),
1040 },
1041 );
1042 let message = format!("{}\n{}", res.message(), lines.join("\n"));
1043 let status = res.status();
1044 res.reworded(status, &message).with_next(next)
1045}
1046
1047fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1051 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
1055 let rt = match crate::tokio_runtime() {
1056 Ok(rt) => rt,
1057 Err(e) => return unknown(e),
1058 };
1059 let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
1060 Ok(Some(p)) => p,
1061 Ok(None) => return Vec::new(),
1062 Err(e) => return unknown(e.0),
1063 };
1064 let open = match rt.block_on(plane.open_tasks()) {
1065 Ok(open) => open,
1066 Err(e) => return unknown(e.0),
1067 };
1068 open.into_iter()
1069 .filter(|t| crate::approvals::still_waits(t.due_at, now))
1070 .filter_map(|t| {
1071 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
1072 let since = (now - t.created_at).whole_minutes().max(0);
1073 let until = t
1074 .due_at
1075 .map(|d| {
1076 format!(
1077 ", denied as expired in {} h",
1078 (d - now).whole_hours().max(0)
1079 )
1080 })
1081 .unwrap_or_default();
1082 Some(format!(
1083 "waiting for a person: {} — {} (for {since} min{until})",
1084 request.task,
1085 request.covers()
1086 ))
1087 })
1088 .collect()
1089}
1090
1091fn status_inner(plane_dir: &Path) -> CommandResult {
1092 let plane_dir = plane_dir.to_path_buf();
1093 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
1095 let mode = s.mode_line();
1096 if !alive(s.pid) {
1097 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
1098 command: command.to_owned(),
1099 why: why.to_owned(),
1100 };
1101 return CommandResult::new(
1102 "status",
1103 CommandStatus::Ok,
1104 &format!(
1105 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
1106 s.feature, s.pid, s.branch
1107 ),
1108 )
1109 .with_next(vec![
1110 step("speccoder run --in-place", "resume where it stopped"),
1111 step(
1112 "speccoder run --in-place --return",
1113 "save what it left and switch back without running",
1114 ),
1115 ]);
1116 }
1117 let res = status_live(&plane_dir);
1118 let message = format!("{}\n{mode}", res.message());
1119 let status = res.status();
1120 return res.reworded(status, &message);
1121 }
1122 status_live(&plane_dir)
1123}
1124
1125fn status_live(plane_dir: &Path) -> CommandResult {
1127 let file = plane_dir.join(FILE);
1128 let live: LiveState = match std::fs::read_to_string(&file)
1129 .ok()
1130 .and_then(|t| serde_json::from_str(&t).ok())
1131 {
1132 Some(l) => l,
1133 None => {
1134 return CommandResult::new(
1135 "status",
1136 CommandStatus::Ok,
1137 "no run has been recorded for this project yet",
1138 );
1139 }
1140 };
1141 let ago = now().saturating_sub(live.updated_at);
1142 let tokens = format!(
1143 "{} in · {} out{}",
1144 live.input_tokens,
1145 live.output_tokens,
1146 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
1147 );
1148 use speccoder_core::next::{Outcome, next};
1149 let outcome = match (&live.finished, alive(live.pid)) {
1150 (None, true) => Some(Outcome::Running),
1151 (None, false) => Some(Outcome::Interrupted {
1152 task: live.task.clone(),
1153 }),
1154 (Some(_), _) => None,
1155 };
1156 let message = match (&live.finished, alive(live.pid)) {
1157 (None, true) => format!(
1158 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
1159 live.pid,
1160 live.feature,
1161 live.task,
1162 stage_word(&live),
1163 now().saturating_sub(live.started_at),
1164 live.last_call.as_deref().unwrap_or("-"),
1165 live.thinking
1166 .as_deref()
1167 .map(|s| format!("\nthe model thinks (a claim): {s}"))
1168 .unwrap_or_default(),
1169 live.said
1170 .as_deref()
1171 .map(|s| format!("\nthe model says (a claim): “{s}”"))
1172 .unwrap_or_default(),
1173 ),
1174 (None, false) => format!(
1175 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
1176 live.feature,
1177 live.task,
1178 stage_word(&live),
1179 live.pid
1180 ),
1181 (Some(outcome), _) => format!(
1182 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
1183 live.feature, live.task
1184 ),
1185 };
1186 CommandResult::new("status", CommandStatus::Ok, &message)
1187 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
1188}
1189
1190#[must_use]
1192pub fn stage_word(live: &LiveState) -> String {
1193 match (live.stage, &live.stage_detail) {
1194 (Some(st), Some(d)) => format!("{} {d}", st.name()),
1195 (Some(st), None) => st.name().to_owned(),
1196 (None, _) => "starting".to_owned(),
1197 }
1198}
1199
1200#[must_use]
1203pub fn tail(text: &str) -> Vec<String> {
1204 let lines: Vec<String> = text
1205 .lines()
1206 .map(|l| {
1207 l.chars()
1208 .filter(|c| !c.is_control())
1209 .take(200)
1210 .collect::<String>()
1211 })
1212 .filter(|l| !l.trim().is_empty())
1213 .collect();
1214 lines[lines.len().saturating_sub(5)..].to_vec()
1215}
1216
1217#[must_use]
1219pub fn status(path: &Path) -> CommandResult {
1220 match crate::worktree::state_dir() {
1221 Some(state) => status_with(path, &state),
1222 None => CommandResult::new(
1223 "status",
1224 CommandStatus::ConfigError,
1225 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1226 )
1227 .with_code("NO-STATE-DIR"),
1228 }
1229}