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 },
142 Usage {
144 task: String,
146 input_tokens: u64,
148 output_tokens: u64,
150 cache_read_tokens: u64,
152 cache_write_tokens: u64,
154 usd: Option<f64>,
156 },
157 TaskFinished {
159 task: String,
161 outcome: String,
163 message: String,
165 run: Option<String>,
167 input_tokens: u64,
169 output_tokens: u64,
171 cache_read_tokens: u64,
173 cache_write_tokens: u64,
175 usd: Option<f64>,
177 regression: Option<speccoder_core::regression::Regression>,
179 },
180 CollectionChecked {
183 task: String,
185 files: Vec<CollectionFile>,
187 },
188 RegressionBisected {
190 task: String,
192 runs: u32,
194 result: Option<String>,
196 },
197 AgentStarted {
201 task: String,
203 agent: String,
205 mode: String,
207 decides_alone: bool,
209 known: bool,
212 },
213 AgentUnasked {
216 task: String,
218 calls: u32,
220 },
221 AgentPlan {
224 task: String,
226 entries: Vec<PlanEntry>,
228 },
229 Said {
233 task: String,
235 turn: u32,
237 text: String,
239 },
240 Reasoning {
244 task: String,
246 turn: u32,
248 text: String,
250 },
251 Notice {
253 text: String,
255 },
256 Bug {
258 task: String,
260 step: BugEvent,
262 },
263 Oracle {
266 step: OracleEvent,
268 },
269 PlanningStopped {
271 unit: Option<String>,
273 kind: String,
275 next_skill: Option<String>,
277 },
278 LadderOffered {
281 unit: String,
283 task: String,
285 signals: Vec<String>,
287 },
288 UnitPromoted {
291 unit: String,
293 from_unit: String,
295 requirement: String,
297 task: String,
299 requirement_sha256: String,
301 conversation_sha256: Vec<String>,
303 signals: Vec<String>,
305 by: String,
307 via: String,
309 },
310 Project {
314 step: ProjectEvent,
316 },
317 AskStep {
319 conversation: String,
321 turn: u32,
323 tool: String,
325 target: String,
327 read: Option<String>,
329 refused: bool,
331 },
332 AskText {
334 conversation: String,
336 turn: u32,
338 delta: String,
340 },
341 AskTurnEnded {
344 tag: String,
346 output: Box<serde_json::Value>,
348 },
349 AskDone {
351 report: Box<speccoder_core::ask::AskReport>,
353 },
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
358#[serde(tag = "event", rename_all = "snake_case")]
359pub enum ProjectEvent {
360 ScaffoldStarted {
362 step: String,
364 argv: Vec<String>,
366 access: String,
368 },
369 ScaffoldFinished {
371 step: String,
373 state: String,
375 exit: Option<i32>,
377 duration_ms: u64,
379 },
380 Created {
382 path_digest: String,
384 mode: String,
386 stack: String,
388 format: String,
390 commit: String,
392 via: String,
394 },
395 Undone {
397 path_digest: String,
399 how: String,
401 via: String,
403 },
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
408pub struct CollectionFile {
409 pub path: String,
411 pub state: speccoder_core::collection::FileState,
413 pub decided_by: Option<speccoder_core::collection::DecidedBy>,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
419#[serde(tag = "kind", rename_all = "snake_case")]
420pub enum OracleEvent {
421 Stage {
423 requirement: String,
425 stage: String,
427 n: u32,
429 of: u32,
431 },
432 Frozen {
434 unit: String,
436 requirement: String,
438 manifest_sha256: String,
440 approval_sha256: String,
442 visible_count: usize,
444 held_out_count: usize,
446 by: String,
448 via: String,
450 },
451 Lapsed {
453 unit: String,
455 requirement: String,
457 frozen_wording_sha256: String,
459 current_wording_sha256: String,
461 },
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
466pub struct StatementClass {
467 pub statement: String,
469 pub class: String,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
475pub struct ShownReproduction {
476 pub statement: String,
478 pub file: String,
480 pub test: String,
482 pub digest: String,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488pub struct ShownSpecialCase {
489 pub statement: String,
491 pub rejected: bool,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
497#[serde(tag = "kind", rename_all = "snake_case")]
498pub enum BugEvent {
499 Recorded {
501 home: String,
503 unit: String,
505 requirement: String,
507 triad_digest: String,
509 via: String,
511 },
512 ReproduceTry {
514 n: u32,
516 of: u32,
518 per_statement: Vec<StatementClass>,
520 },
521 Reproduced {
523 reproductions: Vec<ShownReproduction>,
525 },
526 ConfirmWaiting {
528 digest: String,
530 next: String,
532 },
533 Confirmed {
535 digest: String,
537 by: String,
539 via: String,
541 basis: String,
543 },
544 ConfirmRejected {
546 digest: String,
548 reason: String,
550 },
551 UnchangedSelected {
553 state: String,
555 count: usize,
557 },
558 Discrimination {
560 revert: String,
562 special_case: Vec<ShownSpecialCase>,
564 },
565 Outcome {
567 outcome: String,
569 verdict: Option<String>,
571 code: Option<String>,
573 next: Vec<String>,
575 },
576}
577
578impl BugEvent {
579 #[must_use]
582 pub const fn name(&self) -> &'static str {
583 match self {
584 Self::Recorded { .. } => "bug.recorded",
585 Self::ReproduceTry { .. } => "bug.reproduce_try",
586 Self::Reproduced { .. } => "bug.reproduced",
587 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
588 Self::Confirmed { .. } => "bug.confirmed",
589 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
590 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
591 Self::Discrimination { .. } => "bug.discrimination",
592 Self::Outcome { .. } => "bug.outcome",
593 }
594 }
595
596 #[must_use]
598 pub fn line(&self) -> String {
599 match self {
600 Self::Recorded {
601 unit, requirement, ..
602 } => format!("bug recorded as {requirement} in {unit}"),
603 Self::ReproduceTry {
604 n,
605 of,
606 per_statement,
607 } => format!(
608 "reproduce try {n}/{of}: {}",
609 per_statement
610 .iter()
611 .map(|s| format!("{} {}", s.statement, s.class))
612 .collect::<Vec<_>>()
613 .join(", ")
614 ),
615 Self::Reproduced { reproductions } => format!(
616 "reproduced: {} test(s) fail on an assertion",
617 reproductions
618 .iter()
619 .filter(|r| r.statement.starts_with('E'))
620 .count()
621 ),
622 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
623 Self::Confirmed { by, basis, .. } => {
624 format!("reproductions confirmed by {by} ({basis})")
625 }
626 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
627 Self::UnchangedSelected { state, count } => {
628 format!("pass-to-pass: {count} test(s) ({state})")
629 }
630 Self::Discrimination {
631 revert,
632 special_case,
633 } => format!(
634 "revert {revert}; {} special case(s), {} rejected",
635 special_case.len(),
636 special_case.iter().filter(|s| s.rejected).count()
637 ),
638 Self::Outcome {
639 outcome, verdict, ..
640 } => match verdict {
641 Some(v) => format!("bug {outcome}: {v}"),
642 None => format!("bug {outcome}"),
643 },
644 }
645 }
646}
647
648pub trait Progress: Send + Sync + std::fmt::Debug {
650 fn event(&self, event: &Event);
652}
653
654#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
656pub struct LiveState {
657 pub pid: u32,
659 pub feature: String,
661 pub task: String,
663 pub title: String,
665 #[serde(default)]
667 pub stage: Option<Stage>,
668 #[serde(default)]
670 pub stage_detail: Option<String>,
671 pub last_call: Option<String>,
673 #[serde(default)]
675 pub said: Option<String>,
676 #[serde(default)]
678 pub thinking: Option<String>,
679 #[serde(default)]
681 pub recent: Vec<String>,
682 pub input_tokens: u64,
684 pub output_tokens: u64,
686 pub usd: Option<f64>,
688 pub finished: Option<String>,
690 pub started_at: u64,
692 pub updated_at: u64,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub agent: Option<LiveAgent>,
697}
698
699#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
702pub struct LiveAgent {
703 pub name: String,
705 pub mode: String,
707 pub decides_alone: bool,
710 #[serde(default)]
712 pub known: bool,
713 #[serde(default)]
715 pub unasked: u32,
716 #[serde(default)]
718 pub plan: Vec<PlanEntry>,
719 pub asked: u32,
721 pub allowed: u32,
723 pub waited: u32,
725 pub refused: u32,
727}
728
729fn now() -> u64 {
730 std::time::SystemTime::now()
731 .duration_since(std::time::UNIX_EPOCH)
732 .map_or(0, |d| d.as_secs())
733}
734
735#[must_use]
737pub fn usd(
738 prices: Option<Prices>,
739 input: u64,
740 output: u64,
741 cache_read: u64,
742 cache_write: u64,
743) -> Option<f64> {
744 prices.map(|p| p.usd(input, output, cache_read, cache_write))
745}
746
747#[derive(Debug)]
749pub struct Live {
750 file: PathBuf,
751 prices: Option<Prices>,
752 sink: Option<Arc<dyn Progress>>,
753 state: Mutex<LiveState>,
754}
755
756impl Live {
757 #[must_use]
759 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
760 let _ = std::fs::create_dir_all(dir);
761 Self {
762 file: dir.join(FILE),
763 prices,
764 sink,
765 state: Mutex::new(LiveState {
766 pid: std::process::id(),
767 ..LiveState::default()
768 }),
769 }
770 }
771
772 fn write(&self, state: &LiveState) {
773 if let Ok(text) = serde_json::to_string_pretty(state) {
774 let tmp = self.file.with_extension("json.tmp");
775 if std::fs::write(&tmp, text).is_ok() {
776 let _ = std::fs::rename(&tmp, &self.file);
777 }
778 }
779 }
780}
781
782impl Progress for Live {
783 fn event(&self, event: &Event) {
784 let mut event = event.clone();
785 match &mut event {
787 Event::Usage {
788 input_tokens,
789 output_tokens,
790 cache_read_tokens,
791 cache_write_tokens,
792 usd: price,
793 ..
794 } => {
795 *price = usd(
796 self.prices,
797 *input_tokens,
798 *output_tokens,
799 *cache_read_tokens,
800 *cache_write_tokens,
801 )
802 }
803 Event::TaskFinished {
804 input_tokens,
805 output_tokens,
806 cache_read_tokens,
807 cache_write_tokens,
808 usd: price,
809 ..
810 } => {
811 *price = usd(
812 self.prices,
813 *input_tokens,
814 *output_tokens,
815 *cache_read_tokens,
816 *cache_write_tokens,
817 )
818 }
819 _ => {}
820 }
821 if let Ok(mut s) = self.state.lock() {
822 match &event {
823 Event::TaskStarted {
824 feature,
825 task,
826 title,
827 } => {
828 *s = LiveState {
829 pid: s.pid,
830 feature: feature.clone(),
831 task: task.clone(),
832 title: title.clone(),
833 started_at: now(),
834 ..LiveState::default()
835 };
836 }
837 Event::Stage {
838 stage,
839 state: StageState::Started,
840 detail,
841 ..
842 } => {
843 s.stage = Some(*stage);
844 s.stage_detail.clone_from(detail);
845 }
846 Event::Said { text, .. } => {
847 s.said = text
848 .lines()
849 .find(|l| !l.trim().is_empty())
850 .map(|l| l.chars().take(160).collect());
851 }
852 Event::Reasoning { text, .. } => {
854 s.thinking = text
855 .lines()
856 .rev()
857 .find(|l| !l.trim().is_empty())
858 .map(|l| l.chars().take(160).collect());
859 }
860 Event::AgentStarted {
861 agent,
862 mode,
863 decides_alone,
864 known,
865 ..
866 } => {
867 s.agent = Some(LiveAgent {
868 name: agent.clone(),
869 mode: mode.clone(),
870 decides_alone: *decides_alone,
871 known: *known,
872 ..LiveAgent::default()
873 });
874 }
875 Event::AgentUnasked { calls, .. } => {
876 if let Some(a) = s.agent.as_mut() {
877 a.unasked = *calls;
878 a.decides_alone = true;
879 }
880 }
881 Event::AgentPlan { entries, .. } => {
882 if let Some(a) = s.agent.as_mut() {
883 a.plan.clone_from(entries);
884 }
885 }
886 Event::ApprovalRequested { .. } => {
887 if let Some(a) = s.agent.as_mut() {
888 a.waited += 1;
889 }
890 }
891 Event::Stage { .. } | Event::Resumed { .. } | Event::ApprovalDecided { .. } => {}
892 Event::ToolCall {
893 turn,
894 tool,
895 target,
896 decision,
897 ..
898 } => {
899 if let Some(a) = s.agent.as_mut()
900 && decision != "reported"
901 {
902 a.asked += 1;
903 match decision.as_str() {
904 "allow" => a.allowed += 1,
905 "deny" => a.refused += 1,
906 _ => {}
907 }
908 }
909 let call = format!("{tool} {target} ({decision})");
910 s.recent.push(format!("turn {turn} {call}"));
911 if s.recent.len() > RECENT {
912 s.recent.remove(0);
913 }
914 s.last_call = Some(call);
915 }
916 Event::Usage {
917 input_tokens,
918 output_tokens,
919 usd,
920 ..
921 } => {
922 s.input_tokens = *input_tokens;
923 s.output_tokens = *output_tokens;
924 s.usd = *usd;
925 }
926 Event::TaskFinished {
927 outcome, message, ..
928 } => s.finished = Some(format!("{outcome}: {message}")),
929 Event::Notice { .. }
930 | Event::RunStarted { .. }
931 | Event::Bug { .. }
932 | Event::Oracle { .. }
933 | Event::CollectionChecked { .. }
934 | Event::RegressionBisected { .. }
935 | Event::PlanningStopped { .. }
936 | Event::LadderOffered { .. }
937 | Event::UnitPromoted { .. }
938 | Event::Project { .. }
939 | Event::AskStep { .. }
940 | Event::AskText { .. }
941 | Event::AskTurnEnded { .. }
942 | Event::AskDone { .. } => {}
943 }
944 s.updated_at = now();
945 self.write(&s);
946 }
947 if let Some(sink) = &self.sink {
948 sink.event(&event);
949 }
950 }
951}
952
953#[must_use]
955pub fn target(tool: &str, args: &serde_json::Value) -> String {
956 let text = match tool {
957 "run_command" => args["argv"]
958 .as_array()
959 .map(|a| {
960 a.iter()
961 .filter_map(|v| v.as_str())
962 .collect::<Vec<_>>()
963 .join(" ")
964 })
965 .unwrap_or_default(),
966 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
967 "report_blocked" => format!(
968 "{}: {}",
969 args["kind"].as_str().unwrap_or("?"),
970 args["evidence"].as_str().unwrap_or_default()
971 ),
972 name if name.starts_with(crate::mcp::PREFIX) => args
974 .as_object()
975 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
976 .unwrap_or_default(),
977 _ => args["path"].as_str().unwrap_or_default().to_owned(),
978 };
979 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
980 if one.chars().count() > 60 {
981 format!("{}…", one.chars().take(59).collect::<String>())
982 } else {
983 one
984 }
985}
986
987#[must_use]
991pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
992 let root = match crate::inspect(path, None).data() {
993 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
994 _ => return None,
995 };
996 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
997 .ok()?;
998 let live: LiveState = serde_json::from_str(&text).ok()?;
999 let running = live.finished.is_none() && alive(live.pid);
1000 Some((live, running))
1001}
1002
1003pub fn alive(pid: u32) -> bool {
1005 i32::try_from(pid)
1006 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
1007}
1008
1009pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1012 status_at(path, state, agentplane::core::Timestamp::now_utc())
1013}
1014
1015#[doc(hidden)]
1018pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1019 let inspected = crate::inspect(path, None);
1021 let root = match inspected.data() {
1022 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1023 _ => return CommandResult::carried("report", &inspected),
1024 };
1025 let plane_dir = crate::worktree::project_state_dir(state, &root);
1026 let res = status_inner(&plane_dir);
1027 let lines = waiting_lines(&plane_dir, now);
1028 if lines.is_empty() {
1029 return res;
1030 }
1031 let mut next = res.next().to_vec();
1032 next.insert(
1033 0,
1034 speccoder_core::next::NextStep {
1035 command: "speccoder report --approvals".to_owned(),
1036 why: "see the waiting calls, then approve or reject them".to_owned(),
1037 },
1038 );
1039 let message = format!("{}\n{}", res.message(), lines.join("\n"));
1040 let status = res.status();
1041 res.reworded(status, &message).with_next(next)
1042}
1043
1044fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1048 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
1052 let rt = match crate::tokio_runtime() {
1053 Ok(rt) => rt,
1054 Err(e) => return unknown(e),
1055 };
1056 let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
1057 Ok(Some(p)) => p,
1058 Ok(None) => return Vec::new(),
1059 Err(e) => return unknown(e.0),
1060 };
1061 let open = match rt.block_on(plane.open_tasks()) {
1062 Ok(open) => open,
1063 Err(e) => return unknown(e.0),
1064 };
1065 open.into_iter()
1066 .filter(|t| crate::approvals::still_waits(t.due_at, now))
1067 .filter_map(|t| {
1068 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
1069 let since = (now - t.created_at).whole_minutes().max(0);
1070 let until = t
1071 .due_at
1072 .map(|d| {
1073 format!(
1074 ", denied as expired in {} h",
1075 (d - now).whole_hours().max(0)
1076 )
1077 })
1078 .unwrap_or_default();
1079 Some(format!(
1080 "waiting for a person: {} — {} (for {since} min{until})",
1081 request.task,
1082 request.covers()
1083 ))
1084 })
1085 .collect()
1086}
1087
1088fn status_inner(plane_dir: &Path) -> CommandResult {
1089 let plane_dir = plane_dir.to_path_buf();
1090 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
1092 let mode = s.mode_line();
1093 if !alive(s.pid) {
1094 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
1095 command: command.to_owned(),
1096 why: why.to_owned(),
1097 };
1098 return CommandResult::new(
1099 "status",
1100 CommandStatus::Ok,
1101 &format!(
1102 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
1103 s.feature, s.pid, s.branch
1104 ),
1105 )
1106 .with_next(vec![
1107 step("speccoder run --in-place", "resume where it stopped"),
1108 step(
1109 "speccoder run --in-place --return",
1110 "save what it left and switch back without running",
1111 ),
1112 ]);
1113 }
1114 let res = status_live(&plane_dir);
1115 let message = format!("{}\n{mode}", res.message());
1116 let status = res.status();
1117 return res.reworded(status, &message);
1118 }
1119 status_live(&plane_dir)
1120}
1121
1122fn status_live(plane_dir: &Path) -> CommandResult {
1124 let file = plane_dir.join(FILE);
1125 let live: LiveState = match std::fs::read_to_string(&file)
1126 .ok()
1127 .and_then(|t| serde_json::from_str(&t).ok())
1128 {
1129 Some(l) => l,
1130 None => {
1131 return CommandResult::new(
1132 "status",
1133 CommandStatus::Ok,
1134 "no run has been recorded for this project yet",
1135 );
1136 }
1137 };
1138 let ago = now().saturating_sub(live.updated_at);
1139 let tokens = format!(
1140 "{} in · {} out{}",
1141 live.input_tokens,
1142 live.output_tokens,
1143 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
1144 );
1145 use speccoder_core::next::{Outcome, next};
1146 let outcome = match (&live.finished, alive(live.pid)) {
1147 (None, true) => Some(Outcome::Running),
1148 (None, false) => Some(Outcome::Interrupted {
1149 task: live.task.clone(),
1150 }),
1151 (Some(_), _) => None,
1152 };
1153 let message = match (&live.finished, alive(live.pid)) {
1154 (None, true) => format!(
1155 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
1156 live.pid,
1157 live.feature,
1158 live.task,
1159 stage_word(&live),
1160 now().saturating_sub(live.started_at),
1161 live.last_call.as_deref().unwrap_or("-"),
1162 live.thinking
1163 .as_deref()
1164 .map(|s| format!("\nthe model thinks (a claim): {s}"))
1165 .unwrap_or_default(),
1166 live.said
1167 .as_deref()
1168 .map(|s| format!("\nthe model says (a claim): “{s}”"))
1169 .unwrap_or_default(),
1170 ),
1171 (None, false) => format!(
1172 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
1173 live.feature,
1174 live.task,
1175 stage_word(&live),
1176 live.pid
1177 ),
1178 (Some(outcome), _) => format!(
1179 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
1180 live.feature, live.task
1181 ),
1182 };
1183 CommandResult::new("status", CommandStatus::Ok, &message)
1184 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
1185}
1186
1187#[must_use]
1189pub fn stage_word(live: &LiveState) -> String {
1190 match (live.stage, &live.stage_detail) {
1191 (Some(st), Some(d)) => format!("{} {d}", st.name()),
1192 (Some(st), None) => st.name().to_owned(),
1193 (None, _) => "starting".to_owned(),
1194 }
1195}
1196
1197#[must_use]
1200pub fn tail(text: &str) -> Vec<String> {
1201 let lines: Vec<String> = text
1202 .lines()
1203 .map(|l| {
1204 l.chars()
1205 .filter(|c| !c.is_control())
1206 .take(200)
1207 .collect::<String>()
1208 })
1209 .filter(|l| !l.trim().is_empty())
1210 .collect();
1211 lines[lines.len().saturating_sub(5)..].to_vec()
1212}
1213
1214#[must_use]
1216pub fn status(path: &Path) -> CommandResult {
1217 match crate::worktree::state_dir() {
1218 Some(state) => status_with(path, &state),
1219 None => CommandResult::new(
1220 "status",
1221 CommandStatus::ConfigError,
1222 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1223 )
1224 .with_code("NO-STATE-DIR"),
1225 }
1226}