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 AgentStarted {
192 task: String,
194 agent: String,
196 mode: String,
198 decides_alone: bool,
200 known: bool,
203 },
204 AgentUnasked {
207 task: String,
209 calls: u32,
211 },
212 AgentPlan {
215 task: String,
217 entries: Vec<PlanEntry>,
219 },
220 Said {
224 task: String,
226 turn: u32,
228 text: String,
230 },
231 Reasoning {
235 task: String,
237 turn: u32,
239 text: String,
241 },
242 Notice {
244 text: String,
246 },
247 Bug {
249 task: String,
251 step: BugEvent,
253 },
254 Oracle {
257 step: OracleEvent,
259 },
260 PlanningStopped {
262 unit: Option<String>,
264 kind: String,
266 next_skill: Option<String>,
268 },
269 LadderOffered {
272 unit: String,
274 task: String,
276 signals: Vec<String>,
278 },
279 UnitPromoted {
282 unit: String,
284 from_unit: String,
286 requirement: String,
288 task: String,
290 requirement_sha256: String,
292 conversation_sha256: Vec<String>,
294 signals: Vec<String>,
296 by: String,
298 via: String,
300 },
301 Project {
305 step: ProjectEvent,
307 },
308 AskStep {
310 conversation: String,
312 turn: u32,
314 tool: String,
316 target: String,
318 read: Option<String>,
320 refused: bool,
322 },
323 AskText {
325 conversation: String,
327 turn: u32,
329 delta: String,
331 },
332 AskTurnEnded {
335 tag: String,
337 output: Box<serde_json::Value>,
339 },
340 AskDone {
342 report: Box<speccoder_core::ask::AskReport>,
344 },
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349#[serde(tag = "event", rename_all = "snake_case")]
350pub enum ProjectEvent {
351 ScaffoldStarted {
353 step: String,
355 argv: Vec<String>,
357 access: String,
359 },
360 ScaffoldFinished {
362 step: String,
364 state: String,
366 exit: Option<i32>,
368 duration_ms: u64,
370 },
371 Created {
373 path_digest: String,
375 mode: String,
377 stack: String,
379 format: String,
381 commit: String,
383 via: String,
385 },
386 Undone {
388 path_digest: String,
390 how: String,
392 via: String,
394 },
395}
396
397#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
399pub struct CollectionFile {
400 pub path: String,
402 pub state: speccoder_core::collection::FileState,
404 pub decided_by: Option<speccoder_core::collection::DecidedBy>,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
410#[serde(tag = "kind", rename_all = "snake_case")]
411pub enum OracleEvent {
412 Stage {
414 requirement: String,
416 stage: String,
418 n: u32,
420 of: u32,
422 },
423 Frozen {
425 unit: String,
427 requirement: String,
429 manifest_sha256: String,
431 approval_sha256: String,
433 visible_count: usize,
435 held_out_count: usize,
437 by: String,
439 via: String,
441 },
442 Lapsed {
444 unit: String,
446 requirement: String,
448 frozen_wording_sha256: String,
450 current_wording_sha256: String,
452 },
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
457pub struct StatementClass {
458 pub statement: String,
460 pub class: String,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
466pub struct ShownReproduction {
467 pub statement: String,
469 pub file: String,
471 pub test: String,
473 pub digest: String,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct ShownSpecialCase {
480 pub statement: String,
482 pub rejected: bool,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(tag = "kind", rename_all = "snake_case")]
489pub enum BugEvent {
490 Recorded {
492 home: String,
494 unit: String,
496 requirement: String,
498 triad_digest: String,
500 via: String,
502 },
503 ReproduceTry {
505 n: u32,
507 of: u32,
509 per_statement: Vec<StatementClass>,
511 },
512 Reproduced {
514 reproductions: Vec<ShownReproduction>,
516 },
517 ConfirmWaiting {
519 digest: String,
521 next: String,
523 },
524 Confirmed {
526 digest: String,
528 by: String,
530 via: String,
532 basis: String,
534 },
535 ConfirmRejected {
537 digest: String,
539 reason: String,
541 },
542 UnchangedSelected {
544 state: String,
546 count: usize,
548 },
549 Discrimination {
551 revert: String,
553 special_case: Vec<ShownSpecialCase>,
555 },
556 Outcome {
558 outcome: String,
560 verdict: Option<String>,
562 code: Option<String>,
564 next: Vec<String>,
566 },
567}
568
569impl BugEvent {
570 #[must_use]
573 pub const fn name(&self) -> &'static str {
574 match self {
575 Self::Recorded { .. } => "bug.recorded",
576 Self::ReproduceTry { .. } => "bug.reproduce_try",
577 Self::Reproduced { .. } => "bug.reproduced",
578 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
579 Self::Confirmed { .. } => "bug.confirmed",
580 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
581 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
582 Self::Discrimination { .. } => "bug.discrimination",
583 Self::Outcome { .. } => "bug.outcome",
584 }
585 }
586
587 #[must_use]
589 pub fn line(&self) -> String {
590 match self {
591 Self::Recorded {
592 unit, requirement, ..
593 } => format!("bug recorded as {requirement} in {unit}"),
594 Self::ReproduceTry {
595 n,
596 of,
597 per_statement,
598 } => format!(
599 "reproduce try {n}/{of}: {}",
600 per_statement
601 .iter()
602 .map(|s| format!("{} {}", s.statement, s.class))
603 .collect::<Vec<_>>()
604 .join(", ")
605 ),
606 Self::Reproduced { reproductions } => format!(
607 "reproduced: {} test(s) fail on an assertion",
608 reproductions
609 .iter()
610 .filter(|r| r.statement.starts_with('E'))
611 .count()
612 ),
613 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
614 Self::Confirmed { by, basis, .. } => {
615 format!("reproductions confirmed by {by} ({basis})")
616 }
617 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
618 Self::UnchangedSelected { state, count } => {
619 format!("pass-to-pass: {count} test(s) ({state})")
620 }
621 Self::Discrimination {
622 revert,
623 special_case,
624 } => format!(
625 "revert {revert}; {} special case(s), {} rejected",
626 special_case.len(),
627 special_case.iter().filter(|s| s.rejected).count()
628 ),
629 Self::Outcome {
630 outcome, verdict, ..
631 } => match verdict {
632 Some(v) => format!("bug {outcome}: {v}"),
633 None => format!("bug {outcome}"),
634 },
635 }
636 }
637}
638
639pub trait Progress: Send + Sync + std::fmt::Debug {
641 fn event(&self, event: &Event);
643}
644
645#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
647pub struct LiveState {
648 pub pid: u32,
650 pub feature: String,
652 pub task: String,
654 pub title: String,
656 #[serde(default)]
658 pub stage: Option<Stage>,
659 #[serde(default)]
661 pub stage_detail: Option<String>,
662 pub last_call: Option<String>,
664 #[serde(default)]
666 pub said: Option<String>,
667 #[serde(default)]
669 pub thinking: Option<String>,
670 #[serde(default)]
672 pub recent: Vec<String>,
673 pub input_tokens: u64,
675 pub output_tokens: u64,
677 pub usd: Option<f64>,
679 pub finished: Option<String>,
681 pub started_at: u64,
683 pub updated_at: u64,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub agent: Option<LiveAgent>,
688}
689
690#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
693pub struct LiveAgent {
694 pub name: String,
696 pub mode: String,
698 pub decides_alone: bool,
701 #[serde(default)]
703 pub known: bool,
704 #[serde(default)]
706 pub unasked: u32,
707 #[serde(default)]
709 pub plan: Vec<PlanEntry>,
710 pub asked: u32,
712 pub allowed: u32,
714 pub waited: u32,
716 pub refused: u32,
718}
719
720fn now() -> u64 {
721 std::time::SystemTime::now()
722 .duration_since(std::time::UNIX_EPOCH)
723 .map_or(0, |d| d.as_secs())
724}
725
726#[must_use]
728pub fn usd(
729 prices: Option<Prices>,
730 input: u64,
731 output: u64,
732 cache_read: u64,
733 cache_write: u64,
734) -> Option<f64> {
735 prices.map(|p| p.usd(input, output, cache_read, cache_write))
736}
737
738#[derive(Debug)]
740pub struct Live {
741 file: PathBuf,
742 prices: Option<Prices>,
743 sink: Option<Arc<dyn Progress>>,
744 state: Mutex<LiveState>,
745}
746
747impl Live {
748 #[must_use]
750 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
751 let _ = std::fs::create_dir_all(dir);
752 Self {
753 file: dir.join(FILE),
754 prices,
755 sink,
756 state: Mutex::new(LiveState {
757 pid: std::process::id(),
758 ..LiveState::default()
759 }),
760 }
761 }
762
763 fn write(&self, state: &LiveState) {
764 if let Ok(text) = serde_json::to_string_pretty(state) {
765 let tmp = self.file.with_extension("json.tmp");
766 if std::fs::write(&tmp, text).is_ok() {
767 let _ = std::fs::rename(&tmp, &self.file);
768 }
769 }
770 }
771}
772
773impl Progress for Live {
774 fn event(&self, event: &Event) {
775 let mut event = event.clone();
776 match &mut event {
778 Event::Usage {
779 input_tokens,
780 output_tokens,
781 cache_read_tokens,
782 cache_write_tokens,
783 usd: price,
784 ..
785 } => {
786 *price = usd(
787 self.prices,
788 *input_tokens,
789 *output_tokens,
790 *cache_read_tokens,
791 *cache_write_tokens,
792 )
793 }
794 Event::TaskFinished {
795 input_tokens,
796 output_tokens,
797 cache_read_tokens,
798 cache_write_tokens,
799 usd: price,
800 ..
801 } => {
802 *price = usd(
803 self.prices,
804 *input_tokens,
805 *output_tokens,
806 *cache_read_tokens,
807 *cache_write_tokens,
808 )
809 }
810 _ => {}
811 }
812 if let Ok(mut s) = self.state.lock() {
813 match &event {
814 Event::TaskStarted {
815 feature,
816 task,
817 title,
818 } => {
819 *s = LiveState {
820 pid: s.pid,
821 feature: feature.clone(),
822 task: task.clone(),
823 title: title.clone(),
824 started_at: now(),
825 ..LiveState::default()
826 };
827 }
828 Event::Stage {
829 stage,
830 state: StageState::Started,
831 detail,
832 ..
833 } => {
834 s.stage = Some(*stage);
835 s.stage_detail.clone_from(detail);
836 }
837 Event::Said { text, .. } => {
838 s.said = text
839 .lines()
840 .find(|l| !l.trim().is_empty())
841 .map(|l| l.chars().take(160).collect());
842 }
843 Event::Reasoning { text, .. } => {
845 s.thinking = text
846 .lines()
847 .rev()
848 .find(|l| !l.trim().is_empty())
849 .map(|l| l.chars().take(160).collect());
850 }
851 Event::AgentStarted {
852 agent,
853 mode,
854 decides_alone,
855 known,
856 ..
857 } => {
858 s.agent = Some(LiveAgent {
859 name: agent.clone(),
860 mode: mode.clone(),
861 decides_alone: *decides_alone,
862 known: *known,
863 ..LiveAgent::default()
864 });
865 }
866 Event::AgentUnasked { calls, .. } => {
867 if let Some(a) = s.agent.as_mut() {
868 a.unasked = *calls;
869 a.decides_alone = true;
870 }
871 }
872 Event::AgentPlan { entries, .. } => {
873 if let Some(a) = s.agent.as_mut() {
874 a.plan.clone_from(entries);
875 }
876 }
877 Event::ApprovalRequested { .. } => {
878 if let Some(a) = s.agent.as_mut() {
879 a.waited += 1;
880 }
881 }
882 Event::Stage { .. } | Event::Resumed { .. } | Event::ApprovalDecided { .. } => {}
883 Event::ToolCall {
884 turn,
885 tool,
886 target,
887 decision,
888 ..
889 } => {
890 if let Some(a) = s.agent.as_mut()
891 && decision != "reported"
892 {
893 a.asked += 1;
894 match decision.as_str() {
895 "allow" => a.allowed += 1,
896 "deny" => a.refused += 1,
897 _ => {}
898 }
899 }
900 let call = format!("{tool} {target} ({decision})");
901 s.recent.push(format!("turn {turn} {call}"));
902 if s.recent.len() > RECENT {
903 s.recent.remove(0);
904 }
905 s.last_call = Some(call);
906 }
907 Event::Usage {
908 input_tokens,
909 output_tokens,
910 usd,
911 ..
912 } => {
913 s.input_tokens = *input_tokens;
914 s.output_tokens = *output_tokens;
915 s.usd = *usd;
916 }
917 Event::TaskFinished {
918 outcome, message, ..
919 } => s.finished = Some(format!("{outcome}: {message}")),
920 Event::Notice { .. }
921 | Event::RunStarted { .. }
922 | Event::Bug { .. }
923 | Event::Oracle { .. }
924 | Event::CollectionChecked { .. }
925 | Event::PlanningStopped { .. }
926 | Event::LadderOffered { .. }
927 | Event::UnitPromoted { .. }
928 | Event::Project { .. }
929 | Event::AskStep { .. }
930 | Event::AskText { .. }
931 | Event::AskTurnEnded { .. }
932 | Event::AskDone { .. } => {}
933 }
934 s.updated_at = now();
935 self.write(&s);
936 }
937 if let Some(sink) = &self.sink {
938 sink.event(&event);
939 }
940 }
941}
942
943#[must_use]
945pub fn target(tool: &str, args: &serde_json::Value) -> String {
946 let text = match tool {
947 "run_command" => args["argv"]
948 .as_array()
949 .map(|a| {
950 a.iter()
951 .filter_map(|v| v.as_str())
952 .collect::<Vec<_>>()
953 .join(" ")
954 })
955 .unwrap_or_default(),
956 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
957 "report_blocked" => format!(
958 "{}: {}",
959 args["kind"].as_str().unwrap_or("?"),
960 args["evidence"].as_str().unwrap_or_default()
961 ),
962 name if name.starts_with(crate::mcp::PREFIX) => args
964 .as_object()
965 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
966 .unwrap_or_default(),
967 _ => args["path"].as_str().unwrap_or_default().to_owned(),
968 };
969 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
970 if one.chars().count() > 60 {
971 format!("{}…", one.chars().take(59).collect::<String>())
972 } else {
973 one
974 }
975}
976
977#[must_use]
981pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
982 let root = match crate::inspect(path, None).data() {
983 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
984 _ => return None,
985 };
986 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
987 .ok()?;
988 let live: LiveState = serde_json::from_str(&text).ok()?;
989 let running = live.finished.is_none() && alive(live.pid);
990 Some((live, running))
991}
992
993pub fn alive(pid: u32) -> bool {
995 i32::try_from(pid)
996 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
997}
998
999pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1002 status_at(path, state, agentplane::core::Timestamp::now_utc())
1003}
1004
1005#[doc(hidden)]
1008pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1009 let inspected = crate::inspect(path, None);
1011 let root = match inspected.data() {
1012 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1013 _ => return CommandResult::carried("report", &inspected),
1014 };
1015 let plane_dir = crate::worktree::project_state_dir(state, &root);
1016 let res = status_inner(&plane_dir);
1017 let lines = waiting_lines(&plane_dir, now);
1018 if lines.is_empty() {
1019 return res;
1020 }
1021 let mut next = res.next().to_vec();
1022 next.insert(
1023 0,
1024 speccoder_core::next::NextStep {
1025 command: "speccoder report --approvals".to_owned(),
1026 why: "see the waiting calls, then approve or reject them".to_owned(),
1027 },
1028 );
1029 let message = format!("{}\n{}", res.message(), lines.join("\n"));
1030 let status = res.status();
1031 res.reworded(status, &message).with_next(next)
1032}
1033
1034fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1038 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
1042 let rt = match crate::tokio_runtime() {
1043 Ok(rt) => rt,
1044 Err(e) => return unknown(e),
1045 };
1046 let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
1047 Ok(Some(p)) => p,
1048 Ok(None) => return Vec::new(),
1049 Err(e) => return unknown(e.0),
1050 };
1051 let open = match rt.block_on(plane.open_tasks()) {
1052 Ok(open) => open,
1053 Err(e) => return unknown(e.0),
1054 };
1055 open.into_iter()
1056 .filter(|t| crate::approvals::still_waits(t.due_at, now))
1057 .filter_map(|t| {
1058 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
1059 let since = (now - t.created_at).whole_minutes().max(0);
1060 let until = t
1061 .due_at
1062 .map(|d| {
1063 format!(
1064 ", denied as expired in {} h",
1065 (d - now).whole_hours().max(0)
1066 )
1067 })
1068 .unwrap_or_default();
1069 Some(format!(
1070 "waiting for a person: {} — {} (for {since} min{until})",
1071 request.task,
1072 request.covers()
1073 ))
1074 })
1075 .collect()
1076}
1077
1078fn status_inner(plane_dir: &Path) -> CommandResult {
1079 let plane_dir = plane_dir.to_path_buf();
1080 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
1082 let mode = s.mode_line();
1083 if !alive(s.pid) {
1084 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
1085 command: command.to_owned(),
1086 why: why.to_owned(),
1087 };
1088 return CommandResult::new(
1089 "status",
1090 CommandStatus::Ok,
1091 &format!(
1092 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
1093 s.feature, s.pid, s.branch
1094 ),
1095 )
1096 .with_next(vec![
1097 step("speccoder run --in-place", "resume where it stopped"),
1098 step(
1099 "speccoder run --in-place --return",
1100 "save what it left and switch back without running",
1101 ),
1102 ]);
1103 }
1104 let res = status_live(&plane_dir);
1105 let message = format!("{}\n{mode}", res.message());
1106 let status = res.status();
1107 return res.reworded(status, &message);
1108 }
1109 status_live(&plane_dir)
1110}
1111
1112fn status_live(plane_dir: &Path) -> CommandResult {
1114 let file = plane_dir.join(FILE);
1115 let live: LiveState = match std::fs::read_to_string(&file)
1116 .ok()
1117 .and_then(|t| serde_json::from_str(&t).ok())
1118 {
1119 Some(l) => l,
1120 None => {
1121 return CommandResult::new(
1122 "status",
1123 CommandStatus::Ok,
1124 "no run has been recorded for this project yet",
1125 );
1126 }
1127 };
1128 let ago = now().saturating_sub(live.updated_at);
1129 let tokens = format!(
1130 "{} in · {} out{}",
1131 live.input_tokens,
1132 live.output_tokens,
1133 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
1134 );
1135 use speccoder_core::next::{Outcome, next};
1136 let outcome = match (&live.finished, alive(live.pid)) {
1137 (None, true) => Some(Outcome::Running),
1138 (None, false) => Some(Outcome::Interrupted {
1139 task: live.task.clone(),
1140 }),
1141 (Some(_), _) => None,
1142 };
1143 let message = match (&live.finished, alive(live.pid)) {
1144 (None, true) => format!(
1145 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
1146 live.pid,
1147 live.feature,
1148 live.task,
1149 stage_word(&live),
1150 now().saturating_sub(live.started_at),
1151 live.last_call.as_deref().unwrap_or("-"),
1152 live.thinking
1153 .as_deref()
1154 .map(|s| format!("\nthe model thinks (a claim): {s}"))
1155 .unwrap_or_default(),
1156 live.said
1157 .as_deref()
1158 .map(|s| format!("\nthe model says (a claim): “{s}”"))
1159 .unwrap_or_default(),
1160 ),
1161 (None, false) => format!(
1162 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
1163 live.feature,
1164 live.task,
1165 stage_word(&live),
1166 live.pid
1167 ),
1168 (Some(outcome), _) => format!(
1169 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
1170 live.feature, live.task
1171 ),
1172 };
1173 CommandResult::new("status", CommandStatus::Ok, &message)
1174 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
1175}
1176
1177#[must_use]
1179pub fn stage_word(live: &LiveState) -> String {
1180 match (live.stage, &live.stage_detail) {
1181 (Some(st), Some(d)) => format!("{} {d}", st.name()),
1182 (Some(st), None) => st.name().to_owned(),
1183 (None, _) => "starting".to_owned(),
1184 }
1185}
1186
1187#[must_use]
1190pub fn tail(text: &str) -> Vec<String> {
1191 let lines: Vec<String> = text
1192 .lines()
1193 .map(|l| {
1194 l.chars()
1195 .filter(|c| !c.is_control())
1196 .take(200)
1197 .collect::<String>()
1198 })
1199 .filter(|l| !l.trim().is_empty())
1200 .collect();
1201 lines[lines.len().saturating_sub(5)..].to_vec()
1202}
1203
1204#[must_use]
1206pub fn status(path: &Path) -> CommandResult {
1207 match crate::worktree::state_dir() {
1208 Some(state) => status_with(path, &state),
1209 None => CommandResult::new(
1210 "status",
1211 CommandStatus::ConfigError,
1212 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1213 )
1214 .with_code("NO-STATE-DIR"),
1215 }
1216}